JVM vs Native - Configuration des conteneurs Java dans Kubernetes
Photo de Martin Sanchez sur Unsplash

Dans un article précédent, JVM vs Native - Une réelle comparaison des performances, j’avais montré comment installer une stack Kubernetes complète afin de pouvoir mesurer les métriques de microservices Java. La configuration étant longue et fastidieuse (l’article aussi sans doute), je ne m’étais pas attardé sur la configuration des conteneurs.

Dans cet article, nous allons voir pourquoi, dans une application Java, cette configuration est primordiale et en quoi elle impacte les ressources consommées par une application.

Code source

Toutes les sources sont disponibles sur github.com/scalastic/hotspot-vs-native-part2



Rappel du contexte

Notre but était de comparer l’exécution d’une application Java, entre ses versions Bytecode (JVM HotSpot) et native (compilation avec GraalVM). Pour cela, nous avons mis en place un cluster local Kubernetes avec Prometheus et Grafana pour, respectivement, récolter et présenter les métriques. Nous avons aussi outillé nos microservices Java avec Micrometer afin d’exposer les métriques de nos applications à Prometheus.

Nous obtenions les résultats suivants dans Grafana :

Visualisation du roll-out entre une image JVM et une image Native dans Grafana
Visualisation du roll-out entre une image JVM et une image Native dans Grafana

Et nous constations Ă  propos de :

  • La latence
    • Aucun changement dans la rĂ©activitĂ© des microservices.
  • L’utilisation de l’UC
    • Dans sa version en Bytecode, l’utilisation du CPU a tendance Ă  diminuer avec le temps. Cela est dĂ» Ă  l’action du compilateur HotSpot C2 qui produit un code natif de plus en plus optimisĂ© avec le temps.
    • En revanche, l’utilisation du processeur dans sa version native est faible dès le dĂ©part.
  • L’utilisation de la RAM
    • Étonnamment, les applications natives utilisent plus de mĂ©moire que celles en Bytecode !

En effet, nous n’avions apporté aucune configuration particulière à nos conteneurs. C’est donc le moment de rectifier cela.


Kubernetes : fonctionnement d’un Pod

Attention

Par défaut, lorsque l’on crée un pod, il utilise toutes les ressources système de la machine hôte. C’est dit !

Afin de s’en prémunir, il faut assigner des limites de ressources :

  • Soit au niveau du pod,
  • Soit au niveau du namespace ce qui impactera, par dĂ©faut, les pods qu’il contient.

En réalité, sous le capot, il s’agit des cgroup du noyau Linux que Docker et tous les Container Runtime Interface prennent en compte pour assigner des ressources.

Les différents types de ressources dans Kubernetes

Actuellement, elles sont de 3 types :

  • CPU
  • MĂ©moire
  • Hugepages (depuis Kubernetes v1.14)

Les ressources de type CPU et Mémoire sont dites des ressources de calcul. Les Hugepages sont des mécanismes d’optimisation de la mémoire virtuelle qui réservent une grande quantité de mémoire plutôt que de multiples fragments ce qui accroit les performances du système.

Limites soft et hard

Dans le système d’un OS, les limites de ressource sont de 2 types :

  • Limite soft : quantitĂ© de ressource nĂ©cessaire
  • Limite hard : quantitĂ© maximale autorisĂ©e

On retrouve ces deux limites dans Kubernetes pour gérer les ressources des pods:

  • requests pour la quantitĂ© nĂ©cessaire
  • limits pour la quantitĂ© maximale

Bon Ă  savoir

Si on spécifie uniquement limits, Kubernetes affectera automatiquement la même valeur à requests.

Unité de ressource

La problématique ici est de spécifier une unité commune de CPU ou de mémoire alors que les sytèmes physiques sont hétérogènes.

Limite du CPU

  • Elle est exprimĂ©e en terme de coeur de CPU (CPU core). Il s’agit donc de vCPU/Core dans une architecture Cloud et de coeur hypertheadĂ© lorsqu’il s’agit de bare-metal
  • Un coeur de processeur pouvant ĂŞtre partagĂ© par plusieurs pods, on spĂ©cifie aussi une fraction d’utilisation de ce coeur par pod. On peut l’exprimer en core (par ex. 0.5 soit la moitiĂ© d’un coeur) ou en millicore (par ex. 250m soit le quart d’un coeur)
  • On ne peut pas aller en dessous de 1m ou 0.001 (implicitement en unitĂ© core)

Limite de mémoire

  • Elle est exprimĂ©e soit en octet, soit en son Ă©quivalent binaire : 1024 octets = 1000 bi-octets
  • On peut la simplifier avec les suffixes K, M, G, T, P, E ou en binaire Ki, Mi, Gi, Ti, Pi, Ei

