Containers, Kubernetes, Ephemeral Injectors, and Infrastructure Automation: Guided Hands-On Workflow
The guided workflow builds the injector instead of trusting a prepackaged JMeter image. The final image contains a version-specific Java runtime plus the exact Apache JMeter 5.6.3 binary whose SHA-512 is verified during build. JMX/data/config remain outside the image and are mounted read-only so the same source assets can be run by native JMeter and by the container.
Learning objectives
- Build a custom JMeter image from a verified Apache release.
- Record base-image digest, final image ID, Java/JMeter/plugin provenance.
- Build a private fixture image and user-defined network.
- Mount JMX/data/config read-only and results read-write.
- Run a 100-sample container test with explicit CPU/memory/read-only-root limits.
- Run the same JMX natively and compare correctness/artifact evidence.
1. Safety and prerequisites
127.0.0.1:8027. No ports from
the Docker fixture are published to the LAN/internet. No Docker
socket is mounted into a container. No privileged mode. No real
credentials. Stop on unexpected image provenance, non-local Docker
context, target count >100/run, container OOM/nonzero exit, input
mount writable, or missing host JTL/log artifacts.
2. Create the chapter lab files
Directory layout:
p27-lab/
├── container/
│ ├── Dockerfile.jmeter
│ └── Dockerfile.fixture
├── fixtures/
│ └── container_fixture.py
├── plans/
│ └── container-local.jmx
├── config/
│ └── container.properties
├── data/
│ └── scenario.csv
├── tools/
│ └── verify_artifacts.py
└── results/
3. Build JMeter from the verified Apache binary
Save container/Dockerfile.jmeter:
FROM eclipse-temurin:17.0.20_8-jre-jammy
ARG JMETER_VERSION=5.6.3
ARG JMETER_SHA512=5978a1a35edb5a7d428e270564ff49d2b1b257a65e17a759d259a9283fc17093e522fe46f474a043864aea6910683486340706d745fcdf3db1505fd71e689083
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/* \
&& curl -fsSLo /tmp/apache-jmeter.tgz \
"https://archive.apache.org/dist/jmeter/binaries/apache-jmeter-${JMETER_VERSION}.tgz" \
&& echo "${JMETER_SHA512} /tmp/apache-jmeter.tgz" | sha512sum -c - \
&& tar -xzf /tmp/apache-jmeter.tgz -C /opt \
&& ln -s "/opt/apache-jmeter-${JMETER_VERSION}" /opt/jmeter \
&& rm /tmp/apache-jmeter.tgz \
&& groupadd --system --gid 10001 jmeter \
&& useradd --system --uid 10001 --gid 10001 \
--home-dir /tmp --shell /usr/sbin/nologin jmeter
ENV JMETER_HOME=/opt/jmeter
ENV PATH="/opt/jmeter/bin:${PATH}"
ENV HOME=/tmp
LABEL org.opencontainers.image.title="DevOps Academy JMeter lab injector" \
org.opencontainers.image.version="5.6.3" \
org.opencontainers.image.description="Apache JMeter 5.6.3 built from the verified Apache binary"
USER 10001:10001
WORKDIR /work
ENTRYPOINT ["/opt/jmeter/bin/jmeter"]
CMD ["-v"]
The build uses a version-specific Java base, downloads the Apache binary from Apache's archive, verifies the exact Apache-published SHA-512, creates a non-root JMeter user and copies no plugin or test asset into the image.
4. Record base provenance before build
docker pull eclipse-temurin:17.0.20_8-jre-jammy
docker image inspect eclipse-temurin:17.0.20_8-jre-jammy `
--format "{{json .RepoDigests}}" |
Tee-Object .\results\temurin-repodigests.txt
The exact tag pins Java to 17.0.20+8; the resolved RepoDigest records the platform-specific content retrieved by this machine.
5. Build and verify the JMeter image
docker build --pull `
-f .\container\Dockerfile.jmeter `
-t devops-academy/jmeter:5.6.3-p27 `
.
docker image inspect devops-academy/jmeter:5.6.3-p27 `
--format "{{.Id}}" |
Tee-Object .\results\jmeter-image-id.txt
docker run --rm devops-academy/jmeter:5.6.3-p27 -v
docker run --rm --entrypoint java `
devops-academy/jmeter:5.6.3-p27 -version
docker history --no-trunc devops-academy/jmeter:5.6.3-p27 `
> .\results\jmeter-image-history.txt
Expected: JMeter 5.6.3 and Java 17.0.20. The stock Apache tarball contains JMeter's core extension JARs; “no plugins” here means no additional third-party JAR was copied/installed by this Dockerfile.
6. Create the private fixture image
fixtures/container_fixture.py:
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse, parse_qs
import argparse
import json
import re
import threading
import time
FIXTURE_VERSION = "prompt27-container-fixture-v1"
SAFE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
lock = threading.Lock()
event_log = None
state = {}
def now_ms():
return int(time.time() * 1000)
def reset():
with lock:
state.clear()
def snapshot():
with lock:
return {
run_id: {
"requests": item["requests"],
"errors": item["errors"],
"injectors": dict(item["injectors"]),
"bytes_sent": item["bytes_sent"],
}
for run_id, item in state.items()
}
def write_event(event):
if event_log is None:
return
with lock:
with event_log.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True) + "\n")
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def send_json(self, status, payload):
raw = json.dumps(payload, sort_keys=True).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.send_header("X-Fixture-Version", FIXTURE_VERSION)
self.end_headers()
self.wfile.write(raw)
return len(raw)
def do_POST(self):
parsed = urlparse(self.path)
if parsed.path != "/reset":
self.send_json(404, {"status": "not_found"})
return
reset()
self.send_json(200, {"status": "reset", "epoch_ms": now_ms()})
def do_GET(self):
started = now_ms()
parsed = urlparse(self.path)
if parsed.path == "/health":
self.send_json(200, {
"status": "ok",
"fixture_version": FIXTURE_VERSION,
"epoch_ms": now_ms(),
})
return
if parsed.path == "/stats":
self.send_json(200, {
"fixture_version": FIXTURE_VERSION,
"epoch_ms": now_ms(),
"state": snapshot(),
})
return
if parsed.path != "/work":
self.send_json(404, {"status": "not_found"})
return
q = parse_qs(parsed.query)
run_id = q.get("run_id", [""])[0]
injector = q.get("injector", [""])[0]
scenario = q.get("scenario", [""])[0]
seq_raw = q.get("seq", [""])[0]
payload_raw = q.get("payload", ["2048"])[0]
if not all(SAFE.fullmatch(v or "") for v in (run_id, injector, scenario)):
self.send_json(400, {"status": "invalid_metadata"})
return
try:
seq = int(seq_raw)
payload_bytes = int(payload_raw)
except ValueError:
self.send_json(400, {"status": "invalid_number"})
return
if not 1 <= seq <= 100000 or not 0 <= payload_bytes <= 16384:
self.send_json(400, {"status": "out_of_bounds"})
return
time.sleep(0.010)
payload = {
"status": "ok",
"run_id": run_id,
"injector": injector,
"scenario": scenario,
"seq": seq,
"payload_bytes": payload_bytes,
"padding": "X" * payload_bytes,
}
raw_len = self.send_json(200, payload)
ended = now_ms()
with lock:
item = state.setdefault(run_id, {
"requests": 0,
"errors": 0,
"injectors": {},
"bytes_sent": 0,
})
item["requests"] += 1
item["injectors"][injector] = item["injectors"].get(injector, 0) + 1
item["bytes_sent"] += raw_len
write_event({
"ts_ms": ended,
"operation": "work",
"status": 200,
"run_id": run_id,
"injector": injector,
"scenario": scenario,
"seq": seq,
"client_ip": self.client_address[0],
"client_port": int(self.client_address[1]),
"service_wall_ms": ended - started,
"payload_bytes": payload_bytes,
})
def log_message(self, format, *args):
return
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8027)
parser.add_argument("--log", default="/tmp/server-events.jsonl")
args = parser.parse_args()
global event_log
event_log = Path(args.log).resolve()
event_log.parent.mkdir(parents=True, exist_ok=True)
event_log.write_text("", encoding="utf-8")
print(f"fixture_version={FIXTURE_VERSION}", flush=True)
print(f"listen=http://{args.host}:{args.port}", flush=True)
print(f"event_log={event_log}", flush=True)
ThreadingHTTPServer((args.host, args.port), Handler).serve_forever()
if __name__ == "__main__":
main()
container/Dockerfile.fixture:
FROM python:3.13.7-slim-bookworm
WORKDIR /app
COPY fixtures/container_fixture.py /app/container_fixture.py
ENTRYPOINT ["python", "/app/container_fixture.py"]
CMD ["--host", "0.0.0.0", "--port", "8027", "--log", "/tmp/server-events.jsonl"]
docker build --pull `
-f .\container\Dockerfile.fixture `
-t devops-academy/p27-fixture:1 `
.
The fixture's Python base is the version-specific Docker Official
Image python:3.13.7-slim-bookworm. It is synthetic and
local; it is not part of JMeter's provenance.
7. Create mounted data/properties and author the JMX
data/scenario.csv:
PAYLOAD_SIZE,SCENARIO
2048,container-basic
config/container.properties:
target.host=127.0.0.1
target.port=8027
threads=5
loops=20
pacing.ms=100
payload.bytes=2048
connect.timeout.ms=500
response.timeout.ms=2000
jmeter.httpsampler=HttpClient4
httpclient4.retrycount=0
jmeter.save.saveservice.output_format=csv
jmeter.save.saveservice.print_field_names=true
jmeter.save.saveservice.response_data=false
jmeter.save.saveservice.response_data.on_error=false
jmeter.save.saveservice.samplerData=false
jmeter.save.saveservice.responseHeaders=false
jmeter.save.saveservice.requestHeaders=false
jmeter.save.saveservice.url=false
jmeter.save.saveservice.assertion_results_failure_message=true
sample_variables=INJECTOR_ID,SCENARIO
Author plans/container-local.jmx in the JMeter GUI with
this exact tree/settings:
Test Plan
├── User Defined Variables
│ INJECTOR_ID=${__P(injector.id,native)}
├── HTTP Request Defaults
│ host=${__P(target.host,127.0.0.1)}
│ port=${__P(target.port,8027)}
│ implementation=HttpClient4
│ connect timeout=${__P(connect.timeout.ms,500)}
│ response timeout=${__P(response.timeout.ms,2000)}
├── CSV Data Set Config — Scenario
│ Filename=${__P(data.file)}
│ Variable Names=PAYLOAD_SIZE,SCENARIO
│ Recycle on EOF=true
│ Stop thread on EOF=false
│ Sharing mode=All threads
└── Thread Group
threads=${__P(threads,5)}
loops=${__P(loops,20)}
├── Counter -> SEQ (per user)
└── HTTP Request — Work
GET /work
run_id=${__P(run.id,p27-local)}
injector=${INJECTOR_ID}
scenario=${SCENARIO}
seq=${SEQ}
payload=${PAYLOAD_SIZE}
Use KeepAlive=checked
├── Constant Timer ${__P(pacing.ms,100)} ms
└── Response Assertion: HTTP response code = 200
The JMX does not hard-code Docker DNS.
target.host/data.file/injector.id
are JMeter properties so the same plan runs natively, in Docker and
in the optional Job.
8. Hash immutable inputs
Get-FileHash `
.\plans\container-local.jmx, `
.\config\container.properties, `
.\data\scenario.csv `
-Algorithm SHA256 |
Format-Table |
Out-File .\results\input-sha256.txt
These hashes become the run manifest. A later container result is comparable only if the intended inputs match.
9. Create a private Docker network and start the fixture
docker network create p27-net
docker run -d `
--name p27-fixture `
--network p27-net `
--read-only `
--tmpfs /tmp:rw,noexec,nosuid,size=64m `
--cpus 0.50 `
--memory 256m `
devops-academy/p27-fixture:1
No -p publishes the fixture. The JMeter container
reaches it by Docker DNS name p27-fixture.
Health check from inside the fixture namespace:
docker exec p27-fixture python -c `
"import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8027/health').read().decode())"
10. Run JMeter with read-only inputs and a writable host artifact mount
PowerShell:
$Root = (Resolve-Path ".").Path
$Plans = (Resolve-Path ".\plans").Path
$Config = (Resolve-Path ".\config").Path
$Data = (Resolve-Path ".\data").Path
$Results = "$Root\results\p27-container"
New-Item -ItemType Directory -Force $Results | Out-Null
docker run `
--name p27-jmeter `
--network p27-net `
--read-only `
--tmpfs /tmp:rw,noexec,nosuid,size=128m `
--cpus 1.0 `
--memory 768m `
--pids-limit 256 `
--env "HEAP=-Xms256m -Xmx512m -XX:MaxMetaspaceSize=128m" `
--mount "type=bind,source=$Plans,target=/inputs/plans,readonly" `
--mount "type=bind,source=$Config,target=/inputs/config,readonly" `
--mount "type=bind,source=$Data,target=/inputs/data,readonly" `
--mount "type=bind,source=$Results,target=/artifacts" `
devops-academy/jmeter:5.6.3-p27 `
-n `
-t /inputs/plans/container-local.jmx `
-q /inputs/config/container.properties `
-Jtarget.host=p27-fixture `
-Jtarget.port=8027 `
-Jrun.id=p27-container `
-Jinjector.id=docker `
-Jdata.file=/inputs/data/scenario.csv `
-l /artifacts/results.jtl `
-j /artifacts/jmeter.log
State reads/changes: Docker enforces CPU/memory/PID/root-filesystem
limits; inputs cannot be modified; JMeter creates 5×20 samples and
protocol connections; only /artifacts//tmp
are writable.
11. Inspect mounts/resources/exit state before deleting anything
docker inspect p27-jmeter `
--format "Exit={{.State.ExitCode}} OOM={{.State.OOMKilled}} Status={{.State.Status}}"
docker inspect p27-jmeter `
--format "Memory={{.HostConfig.Memory}} NanoCpus={{.HostConfig.NanoCpus}} Pids={{.HostConfig.PidsLimit}} ReadonlyRootfs={{.HostConfig.ReadonlyRootfs}}"
docker inspect p27-jmeter `
--format "{{json .Mounts}}" `
> .\results\p27-container\mounts.json
docker stats --no-stream p27-fixture
Get-Item .\results\p27-container\results.jtl,
.\results\p27-container\jmeter.log |
Select-Object FullName,Length,LastWriteTime
Require exit code 0, OOM=false, three input mounts read-only, artifact mount writable and non-empty host JTL/log.
12. Persist independent target evidence
docker cp `
p27-fixture:/tmp/server-events.jsonl `
.\results\p27-container\target-events.jsonl
docker logs p27-fixture `
> .\results\p27-container\fixture-container.log 2>&1
Copy before removing the fixture because its /tmp is
intentionally ephemeral.
13. Verify artifacts independently
Save tools/verify_artifacts.py:
import csv
import json
import sys
from collections import Counter
from pathlib import Path
if len(sys.argv) != 6:
raise SystemExit(
"usage: verify_artifacts.py <jtl> <jmeter.log> <target-events.jsonl> <run_id> <expected>"
)
jtl_path = Path(sys.argv[1])
log_path = Path(sys.argv[2])
events_path = Path(sys.argv[3])
run_id = sys.argv[4]
expected = int(sys.argv[5])
rows = list(csv.DictReader(jtl_path.open(newline="", encoding="utf-8")))
if rows:
required = {"success", "INJECTOR_ID", "SCENARIO"}
missing = required - set(rows[0].keys())
if missing:
raise SystemExit(f"missing JTL columns: {sorted(missing)}")
events = [
json.loads(line)
for line in events_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
target = [e for e in events if e.get("operation") == "work" and e.get("run_id") == run_id]
result = {
"run_id": run_id,
"expected": expected,
"jtl_rows": len(rows),
"jtl_failures": sum(r.get("success", "").lower() != "true" for r in rows),
"jtl_injectors": dict(Counter(r.get("INJECTOR_ID") for r in rows)),
"jtl_scenarios": dict(Counter(r.get("SCENARIO") for r in rows)),
"jtl_bytes": jtl_path.stat().st_size,
"jmeter_log_bytes": log_path.stat().st_size,
"target_events": len(target),
"target_injectors": dict(Counter(e.get("injector") for e in target)),
"target_failures": sum(int(e.get("status", 0)) >= 400 for e in target),
}
result["status"] = (
"PASS"
if result["jtl_rows"] == expected
and result["target_events"] == expected
and result["jtl_failures"] == 0
and result["target_failures"] == 0
else "FAIL"
)
print(json.dumps(result, indent=2))
raise SystemExit(0 if result["status"] == "PASS" else 3)
python .\tools\verify_artifacts.py `
.\results\p27-container\results.jtl `
.\results\p27-container\jmeter.log `
.\results\p27-container\target-events.jsonl `
p27-container `
100
Require PASS: 100 JTL rows, 100 target events, zero failures,
injector=docker, scenario=container-basic.
14. Prove the identical JMX outside the container
Stop/remove only the Docker fixture first so port/state cannot be confused. Run the same Python fixture natively:
python .\fixtures\container_fixture.py `
--host 127.0.0.1 `
--port 8027 `
--log .\results\p27-native\target-events.jsonl
Then native JMeter 5.6.3:
$DataFile = (Resolve-Path ".\data\scenario.csv").Path
& "$env:JMETER_HOME\bin\jmeter.bat" `
-n `
-t .\plans\container-local.jmx `
-q .\config\container.properties `
-Jtarget.host=127.0.0.1 `
-Jtarget.port=8027 `
-Jrun.id=p27-native `
-Jinjector.id=native `
-Jdata.file="$DataFile" `
-l .\results\p27-native\results.jtl `
-j .\results\p27-native\jmeter.log
Verify 100/100 successes. Compare correctness/counts/settings, not raw latency as if the native/container network/resource environments were identical.
15. Challenge
A colleague proposes baking container-local.jmx into
the JMeter image because “then CI cannot lose it.” Should you?
It is a valid pattern only if the image version/digest intentionally becomes the test-plan version. For this course, mounted hashed JMX/data are preferable because one verified runtime image can execute multiple versioned tests and the same JMX can run natively. Never bake secrets into either pattern.
Knowledge check
What makes the mandatory JMeter image provenance stronger than a random jmeter:latest image?
Version-specific Java base provenance plus Apache's exact JMeter binary and verified published SHA-512, with final local image ID recorded.
Which mount must be writable?
Only the artifact output mount (and disposable /tmp tmpfs); JMX/data/config inputs are read-only.
Why inspect ExitCode and OOMKilled?
A JTL file can exist even when the container was killed/failed; process exit/resource state is part of validity.
Why does the Docker run use p27-fixture instead of 127.0.0.1?
The target is a sibling container on a user-defined network; container localhost points to the injector itself.
What does the native proof establish?
The same JMX/data/property model is portable outside Docker; it does not claim identical performance between runtime environments.
Official references and version notes
- Apache JMeter downloads — JMeter 5.6.3, Java requirement, SHA-512/PGP integrity verification.
- Apache-published JMeter 5.6.3 SHA-512 — checksum used by the lab Dockerfile.
- Eclipse Temurin Docker Official Image — version-specific Java 17.0.20+8 JRE base used by the custom injector image.
- Docker bind mounts — read-only input mounts and writable host artifact mounts.
- Docker run reference — read-only root filesystem, CPU/memory limits, mounts, networks and exit-state inspection.
-
Kubernetes Jobs
— run-to-completion semantics, parallelism/completions,
restartPolicy, backoff and active deadlines. - kind Quick Start — local cluster lifecycle and loading locally built images.
Version-sensitive statements were rechecked against current
primary documentation on 2026-09-05. The course baseline remains
Apache JMeter 5.6.3, requiring Java 8+; this
chapter uses
Eclipse Temurin 17.0.20+8 JRE (Jammy) as the
version-specific Java base and records its resolved registry
digest locally. The JMeter layer is built from Apache's binary
tarball and verifies Apache's published SHA-512:
5978a1a35edb5a7d428e270564ff49d2b1b257a65e17a759d259a9283fc17093e522fe46f474a043864aea6910683486340706d745fcdf3db1505fd71e689083. Apache JMeter does not need a third-party plugin for the
mandatory lab. Docker bind mounts are writable by default, so the
JMX/data/config inputs are explicitly readonly; only
the artifact mount is writable. The optional Kubernetes path uses
current batch/v1 Job semantics with
restartPolicy: Never, backoffLimit: 0,
activeDeadlineSeconds, resource requests/limits, and
no cloud requirement. The local cluster example assumes
kind v0.32.0; a current minikube installation is
an optional equivalent.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.