Skip to content

JMeter

PerfShop integrates Apache JMeter as two complementary Docker services that together form a browser-driven load testing platform:

  • perfshop-jmeter — a justb4/jmeter:5.5 container that stays permanently idle. It never starts a shot on its own; it waits for an execution order delivered through docker exec.
  • perfshop-jmeter-ui — a Node.js / Express application that provides a web control interface: scenario tree browsing, JMX editor, shot launching, log streaming, HTML report retrieval, and live metrics exposure.

The pair lets a student launch a complete shot without ever opening a terminal, while preserving the full power and compatibility of standalone JMeter.

Source of truth

This page is drawn from the perfshop-jmeter and perfshop-jmeter-ui blocks of docker-compose.desktop.yml / docker-compose.build.yml, from the content of the jmeter/ folder (scenarios/, scripts/, plugins/), and from the 9 Node modules in jmeter-ui/src/ (server.js, config.js, i18n.js, docker.js, security.js, auth.js, jmx-parser.js, scenarios.js, jmeter.js).

Architecture of the pair

flowchart TB
  subgraph browser["Browser (instructor or student)"]
    UI["JMeter UI<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/>(8h session + CSRF + rate limit)"]
    SC["scenarios.js<br/>(tree, CRUD)"]
    JP["jmx-parser.js<br/>(.jmx editing)"]
    JM["jmeter.js<br/>(launch + status + logs)"]
    DK["docker.js<br/>(Docker socket)"]
    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 (during a shot)"/]
    ENT --- JAR
    JAR --- PP
  end

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

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

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

  UI -->|HTTPS| SRV
  DK -->|mounts| 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

The key point: perfshop-jmeter-ui does not launch JMeter inside its own container. It uses the Docker socket mounted as a bind mount to run docker exec perfshop-jmeter jmeter -n -t .... This ensures that shots run inside the official JMeter image, with all its dependencies and classpath already configured, and without penalizing the UI's performance with the JMeter JVM.

perfshop-jmeter — permanent idle container

Docker Compose configuration

Key Value
Image justb4/jmeter:5.5 (variable JMETER_IMAGE)
container_name perfshop-jmeter
Network 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 installed' && tail -f /dev/null"

Why the tail -f /dev/null entrypoint?