Voici un tableau récapitulatif :

NomOctetsSuffixeNomBi-OctetsSuffixe
kilooctet103Kkibioctet210Ki
mégaoctet106Mmébioctet220Mi
gigaoctet109Ggibioctet230Gi
téraoctet1012Ttébioctet240Ti
pétaoctet1015Ppétioctet250Pi
exaoctet1018Eexioctet260Ei

Fonctionnement des limits dans Kubernetes

Kubernetes laisse le soin au Container Runtime (par exemple Docker) de gérer les limits :

  • Pour le CPU, par exemple avec Docker, il calcule un quota de seconde qu’un pod est en droit d’utiliser toutes les 100ms. Lorsqu’un pod consomme son quota, Docker le met en attente pour 100ms et passe aux pods suivants. Si le pod consomme moins que son quota, il passe lĂ  encore aux pods suivants.
  • Cette mĂ©thode de rĂ©partition du CPU est appelĂ©e Completely Fair Scheduler.
  • Pour la mĂ©moire, lorsque limits est atteinte, le container runtime va supprimer le pod (qui redĂ©marrera ensuite) avec un Out Of Memory (OOM).
  • A noter aussi, que lorsqu’un pod dĂ©passe sa requests, il devient candidat Ă  une Ă©viction si l’hĂ´te manque de ressources mĂ©moire. Il est donc important de ne pas sous-estimer la valeur de requests.

Exemple de configuration des ressources d’un pod

Prenons l’exemple du microservice hasher-java et configurons son déploiement.

  • Les requests, quantitĂ© de ressources nĂ©cessaires, se configure dans Kubernetes avec spec.containers[].resources.requests.
  • les limits, quantitĂ© maximale autorisĂ©e, se configure avec spec.containers[].resources.limits.

Pour le microservice hasher-java, voici ce que cela donne :

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hasher
  namespace: demo
  labels:
    app: hasher
spec:
  replicas: 1
  selector:
    matchLabels:
      app: hasher
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1
      maxSurge: 1
  template:
    metadata:
      name: hasher
      labels:
        app: hasher
    spec:
      containers:
        - image: hasher-jvm:1.0.0
          imagePullPolicy: IfNotPresent
          name: hasher
          resources:
            requests:
              memory: "50Mi"
              cpu: "50m"
            limits:
              memory: "256Mi"
              cpu: "200m"
          ports:
            - containerPort: 8080
              name: http-hasher
              protocol: TCP
          readinessProbe:
            failureThreshold: 3
            httpGet:
              path: /actuator/health
              port: 8080
              scheme: HTTP
            initialDelaySeconds: 10
            periodSeconds: 30
            successThreshold: 1
            timeoutSeconds: 2
Yaml

D’accord, alors on est bon maintenant ?

Pas sûr, il reste encore des éléments à vérifier côté Java… Voyons de quoi il s’agit.


Java dans Kubernetes

La JVM interroge l’OS hôte pour configurer le nombre de threads du Garbage Collector et la mémoire à utiliser. Dans un environnement conteneurisé, les informations de l’OS ne reflètent pas celle du conteneur.

Cette problématique a été traitée en 2017 et est gérée depuis la version Java 10 b34 ainsi que les versions ultérieures. La correction a aussi été reportée sur le JDK 8 à partir de la version Java 8u191. Elle se traduit par l’ajout d’un paramètre -XX:+UseContainerSupports qui est activé par défaut dans la JVM et qui lui permet d’extraire les bonnes informations des conteneurs.

D’autres paramètres apparaissent au fil des versions Java afin d’affiner le fonctionnement dans les conteneurs: -XX:ActiveProcessorCount, -XX:PreferContainerQuotaForCPUCount, -XX:MaxRAMPercentage.

Mais si vous utilisez des versions du JDK intégrant UseContainerSupports, tout devrait bien se passer.


DĂ©monstration

Voyons ce que cette nouvelle configuration apporte Ă  nos microservices.

Création de l’environnement Kubernetes

Repartons d’un environnement Kube qui contient toutes les composants nécessaires à notre démo :

  • Un cluster k8s (local)
  • Metrics Server
  • Prometheus
  • Grafana

Pour cela, placez-vous à la racine du dépôt git que vous avez cloné puis lancez les commandes suivantes :

kubectl apply -f ./k8s/
Zsh

Cela peut prendre quelques minutes avant que tous les composants soient fonctionnels. Celui qui nous intéresse en premier lieu est Grafana.

