Aller au contenu

JMeter

PerfShop intègre Apache JMeter sous la forme de deux services Docker complémentaires qui forment ensemble une plateforme de tir de charge pilotable depuis le navigateur :

  • perfshop-jmeter — un container justb4/jmeter:5.5 qui reste en permanence idle. Il ne démarre jamais de tir tout seul ; il attend qu'un ordre d'exécution lui parvienne via docker exec.
  • perfshop-jmeter-ui — une application Node.js / Express qui fournit une interface web de pilotage : parcours d'arborescence des scénarios, éditeur JMX, lancement de tirs, streaming des logs, récupération des rapports HTML, et exposition des métriques live.

Le tandem permet à un étudiant de lancer un tir complet sans jamais ouvrir un terminal, tout en conservant la puissance et la compatibilité totale de JMeter standalone.

Source de vérité

Cette page est tirée des blocs perfshop-jmeter et perfshop-jmeter-ui des fichiers docker-compose.desktop.yml / docker-compose.build.yml, du contenu du dossier jmeter/ (scenarios/, scripts/, plugins/), et des 9 modules Node du dossier jmeter-ui/src/ (server.js, config.js, i18n.js, docker.js, security.js, auth.js, jmx-parser.js, scenarios.js, jmeter.js).

Architecture du tandem

flowchart TB
  subgraph browser["Navigateur (formateur ou étudiant)"]
    UI["UI JMeter<br/>http://localhost:3005"]
  end

  subgraph jmui["perfshop-jmeter-ui<br/>(Node 20, Express, port 3005)"]
    direction TB
    SRV["server.js"]
    AU["auth.js<br/>(session 8h + CSRF + rate limit)"]
    SC["scenarios.js<br/>(arborescence, CRUD)"]
    JP["jmx-parser.js<br/>(édition des .jmx)"]
    JM["jmeter.js<br/>(lancement + status + logs)"]
    DK["docker.js<br/>(socket Docker)"]
    SRV --> AU
    SRV --> SC
    SRV --> JP
    SRV --> JM
    JM --> DK
  end

  subgraph jm["perfshop-jmeter<br/>(justb4/jmeter:5.5, idle)"]
    ENT["entrypoint:<br/>cp /plugins/*.jar ... && tail -f /dev/null"]
    JAR["jmeter.jar"]
    PP[/"Prometheus plugin<br/>:9270 (pendant un tir)"/]
    ENT --- JAR
    JAR --- PP
  end

  subgraph vols["Volumes partagés<br/>(bind mounts)"]
    SCEN["./jmeter/scenarios"]
    SCRIPTS["./jmeter/scripts"]
    RESULTS["./jmeter/results"]
    PLUGS["./jmeter/plugins"]
    LOGS["./jmeter/logs"]
  end

  subgraph obs["Observabilité"]
    PROM["prometheus<br/>(job jmeter, scrape :9270)"]
    LOKI["loki<br/>(tail jmeter.log)"]
    GRAF["grafana<br/>(dashboard perfshop-jmeter-live)"]
  end

  DOCK[/"/var/run/docker.sock"/]

  UI -->|HTTPS| SRV
  DK -->|monte| DOCK
  DOCK -.->|docker exec perfshop-jmeter<br/>jmeter -n -t ... -l ...| JAR

  jm --- vols
  jmui --- vols

  PROM -->|scrape 5s| PP
  LOKI -->|tail| LOGS
  PROM --> GRAF
  LOKI --> GRAF

Le point clé : perfshop-jmeter-ui ne lance pas JMeter dans son propre container. Il exploite le socket Docker monté en bind mount pour exécuter docker exec perfshop-jmeter jmeter -n -t .... Cela garantit que les tirs tournent dans l'image JMeter officielle, avec toutes ses dépendances et son classpath déjà configurés, et sans pénaliser les performances de l'UI avec la JVM JMeter.

perfshop-jmeter — container permanent idle

Configuration Docker Compose

Clé Valeur
Image justb4/jmeter:5.5 (variable JMETER_IMAGE)
container_name perfshop-jmeter
Réseau perfshop-network
restart unless-stopped
Entrypoint /bin/sh -c "cp /plugins/*.jar /opt/apache-jmeter-5.5/lib/ext/ 2>/dev/null \|\| true && echo '[JMeter] Plugins installés' && tail -f /dev/null"

