JVM vs Native - Une réelle comparaison des performances
Photo de Tyler Nix sur Unsplash

Pour comparer l’exécution d’une application Java entre ses versions Bytecode (JVM) et native (GraalVM), il faut, tout d’abord, décider de son architecture et des framewoks à utiliser. Dans un deuxième temps, il faut aussi se demander ce que l’on va mesurer.

Récemment, je suis tombé sur un cours très intéressant, containers and orchestration, de Jérôme Petazzoni. Il utilise différentes applications Python et Ruby qui entrent en interaction au moyen de conteneurs Docker. Ils agissent comme un maillage de microservices. L’efficacité du système est mesuré en fonction du nombre de traitements exécutés par seconde.

Cela m’a semblé un bon exemple pour servir de base à ce comparatif en :

  • Transposant le code en langage Java sous les frameworks Spring Boot / WebFlux et en utilisant Spring Native pour le build en Bytecode ou en natif,
  • Jouant sur le nombre de conteneurs afin de faire varier la charge du système.

Voyons cela en détails.

Code source

Toutes les sources sont conservées sur https://github.com/scalastic/hotspot-vs-native

MAJ

La configuration des conteneurs est primordiale lorsqu’il s’agit de mesurer des consommations mémoire et CPU. Une mise à jour de cet article est disponible à JVM vs Native - Configuration des conteneurs Java dans Kubernetes



Exigences

Pour mettre en Ĺ“uvre cette solution, nous aurons besoin de :

  1. Un cluster Kubernetes pour exécuter nos conteneurs,
  2. Différentes mesures des traitements provenant des microservices
  3. Prometheus et Grafana pour récolter et afficher ces mesures,
  4. Une application Java compilable en Bytecode et en natif

Et bien, ce n’est pas grand-chose et cela existe déjà :

  • Dans un article prĂ©cĂ©dent, j’explique comment installer une stack complète Kubernetes, Prometheus et Grafana - Installez Kubernetes, Prometheus et Grafana en local,
  • En intĂ©grant Micrometer Ă  une application Java Spring Boot, il est possible d’exposer les mesures de ses services - HasherHandler.java,
  • Pour une application Python, la bibliothèque prometheus_client permet aussi d’exposer des mesures - worker.py,
  • En configurant le POM Maven avec la dĂ©pendance org.springframework.experimental:spring-native, il est possible de compiler l’application aussi bien en Bytecode ou qu’en natif.

Version de Spring

Ce sont les dernières versions en date de Spring Experimental qui seront utilisées pour développer nos microservices Java. En effet, elles corrigent et améliorent continuellement les bogues et les performances du build natif. Mais il faut bien garder à l’esprit qu’il s’agit de versions en Bêta :

  • Spring 2.5.0-RC1
  • Spring Native 0.10.0-SNAPSHOT

Architecture d’application

Voyons de quoi est faite l’application:

L'architecture de l'application démo
L'architecture de l'application démo

L’application est composée de 4 microservices :

  1. worker : l’orchestrateur d’algorithmes [Python] qui obtient 1 un nombre aléatoire, 2 le hacher et 3 incrémenter un compteur dans la base de données redis,
  2. rng : le générateur de nombres aléatoires [Java],
  3. hasher : le processeur de hachage [Java],
  4. redis : la base de données qui enregistre un compteur de cycles de traitements.

Build de l’appli

Le but de la compilation est de produire une image Docker par microservice. Pour les microservices Java, il y aura deux images, la première en Bytecode, la seconde en natif.

Facultatif

J’ai mis ces images dans un registre public sur Docker Hub, vous pouvez donc passer cet étape de build.

Exigences pour le build

Toutefois, si vous souhaitez créer ces images Docker, vous devrez installer :

La façon facile

Note

  • Il devrait fonctionner sur des systèmes basĂ©s sur Linux et macOS - et sur Windows avec quelques petites modifications
  • Cela va prendre du temps……. 10-20 min en fonction de votre connexion internet et de votre processeur ! C’est le prix Ă  payer pour compiler du code natif.

Pour ce faire, exécutez ce script, à la racine du projet :

./build_docker_images.sh
Bash

Résumé des commandes exécutées

  • Pour une application non-java :
docker build -t <app_docker_tag> ./<app_dir>
Bash
  • Pour une image basĂ©e sur la JVM :
cd <app_dir>
mvn clean package
docker build -t <app_docker_tag> .
Bash
  • Pour une image native Java :