Dashboard Grafana

  • Connectez-vous Ă  l’interface de Grafana : http://localhost:3000/

    Le login / mot de passe par défaut est admin / admin.

  • Importez le dashboard qui se trouve Ă  la racine du projet sous ./grafana/dashboard.json.

    1. Pour cela, allez dans le menu Dashboards / Manage puis cliquez sur le bouton Import.
    2. Cliquez ensuite sur Upload JSON file et sélectionnez le fichier ./grafana/dashboard.json.
    3. Dans le champ prometheus, sélectionnez la Data Source qui a été créée avec les composants Kube et qui s’appelle prometheus.
    4. Cliquez sur Import.

Vous devriez voir le dashboard de notre démo :

Dashboard Grafana à sa création
Dashboard Grafana à sa création

Lancement de l’application démo et de ses microservices en Bytecode

Nous allons démarrer l’application compilée en Bytecode avec 10 workers, 5 hashers et 5 rngs :

kubectl apply -f ./app/demo-jvm.yaml
Zsh

Laissons un peu de temps à l’application pour remonter les images Docker et se stabiliser. Vous devriez observer au bout de quelques minutes :

Visualisation de l'application démo au démarrage avec des microservices en Bytecode
Visualisation de l'application démo au démarrage avec des microservices en Bytecode

Qu’observe-t-on ?

  • Pour le CPU
    • Un pic Ă  700m lors du dĂ©ploiement des microservices Java : les compilateurs C1/C2 qui se mettent en route.
    • On constate ensuite une diminution progressive de la consomation CPU passant de 200m Ă  100m : le rĂ©sultat de l’optimisation du code natif produit par le compilateur C2.
  • Pour le RAM
    • Elle monte rapidement Ă  750Mo pour se stabiliser Ă  cette valeur.

Suppression de l’application

Supprimons l’application en lançant la commande suivante :

kubectl delete -f ./app/demo-jvm.yaml
Zsh

A présent, voyons comment se déroule le déploiement de la version compilée en code natif.

Lancement de l’application démo et ses microservices en natif

Procédons comme auparavant et lançons la version native de l’application :

kubectl apply -f ./app/demo-native.yaml
Zsh

Laissons-lui quelques minutes afin d’observer son comportement dans le temps :

Visualisation de l'application démo au démarrage avec des microservices en Bytecode
Visualisation de l'application démo au démarrage avec des microservices en Bytecode

Que constate-t-on ?

  • Pour le CPU
    • Aucun pic de consommation au dĂ©marrage mais tout de suite une consommation qui se stabilise Ă  35m : en effet, le code natif a dĂ©jĂ  Ă©tĂ© compilĂ© et optimisĂ©.
  • Pour le RAM
    • Elle augmente lĂ©gèrement mais reste en dessous des 200Mo.

Conclusion

  • On constate, dans un environnement contraint, que le code natif de notre application Spring Boot, produit par GraalVM, consomme 3x moins de CPU que la mĂŞme application compilĂ©e en Bytecode.
  • En ce qui concerne la mĂ©moire, on constate aussi une diminution d’un facteur 4 pour l’application Spring Boot en code natif.
  1. Cela diffère complètement de ce que nous avions observé dans nos tests, sans contrainte CPU et mémoire sur les pods. On voit bien alors l’avantage que procure une bonne configuration de ses pods.
  2. A noter aussi, dans notre cas, que pour un même cluster Kubernetes (et donc pour le même coût), il sera possible d’exécuter 3x plus de microservices avec une application Spring Boot, compilée en code natif avec GraalVM.

L’arrivée de GraalVM marque donc bien un changement profond dans l’écosystème Java. Les équipes de Spring, en migrant vers GraalVM, vont permettre à nos applications legacy de profiter pleinement des environnements contraints comme le Cloud. Et tout cela, en maitrisant les coûts.

Autre remarque importante, ces tests ont été effectués avec une version non encore optimisée de Spring Native, la version 0.10.0-SNAPSHOT. C’est en effet dans la prochaine itération, la 0.11.0, que les équipes de Spring vont optimiser la consommation des ressources mais nul doute que cela est, d’ores et déjà, très prometteur.

Cheers…

Jean-Jerome Levy

Ecrit par

Jean-JĂ©rĂ´me LĂ©vy

Consultant DevOps

Professionnel chevronné dans le domaine de l’informatique, cumulant plus de 20 années d’expérience au sein de DSI de grandes entreprises, mon expertise diversifiée m’a permis de jouer un rôle clé dans de nombreux projets, caractérisés par la mise en place de pratiques DevOps innovantes.