Mettre à l'épreuve les performances de son API avec k6


Une des étapes importantes dans le cycle de vie d’une application est de vérifier qu’elle supporte une certaine charge d’utilisation et que son comportement/sa rapidité de réponse n’est pas altéré par un usage intensif.

K6 est un outil gratuit et open-source qui se propose d’aider les développeurs à détecter des régressions de performance afin de construire des systèmes robustes.
L’outil nous permet en outre de faire du smoke-testing, du load-testing, du stress-testing ou encore du soak-testing d’API.

Cet outil peut être utilisé pour vérifier le comportement de l’application lors d’une montée en charge subite (spike par exemple) mais peut également servir de moniteur de performance si l’on se sert des métriques pour analyser continuellement le comportement d’une application en production.

K6 comprend une CLI ainsi qu’une API en JavaScript ES6 qui nous permettra d’écrire les différents scripts d’exécution de tests, il est basé sur un moteur en Go et atteint des performances assez intéressantes lorsqu’on le compare à d’autres outils comme Gatling ou JMeter. Je vous recommande de lire cet article publié sur le blog de k6 qui propose un benchmark assez détaillé.

Dans cet article, je me propose d’introduire cet outil de manière à présenter divers aspects utiles pour les différents types de tests.

J’ai préparé un repository GitHub possédant plusieurs branches que j’utiliserai afin de présenter les différents concepts par le biais d’exemples.



Mise en place


L’installation est relativement simple et est détaillée pour différents OS et distributions sur la documentation officielle.

Il est maintenant temps d’avoir une API fonctionnelle à disposition que nous pourrons tester ensemble dans la suite de l’article. Pour cela, j’ai créé un simple serveur Node.js qui se comporte comme suit :

  • un endpoint GET /slow qui répond 200 ou 404 (plus rarement) après avoir fait un calcul qui dure entre 50 ms et 250 ms.
  • un endpoint GET /fast qui répond 200 ou 404 (plus rarement) après avoir fait un calcul qui dure entre 20 ms et 50 ms.

Ci-dessous un snippet du code qui représente notre API, que l’on peut aussi trouver sur le repository GitHub mentionné plus haut :

// app.js

const express = require("express");
const app = express();
const port = 3000;
 
app.get("/slow", (req, res) =>
 setTimeout(() => handleResponse(res), getRandomBetween(50, 250))
);
 
app.get("/fast", (req, res) =>
 setTimeout(() => handleResponse(res), getRandomBetween(20, 50))
);
 
const handleResponse = (res) => isRessourceFound() ? res.status(200).send() : res.status(404).send();
 
const isRessourceFound = () => Math.random() <= 0.9;
 
const getRandomBetween = (min, max) =>
 min + Math.floor(Math.random() * Math.floor(max));
 
app.listen(port);

Vous pouvez d’ores et déjà lancer le serveur en local si vous souhaitez suivre l’article avec les exemples fonctionnels de votre côté :

$ node app.js



Première introduction aux rapports k6


Nous allons maintenant voir comment créer nos premiers rapports d’analyse de performance k6 sur les deux endpoints présentés dans la partie précédente.

Voici les deux scripts k6 qui sont également présents sur GitHub :

// k6/slow-endpoint.js

import http from 'k6/http';
 
export default function slow() {
 http.get('http://localhost:3000/slow'); 
}

// k6/fast-endpoint.js

import http from 'k6/http';
 
export default function fast() {
 http.get('http://localhost:3000/fast');
}

Ces scripts sont très simples mais procurent néanmoins un rapport assez détaillé lorsqu’ils sont exécutés.

Pour ce faire, il suffit de lancer ces scripts via la CLI de k6 avec certaines options :

$ k6 run --vus 3 --iterations 50 k6/slow-endpoint.js

Cette première commande permet d’effectuer 50 appels sur le endpoint /slow partagés entre 3 virtual users (vus). Le rapport affiché dans le terminal à l’issue de cette commande est le suivant :

On peut voir sur ce rapport assez complet plusieurs informations comme la synthèse de l'exécution du script en haut dans la section scenarios, nous reviendrons plus en détails sur les scenarios dans une prochaine section.

