JMeter¶
PerfShop integrates Apache JMeter as two complementary Docker services that together form a browser-driven load testing platform:
perfshop-jmeter— ajustb4/jmeter:5.5container that stays permanently idle. It never starts a shot on its own; it waits for an execution order delivered throughdocker 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:
- Copy plugins once at startup (via
cp /plugins/*.jar ...) - Launch shots on demand through
docker execwithout paying the cost of a fulldocker run(image pull, container creation, volume mounting) - 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 (load1–load500).
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:
When serving /, the server:
- Reads
public/index.html - Looks for the
<!-- @CONFIG_INJECT@ -->marker - Replaces it with
<script>window.__CONFIG__ = {...};</script> - 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:
- Reads a persistence file that records the state of the last running shot
- If a shot was active at the UI container crash, queries Docker to check whether it is still running in
perfshop-jmeter - If so, reattaches the UI to that shot (the student finds their running shot again after a UI service restart)
- 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¶
- QA overview
- Scripts UI — the same Node + bind socket pattern, but for driving
perfshop-test-runner perfshop-jmeter-livedashboard- Prometheus and JMeter PromQL