cd <app_dir>
mvn spring-boot:build-image
Bash

A partir de Docker Hub

Vous pouvez rapatrier les images Ă  partir de Docker Hub en saisissant :

docker pull jeanjerome/rng-jvm:1.0.0
docker pull jeanjerome/hasher-jvm:1.0.0
docker pull jeanjerome/worker-python:1.0.0
docker pull jeanjerome/rng-native:1.0.0
docker pull jeanjerome/hasher-native:1.0.0
Bash

VĂ©rification

Pour lister vos images locales, entrez :

images docker
Bash

Vous devriez voir au moins ces images dans votre registre local:

REPOSITORY                TAG        IMAGE ID       CREATED             SIZE
rng-jvm                   1.0.0      f4bfdacdd2a1   4 minutes ago       242MB
hasher-jvm                1.0.0      ab3600420eab   11 minutes ago      242MB
worker-python             1.0.0      e2e76d5f8ad4   38 hours ago        55MB
hasher-native             1.0.0      629bf3cb8760   41 years ago        82.2MB
rng-native                1.0.0      68e484d391f3   41 years ago        82.2MB
Bash

Note

La date de création des images natives semblent erronées. Ce n’est pas le cas, l’explication est ici : Time Travel with Pack


Configuration de Kubernetes

Tout d’abord, nous devons définir la configuration kubernetes de notre application et indiquer à Prometheus où trouver les métriques.

Architecture de la stack Kubernetes

Voyons comment installer ces microservices dans notre cluster kubernetes :

  • L’architecture de l’application est dĂ©ployĂ©e dans un espace de nom dĂ©diĂ©, demo,
  • Les outils de suivi se trouvent dans un autre espace de nom appelĂ© monitoring.
Architecture de notre cluster Kubernetes
Architecture de notre cluster Kubernetes
  1. Nous voulons gérer le nombre de conteneurs - pods dans ce cas - pour chaque microservice,
  2. Nous souhaitons également pouvoir changer l’image du pod (Bytecode ou natif) sans avoir besoin de tout redéployer.

    => Une telle ressource Kubernetes existe déjà, Deployment

  3. Nous avons besoin que nos microservices communiquent entre eux dans le cluster Kubernetes.

    => C’est le travail de la ressource Service.

  4. La base de données Redis n’a pas besoin d’être accessible de l’extérieur mais seulement de l’intérieur du cluster.

    => C’est déjà le cas car, par défaut, les Services Kubernetes sont de type ClusterIP.

  5. Nous voulons que les métriques de l’application soient collectés par Prometheus.

    => Voici comment le configurer

Jetez un coup d’œil à la configuration du microservice Hasher ci-dessous:

Configuration Kubernetes du microservices Hasher
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hasher
  namespace: demo
  labels:
    app: hasher
spec:
  replicas: 1
  selector:
    matchLabels:
      app: hasher
  template:
    metadata:
      name: hasher
      labels:
        app: hasher
    spec:
      containers:
        - image: hasher-jvm:1.0.0
          imagePullPolicy: IfNotPresent
          name: hasher
          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
---
apiVersion: v1
kind: Service
metadata:
  name: hasher
  namespace: demo
  labels:
    app: hasher
  annotations:
    prometheus.io/scrape: 'true'
    prometheus.io/scheme: http
    prometheus.io/path: /actuator/prometheus
    prometheus.io/port: '8080'
spec:
  ports:
    - port: 8080
      protocol: TCP
      targetPort: http-hasher
  selector:
    app: hasher
Yaml

Configuration de Grafana

Pour afficher les metriques récoltés par Prometheus, Grafana a besoin de :

  1. Une source de données vers Prometheus,
  2. Un tableau de bord décrivant les métriques à afficher et sous quelle forme.

Si vous avez suivi mon article précédent Installer localement Kubernetes, Prometheus et Grafana, la source de données est déjà configurée et vous pouvez passer l’étape suivante. L’interface de Grafana est alors accessible à http://localhost:3000/

Configuration de la source de données

Grafana utilise des fichiers au format YAML pour configurer une source de données. On peut le définir grâce à la ressources Kubernetes ConfigMap:

apiVersion: v1
kind: Namespace
metadata:
  name: monitoring
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: grafana-datasources
  namespace: monitoring