Plus bas, nous pouvons voir le cœur du rapport ; les différentes métriques par défaut fournies par k6 présentent diverses informations qui peuvent nous intéresser. Parmi elles, la métrique http_req_duration nous permet de connaître la durée totale de la requête avant d’obtenir une réponse de la part du serveur, et ce sous diverses formes :

  • avg : temps moyen
  • min : temps minimum
  • med : temps médian
  • max : temps maximum
  • p(90) : temps maximum pour les 90% de requêtes ayant pris le moins de temps, on peut également appeler cette valeur 90-percentile
  • p(95) : temps maximum pour les 95% de requêtes ayant pris le moins de temps, on peut également appeler cette valeur 95-percentile


Ces informations sont bien cohérentes avec le temps d’exécution de notre endpoint (entre 50ms et 250ms). On peut noter que certaines requêtes prennent plus que 250ms (le max sur 50 itérations est à 295ms) mais c’est tout à fait normal. Cela peut être justifié par le fait que la machine sur laquelle je fais tourner le serveur est également utilisée par d’autres processus qui l’empêchent de répondre aussi rapidement qu’un serveur dédié sur un Cloud par exemple.

En outre, ce qui nous intéresse ici est plutôt la tendance générale visible sur les rapports ainsi que leur cohérence avec les endpoints étudiés.

Voici l’exécution du script pour le endpoint /fast avec des options différentes :

$ k6 run --vus 50 --duration 1s k6/fast-endpoint.js

Ici, on crée 50 utilisateurs virtuels qui feront des requêtes continuellement pendant 1 seconde. Le résultat est le suivant :

Avec cette configuration-ci, les utilisateurs virtuels ont réussi à effectuer 1062 requêtes en 1 seconde, comme on peut le voir indiqué au-dessus du rapport.

On peut voir que le contenu du rapport n’est pas le même, avec des valeurs bien plus faibles pour le p(90) du http_req_duration par exemple, ce qui est tout à fait normal compte tenu du fait que nous étudions l’endpoint le plus rapide (20ms à 50ms de temps d’exécution théorique).



Utiliser des métriques customisées


Dans cette prochaine partie, nous allons voir comment créer nos propres métriques dans les scripts k6 afin de customiser les rapports générés.

Par défaut, k6 génère un rapport avec certaines métriques comme nous l’avons vu précédemment, mais il peut être pratique, dans de nombreux cas, d'avoir ses propres métriques. Pour cela k6 propose divers types de métriques “custom” qui sont les suivantes :

  • Le “Counter” : Le compteur permet d’additionner une suite de valeurs données
  • La “Gauge” : La jauge permet de stocker le min, max et la dernière valeur ajoutée
  • Le “Rate” : Le taux mesure le pourcentage de valeurs ajoutées qui ne sont pas nulles
  • La “Trend” : La tendance donne des statistiques sur les différentes valeurs données (min, max, moyenne, percentiles). C’est la même forme que l’on a vu pour http_req_duration.


Afin de comprendre comment ces métriques personnalisées fonctionnent, nous allons mettre en place un taux pour mesurer le pourcentage d'erreurs 4xx que l’on récupère sur un test du endpoint /fast.
Pour ce faire, il suffit de mettre à jour le script comme suit :

// k6/fast-endpoint.js

import http from 'k6/http';
import { Rate } from 'k6/metrics';
 
const errorRate = new Rate('error_rate');
 
export default function fast() {
 const res = http.get('http://localhost:3000/fast');
 errorRate.add(res.status >= 400);
}


Vous pouvez noter que l’on stocke le résultat de la requête dans une constante res, cela nous permet ensuite d’accéder à plusieurs informations sur l’exécution de cette requête, comme par exemple le status de la réponse, mais aussi le body, la durée, les headers, etc… Vous pourrez trouver plus d’informations sur les différentes propriétés de l’objet de réponse sur la documentation officielle.

Si l’on exécute à nouveau le script :

$ k6 run --vus 50 --duration 1s k6/fast-endpoint.js


On obtient cette fois-ci le rapport suivant :


On peut voir qu’une nouvelle ligne s’ajoute parmi les métriques classiques : error_rate.

On peut noter qu’on obtient environ 10% d’erreurs, ce qui est justifié par le comportement du serveur :

// app.js
 
...
const handleResponse = (res) => isRessourceFound() ? res.status(200).send() : res.status(404).send();
 
const isRessourceFound = () => Math.random() <= 0.9;
...



