Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.git
.gitignore
*.md
test.jmx
docker-compose*.yml
41 changes: 41 additions & 0 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Docker image

on:
push:
branches: [master, main]
pull_request:
branches: [master, main]

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
target: [jmeter-base, jmeter-worker]
steps:
- uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build ${{ matrix.target }}
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
target: ${{ matrix.target }}
push: false
load: true
tags: jmeter-docker:${{ matrix.target }}-ci
build-args: |
JMETER_VERSION=5.6.3
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Smoke check JMeter
if: matrix.target == 'jmeter-base'
run: docker run --rm jmeter-docker:jmeter-base-ci jmeter --version
85 changes: 85 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# syntax=docker/dockerfile:1

# Pin digest = multi-arch manifest list for reproducible builds (bump when retagging base).
# Resolve: docker buildx imagetools inspect eclipse-temurin:21-jre-jammy --format '{{json .Manifest}}'
FROM eclipse-temurin:21-jre-jammy@sha256:199aebeb3adcde4910695cdebfe782ada38dadb6cc8013159b58d3724451befd AS jmeter-base

ARG JMETER_VERSION=5.6.3
ARG JMETER_UID=10001
ARG JMETER_GID=10001

# Plugins Manager + its required cmdrunner dependency. Pulled from Maven Central (immutable,
# versioned) so both can be pinned + verified like the JMeter archive — unlike the rolling
# jmeter-plugins.org/get/ "latest" endpoint, which can't be checksummed.
# On bump, recompute: curl -fsSL <jar-url> | sha512sum
# (Maven Central only publishes .sha1/.md5, so derive the SHA512 from the jar itself.)
ARG PLUGINS_MGR_VERSION=1.12
ARG PLUGINS_MGR_SHA512=b65c919512858a438b7be5f93f60888cfab4c59def843c2e987242bce36a9b09b3644e59c4f7f8e15a132b1fda2e39c16d93db8c48cc5d497b5c899f0b44300f
ARG CMDRUNNER_VERSION=2.3
ARG CMDRUNNER_SHA512=7f71fe42f4ead4ccddd68148e97a46b9262bdb05fe5e590a725331513549122dc64d4cb524635b2f0e3e7d3ee4bb3c2807738cd3c7e2d0d7a503ea78234dab51

LABEL org.opencontainers.image.title="Apache JMeter — base image"
LABEL org.opencontainers.image.description="JMeter 5.x with JMeter Plugins Manager and common jp@gc plugins (load shaping, PerfMon, JTL graphs, merge)"
LABEL org.opencontainers.image.licenses="Apache-2.0"
LABEL org.opencontainers.image.url="https://jmeter.apache.org/"

# Separate ENV instructions: within one ENV, ${JMETER_HOME} would resolve to its
# pre-instruction (empty) value, leaving JMeter off PATH.
ENV JMETER_HOME=/opt/apache-jmeter-${JMETER_VERSION}
ENV PATH=${JMETER_HOME}/bin:${PATH}

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends bash ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*

RUN set -eux; \
base="https://archive.apache.org/dist/jmeter/binaries"; \
tgz="${base}/apache-jmeter-${JMETER_VERSION}.tgz"; \
curl -fSL --retry 3 -o "/tmp/apache-jmeter-${JMETER_VERSION}.tgz" "${tgz}"; \
curl -fSL --retry 3 -o "/tmp/apache-jmeter-${JMETER_VERSION}.tgz.sha512" "${tgz}.sha512"; \
(cd /tmp && sha512sum -c "/tmp/apache-jmeter-${JMETER_VERSION}.tgz.sha512"); \
tar -xzf "/tmp/apache-jmeter-${JMETER_VERSION}.tgz" -C /opt \
&& rm "/tmp/apache-jmeter-${JMETER_VERSION}.tgz" "/tmp/apache-jmeter-${JMETER_VERSION}.tgz.sha512"; \
rm -rf "${JMETER_HOME}/printable_docs" "${JMETER_HOME}/docs"

# JMeter Plugins Manager + cmdrunner (required installer dependency)
RUN set -eux; \
maven="https://repo1.maven.org/maven2"; \
curl -fSL --retry 3 -o "${JMETER_HOME}/lib/ext/plugins-manager.jar" \
"${maven}/kg/apc/jmeter-plugins-manager/${PLUGINS_MGR_VERSION}/jmeter-plugins-manager-${PLUGINS_MGR_VERSION}.jar"; \
printf '%s %s\n' "${PLUGINS_MGR_SHA512}" "${JMETER_HOME}/lib/ext/plugins-manager.jar" | sha512sum -c -; \
curl -fSL --retry 3 -o "${JMETER_HOME}/lib/cmdrunner-${CMDRUNNER_VERSION}.jar" \
"${maven}/kg/apc/cmdrunner/${CMDRUNNER_VERSION}/cmdrunner-${CMDRUNNER_VERSION}.jar"; \
printf '%s %s\n' "${CMDRUNNER_SHA512}" "${JMETER_HOME}/lib/cmdrunner-${CMDRUNNER_VERSION}.jar" | sha512sum -c -; \
java -cp "${JMETER_HOME}/lib/ext/plugins-manager.jar" \
org.jmeterplugins.repository.PluginManagerCMDInstaller