data:
  prometheus.yaml: |-
    {
        "apiVersion": 1,
        "datasources": [
            {
               "access":"proxy",
                "editable": true,
                "name": "prometheus",
                "orgId": 1,
                "type": "prometheus",
                "url": "http://prometheus-service.monitoring.svc:8080",
                "version": 1
            }
        ]
    }
Yaml

Reste à passer cette ressource à Grafana dans la définition de son Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: grafana
  namespace: monitoring
spec:
  replicas: 1
  template:
    spec:
      containers:
        - image: grafana/grafana:latest
          name: grafana
.../...
          volumeMounts:
            - mountPath: /etc/grafana/provisioning/datasources
              name: grafana-datasources
              readOnly: false
      volumes:
        - name: grafana
          emptyDir: {}
        - name: grafana-datasources
          configMap:
            defaultMode: 420
            name: grafana-datasources
Yaml

Configuration du tableau de bord

  1. Connectez-vous à l’interface web de Grafana,
  2. Importer le tableau de bord pré-défini demo-dashboard.json,
  3. Afficher le tableau de bord.

Vous devriez alors voir un tableau de bord vide comme celui-ci :

Le tableau de bord démo dans Grafana
Le tableau de bord démo dans Grafana

Description du tableau de bord de démonstration

Description du tableau de bord démo de Grafana
Description du tableau de bord démo de Grafana
  • Les lignes du tableau (Ă©tiquetĂ©es de A Ă  C) reprĂ©sentent les 3 microservices, respectivement, Worker, Random Number Generator -RNG- and Hasher.

  • Les colonnes (numĂ©rotĂ©es de 1 Ă  4) reprĂ©sentent diffĂ©rents mĂ©triques:

    • Dans la colonne 1, on peut voir le nombre de pods en cours d’exĂ©cution ainsi que la vitesse des traitements
    • Dans la colonne 2 est affichĂ© l’historique des vitesses de traitement, pour chaque microservice,
    • Dans la colonne 3 s’affiche la consommation de CPU de chaque pod,
    • Dans la colonne 4, la consommation de RAM de chaque pod.

Démarrage de l’application

Une configuration Kubernetes a été créée avec des Replicas de 1 pod pour chaque microservice et des images Java compilées en Bytecode.

  • Pour dĂ©marrer l’application dans Kubernetes, entrez :
kubectl apply -f _kube/k8s-app-jvm.yml
Bash
  • Vous devriez voir en sortie :
namespace/demo created
deployment.apps/hasher created
service/hasher created
deployment.apps/rng created
service/rng created
deployment.apps/redis created
service/redis created
deployment.apps/worker created
service/worker created
Bash
  • Visualisez le dĂ©marrage des pods dans Grafana:
DĂ©marrage de l'application dans Grafana
DĂ©marrage de l'application dans Grafana

RĂ©sultat

  • La vitesse de traitement observĂ©e, situĂ©e dans la cellule A1, nous donne une mesure de base de l’efficacitĂ© de notre application : 3,20 cycles/s.
  • En fonction des ressources allouĂ©es Ă  votre espace, vous pouvez obtenir un rĂ©sultat diffĂ©rent.

Modification de la configuration de Kubernetes

Aperçu

  • Voyons la situation actuelle du dĂ©ploiement en entrant :
kubectl get deployment -n demo
Bash
  • Ce qui devrait envoyer :
NAME     READY   UP-TO-DATE   AVAILABLE   AGE
hasher   1/1     1            1           13m
redis    1/1     1            1           13m
rng      1/1     1            1           13m
worker   1/1     1            1           13m
Bash

Augmentez le nombre de pods

  • Pour augmenter les pods du worker Ă  2 :
kubectl scale deployment worker --replicas=2 -n demo
Bash
  • Ce qui renvoie :
deployment.apps/worker scaled
Bash

Incidence sur l’application

  • Jetons un coup d’œil au tableau de bord de Grafana :
Visualisation des 2 workers dans Grafana
Visualisation des 2 workers dans Grafana

RĂ©sultats

Vous remarquez que la vitesse de l’application est multipliée par x2.

Augmentez encore le nombre de pods

  • Passons Ă  10 workers :
kubectl scale deployment worker --replicas=10 -n demo
Bash
Visualisation des 10 workers dans Grafana
Visualisation des 10 workers dans Grafana

RĂ©sultats

La vitesse du processus augmente, mais n’atteint pas exactement 10 fois plus : la latence des 2 microservices, rng et hasher, qui a légèrement augmenté, explique cela.

  • Augmentons le nombre de pods pour hasher et rng :