Utiliser des options


Jusqu’à présent, nous avons exécuté nos scripts k6 en passant les différentes options via la CLI en utilisant des paramètres (--vus , --duration, etc…). Il est possible de créer un fichier de configuration que l’on nommera config.json afin qu’il contienne le paramétrage de nos tests.

// k6/config.json

{
  "vus": 5,
  "duration": "10s"
}

Ce fichier de configuration très simple peut maintenant être directement passé en paramètre lors de l’exécution du script k6 de la manière suivante :

$ k6 run --config k6/config.json k6/fast-endpoint.js


Les options utilisées jusqu’à présent sont très simples, mais il en existe bien d’autres comme --userAgent, --stages, etc. Une liste exhaustive des options possibles est disponible dans la documentation de k6.



Mettre en place des scénarios


Nous allons voir dans cette partie qu’il est possible de configurer nos tests de charge plus en profondeur en tirant parti d’une fonctionnalité de k6, les scénarios.
Grâce aux scénarios, nous allons pouvoir paramétrer le comportement des utilisateurs virtuels vis-à-vis de l’application que l’on teste.

On pourrait par exemple indiquer que pendant un certain temps, seulement quelques utilisateurs font des requêtes, puis graduellement le nombre d’utilisateurs augmente, puis redescend.
L’écriture de scénarios permet de confronter les APIs testées à des situations proches du réel.
Nous pouvons à partir de maintenant réunir nos deux scripts d’appels aux endpoints (jusqu’à présent k6/fast-endpoint.js et k6/slow-endpoint.js) :

// index.js
 
import http from 'k6/http';
import { Rate } from 'k6/metrics';
 
const errorRate = new Rate('error_rate');
 
export function fast() {
 const res = http.get('http://localhost:3000/fast');
 errorRate.add(res.status >= 400);
}
 
export function slow() {
 http.get('http://localhost:3000/slow');
}


Voici la nouvelle configuration avec quelques scénarios paramétrés :

// k6/config.json
 
{
 "scenarios": {
   "example_scenario_1": {
     "executor": "shared-iterations", // partage les itérations entre tous les utilisateurs virtuels
     "startTime": "5s", // attend 5 secondes avant de lancer les requêtes
     "gracefulStop": "5s", // donne 5 secondes de plus aux requêtes avant de forcer leur arrêt.
     "env": { "EXAMPLEVAR": "testing" }, // intègre une variable d’environnement
     "tags": { "example_tag": "testing" }, // intègre un tag
     "vus": 10, // simule 10 utilisateurs virtuels
     "iterations": 200, // les vus partagent 200 itérations
     "maxDuration": "10s", // limite la durée du test à 10s
     "exec": "slow" // se base sur la fonction du script nommée “slow”
   },
   "example_scenario_2": {
     "executor": "per-vu-iterations", // sépare les itérations pour chaque utilisateur
     "vus": "5", // simule 5 utilisateurs virtuels
     "iterations": "30", // les vus ont chacun 200 itérations
     "exec": "slow" // se base sur la fonction du script nommée “slow”
   },
   "example_scenario_3": {
     "executor": "constant-arrival-rate", // un nombre fixe d’itérations exécutées dans une période définie (cf duration)
     "rate": 200, // nombre d’itérations par seconde
     "duration": "10s", // durée fixée à 10 secondes
     "preAllocatedVUs": 50, // la taille du pool d’utilisateurs virtuels par défaut
     "maxVUs": 100, // si le nombre de preAllocatedVUs n’est pas suffisant, il est possible d’aller jusqu’à 100
     "exec": "fast" // se base sur la fonction du script nommée “fast”
   }
 }
}


Avant d’aller plus loin, vous pouvez trouver ci-dessous des références pour mieux comprendre certains concepts utilisés dans la configuration ci-dessus :


Avec cette configuration, il est maintenant possible de lancer le script comme ceci :

$ k6 run --config k6/config.json k6/index.js


On obtient alors le rapport suivant :


On peut noter plusieurs choses intéressantes sur ce rapport, comme le compte rendu d’exécution des 3 scénarios :


Un des désavantages de cette méthode est que les métriques par défaut de k6 sont mutualisées pour tous les scénarios. Cela nous donne des informations moins intéressantes comme par exemple http_req_duration qui mêle la durée des requêtes envoyées sur /fast et /slow.

