Lancement de tests de performance avec Apache JMeter

Bonjour à tous, aujourd'hui nous allons vous parler de la mise en œuvre de test de performance, en s'appuyant sur l'outil open source Apache JMeter.

Que vous ayez mis en place une chaîne d'intégration continue ou même de déploiement continu, il est nécessaire d'intégrer la phase de test de performance dans votre chaîne afin d'assurer un niveau de qualité et de performance toujours acceptable vis-à-vis de vos SLAs.

Je rédige cet article aujourd'hui car la performance est un domaine dont nous parlons trop peu. C'est la raison pour laquelle nous ne trouvons pas beaucoup de documents et articles, s'articulant autour du DevOps et de l'automatisation des processus, qui mettent l'accent sur cette partie là.

Aujourd'hui nous allons vous présentez cette phase, qui se situe entre intégration continue et déploiement continu, la performance continue ou continuous performance testing.

Un test de performance, qu'est-ce que c'est ?

Load Testing is the art of creating artificially generated work that mimics the real work generated by the real users.
-- Bob Wescott – The every computer performance book

Les tests de performance permettent, par injection de charge, de soumettre un service à une activité représentative et reproductible (modèle de charge), définie à partir des observations ou prévisions d'activité réelle des utilisateurs (modèle d'usage).

Autrement dit, les tests de performance permettent de détecter la mauvaise performance d'un service, la dégradation ou les mauvais temps de réponse, l'incapacité de répondre à la demande, l'indisponibilité etc.

Pourquoi faire des tests de performance ?

Il est important d'effectuer des tests de performance car tous ces indicateurs ont un impact immédiat sur les affaires d'une entreprise. S'ils sont mauvais, cela peut engendrer une perte de revenus, la chute des ventes, la fuite de clients à la concurrence, une perte d'audience, ou encore la dégradation de l'image de marque de l'entreprise !

Les différentes métriques à prendre en compte et à surveiller

Afin de valider la performance de l’application, il est important de superviser à la fois les métriques de cette dernière mais aussi celles de la machine hôte (lorsque vous le pouvez).

Nous pouvons noter quelques métriques à surveiller lors d'un test en charge :

  • CPU
  • RAM
  • Disque
  • Réseau
  • Les temps de réponse
  • Le nombre de hit par seconde
  • La quantité de threads lancés
  • La récurrence de l'accès à une base de données
  • et bien d'autres...

Les différents types de tests de performance

Maintenant que vous connaissez les métriques que vous allez superviser, il est important de bien avoir étudié le contexte dans lequel le tir sera fait, et quel type de charge sera appliqué.

La plupart du temps, il est recommandé d'appliquer au moins ces 4 types de tir pour s'assurer de la tangibilité des résultats mais aussi de la qualité de l'application.

  • Unitaire : Le test unitaire permet de valider l'ensemble des endpoints de l'application, comme un test fonctionnel, et permet de relever les temps de réponse qui nous permettront de calculer le nombre d'utilisateurs virtuels à lancer par la suite.
  • Nominal : Ce type de test est un test qui appliquera une volumétrie standard sur l'application, type production, ou afin de répondre aux SLAs.
  • Endurance : Le tir d'endurance est un tir de plusieurs heures, minimum 8 heures. Ce type de test permet de mettre en exergue des problèmes que ne détecterait pas un test en palier ou nominal, comme une fuite mémoire de la JVM par exemple.
  • En palier : Ce type d'injection permet d'appliquer différents niveaux de charge sur une application, pouvant aller de +100% jusque +200% ou +300% afin de détecter les limites de l'application, ou de tester une application en mode dégradé (1 réplica de up sur 3 par exemple).

Les différents outils de tests de performance