# Plugin IDs match https://jmeter-plugins.org/repo/ (see PluginsManagerCMD install).
RUN "${JMETER_HOME}/bin/PluginsManagerCMD.sh" install \
jpgc-autostop,jpgc-casutg,jpgc-cmd,jpgc-csl,jpgc-dummy,jpgc-ffw,jpgc-functions,jpgc-graphs-dist,jpgc-json,jpgc-mergeresults,jpgc-perfmon,jpgc-tst \
&& "${JMETER_HOME}/bin/PluginsManagerCMD.sh" status

RUN groupadd --gid "${JMETER_GID}" jmeter \
&& useradd --uid "${JMETER_UID}" --gid jmeter --create-home --shell /bin/bash jmeter \
&& mkdir -p /jmeter-work \
&& chown -R jmeter:jmeter "${JMETER_HOME}" /jmeter-work

WORKDIR /jmeter-work
USER jmeter

# --- Worker (remote execution engine via jmeter-server) ---------------------
FROM jmeter-base AS jmeter-worker

USER root

COPY scripts/docker-entrypoint-jmeter-worker.sh /usr/local/bin/docker-entrypoint-jmeter-worker.sh
RUN chmod +x /usr/local/bin/docker-entrypoint-jmeter-worker.sh \
&& chown root:root /usr/local/bin/docker-entrypoint-jmeter-worker.sh

USER jmeter

EXPOSE 1099 50000

ENTRYPOINT ["/usr/local/bin/docker-entrypoint-jmeter-worker.sh"]
200 changes: 141 additions & 59 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,122 +1,204 @@
# jmeter-docker