Pour pallier ce problème, il est recommandé de créer ses propres métriques comme nous l’avons déjà vu.
Lors du lancement du script, on peut voir que example_scenario_1 est en “waiting” pendant les 5 premières secondes, c’est le “startTime” défini de la configuration :


On peut également voir que example_scenario_2 voit ses VUs osciller entre 8 et 15, il n’a pas réellement besoin d’en générer plus (malgré un pool de 50) pour arriver à faire 200 iters/s.

Pour conclure cette partie, il est important de comprendre que tous les scénarios ne sont pas nécessairement intéressants, mais la possibilité de customisation doit vous permettre de créer des scénarios qui ont du sens pour vos cas d’études.
Vous pourrez trouver plus d’informations sur l’utilisation des scénarios dans la documentation officielle.



Profiter d’indicateurs grâce aux thresholds


Une des utilisations que l’on peut faire de k6 est de l’intégrer comme étape d’un pipeline de CI/CD afin de vérifier que les performances de l’application sont correctes lorsque l’on implémente une nouvelle fonctionnalité, par exemple.
Ainsi, il serait intéressant de voir l’étape du pipeline échouer selon certains critères que l’on pourrait définir dans la configuration.
C’est ce que l’on va pouvoir faire grâce aux tags et aux thresholds.

Pour commencer, modifions k6/index.js afin de donner un tag à chaque méthode :

// index.js
 
import http from 'k6/http';
import { Rate } from 'k6/metrics';
 
const errorRate = new Rate('error_rate');
 
export function fast() {
 const res = http.get('http://localhost:3000/fast', {
   tags: { type: 'fast_endpoint' },
 });
 errorRate.add(res.status >= 400);
}
 
export function slow() {
 http.get('http://localhost:3000/slow', {
   tags: { type: 'slow_endpoint' },
 });
}


Ainsi, il est possible de faire référence à un appel via son tag dans la configuration, cela nous permet de mettre à jour la configuration de la manière suivante :

// k6/config.json
 
{
 "scenarios": { ... },
 "thresholds": {
   "http_req_duration{type:fast_endpoint}": [ 
     { "threshold": "p(95) < 80", "abortOnFail": true } // Le test s’arrête si ce threshold est KO
   ],
   "http_req_duration{type:slow_endpoint}": [ 
     { "threshold": "p(90) < 265", "abortOnFail": false } // Le test continue même si ce threshold est KO
   ]
 }
}


Comme on peut le voir, nous avons ajouté deux thresholds, un premier qui créera une erreur si le p(95) de http_req_duration sur le tag fast_endpoint dépasse 80ms, et un autre qui créera une erreur si le p(90) de http_req_duration sur le tag slow_endpoint dépasse 265ms.

Si on lance le script, on obtient le rapport suivant :


On peut directement voir les 2 nouveaux thresholds dans le rapport sous la forme d’une métrique additionnelle de type Trend.

Le premier threshold est OK car le p(95) du fast_endpoint est inférieur à 80ms :


Le deuxième threshold est KO car le p(90) du slow_endpoint est supérieur à 265ms :


Ce qui génère à la fin du test une erreur :


Ceci est une simple ébauche de ce qu’il est possible de faire avec les thresholds, mais au même titre que les scénarios, il est possible de customiser en profondeur cette fonctionnalité afin de rendre votre script parfaitement adapté aux cas que vous souhaitez tester.



Pour aller plus loin


Nous avons pu voir ensemble le fonctionnement général de k6 ainsi que quelques fonctionnalités qui pourraient vous être utiles pour mettre en place vos tests de performances !

Bien entendu, cet article a pour but de présenter certains concepts de k6 sans aller particulièrement dans les détails, je vous recommande de creuser la documentation afin d’en apprendre plus sur cet outil.
Je vous conseille notamment de lire la partie qui traite du type d’output pour les rapports k6, ce qui vous permettra éventuellement d’afficher vos rapports directement dans le cloud sur des plateformes de monitoring.
Vous pouvez également consulter cette partie, qui donne plus d’informations sur l’automatisation des tests de performances avec k6.

Pour finir, n’hésitez pas à faire un tour sur la page github de k6 afin de mettre une étoile et éventuellement de contribuer au projet !