Pourquoi l'entrypoint tail -f /dev/null ?

L'image justb4/jmeter:5.5 est conçue pour exécuter un seul tir puis se terminer — c'est son usage classique en CI. PerfShop a besoin de l'inverse : un container qui reste démarré en permanence pour qu'on puisse :

  1. Copier les plugins une seule fois au démarrage (via cp /plugins/*.jar ...)
  2. Lancer des tirs à la demande via docker exec sans payer le coût d'un docker run complet (téléchargement d'image, création de container, montage des volumes)
  3. Conserver l'état entre les tirs (fichiers temporaires, logs agrégés)

Le tail -f /dev/null est l'astuce standard pour garder un container Docker vivant indéfiniment sans consommer de CPU.

Plugins copiés au démarrage

Le dossier ./jmeter/plugins est bind-mounté sur /plugins et contient un plugin externe :

Plugin Fichier Rôle
JMeter Prometheus Listener jmeter-prometheus-plugin-0.6.0.jar Expose les métriques d'un tir JMeter en cours sur un endpoint HTTP Prometheus (port 9270 par défaut)

Le plugin est copié dans /opt/apache-jmeter-5.5/lib/ext/ au démarrage du container. Une fois le container en place, JMeter charge automatiquement le plugin au lancement de chaque tir — il devient utilisable dans les scénarios via l'élément Prometheus Listener côté JMeter.

Volumes bind-mountés

Bind mount Point de montage Rôle
./jmeter/scenarios /scenarios Fichiers JMX de scénarios (sources versionnables)
./jmeter/scripts /scripts Scripts Groovy/Java utilisés par certains samplers
./jmeter/results /results Sorties des tirs (fichiers CSV/JTL + rapports HTML)
./jmeter/plugins /plugins Plugins externes à injecter dans lib/ext/
./jmeter/logs /jmeter-logs Logs JMeter (lus par Promtail pour Loki)

Les mêmes dossiers sont aussi montés côté perfshop-jmeter-ui (sous /app/scenarios, /app/scripts, /app/results, /app/plugins) pour que l'UI puisse lire et écrire les mêmes fichiers que JMeter.

Variables d'environnement

Variable Valeur par défaut Rôle
HEAP -Xms256m -Xmx${JMETER_MAX_RAM:-512}m -XX:MaxMetaspaceSize=128m Options JVM passées à JMeter
JMETER_CUSTOM_PLUGINS_FOLDER /plugins Dossier scanné par JMeter pour les plugins supplémentaires

Dimensionnement de la heap JMeter

La variable JMETER_MAX_RAM contrôle le -Xmx de JMeter. 512 Mo suffisent pour un tir de quelques centaines de threads virtuels ; au-delà, il faut monter cette valeur dans le .env avant le up -d pour éviter les OutOfMemoryError côté injecteur (qui seraient alors injustement attribués au SUT).

Catalogue de scénarios livrés

Le dossier ./jmeter/scenarios contient plusieurs fichiers JMX prêts à l'emploi, organisés en sous-dossiers thématiques :

Scénario vUsers max recommandés Usage
simple-api-products.jmx Tir minimal sur l'API produits — exemple et smoke test
baseline-nominal.jmx Tir de référence nominal avant injection de chaos
parcours-metier-nominal.jmx 500 Parcours métier complet T01–T10 (login → commande) sans chaos scripting
E2E/parcours-complet-e2e.jmx 500 Parcours end-to-end complet, durée minimale 2h
Fonctionnel/ Scénarios de validation fonctionnelle courts (sans montée en charge)
Master/ Scénarios maîtres combinant plusieurs modules via Test Fragments

Capacité de charge

Les scénarios parcours-metier-nominal.jmx et E2E/parcours-complet-e2e.jmx supportent jusqu'à 500 vUsers simultanés grâce aux 500 comptes de charge dédiés (load1load500). Au-delà de 100 vUsers, penser à ajuster JMETER_MAX_RAM dans le .env.

Fichier de données utilisateurs — jmeter/scripts/users.csv

Le fichier users.csv est le jeu de données d'authentification partagé par tous les scénarios qui font appel à POST /api/auth/login. Il est monté dans le container JMeter sous /scripts/users.csv.

Format (501 lignes : 1 header + 500 données) :

userEmail,userPassword,userName
load1@perfshop.com,LoadTest1!,Load User1
load2@perfshop.com,LoadTest2!,Load User2
...
load500@perfshop.com,LoadTest500!,Load User500

Paramétrage JMeter dans les JMX :

Propriété CSVDataSet Valeur Raison
filename /scripts/users.csv Chemin Docker (bind mount ./jmeter/scripts/scripts)
shareMode shareMode.all Tous les threads partagent un pointeur unique — chaque thread reçoit un compte distinct, zéro collision
ignoreFirstLine true Saute le header userEmail,userPassword,userName
recycle true Repart au début une fois les 500 lignes épuisées
stopThread false Un thread ne s'arrête jamais faute de compte disponible

shareMode.all vs shareMode.thread

Avec shareMode.thread (ancienne valeur), chaque thread relisait le CSV depuis le début indépendamment — deux threads pouvaient utiliser le même compte simultanément, causant des erreurs de cooldown anti-doublon côté backend. Avec shareMode.all, le pointeur avance séquentiellement pour l'ensemble des threads : account N → thread N, sans conflit.

Source des comptes : migration Flyway V2__data_users.sql (présente dans migration-fr/ et migration-en/). Les mots de passe sont hashés BCrypt strength 10 en base ( préfixe $2b$10$). Le CSV contient les mots de passe en clair — nécessaire pour que JMeter puisse les envoyer au POST /api/auth/login qui effectue la comparaison BCrypt côté backend.

perfshop-jmeter-ui — Express Node.js

Configuration Docker Compose

Clé Valeur
Image de base node:20.19-alpine
working_dir /app
command sh -c "npm install --omit=dev && node src/server.js"
Port exposé 3005 (hôte) → 3005 (container), variable JMETER_UI_PORT

L'image n'est pas buildée à partir d'un Dockerfile : le code source est monté en bind mount (./jmeter-ui:/app) et npm install tourne au démarrage. Les node_modules sont persistés dans un volume nommé (jmeter-ui-modules:/app/node_modules) pour ne pas être perdus à chaque redémarrage.

Bind mounts critiques

Bind mount Point de montage Rôle
./jmeter-ui /app Code source Node.js (hot-reload en édition)
./jmeter/scripts /app/scripts Lecture des scripts Groovy
./jmeter/scenarios /app/scenarios Lecture/écriture des fichiers JMX
./jmeter/results /app/results Lecture des rapports et des fichiers JTL
./jmeter/plugins /app/plugins Lecture de la liste des plugins
/var/run/docker.sock /var/run/docker.sock Accès à l'API Docker pour docker exec
jmeter-ui-modules (volume nommé) /app/node_modules Cache des dépendances Node

Socket Docker : accès complet au démon

Le bind mount /var/run/docker.sock donne à perfshop-jmeter-ui un accès complet au démon Docker de l'hôte. Ce choix est assumé pour permettre le pattern docker exec sans complexité réseau supplémentaire, mais il impose de ne jamais exposer directement ce service sur internet. Un reverse proxy avec auth forte et restriction IP est indispensable pour une exposition publique.

Variables d'environnement

Variable Défaut Rôle
PORT / SCRIPTS_UI_PORT 3005 Port HTTP du serveur Node
PUBLIC_JMETER_URL http://localhost:3005 URL publique exposée au navigateur
JMETER_TARGET_INTERNAL http://perfshop-app:8080 Cible « backend direct » (bypasse nginx)
JMETER_TARGET_EXTERNAL PUBLIC_API_URL Cible publique équivalente côté reverse proxy
JMETER_TARGET_FRONTEND PUBLIC_FRONTEND_URL Cible frontend (pour tirs front)
JMETER_PROMETHEUS_PORT 9270 Port exposé par le plugin Prometheus côté JMeter
PROMETHEUS_INTERNAL_URL http://perfshop-prometheus:9090 URL interne de Prometheus pour requêter les métriques live
PERFSHOP_API_INTERNAL (obligatoire, fail-fast) URL interne du backend pour healthchecks
JMETER_CONTAINER_NAME perfshop-jmeter Nom du container cible pour docker exec
JMETER_MAX_RAM 512 (ou 1024 selon cible) Heap max transmise à JMeter
GRAFANA_URL PUBLIC_GRAFANA_URL Lien profond vers le dashboard perfshop-jmeter-live depuis l'UI
PERFSHOP_LANG fr ou en Langue de l'UI et des traductions serveur
SESSION_SECRET perfshop-jmeter-ui-secret (dev) Secret de signature des cookies de session — fail-fast si laissé à la valeur par défaut avec NODE_ENV=production

Les 9 modules du dossier src/

Module Lignes Rôle
server.js 103 Orchestration Express, session, middlewares, montage des routes, injection window.__CONFIG__ via marker HTML
config.js 51 Lecture et validation des variables d'environnement au démarrage
i18n.js 51 Chargement des bundles de traduction depuis public/i18n/{fr,en}.json
docker.js 94 Client bas niveau pour l'API Docker (mode Detach:true) via le socket Unix
security.js 47 Middlewares csrfProtection et rateLimit(windowMs, maxHits)
auth.js 68 Login/logout/status + middleware requireAuth + liste PUBLIC_PATHS
jmx-parser.js 137 Parsing et édition des fichiers JMX (structure XML)
scenarios.js 218 Arborescence, CRUD, upload, édition des scénarios
jmeter.js 548 Lancement, stop, status, logs, résultats, métriques live (module central)

Injection de la configuration dans la page

Le serveur expose la configuration navigateur via un marker HTML stable placé dans public/index.html :

<!-- @CONFIG_INJECT@ -->

Au servage de /, le serveur :

  1. Lit public/index.html
  2. Cherche le marker <!-- @CONFIG_INJECT@ -->
  3. Le remplace par <script>window.__CONFIG__ = {...};</script>
  4. En cas de marker absent, log une erreur et fait une injection de fallback avant </head>

Ce mécanisme évite le double chargement config + HTML et garantit que le JavaScript côté navigateur a accès à PUBLIC_CONFIG avant tout autre script.

Endpoints REST exposés

Méthode Route Module Auth Rôle
POST /api/auth/login auth.js public (rate-limité) Authentification
POST /api/auth/logout auth.js session Fermeture de session
GET /api/auth/status auth.js public État de la session courante
GET /api/scenarios/tree scenarios.js session Arborescence complète des JMX
GET /api/scenarios/:path scenarios.js session Lecture d'un JMX
POST /api/scenarios/upload scenarios.js session Upload d'un JMX
PUT /api/scenarios/:path scenarios.js session Modification d'un JMX (via jmx-parser)
POST /api/run jmeter.js session Lance un tir (docker exec perfshop-jmeter jmeter -n -t ...)
POST /api/stop jmeter.js session Stoppe le tir courant (stoptest.sh puis SIGTERM)
GET /api/status jmeter.js session État courant (running, PID, temps écoulé, JMX)
GET /api/logs jmeter.js session Streaming des dernières lignes de jmeter.log
GET /api/results jmeter.js session Liste des rapports disponibles dans /results
GET /api/results/:id jmeter.js session Détail d'un rapport
DELETE /api/results/:id jmeter.js session Suppression d'un rapport
GET /api/metrics-live jmeter.js session Interroge Prometheus et renvoie les métriques live du tir en cours
GET /api/jmeter-container/status jmeter.js public Status du container perfshop-jmeter (UP/DOWN)
GET /api/jmeter/status jmeter.js public Alias public pour monitoring externe

Sécurité

Trois couches successives protègent l'UI :

flowchart LR
  REQ["Requête HTTP"]
  REQ --> S1["CSRF protection<br/>(global)"]
  S1 --> S2["Rate limiting<br/>(POST /api/auth/login<br/>10 tentatives / min / IP)"]
  S2 --> S3["Auth gate<br/>(apiAuthGate sur /api/*<br/>sauf PUBLIC_PATHS)"]
  S3 --> S4["requireAuth<br/>(middleware par route)"]
  S4 --> H["Handler"]
Couche Détail
CSRF Token généré par session, vérifié sur toutes les requêtes mutantes (POST, PUT, DELETE)
Rate limit rateLimit(60_000, 10) : 10 tentatives de login par minute par IP, puis HTTP 429
Session express-session 8 heures, cookie httpOnly, secure conditionnel à HTTPS, sameSite lax en HTTP et none en HTTPS cross-site
Auth gate apiAuthGate sur /api/* avec liste PUBLIC_PATHS explicite (/api/auth/*, /api/jmeter/status, /api/jmeter-container/status)
requireAuth Middleware systématique par route sensible — double filet de sécurité même si l'auth gate est mal configurée

Récupération d'état au redémarrage

Au démarrage, server.js appelle await recoverRunState() (défini dans jmeter.js, ligne 520). Cette fonction :

  1. Lit un fichier de persistance qui mémorise l'état du dernier tir en cours
  2. Si un tir était actif au crash du container, interroge Docker pour vérifier s'il tourne encore dans perfshop-jmeter
  3. Si oui, rebranche l'UI sur ce tir (l'étudiant retrouve son tir en cours après un redémarrage du service UI)
  4. Si non, marque le tir comme terminé de manière propre

C'est le pattern standard de résilience cross-container : l'état vivant (le tir) est dans un container, l'état métadonnées (qui, quand, quel JMX) est dans l'autre, et la synchronisation est faite au démarrage.

Flux d'un tir complet

sequenceDiagram
  autonumber
  actor F as Formateur
  participant UI as Navigateur<br/>(jmeter-ui)
  participant SRV as perfshop-jmeter-ui<br/>(Node)
  participant SOCK as /var/run/docker.sock
  participant JM as perfshop-jmeter<br/>(idle)
  participant PROM as prometheus
  participant LOKI as loki
  participant GRAF as grafana

  F->>UI: Login (email/mdp)
  UI->>SRV: POST /api/auth/login
  SRV-->>UI: 200 + cookie session

  F->>UI: Sélectionne scenario.jmx
  UI->>SRV: GET /api/scenarios/tree
  SRV-->>UI: arborescence JMX

  F->>UI: Clique « Lancer »
  UI->>SRV: POST /api/run {jmx: "Master/baseline.jmx"}
  SRV->>SOCK: docker exec perfshop-jmeter jmeter -n -t /scenarios/... -l /results/...
  SOCK->>JM: exec
  JM->>JM: Démarre le tir
  JM->>PROM: expose :9270/metrics (plugin Prometheus)

  loop Pendant le tir
    UI->>SRV: GET /api/status (polling)
    SRV-->>UI: {running: true, elapsed, ...}

    UI->>SRV: GET /api/metrics-live
    SRV->>PROM: PromQL sur jmeter_*
    PROM-->>SRV: métriques
    SRV-->>UI: {tps, latency_p95, errors, ...}

    UI->>SRV: GET /api/logs
    SRV->>JM: docker logs --tail 50 (via socket)
    JM-->>SRV: lignes log
    SRV-->>UI: logs récents
  end

  Note over JM,LOKI: En parallèle, Promtail lit /jmeter-logs/jmeter.log<br/>et le pousse dans Loki (job static_configs)

  JM->>JM: Fin du tir, rapport HTML généré dans /results
  UI->>SRV: GET /api/results
  SRV-->>UI: liste des rapports
  F->>UI: Clique sur le rapport → ouvre le HTML dans un iframe
  F->>GRAF: Ouvre le dashboard perfshop-jmeter-live
  GRAF->>PROM: PromQL (jmeter_*)
  GRAF->>LOKI: LogQL ({job="jmeter-log"})

Intégration observabilité

Métriques Prometheus (pendant un tir)

Le plugin JMeter Prometheus Listener expose, pour toute la durée d'un tir, un endpoint HTTP :9270/metrics à l'intérieur du container perfshop-jmeter. Prometheus scrape ce job toutes les 5 secondes avec honor_labels: true, ce qui permet au plugin de définir lui-même ses labels de ventilation (par sampler name, par code HTTP, etc.) sans collision avec les labels de scrape.

Le dashboard Grafana perfshop-jmeter-live (22 panels mixant Prometheus et Loki) consomme directement ces métriques ; détails dans ../observability/dashboards.md.

Logs Loki

Le fichier ./jmeter/logs/jmeter.log est lu par Promtail via un job static_configs dédié. L'étiquette Loki est {job="jmeter-log"} et permet au dashboard jmeter-live d'afficher en temps réel les dernières lignes du log à côté des courbes de latence. Voir ../observability/loki.md.

Logs Vector → OpenSearch

Vector collecte également les logs Docker de perfshop-jmeter et les indexe dans OpenSearch sous perfshop-jmeter* via la règle de routage service_family = "jmeter" (vector.toml). Voir ../observability/opensearch.md.

Pour aller plus loin