Comprendre k6 et le load test d’une API
Objectif
L’objectif de cet article est simple : apprendre à simuler de la charge sur une API existante avec k6, pour observer ses temps de réponse, ses erreurs éventuelles et son comportement quand plusieurs utilisateurs arrivent en même temps.
Mise en contexte
Imaginons une scène assez classique.
Une nouvelle fonctionnalité est publiée. Tout marche en staging. Les tests passent. L’API répond bien depuis un navigateur.
Puis le produit passe dans une publicité à la télévision, une newsletter part à plusieurs milliers de contacts, un post LinkedIn commence à tourner, ou un client important ouvre l’accès à tous ses utilisateurs d’un coup.
Et là, la vraie question arrive :
Qu’est-ce qui se passe si beaucoup d’utilisateurs arrivent en même temps ?
Une API peut être parfaitement correcte avec un utilisateur, mais fragile avec cent utilisateurs. Et parfois, le problème ne vient même pas du code HTTP. Il peut venir de PostgreSQL, du pool de connexions, du CPU, de la RAM, du réseau, ou d’une requête SQL qui devient trop coûteuse quand elle est appelée en boucle.
C’est quoi un load test ?
Un load test, c’est un test qui simule du trafic sur une application pour observer son comportement.
Le but n’est pas juste de “taper fort” sur une API. Le but est de répondre à des questions concrètes :
- combien de requêtes l’API peut traiter correctement ?
- à partir de quand les temps de réponse montent ?
- est-ce que des erreurs commencent à apparaître ?
- quelle ressource sature en premier ?
- est-ce que la base de données tient le rythme ?
Un load test bien fait ressemble plus à une répétition générale qu’à une attaque.
Le principe est simple : choisir un scénario réaliste, définir une charge, observer les métriques, puis augmenter progressivement.
Et si 10 millions d’utilisateurs arrivent ?
Quand une équipe dit “10 millions d’utilisateurs arrivent d’un coup”, il faut préciser ce que cela signifie vraiment.
Dans la vraie vie, ça ne veut pas toujours dire 10 millions de requêtes exactement à la même milliseconde. Ça veut souvent dire :
beaucoup de personnes actives
sur une période courte
avec des pics de trafic
et des actions répétées
Ce qui compte pour l’infrastructure, ce n’est pas seulement le nombre total d’utilisateurs inscrits. C’est surtout :
combien sont actifs en même temps
combien de requêtes ils génèrent par seconde
quelles routes ils appellent
combien de lectures et d’écritures partent vers la base
Exemple très simplifié :
10 000 utilisateurs actifs
1 action toutes les 5 secondes
= environ 2 000 actions par seconde
Si chaque action déclenche 3 appels HTTP, le trafic peut vite monter à :
6 000 requêtes HTTP par seconde
À ce niveau, le sujet n’est plus “est-ce que l’endpoint répond ?”. Le sujet devient la capacité réelle du système.
Un load test sert justement à reproduire ce genre de pression, mais de manière contrôlée. Le test ne commence pas directement par un volume massif. Il démarre petit, mesure le comportement, puis augmente par paliers.
10 VUs -> 50 VUs -> 100 VUs -> 500 VUs -> ...
À chaque palier, l’objectif est de vérifier si l’application reste stable.
Lire un résultat k6
Avant de regarder le code, voici le genre de résultat que k6 donne.
Sur cette première vue, trois indicateurs sont particulièrement utiles.
Le premier, c’est le HTTP Request Rate : combien de requêtes par seconde partent vers l’API. Ce chiffre donne une idée du débit généré par le scénario.
Le deuxième, c’est le HTTP Request Failed. L’objectif est de rester à 0. Si cette courbe monte, l’API commence à répondre avec des erreurs ou des timeouts.
Le troisième, c’est le HTTP Request Duration. C’est le graphe le plus important pour sentir la latence.
Dans le rapport final, les métriques à surveiller en priorité sont :
http_reqs
http_req_failed
http_req_duration p95
checks
vus_max
Sur ce POC, un run typique donne :
921 requêtes HTTP
307 itérations
0 erreur HTTP
checks 100%
p95 autour de quelques ms
10 VUs max
Pourquoi 921 requêtes ?
Parce qu’une itération du scénario fait trois appels HTTP :
307 iterations x 3 requêtes = 921 requêtes
Le point à retenir n’est pas “cette API est invincible”. Le point à retenir est plus précis :
Dans ce scénario local Docker, avec 10 utilisateurs virtuels, l’API répond correctement, lit et écrit dans PostgreSQL, et reste stable.
Le p95 en deux mots
S’il ne fallait garder qu’une métrique de latence, ce serait le p95.
p95, ça veut dire 95e percentile.
Si le rapport affiche :
p95 = 4 ms
cela signifie :
95% des requêtes ont répondu en 4 ms ou moins.
C’est plus utile que la moyenne, parce que la moyenne peut cacher des requêtes lentes.
Exemple :
avg = 20 ms
p95 = 800 ms
La moyenne semble acceptable, mais une partie des utilisateurs subit déjà de mauvaises performances.
En load testing, les percentiles p90, p95, p99 permettent de comprendre ce que vivent les utilisateurs au-delà du cas moyen.
Le contexte du test
Le but de cet article n’est pas de vous obliger à recréer exactement mon API. Le vrai objectif est plus simple : apprendre à brancher k6 sur une API existante, écrire un petit scénario réaliste, puis lire les résultats.
Pour l’exemple, j’utilise un POC volontairement petit :
- une API HTTP
- une base PostgreSQL
- un script k6 lancé dans le même réseau Docker
Le code complet du POC est disponible ici : github.com/pabiosoft/bun-k6-poc.
Dans mon cas, l’organisation utile ressemble surtout à ceci :
bun-k6-poc/
├── docker-compose.yml
├── api/
│ └── src/
│ ├── index.ts
│ ├── server.ts
│ ├── products/
│ │ └── ...
│ └── product-views/
│ └── ...
└── k6/
├── load-test.js
└── reports/
└── k6-report.html
Il y a seulement deux choses à retenir dans cette arborescence.
La première, c’est que api/ contient l’application testée. Ici, c’est une API Bun/Express qui expose quelques routes produit :
GET /health
GET /api/products
GET /api/products/:id
POST /api/product-views
La deuxième, c’est que k6/load-test.js contient le scénario de test. C’est ce fichier que vous adapterez le plus souvent dans vos propres projets.
Dans mon POC, docker-compose.yml démarre trois services :
db: PostgreSQL 16api: l’API Bun/Expressk6: le conteneur qui lance le test de charge
Dans Docker Compose, k6 n’appelle pas localhost. Il appelle le nom du service API :
http://api:3000
Cette valeur est passée au conteneur k6 avec la variable :
BASE_URL=http://api:3000
Le chemin réel du test est donc :
conteneur k6 -> service api -> service db
Ce point est important, mais il dépend de votre contexte.
Si votre API existe déjà, vous n’avez pas besoin de reprendre toute cette arborescence. Vous pouvez simplement garder un fichier load-test.js, puis changer l’URL appelée par k6.
Exemple si votre API est déjà en ligne :
BASE_URL=https://api.votre-domaine.com
Exemple si votre API tourne dans le même Docker Compose que k6 :
BASE_URL=http://api:3000
Exemple si k6 tourne en local sur votre machine et que votre API tourne aussi en local :
BASE_URL=http://localhost:3000
Attention à un détail important : localhost dépend toujours de l’endroit où k6 tourne. Si k6 tourne dans un conteneur Docker, localhost désigne le conteneur k6 lui-même, pas forcément votre machine. Dans ce cas, il faut utiliser le nom du service Docker Compose, comme http://api:3000, ou une adresse adaptée à votre environnement.
Le plus important est donc de savoir où tourne k6 et où tourne l’API.
Pour tester votre propre API, le minimum ressemble à ceci :
- choisir une ou deux routes représentatives
- définir
BASE_URL - écrire le parcours dans
load-test.js - ajouter des
checkspour vérifier les réponses - lancer d’abord avec peu de VUs
Il faut aussi éviter un piège classique : ne commencez pas par tester une API de production avec une grosse charge. Démarrez petit, prévenez l’équipe si l’environnement est partagé, et vérifiez que le scénario ne crée pas de données gênantes en base.
Dans mon exemple, le scénario écrit des vues produit. Sur votre API, une route POST peut créer des commandes, envoyer des emails ou déclencher des traitements. Avant de la mettre dans un load test, vérifiez toujours ses effets de bord.
Le scénario testé
Le scénario ne se limite pas à un simple /ping.
Le use case choisi est un mini catalogue produits :
GET /api/products
GET /api/products/:id
POST /api/product-views
Les deux premières routes lisent dans PostgreSQL. La troisième écrit une vue produit, comme pourrait le faire un site e-commerce quand un utilisateur consulte une fiche produit.
Le test couvre donc :
- une lecture liste
- une lecture détail
- une petite écriture
- le réseau entre conteneurs
- PostgreSQL derrière l’API
Le fichier load-test.js
Le coeur du test est dans k6/load-test.js. On va le construire progressivement.
La première étape consiste à importer les outils k6 dont on a besoin :
import http from "k6/http";
import { check, sleep } from "k6";
http sert à envoyer les requêtes. check sert à vérifier les réponses. sleep sert à ajouter une pause entre deux itérations.
Ensuite, on définit la charge avec options :
export const options = {
stages: [
{ duration: "10s", target: 10 },
{ duration: "20s", target: 10 },
{ duration: "10s", target: 0 },
],
thresholds: {
http_req_failed: ["rate<0.01"],
http_req_duration: ["p(95)<500"],
},
};
Ce profil veut dire :
0 -> 10 VUs pendant 10 secondes
10 VUs pendant 20 secondes
10 -> 0 VUs pendant 10 secondes
Un VU est un utilisateur virtuel.
Les thresholds posent les règles de succès :
moins de 1% d'erreurs HTTP
95% des requêtes sous 500 ms
On ajoute ensuite l’URL de l’API à tester :
const baseUrl = __ENV.BASE_URL || "http://localhost:3000";
Cette ligne permet de lancer le même script contre plusieurs environnements.
Par exemple :
BASE_URL=http://api:3000
BASE_URL=http://localhost:3000
BASE_URL=https://api.votre-domaine.com
Après cela, on écrit le scénario exécuté par chaque utilisateur virtuel :
GET /api/products?limit=20
GET /api/products/:id
POST /api/product-views
sleep 1s
Dans k6, ce scénario se met dans la fonction default :
const productIds = [1, 2, 3, 4, 5];
export default function () {
const products = http.get(`${baseUrl}/api/products?limit=20`);
const productId = productIds[__ITER % productIds.length];
const product = http.get(`${baseUrl}/api/products/${productId}`);
const productView = http.post(
`${baseUrl}/api/product-views`,
JSON.stringify({
productId,
sessionId: `vu-${__VU}`,
source: "k6",
}),
{
headers: { "Content-Type": "application/json" },
},
);
sleep(1);
}
Ici, __ITER est le numéro de l’itération en cours. Il sert à alterner entre plusieurs produits. __VU est l’identifiant de l’utilisateur virtuel. Il permet de générer un sessionId simple pour le test.
Le sleep(1) évite de transformer le test en tir continu irréaliste. Dans une vraie application, un utilisateur charge, lit, clique, attend, puis continue.
Si vous testez votre propre API, c’est surtout cette fonction default que vous allez adapter. Vous remplacez les routes produit par vos propres routes, puis vous gardez la même logique :
- faire une requête
- vérifier la réponse
- enchaîner sur l’action suivante
- ajouter une pause réaliste
Les checks
k6 ne doit pas seulement envoyer du trafic. Il doit aussi vérifier que l’application répond correctement.
Pour cela, on ajoute des check après les requêtes importantes.
Exemple après la liste des produits :
check(products, {
"products list returns 200": (response) => response.status === 200,
"products list is not empty": (response) => response.json("data").length > 0,
});
Exemple après le détail produit :
check(product, {
"product detail returns 200": (response) => response.status === 200,
"product detail has id": (response) => response.json("data.id") === productId,
});
Exemple après l’écriture de la vue produit :
check(productView, {
"product view returns 201": (response) => response.status === 201,
"product view has id": (response) => Number(response.json("data.id")) > 0,
});
Dans ce POC, les checks vérifient donc :
products list returns 200
products list is not empty
product detail returns 200
product detail has id
product view returns 201
product view has id
Dans le fichier final, ces checks sont placés directement après chaque requête. Cela permet de savoir quelle étape du parcours commence à échouer quand la charge augmente.
Une API qui répond vite avec des erreurs, ce n’est pas une API en bonne santé.
Si vous testez votre propre API, gardez la même idée. Ne vérifiez pas seulement le status HTTP. Vérifiez aussi un petit élément métier dans la réponse :
check(response, {
"returns 200": (response) => response.status === 200,
"has expected data": (response) => response.json("data") !== null,
});
Le but est simple : s’assurer que l’API répond vite, mais aussi qu’elle répond correctement.
Lancer le test
Si vous utilisez le POC Docker Compose de l’article, l’API et PostgreSQL sont démarrés avec :
docker compose up --build api
k6 est ensuite lancé dans un autre terminal :
docker compose --profile test up k6
Dans ce cas, BASE_URL vaut http://api:3000, parce que k6 tourne dans le même réseau Docker Compose que l’API.
Si vous voulez tester une API déjà existante, vous pouvez lancer k6 directement contre son URL.
Avec k6 installé sur votre machine :
BASE_URL=https://api.votre-domaine.com k6 run k6/load-test.js
Si votre API tourne en local :
BASE_URL=http://localhost:3000 k6 run k6/load-test.js
Et si vous préférez lancer k6 avec Docker, sans installer k6 localement :
docker run --rm \
-e BASE_URL=https://api.votre-domaine.com \
-v "$PWD/k6:/scripts" \
grafana/k6:0.54.0 run /scripts/load-test.js
Le principe reste le même : BASE_URL indique à k6 quelle API tester, et load-test.js décrit le parcours utilisateur.
Dans la configuration Docker Compose du POC, le dashboard k6 est activé avec les variables K6_WEB_DASHBOARD. Pendant que le test tourne, il est accessible ici :
http://localhost:5665
Important : ce dashboard est live. Quand k6 termine, le conteneur s’arrête, donc le dashboard disparaît.
Pour consulter les résultats après coup, le rapport HTML généré par cette configuration Docker est disponible ici :
k6/reports/k6-report.html
Si vous lancez k6 directement avec k6 run, vous aurez déjà le résumé dans le terminal. Le dashboard et l’export HTML demandent une configuration supplémentaire. Pour un premier test, le résumé terminal suffit souvent.
La suite logique
Une fois ce premier test validé, la charge peut être augmentée progressivement :
10 VUs
50 VUs
100 VUs
200 VUs
À chaque palier, les métriques à observer sont :
- le taux d’erreur
- le p95
- le p99
- les requêtes par seconde
- le CPU de l’API
- la RAM
- PostgreSQL
- le pool de connexions
Le but n’est pas juste de faire monter les chiffres. Le but est de trouver le premier point de rupture.
Exemple :
10 VUs -> p95 4 ms, 0 erreur
50 VUs -> p95 30 ms, 0 erreur
100 VUs -> p95 150 ms, 0 erreur
200 VUs -> p95 1200 ms, erreurs DB
À ce moment-là, le test commence à révéler le comportement réel du système.
Conclusion
k6 n’est pas seulement un outil pour “envoyer beaucoup de requêtes”.
C’est un outil pour écrire un scénario, poser des seuils, observer les métriques et comprendre comment une API réagit sous charge.
Dans ce POC, le plus important n’est pas le chiffre exact du p95. Le plus important, c’est la démarche :
un scénario réaliste
une infra reproductible
des checks fonctionnels
des thresholds clairs
un rapport lisible
À partir de là, la discussion sur la performance repose sur des données, pas sur des impressions.