Il existe plusieurs outils mis à votre disposition pour faire des tests de performance. Le choix de votre outil d'injection dépendra de plusieurs facteurs, comme l'environnement d’exécution de votre application (IaaS, CaaS, PaaS ?), des protocoles que vous allez tester, de votre matériel hardware, mais aussi des coûts de la licence. Les outils d'injection les plus populaires sont les suivants :

  • JMeter, développé au sein de la Fondation Apache
  • Neoload, de Neotys.
  • LoadRunner, développé par la société Mercury en 1999, racheté par HP en 2006, pour finalement être acquis en 2016 par MicroFocus .

Démonstration

Prérequis

  • 2 machines virtuelles (Debian 10, 2 vCPUs, 4GB de RAM, 16GB de disque)
  • Git (ici 2.20.1)
  • Docker (ici 19.03.5) et docker-compose d'installés
  • 3 Runners Gitlab (un qui fera la construction automatique de votre application, un pour le déploiement automatisé de la version de l'application de test, et un pour automatiser le test en charge) :
    • avec comme executor le type shell
      • avec comme tags :
        • shell,vsprd-1 pour la première VM
        • shell,vsprd-2 pour la seconde VM
        • appartenant au groupe docker pour pouvoir utiliser la CLI Docker
    • avec comme executor le type docker
      • avec comme tags : docker,vsprd-1

Architecture de l'environnement

Afin de mettre en œuvre une chaîne automatisée de test de performance, voici l'infrastructure mise en place (pour plus de facilité vous pouvez évidemment tout mettre sur une seule machine).

L'architecture proposée est la suivante :

L'objectif de ce lab est de vous montrer :

  • Comment effectuer un test en charge automatisé grâce à la fonctionnalité CI / CD de Gitlab avec l'outil de test en charge Apache JMeter
  • Validation des SLAs avant le passage en qualif.

Pour ce lab, une API REST SpringBoot est développée et contactera une base de données MySQL. Pour la supervision, nous déploierons :

Installation des Runners Gitlab

Afin d'automatiser les tests unitaires, la construction de vos applications, les déploiements ainsi que les différents tests de performance, vous allez devoir utiliser des machines dédiées. Nous pourrions utiliser comme orchestrateur Jenkins, mais nous utiliserons la fonctionnalité CI / CD de Gitlab, qui nous permettra de centraliser l'ensemble à un seul endroit.

Installation du Runner (à faire sur les deux machines virtuelles) :

1root@vsprd-1:~# curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | bash
2root@vsprd-1:~# apt-get install gitlab-runner -y

Enregistrez votre Runner comme ici :

1root@vsprd-1:~# gitlab-runner register \
2   --non-interactive \
3   --url "https://gitlab.com/" \
4   --registration-token "PROJECT_REGISTRATION_TOKEN" \
5   --executor "shell" \
6   --name "[VSPRD-1]net-security-gitlab-runner" \
7   --tag-list "shell,vsprd-1" \
8   --run-untagged="false" \
9   --locked="true"

Faites de même sur vsprd-2 avec les tags shell,vsprd-2 et le name [VSPRD-2]net-security-gitlab-runner

Enfin, déployez votre dernier Runner avec comme executor docker. Ce Runner vous permettra de construire votre application automatiquement et de la pousser au sein de la registry privée de Gitlab.

 1root@vsprd-1:~# gitlab-runner register \
 2   --non-interactive \
 3   --url "https://gitlab.com/" \
 4   --registration-token "PROJECT_REGISTRATION_TOKEN" \
 5   --executor "docker" \
 6   --name "[VSPRD-1]net-security-gitlab-runner-docker" \
 7   --tag-list "docker,vsprd-1" \
 8   --run-untagged="false" \
 9   --locked="true" \
10   --docker-image="alpine:3.10.3"

Une fois que vos Runners sont créés et lancés, vous devriez les voir avec un statut UP au sein de votre projet sous : Settings -> CI / CD -> Runners

Il ne vous reste plus qu'à ajouter l'utilisateur gitlab-runner au groupe docker sur chacune de vos machines afin que vous puissiez utiliser les commandes de Docker.

root@vsprd-1:~# usermod -aG docker gitlab-runner

Vérification des Runners

Avant de lancer un quelconque job, n'oubliez pas de supprimer le fichier .bash_logout dans le repertoire home de votre Runner Gitlab

root@vsprd-1:~# rm -f /home/gitlab-runner/.bash_logout

Maintenant que vos Runners Gitlab sont sécurisés et affectés au projet, nous pouvons vérifier qu'ils sont bien opérationnels en créant le fichier .gitlab-ci.yml au sein de votre repository Git :

 1# ==================================================
 2# Stages definition
 3# ==================================================
 4stages:
 5  - test-runner-vsprd-1-shell
 6  - test-runner-vsprd-2-shell
 7  - test-runner-vsprd-1-docker
 8
 9
10# ==================================================
11# Stage: test-runner-vsprd-1-shell
12# ==================================================
13test-runner-vsprd-1-shell:
14    stage: test-runner-vsprd-1-shell
15    tags:
16        - vsprd-1,shell
17    script:
18        - hostname
19        - docker --version
20        
21
22# ==================================================
23# Stage: test-runner-vsprd-2-shell
24# ==================================================
25test-runner-vsprd-2-shell:
26    stage: test-runner-vsprd-2-shell
27    tags:
28        - vsprd-2
29    script:
30        - hostname
31        - docker --version
32        
33
34# ==================================================
35# Stage: test-runner-vsprd-1-docker
36# ==================================================
37test-runner-vsprd-1-docker:
38    stage: test-runner-vsprd-1-docker
39    image: maven:3.5.3-jdk-8
40    tags:
41        - vsprd-1,docker
42    script:
43        - mvn --version

Vos Runners Gitlab sont désormais opérationnels et peuvent automatiser les tests en charge.

Création de notre plan de charge

Comme expliquer précédemment, avant de lancer un test en charge, il faut que vous ayez connaissance de l'environnement dans lequel vous allez travailler et ce que vous allez tester.

Pour notre part, c'est une application JAVA Spring Boot, avec une base de données MySQL. Cette application est une API qui recense les animaux au sein d'un refuge. La base est déjà peuplée d'environ 950 000 entrées.

Aujourd'hui en production, nous détectons une volumétrie constante de 5 requêtes par seconde sur l'API. Nous devons donc nous assurer de ne pas avoir de baisse de performance au fil des différentes modifications et des différentes releases. C'est pourquoi le continuous performance testing a toute son importance.

Pour notre plan de charge, nous utiliserons l'outil Apache JMeter 5.2.1.

Définition des différents types de plan de test

Nous allons ici définir plusieurs types de plan de charge. Un plan de test lorsqu'un développeur fera une modification sur la branche develop, donc un plan de test du type unitaire, orienté tests fonctionnels.

Deux plans de charge lors d'une nouvelle release de l'application. Un test en charge de type nominal, afin de s'assurer de la non-régression de la performance, un de type palier, afin d'avoir une idée du nombre de requêtes par seconde que pourra encaisser l'application lors de cas extrême, par exemple 2 ou 3 fois le volume nominal.

Pour pouvoir déterminer le nombre d'utilisateurs virtuels (VUs) qui contacteront l'API, nous devons nous assurer qu'un utilisateur ait le temps d'envoyer la requête et de recevoir la réponse, avant d'émettre une nouvelle requête.

Il est important de ne pas surcharger un même VU afin d'obtenir la volumétrie souhaitée. La solution à adopter est de multiplier le nombre de VUs afin d'avoir notre nombre de hits par seconde.

Dans notre cas, il arrive d'obtenir des temps de réponse MAX de l'ordre de 450ms. Nous savons donc que nous ne pouvons pas demander à un utilisateur virtuel de faire plus d'1 requête par seconde (par sécurité).

Les différents profils de charge sont les suivants :

  • unitaire
    • 1 requête par seconde / utilisateur
    • 1 utilisateur
    • Durée du palier : 60 secondes

  • nominal
    • 1 requête par seconde / utilisateur
    • 5 utilisateurs
    • Montée des utilisateurs : 50 secondes
    • Durée du palier : 30 minutes<figure

  • palier
    • 1 requête par seconde / utilisateur
    • 15 utilisateurs au total
    • Montée des utilisateurs : en 50 secondes par 5 à chaque palier
    • Durée du palier : 30 minutes
    • Nombre de paliers : 3

La répartition des requêtes HTTP sur les différents endpoints de l'API est la suivante :

  • GET - /v2/pets/{id} : 50%
  • POST - /v2/pets : 20%
  • GET - /v2/pets?status={status}&page={page} : 15%
  • PUT - /v2/pets/{id} : 10%
  • DELETE - /v2/pets/{id} : 5%

Création de notre premier plan de test

Maintenant que nous avons défini les différents profils de charge ainsi que les nombres de requêtes et utilisateurs virtuels, nous allons pouvoir nous atteler à la création de notre premier plan de test.

Notre plan de test se présente sous la forme suivante :

Nous allons utiliser différents types d'objets pour notre plan de charge.

Configuration

De type Variables pré-définis, Paramètres HTTP par défaut ou encore Gestionnaire d'entêtes HTTP, ces objets de configuration nous permettrons de paramétrer l'ensemble de notre plan de test. Nous pourrons le variabiliser, utiliser des variables par défaut etc.

Moteurs d'utilisateurs

Le Moteur d'utilisateurs nous permettra d'indiquer au plan de charge le nombre d’utilisateurs à lancer, sur quelle période, avec quelle ramp-up etc.

Afin de ne pas avoir plusieurs plans de test, ce qui serait fastidieux à maintenir, nous allons utiliser les plugins d'Apache JMeter mis à disposition, et plus particulièrement le plugin SteppingThreadGroup, qui nous permet de variabiliser notre profil de charge, de faire des tests de type palier et surtout de n'utiliser qu'un fichier .jmx.

Contrôleur de débit

Le Contrôleur de débit s'avère être un atout majeur pour nos tests de performance. Effectivement, il va nous permettre d'associer un pourcentage de la volumétrie globale sur nos endpoints. Comme je vous l'ai indiqué plus haut, nos relevés nous ont permis d'obtenir la répartition des requêtes sur notre API, que nous allons pouvoir appliquer lors de notre charge grâce au Contrôleur de débit.

Requête HTTP

L'objet Requête HTTP permet de créer, comme son nom l'indique, la requête qui sera envoyée vers le serveur applicatif lors de la charge. Il est possible de créer un body avec des variables, récupérées depuis un ficher source CSV. Ici les valeurs du protocole, de l'IP et du port ne sont pas spécifiées, car identiques à celles qui sont définies dans les Variables pré-définies et dans les Paramètres HTTP par défaut.

Source de données

Pour le lancement du test de performance, il est fort probable que vous utilisiez des jeux de données que vous aura fournies votre MOE. Ces données peuvent être extraites et utilisées depuis un fichier CSV. Il est possible de recycler, ou pas, ces données lorsqu'elles sont toutes utilisées.

** Attention, pensez RGPD et anonymisation des données personnelles de vos jeux de données s'il y en a. **

Assertion

L'Assertion, comme pour nos tests unitaires, nous permet de valider la réponse de la requête. Ici, nous testons simplement que le code HTTP de retour soit égal à 200. Nous pourrions tester lors d'un POST que le corps de la réponse contienne le nouvel id de l'animal ou encore son nom, ou son statut available.

Récepteurs

Les objets de type Récepteurs permettent, comme le nom l'indique, de collecter des données concernant le tir. Ils sont très utiles en mode GUI, lorsque vous commencez à alimenter votre plan de test et faites des tirs unitaires, vous pouvez observer les requêtes, leur format, le contenu du body et des réponses, avec les codes de retour etc.

Une chose très pratique des récepteurs est qu'ils permettent d'observer les temps de réponse de votre application, comme les MIN, les MAX, la MOYENNE, ou encore différents percentiles. Le nombre de hits par seconde est aussi une donnée que vous pouvez récupérer au travers des récepteurs.

Arbre de résultats

Rapport agrégé
>

Pensez à désactiver les objets de type Récepteurs, comme les Arbres de résultats, qui sont consommateurs en ressources et qui n'ont guère d'intérêt en ligne de commande.

Exécution des tests de performance

Maintenant que notre plan de charge est prêt et variabilisé, nous pouvons créer l'image Docker Apache JMeter qui nous permettra de faire notre test en charge. Pour cela, nous nous appuierons sur l'image Docker egaillardon/jmeter-plugins car elle contient l'ensemble des plugins nécessaires au bon fonctionnement de notre test en charge.

Il ne nous reste plus qu'à ajouter un stage performance-testing en appliquant les valeurs aux variables associées afin de lancer un test unitaire, ou un test en charge lors d'une nouvelle release.

Test de la chaîne automatisée

Pour tester notre application, rien de plus simple, nous allons dans un premier temps tester une modification du code source depuis la branche develop.

Nous voyons ici que le test unitaire JMeter se lance automatiquement comme souhaité. Lors d'une release, le test de type nominal se lance de même.

La validation pour le prochain stage se fait de même automatiquement. Vous constatez que nos SLAs concernent la volumétrie du tir (90% min), le nombre d'erreur (de 0), ainsi que les temps de réponse du 90th percentile de chaque endpoint (< 500ms).

D'ailleurs, les SLAs de la v0.0.1 n'ont pas été validés, c'est pourquoi le pipeline est en échec. La v0.0.2, avec l'ajout de l'index sur la colonne status, améliore les temps de réponse, permet la validation des SLAs et donc du pipeline.

!Pipeline Gitlab CI/CD

La validation de la performance n'est pas optimum, nous recommandons évidemment le regard avisé d'un métrologue ou d'un expert qui regardera plus en détails les différentes métriques relevées pour ces tests de performance avant un passage en production. Néanmoins, vous avez mis en œuvre une chaîne automatisée de type continuous performance testing, et elle peut évidemment être améliorée !

Tunning

Notre test de performance passé, nous pouvons désormais analyser les résultats.

Nous observons sur un tir de 30 minutes, que l'endpoint getByStatus augmente de façon significative en fonction du temps, et des différents INSERT dans la base de données.

>

Nous devons prendre une décision afin d'améliorer les accès à la base de données. L'objectif ici n'est pas de faire un tunning puissant et de fond. L'objectif est de montrer l'importance de l'étape de la performance au sein de la chaîne CI / CD.

Pour la version v0.0.2, nous avons opté pour une simple mise en place d'un index sur la colonne status de la table pet. Le résultat est sans appel :

>

Pour une même volumétrie, nous avons divisé par 4 les temps de réponses, mais nous les avons aussi stabilisés.

En ce qui concerne les métriques remontées côté Micrometer :

Tableau de bord Micrometer - Grafana

Et mémoire de la JVM :

Tableau de bord Mémoire JVM – Grafana

Aucune fuite mémoire n'est à déplorer.

Les résultats du test en charge de type palier sont encore plus parlants :

Résultats du test de performance palier avant tunning
>

Résultats du test de performance palier après tunning

Il est vrai que pour une volumétrie +300%, il faudra certainement améliorer la gestion des threads côté Java ou du nombre de connexions à la base de données pour obtenir des temps de réponse plus faibles encore.

Dans un prochain article, je vous parlerai de l'intégration d'un APM (Application Performance Management) au sein de votre développement (sous forme d'agent / serveur) afin d'avoir davantage d'informations sur le comportement de votre application et pouvoir appliquer un tuning plus fin encore.

Si vous le souhaitez, vous pouvez retrouver l'ensemble des sources utilisées pour cet article ici : https://gitlab.com/adrienpavone/jmeter-performance-testing-lab.

Merci pour votre lecture et à bientôt !

APavone