Chapter 27Lesson 02~330 minutes

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.

Verified Apache binaryRead-only mountsPrivate Docker networkHost artifactsExit state

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

Local/disposable only. Required Docker workload = 5×20=100 requests to the private fixture service; native proof = another 100 requests to 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?

Which mount must be writable?

Why inspect ExitCode and OOMKilled?

Why does the Docker run use p27-fixture instead of 127.0.0.1?

What does the native proof establish?

Next lesson

Choose what belongs in the image, runtime and orchestrator

Lesson 3 compares custom/community images, baked/mounted assets, Compose/Jobs, vertical/multiple injectors, and persistent artifacts/remote telemetry.

Official references and version notes

Version and compatibility note

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.