Docker images for JMeter 3.2 master and slave configurations
Apache [JMeter](https://jmeter.apache.org/) **5.6.3** with the [Plugins Manager](https://jmeter-plugins.org/wiki/PluginsManager/) and common **jp@gc** plugins baked in.

## On a local machine
Two roles:

### Bring up slaves
- **Workers** run the `jmeter-worker` image, which starts `jmeter-server` on `1099/tcp` and `50000/tcp`.
- The **main controller** runs the `jmeter-base` image, publishes `60000/tcp`, and launches the distributed test against the workers.

```bash
sudo docker run -dit --name slave1 bsmithio/jmeter-slave:latest /bin/bash && \
sudo docker run -dit --name slave2 bsmithio/jmeter-slave:latest /bin/bash && \
sudo docker run -dit --name slave3 bsmithio/jmeter-slave:latest /bin/bash
```
## Bundled jp@gc plugins

### Bring up master
Plugins are installed at build time by the `PluginsManagerCMD.sh install …` line in the [Dockerfile](Dockerfile) — a comma-separated list of plugin IDs from the [jp@gc catalog](https://jmeter-plugins.org/repo/).

```bash
sudo docker run -dit --name master bsmithio/jmeter-master:latest /bin/bash
```
- **Change the bundled set:** edit that `install` list in the [Dockerfile](Dockerfile) (use `id=version` to pin), then rebuild. `jmeter-worker` builds on `jmeter-base`, so both get the same plugins.
- **Find plugin IDs:** browse the [catalog](https://jmeter-plugins.org/repo/), or run `PluginsManagerCMD.sh status` / `available` in a container (see the [CLI docs](https://jmeter-plugins.org/wiki/PluginsManagerAutomated/)).
- **Add plugins without rebuilding:** in your own Dockerfile, `FROM jmeter-docker:jmeter-base-5.6.3`, then as `root` run `"${JMETER_HOME}/bin/PluginsManagerCMD.sh" install your-id` (and `chown -R jmeter:jmeter "${JMETER_HOME}"`), then switch back to `USER jmeter`.

### Check if they're all running
## Build

```bash
sudo docker ps -a
export DOCKER_BUILDKIT=1

docker compose build
# …or with Bake:
docker buildx bake

# …or a single target:
docker build --pull --target jmeter-base -t bsmithio/jmeter-base:latest .
docker build --pull --target jmeter-worker -t bsmithio/jmeter-worker:latest .
```

### Get all of their IP addresses
Compose and Bake tag the images `jmeter-docker:jmeter-base-<version>` and `jmeter-docker:jmeter-worker-<version>` — the tags used in the examples below.

To change the JMeter version, update `JMETER_VERSION` in each of:

- [docker-compose.yml](docker-compose.yml) (`x-jmeter-version`)
- [docker-bake.hcl](docker-bake.hcl) (`variable "JMETER_VERSION"`)
- [Dockerfile](Dockerfile) (`ARG JMETER_VERSION` default)
- [.github/workflows/docker-image.yml](.github/workflows/docker-image.yml) (`build-args` / tags)

Also refresh the `.sha512` file when the archive version changes.

```bash
sudo docker inspect --format '{{ .Name }} => {{ .NetworkSettings.IPAddress }}' $(sudo docker ps -a -q)
docker build --pull --target jmeter-base \
--build-arg JMETER_VERSION=5.6.3 \
-t my/jmeter-base:5.6.3 .
```

### Put a sample test plan in the master container
## On your laptop

### Start the main controller

```bash
sudo docker exec -i master sh -c 'cat > /home/jmeter/apache-jmeter-3.2/bin/test.jmx' < test.jmx
docker run --detach --name main-controller \
-p 60000:60000 \
jmeter-docker:jmeter-base-5.6.3
```

### Go inside the master container
### Start the workers

Set `LOCALIP` to the address the controller uses to reach each worker.

```bash
sudo docker exec -it master /bin/bash
docker run --detach \
--name worker1 \
-e LOCALIP='192.168.1.101' \
-p 1099:1099 -p 50000:50000 \
jmeter-docker:jmeter-worker-5.6.3

docker run --detach \
--name worker2 \
-e LOCALIP='192.168.1.102' \
-p 1099:1099 -p 50000:50000 \
jmeter-docker:jmeter-worker-5.6.3
```

### Run the sample test plan
### Check they're running

```bash
/home/jmeter/apache-jmeter-3.2/bin/jmeter -n -t \
/home/jmeter/apache-jmeter-3.2/bin/test.jmx \
-Djava.rmi.server.hostname=172.17.0.5 \
-Dclient.rmi.localport=60000 \
-R172.17.0.2,172.17.0.3,172.17.0.4
docker container ls --format '{{.Names}}\t{{.Status}}'
```

## On AWS instances
### Copy a test plan into the controller

### Create AWS instances with the following security groups:
`${JMETER_HOME}` is `/opt/apache-jmeter-5.6.3` inside the image.

```bash
Port=22 Protocol=tcp Source=0.0.0.0/0
Port=1099 Protocol=tcp Source=0.0.0.0/0
Port=50000 Protocol=tcp Source=0.0.0.0/0
Port=60000 Protocol=tcp Source=0.0.0.0/0
docker exec -i main-controller sh -c 'cat > "${JMETER_HOME}/bin/test.jmx"' < test.jmx
```

### Install docker on the instances
### Run the distributed test

Set `CONTROLLER_IP`, `CLIENT_PORT`, and the worker IPs for your environment.

```bash
sudo su -
apt-get update
apt-get install docker.io
docker exec -it main-controller "${JMETER_HOME}/bin/jmeter" \
-n \
-t "${JMETER_HOME}/bin/test.jmx" \
-Djava.rmi.server.hostname="${CONTROLLER_IP}" \
-Dclient.rmi.localport="${CLIENT_PORT}" \
-R "${WORKER1_IP},${WORKER2_IP}"
```

### Get out of `root` user
### Shell into a container

```bash
exit
docker exec -it main-controller bash # controller
docker exec -it worker1 bash # worker
```

### On the slave instances
## On AWS (or similar) hosts

Where `$LOCALIP` should be the public IP address of the host.
### Firewall / security groups

```bash
sudo docker run -dit -e LOCALIP='(slave1 ip)' -p 1099:1099 -p 50000:50000 bsmithio/jmeter-slave:latest /bin/bash
sudo docker run -dit -e LOCALIP='(slave2 ip)' -p 1099:1099 -p 50000:50000 bsmithio/jmeter-slave:latest /bin/bash
sudo docker run -dit -e LOCALIP='(slave3 ip)' -p 1099:1099 -p 50000:50000 bsmithio/jmeter-slave:latest /bin/bash
```
> ⚠️ **By default, workers require no authentication and use no encryption.** They run any test plan they receive, and test plans can run arbitrary code (JSR223/Groovy, OS Process samplers). Anyone who can reach `1099/tcp` or `50000/tcp` can run code inside the worker. **Never expose these ports to the public internet** — never `0.0.0.0/0`. Restrict them to the controller and workers, prefer a private network (VPC/VPN), and set `LOCALIP` to a private address. To require authentication and encryption, enable [RMI SSL](#enabling-rmi-ssl).

Open only what you need, scoped to trusted sources:

### On the master instance, do
- **22/tcp** — SSH, restricted to your admin IPs
- **1099/tcp** & **50000/tcp** — workers, restricted to the controller and workers
- **60000/tcp** — controller (`-Dclient.rmi.localport`), restricted to the workers

### Controller

```bash
sudo docker run -dit --name master -p 60000:60000 bsmithio/jmeter-master:latest /bin/bash
docker run --detach --name main-controller \
--restart unless-stopped \
-p 60000:60000 \
jmeter-docker:jmeter-base-5.6.3
```

### Copy the test to the master container
### Workers

The lookup below sets `LOCALIP` to the host's **public** IP — use it only if the controller reaches workers over public addresses. On a private network, use the private IP and bind ports to that interface (e.g. `-p 10.0.0.5:1099:1099`).

```bash
curl https://raw.githubusercontent.com/smithbr/jmeter-docker/master/test.jmx > test.jmx
docker run --detach \
--name worker1 \
--restart unless-stopped \
-e LOCALIP="$(curl -fs https://checkip.amazonaws.com)" \
-p 1099:1099 -p 50000:50000 \
jmeter-docker:jmeter-worker-5.6.3
```

### Move the test to the container
Copy in `test.jmx` and run the test the same way as [on your laptop](#copy-a-test-plan-into-the-controller).

## Enabling RMI SSL

To require authentication and encryption, give the controller and every worker the **same** keystore — a peer without it can't connect, which closes the code-execution risk noted above.

### 1. Generate one shared keystore

Generate it once and copy the same file to every host:

```bash
sudo docker exec -i master sh -c 'cat > /home/jmeter/apache-jmeter-3.2/bin/test.jmx' < test.jmx
docker run --rm -v "$(pwd):/out" -w /out \
jmeter-docker:jmeter-base-5.6.3 \
sh -c '"${JMETER_HOME}/bin/create-rmi-keystore.sh" && cp rmi_keystore.jks /out/'
```

### Enter the master container
This writes `rmi_keystore.jks` to the current directory; note the password you set (JMeter's default is `changeit`). The same file and password must go to the controller and all workers — a per-host keystore won't work.

### 2. Start workers with the keystore

Mount it read-only and set `RMI_KEYSTORE` and `RMI_KEYSTORE_PASSWORD`:

```bash
sudo docker exec -it master /bin/bash
docker run --detach \
--name worker1 \
--restart unless-stopped \
-e LOCALIP='192.168.1.101' \
-e RMI_KEYSTORE=/rmi_keystore.jks \
-e RMI_KEYSTORE_PASSWORD='changeit' \
-v "$(pwd)/rmi_keystore.jks:/rmi_keystore.jks:ro" \
-p 1099:1099 -p 50000:50000 \
jmeter-docker:jmeter-worker-5.6.3
```

### And run the test
Env vars: `RMI_KEYSTORE` (path in the container; setting it enables SSL), `RMI_KEYSTORE_PASSWORD` (required), `RMI_KEYSTORE_TYPE` (optional, default `JKS`). The password is read from a private file, not the command line, so it never appears in the process list.

### 3. Run the controller with the same keystore

Mount the keystore and pass the SSL properties on the run:

```bash
/home/jmeter/apache-jmeter-3.2/bin/jmeter -n -t \
/home/jmeter/apache-jmeter-3.2/bin/test.jmx \
-Djava.rmi.server.hostname=(master ip) \
-Dclient.rmi.localport=60000 \
-R(slave1 ip),(slave2 ip),(slave3 ip)
docker run --detach --name main-controller \
--restart unless-stopped \
-p 60000:60000 \
-v "$(pwd)/rmi_keystore.jks:/rmi_keystore.jks:ro" \
jmeter-docker:jmeter-base-5.6.3

docker exec -it main-controller "${JMETER_HOME}/bin/jmeter" \
-n -t "${JMETER_HOME}/bin/test.jmx" \
-Djava.rmi.server.hostname="${CONTROLLER_IP}" \
-Dclient.rmi.localport="${CLIENT_PORT}" \
-Jserver.rmi.ssl.disable=false \
-Jserver.rmi.ssl.keystore.file=/rmi_keystore.jks \
-Jserver.rmi.ssl.keystore.password='changeit' \
-Jserver.rmi.ssl.truststore.file=/rmi_keystore.jks \
-Jserver.rmi.ssl.truststore.password='changeit' \
-R "${WORKER1_IP},${WORKER2_IP}"
```

> Keep the firewall rules even with SSL on. To rotate the keystore, regenerate it and redistribute to every host at once.
Loading
Loading