The justb4/jmeter:5.5 image is designed to run a single shot and exit — its classic CI usage. PerfShop needs the opposite: a container that stays up permanently so we can:

  1. Copy plugins once at startup (via cp /plugins/*.jar ...)
  2. Launch shots on demand through docker exec without paying the cost of a full docker run (image pull, container creation, volume mounting)
  3. Preserve state between shots (temporary files, aggregated logs)

tail -f /dev/null is the standard trick for keeping a Docker container alive indefinitely without burning CPU.

Plugins copied at startup

The ./jmeter/plugins folder is bind-mounted at /plugins and contains one external plugin:

Plugin File Role
JMeter Prometheus Listener jmeter-prometheus-plugin-0.6.0.jar Exposes the metrics of a running JMeter shot on a Prometheus HTTP endpoint (default port 9270)

The plugin is copied to /opt/apache-jmeter-5.5/lib/ext/ when the container starts. Once the container is in place, JMeter automatically loads the plugin at every shot — it becomes usable in scenarios through the Prometheus Listener element on the JMeter side.

Bind-mounted volumes

Bind mount Mount point Role
./jmeter/scenarios /scenarios JMX scenario files (versionable sources)
./jmeter/scripts /scripts Groovy/Java scripts used by some samplers
./jmeter/results /results Shot outputs (CSV/JTL files + HTML reports)
./jmeter/plugins /plugins External plugins to inject in lib/ext/
./jmeter/logs /jmeter-logs JMeter logs (read by Promtail for Loki)

The same folders are also mounted on the perfshop-jmeter-ui side (under /app/scenarios, /app/scripts, /app/results, /app/plugins) so that the UI can read and write the same files as JMeter.

Environment variables

Variable Default Role
HEAP -Xms256m -Xmx${JMETER_MAX_RAM:-512}m -XX:MaxMetaspaceSize=128m JVM options passed to JMeter
JMETER_CUSTOM_PLUGINS_FOLDER /plugins Folder scanned by JMeter for additional plugins

Sizing the JMeter heap

The JMETER_MAX_RAM variable controls JMeter's -Xmx. 512 MB is enough for a shot with a few hundred virtual threads; beyond that, this value must be raised in .env before up -d to avoid OutOfMemoryError on the injector side (which would otherwise unfairly be blamed on the SUT).

Shipped scenario catalog

The ./jmeter/scenarios folder contains several ready-to-use JMX files, organized into themed subfolders:

Scenario Max recommended vUsers Usage
simple-api-products.jmx Minimal shot against the products API — example and smoke test
baseline-nominal.jmx Nominal reference shot before any chaos injection
parcours-metier-nominal.jmx 500 Full business journey T01–T10 (login → order) without chaos scripting
E2E/parcours-complet-e2e.jmx 500 Full end-to-end journey, minimum duration 2h
Fonctionnel/ Short functional validation scenarios (no load ramp-up)
Master/ Master scenarios combining multiple modules via Test Fragments

Load capacity

The parcours-metier-nominal.jmx and E2E/parcours-complet-e2e.jmx scenarios support up to 500 simultaneous vUsers thanks to the 500 dedicated load accounts (load1load500). Beyond 100 vUsers, remember to adjust JMETER_MAX_RAM in .env.

User data file — jmeter/scripts/users.csv

The users.csv file is the authentication dataset shared by all scenarios that call POST /api/auth/login. It is mounted in the JMeter container at /scripts/users.csv.

Format (501 lines: 1 header + 500 data rows):

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

JMeter configuration in the JMX files:

CSVDataSet property Value Reason
filename /scripts/users.csv Docker path (bind mount ./jmeter/scripts/scripts)
shareMode shareMode.all All threads share a single pointer — each thread gets a distinct account, zero collision
ignoreFirstLine true Skips the userEmail,userPassword,userName header
recycle true Loops back to the start once all 500 lines are consumed
stopThread false A thread never stops due to lack of available accounts

shareMode.all vs shareMode.thread

With shareMode.thread (the former value), each thread re-read the CSV from the beginning independently — two threads could use the same account simultaneously, causing cooldown anti-duplicate errors on the backend. With shareMode.all, the pointer advances sequentially across all threads: account N → thread N, no conflict.

Account source: Flyway migration V2__data_users.sql (present in migration-fr/ and migration-en/). Passwords are BCrypt-hashed at strength 10 in the database (prefix $2b$10$). The CSV contains passwords in plaintext — required so that JMeter can send them to POST /api/auth/login, which performs the BCrypt comparison on the backend.

perfshop-jmeter-ui — Express Node.js

Docker Compose configuration

Key Value
Base image node:20.19-alpine
working_dir /app
command sh -c "npm install --omit=dev && node src/server.js"
Exposed port 3005 (host) → 3005 (container), variable JMETER_UI_PORT

The image is not built from a Dockerfile: the source code is mounted as a bind mount (./jmeter-ui:/app) and npm install runs at startup. The node_modules are persisted in a named volume (jmeter-ui-modules:/app/node_modules) so they are not lost on each restart.

Critical bind mounts

Bind mount Mount point Role
./jmeter-ui /app Node.js source code (hot-reload during editing)
./jmeter/scripts /app/scripts Read Groovy scripts
./jmeter/scenarios /app/scenarios Read/write JMX files
./jmeter/results /app/results Read reports and JTL files
./jmeter/plugins /app/plugins Read the list of plugins
/var/run/docker.sock /var/run/docker.sock Access to the Docker API for docker exec
jmeter-ui-modules (named volume) /app/node_modules Cache for Node dependencies

Docker socket: full access to the daemon

The /var/run/docker.sock bind mount gives perfshop-jmeter-ui full access to the host Docker daemon. This design choice is intentional to allow the docker exec pattern without additional network complexity, but it requires that this service is never directly exposed to the internet. A reverse proxy with strong auth and IP restriction is mandatory for any public exposure.

Environment variables

Variable Default Role
PORT / SCRIPTS_UI_PORT 3005 HTTP port of the Node server
PUBLIC_JMETER_URL http://localhost:3005 Public URL exposed to the browser
JMETER_TARGET_INTERNAL http://perfshop-app:8080 "Direct backend" target (bypasses nginx)
JMETER_TARGET_EXTERNAL PUBLIC_API_URL Equivalent public target via reverse proxy
JMETER_TARGET_FRONTEND PUBLIC_FRONTEND_URL Frontend target (for front-end shots)
JMETER_PROMETHEUS_PORT 9270 Port exposed by the Prometheus plugin on the JMeter side
PROMETHEUS_INTERNAL_URL http://perfshop-prometheus:9090 Internal Prometheus URL for live metric queries
PERFSHOP_API_INTERNAL (mandatory, fail-fast) Internal backend URL for health checks
JMETER_CONTAINER_NAME perfshop-jmeter Name of the target container for docker exec
JMETER_MAX_RAM 512 (or 1024 depending on target) Max heap passed to JMeter
GRAFANA_URL PUBLIC_GRAFANA_URL Deep link to the perfshop-jmeter-live dashboard from the UI
PERFSHOP_LANG fr or en Language of the UI and server-side translations
SESSION_SECRET perfshop-jmeter-ui-secret (dev) Session cookie signing secret — fail-fast if left at default value with NODE_ENV=production

The 9 modules in src/

Module Lines Role
server.js 103 Express orchestration, session, middlewares, route mounting, window.__CONFIG__ injection via HTML marker
config.js 51 Environment variable reading and validation at startup
i18n.js 51 Loads translation bundles from public/i18n/{fr,en}.json
docker.js 94 Low-level Docker API client (Detach:true mode) through the Unix socket
security.js 47 csrfProtection and rateLimit(windowMs, maxHits) middlewares
auth.js 68 Login/logout/status + requireAuth middleware + PUBLIC_PATHS list
jmx-parser.js 137 Parsing and editing of JMX files (XML structure)
scenarios.js 218 Tree, CRUD, upload, scenario editing
jmeter.js 548 Launch, stop, status, logs, results, live metrics (central module)

Injecting configuration into the page

The server exposes the browser configuration via a stable HTML marker placed in public/index.html:

<!-- @CONFIG_INJECT@ -->

When serving /, the server:

  1. Reads public/index.html
  2. Looks for the <!-- @CONFIG_INJECT@ --> marker
  3. Replaces it with <script>window.__CONFIG__ = {...};</script>
  4. If the marker is absent, logs an error and falls back to injecting before </head>

This mechanism avoids double-loading config + HTML and ensures that the browser-side JavaScript has access to PUBLIC_CONFIG before any other script.

Exposed REST endpoints

Method Route Module Auth Role
POST /api/auth/login auth.js public (rate-limited) Authentication
POST /api/auth/logout auth.js session Session close
GET /api/auth/status auth.js public Current session state
GET /api/scenarios/tree scenarios.js session Full JMX tree
GET /api/scenarios/:path scenarios.js session Read a JMX
POST /api/scenarios/upload scenarios.js session Upload a JMX
PUT /api/scenarios/:path scenarios.js session Edit a JMX (via jmx-parser)
POST /api/run jmeter.js session Launch a shot (docker exec perfshop-jmeter jmeter -n -t ...)
POST /api/stop jmeter.js session Stop the current shot (stoptest.sh then SIGTERM)
GET /api/status jmeter.js session Current state (running, PID, elapsed time, JMX)
GET /api/logs jmeter.js session Streaming of the latest lines of jmeter.log
GET /api/results jmeter.js session List of available reports in /results
GET /api/results/:id jmeter.js session Report details
DELETE /api/results/:id jmeter.js session Delete a report
GET /api/metrics-live jmeter.js session Queries Prometheus and returns live metrics for the current shot
GET /api/jmeter-container/status jmeter.js public Status of the perfshop-jmeter container (UP/DOWN)
GET /api/jmeter/status jmeter.js public Public alias for external monitoring

Security

Three successive layers protect the UI:

flowchart LR
  REQ["HTTP request"]
  REQ --> S1["CSRF protection<br/>(global)"]
  S1 --> S2["Rate limiting<br/>(POST /api/auth/login<br/>10 attempts / min / IP)"]
  S2 --> S3["Auth gate<br/>(apiAuthGate on /api/*<br/>except PUBLIC_PATHS)"]
  S3 --> S4["requireAuth<br/>(per-route middleware)"]
  S4 --> H["Handler"]
Layer Detail
CSRF Token generated per session, verified on all mutating requests (POST, PUT, DELETE)
Rate limit rateLimit(60_000, 10): 10 login attempts per minute per IP, then HTTP 429
Session express-session 8 hours, httpOnly cookie, secure conditional on HTTPS, sameSite lax over HTTP and none over cross-site HTTPS
Auth gate apiAuthGate on /api/* with explicit PUBLIC_PATHS list (/api/auth/*, /api/jmeter/status, /api/jmeter-container/status)
requireAuth Systematic per-sensitive-route middleware — double safety net even if the auth gate is misconfigured

State recovery on restart

At startup, server.js calls await recoverRunState() (defined in jmeter.js, line 520). This function:

  1. Reads a persistence file that records the state of the last running shot
  2. If a shot was active at the UI container crash, queries Docker to check whether it is still running in perfshop-jmeter
  3. If so, reattaches the UI to that shot (the student finds their running shot again after a UI service restart)
  4. If not, marks the shot as cleanly terminated

This is the standard cross-container resilience pattern: the live state (the shot) lives in one container, the metadata state (who, when, which JMX) lives in the other, and synchronization happens at startup.

Full shot flow

sequenceDiagram
  autonumber
  actor F as Instructor
  participant UI as Browser<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/password)
  UI->>SRV: POST /api/auth/login
  SRV-->>UI: 200 + session cookie

  F->>UI: Selects scenario.jmx
  UI->>SRV: GET /api/scenarios/tree
  SRV-->>UI: JMX tree

  F->>UI: Clicks "Launch"
  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: Starts the shot
  JM->>PROM: exposes :9270/metrics (Prometheus plugin)

  loop During the shot
    UI->>SRV: GET /api/status (polling)
    SRV-->>UI: {running: true, elapsed, ...}

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

    UI->>SRV: GET /api/logs
    SRV->>JM: docker logs --tail 50 (via socket)
    JM-->>SRV: log lines
    SRV-->>UI: recent logs
  end

  Note over JM,LOKI: In parallel, Promtail reads /jmeter-logs/jmeter.log<br/>and pushes it to Loki (static_configs job)

  JM->>JM: Shot ends, HTML report generated in /results
  UI->>SRV: GET /api/results
  SRV-->>UI: list of reports
  F->>UI: Clicks the report → opens HTML in an iframe
  F->>GRAF: Opens the perfshop-jmeter-live dashboard
  GRAF->>PROM: PromQL (jmeter_*)
  GRAF->>LOKI: LogQL ({job="jmeter-log"})

Observability integration

Prometheus metrics (during a shot)

The JMeter Prometheus Listener plugin exposes, for the entire duration of a shot, an HTTP endpoint :9270/metrics inside the perfshop-jmeter container. Prometheus scrapes this job every 5 seconds with honor_labels: true, which lets the plugin define its own breakdown labels (by sampler name, HTTP code, etc.) without collision with the scrape labels.

The Grafana dashboard perfshop-jmeter-live (22 panels mixing Prometheus and Loki) consumes these metrics directly; details in ../observability/dashboards.md.

Loki logs

The ./jmeter/logs/jmeter.log file is read by Promtail through a dedicated static_configs job. The Loki label is {job="jmeter-log"} and lets the jmeter-live dashboard display the latest log lines in real time alongside the latency curves. See ../observability/loki.md.

Vector logs → OpenSearch

Vector also collects Docker logs from perfshop-jmeter and indexes them in OpenSearch under perfshop-jmeter* via the service_family = "jmeter" routing rule (vector.toml). See ../observability/opensearch.md.

Further reading