kubectl scale deployment hasher rng --replicas=5 -n demo
Bash
Visualisation des microservices RNG et Hasher dans Grafana
Visualisation des microservices RNG et Hasher dans Grafana

RĂ©sultats

  • L’augmentation du nombre de pods de hasher et rng a rĂ©duit leur latence, mais elle reste tout de mĂŞme un peu plus Ă©levĂ©e qu’au dĂ©but,
  • Un autre facteur est limitant mais nous ne voyons pas lequel dans les donnĂ©es affichĂ©es.

Déployons la version native de l’application

  • Remplacez l’image actuelle des pods par leur version native en mettant Ă  jour leur Deployment :
kubectl set image deployment/hasher hasher=hasher-native:1.0.0 -n demo
kubectl set image deployment/rng rng=rng-native:1.0.0 -n demo
Bash
  • Surveillez le dĂ©ploiement :
kubectl rollout status deployment/hasher -n demo
Bash
  • Et ouvrez le tableau de bord Grafana :
Visualisation du déploiement des images natives dans Grafana
Visualisation du déploiement des images natives dans Grafana

RĂ©sultats

La latence

  • Aucun changement dans la rĂ©activitĂ© des microservices: sans doute, le code est trop simple pour bĂ©nĂ©ficier d’un build native.

L’utilisation de l’UC

  • Avec le Bytecode, l’utilisation du CPU avait tendance Ă  diminuer avec le temps. Cela Ă©tait 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 natif 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 : c’est d’autant plus Ă©tonnant que la rĂ©duction de l’empreinte mĂ©moire est l’un des avantages citĂ©s par la communautĂ©.
  • Est-ce Ă  cause des versions BĂŞta employĂ©es dans cette dĂ©mo ou bien une fuite de mĂ©moire dans l’implĂ©mentation ?

MAJ

La configuration des conteneurs est primordiale lorsqu’il s’agit de mesurer des consommations mémoire et CPU. Une mise à jour de cet article est disponible à JVM vs Native - Configuration des conteneurs Java dans Kubernetes


Supprimons tout

  • Pour supprimer simplement l’application et tous ses microservices, saisissez :
kubectl delete -f _kube/k8s-app-jvm.yml
Bash
  • qui supprimera toutes les configurations Kubernetes crĂ©Ă©es prĂ©cĂ©demment :
namespace "demo" deleted
deployment.apps "hasher" deleted
service "hasher" deleted
deployment.apps "rng" deleted
service "rng" deleted
deployment.apps "redis" deleted
service "redis" deleted
deployment.apps "worker" deleted
service "worker" deleted
Bash

Conclusion

Nous avons appris à installer une stack Kubernetes complète afin de pouvoir mesurer les métriques d’une application.

Cependant, nous n’obtenons pas les résultats escomptés dans le contexte des applications natives. Une explication pourrait être un manque de la version Spring Beta : Spring Native vient de passer à la version 0.10.0-SNAPSHOT et c’est précisément la version où des améliorations de performance sont prévues.

Je vais ouvrir un ticket auprès de l’équipe de Spring Boot pour leur demander leur analyse.~

MAJ

La configuration des conteneurs est primordiale lorsqu’il s’agit de mesurer des consommations mémoire et CPU. Une mise à jour de cet article est disponible à JVM vs Native - Configuration des conteneurs Java dans Kubernetes


Quelle est la prochaine Ă©tape ?

Qu’est-ce qui manque pour une évaluation encore plus réaliste ?

  • La configuration de Kubernetes doit toujours inclure une limite de ressources ce qui n’a pas Ă©tĂ© effectuĂ© dans cette dĂ©mo.
  • J’aurais pu utiliser des Horizontal Pod Autoscaler (HPA) et encore mieux des HPA avec des mĂ©triques personnalisĂ©es (lisez ce post pour plus de dĂ©tails).

Question

  • J’aurais aimĂ© trouver quelque chose sur des Scalers qui s’auto-rĂ©gulent et capables de maximiser une mĂ©trique mais rien Ă  propos d’une telle chose…
  • Avez-vous dĂ©jĂ  entendu parler de quelque chose du mĂŞme genre ?

Liens utiles

Voici quelques liens pour une lecture plus approfondie :

Et bien, voilà, c’est à votre tour de jouer avec les applications natives à présent !

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.