Skip to main content
Back to blog
tools 18 March 2026 10 min read (Updated: 9 September 2026)

JMeter on Kubernetes (and k6, Gatling): Distributed Load Testing Done Right

Run distributed JMeter on Kubernetes with StatefulSet workers and RMI done properly — plus why k6 and Gatling need different K8s primitives, not one chart.

M

Mark

Performance Testing Expert

When teams move onto Kubernetes, JMeter often stays on a laptop or a single CI runner. At a few hundred virtual users the runner saturates first — so the chart measures the injector, not the system under test.

Distributed load testing spreads generation across pods. Most guides and Helm charts you will find cover one tool, usually k6. Search for “JMeter on Kubernetes” and you get RMI snippets that break the moment a normal Service load-balances the worker pods, or a callback port that a NetworkPolicy silently blocks.

This post starts with JMeter’s controller–worker model on Kubernetes — StatefulSet, headless Service, ports, and the two mistakes that quietly distribute nothing — then shows why k6 and Gatling need different primitives, and why a single “unified” chart fails all three.

(Looking to test a system that runs on Kubernetes rather than run injectors on it? That is a different job — see Kubernetes performance testing.)

Why distributed load testing matters

A single load generator has a ceiling. Once CPU, memory, or network throughput is saturated on the runner, you’re not measuring your application’s limits — you’re measuring the load generator’s. For serious capacity testing — proving a system can handle 10,000 concurrent users, or validating autoscaling behaviour under sustained load — you need to spread the work.

Distributed testing also enables geographic distribution (generate load from multiple regions simultaneously) and avoids the client-side TLS/TCP overhead that can distort results at high concurrency on a single machine.

JMeter on Kubernetes: the two mistakes that waste a day

Before the deep dive, the two failure modes behind almost every “JMeter distributed test hangs on Kubernetes” thread:

1. A normal Service in front of the workers. A standard Service load-balances, so the controller’s -R list resolves to whichever worker pod answered that second — connections land on the wrong pods and the test runs on one worker or none. Workers belong in a StatefulSet behind a headless Service, so each pod gets a stable ordinal DNS name (jmeter-worker-0.jmeter-workers…) that the -R list can address individually.

2. The RMI callback. Workers stream results back to the controller over RMI, so the controller must be reachable at the hostname it advertises — java.rmi.server.hostname on the controller has to resolve from inside the worker pods, and the callback port must be fixed and open in any NetworkPolicy. Get it wrong and the test starts, hangs, or reports a fraction of the load with no error that names the cause.

Two quieter versions of the same day lost: every pod must run the same JMeter version and plugin set (a missing plugin on one worker fails only that worker’s samplers), and CSV data files are not distributed for you — each worker needs its own copy or slice mounted, or every “unique” user logs in as the same row.

The fundamental problem: three tools, three distributed models

JMeter, k6, and Gatling each have a completely different approach to distribution. This is not a quirk — it reflects each tool’s underlying architecture. Understanding the model is essential to deploying it correctly.

JMeter: RMI controller-worker

JMeter uses Java RMI (Remote Method Invocation) for distributed testing. A controller node coordinates the test and a set of worker nodes execute it. The controller connects to workers over two ports: 1099 (RMI registry) and 50000 (RMI data channel).

The critical constraint: the controller connects to the workers, not the other way around. Workers must be resolvable by DNS name or IP from the controller at startup. Workers must also be able to reach the controller for result streaming — this is a bidirectional communication channel.

This has direct implications for Kubernetes deployment:

  • Workers need stable, predictable network identities — a StatefulSet with headless service is the right primitive, not a Deployment
  • Pod ordinal names (jmeter-worker-0, jmeter-worker-1) give you the DNS names you need
  • The controller must wait for all workers to be ready before starting — an init container doing DNS resolution handles this
  • The -R flag passed to the controller lists all worker hosts — this must be generated dynamically from the StatefulSet replicas

k6: stateless segment runners

k6’s distributed model is fundamentally different. There is no coordinator. Each k6 runner is stateless and executes an independent slice of the total virtual user load. If you have 3,000 VUs and three runners, each runs 1,000 VUs independently.

Coordination happens through configuration, not communication. Each runner is told its index (RUNNER_INDEX) and the total runner count (RUNNERS_TOTAL). The script uses this to calculate its share:

const vuShare = Math.ceil(__ENV.VU_COUNT / __ENV.RUNNERS_TOTAL);

Because runners are stateless and interchangeable, a Kubernetes Deployment is the correct primitive. Runners can be scaled up or down without any cluster reconfiguration. Metrics stream independently to Prometheus or InfluxDB.

This simplicity is k6’s biggest advantage for Kubernetes. There’s no choreography, no init containers waiting for peers, no bidirectional communication. You deploy pods and they run.

Gatling: Akka actor coordination

Gatling’s distributed model uses Akka remoting. A master node coordinates workers over a cluster formed using Akka’s actor system. The complication: Akka requires IP addresses, not DNS names, for cluster membership.

This makes Kubernetes deployment non-trivial. A StatefulSet gives you stable DNS (gatling-worker-0.gatling-workers), but Gatling cannot use these names directly — it needs the actual pod IPs. An init container must run kubectl get pods to discover worker IPs and write them to a shared volume that the master reads at startup.

Because the init container needs to query the Kubernetes API, it requires RBAC permissions — a ServiceAccount, Role, and RoleBinding scoped to get and list on pods.

Workers also need a dedicated port (2552) open for Akka remoting, which requires explicit NetworkPolicy rules.

Why a single unified chart fails

Some teams try to build one Helm chart that supports all three tools through configuration flags. This always results in a chart that makes bad tradeoffs for every tool.

JMeter needs a StatefulSet for workers and a Job for the controller. k6 needs a Deployment. Gatling needs a StatefulSet for workers with Akka ports, a Job for the master, and RBAC resources. These aren’t configuration details — they’re fundamental to how each tool operates.

A chart that uses Deployment for everything will break JMeter’s RMI discovery. A chart that creates RBAC for everything will add unnecessary permissions to k6 runners. A chart that uses StatefulSet for k6 adds state where none is needed.

The right approach is three separate charts, each using the K8s primitives that match the tool’s distributed model.

ToolWorker WorkloadController WorkloadCoordination
JMeterStatefulSetJobJava RMI (ports 1099, 50000)
k6Deployment— (all runners equal)None — config only
GatlingStatefulSetJobAkka remoting (port 2552)

Deploying with Helm

Getting this right means handling init containers, DNS generation, RBAC, NetworkPolicy, non-root security contexts and multi-arch images — the parts that are fiddly rather than difficult, and the parts that cost the most time.

Prerequisites

# Kubernetes 1.24+ and Helm 3.10+
kubectl create namespace perf-testing

# Get your free license key at:
# registry.martkos-it.co.uk — licensed images, see perf-containers
# Then generate a registry token and create the pull secret:
TOKEN=$(curl -s https://updates.martkos-it.co.uk/api/v1/registry-token \
  -H "X-License-Key: YOUR_LICENSE_KEY" \
  -H "X-Product: perf-containers" | jq -r .token)

kubectl create secret docker-registry martkos-registry \
  --docker-server=registry.martkos-it.co.uk \
  --docker-username=license \
  --docker-password="$TOKEN" \
  --namespace perf-testing

JMeter distributed test

# Load your test plan
kubectl create configmap my-test-plan \
  --from-file=test-plan.jmx=./test.jmx \
  --namespace perf-testing

helm install my-jmeter-test \
  oci://registry.martkos-it.co.uk/charts/perf-jmeter \
  --version 1.0.0 \
  --namespace perf-testing \
  --set test.configMapName=my-test-plan \
  --set worker.replicas=5 \
  --set global.imagePullSecrets[0].name=martkos-registry

Five worker pods spin up as a StatefulSet. An init container on the controller waits for all five to resolve via DNS before submitting the test plan via RMI. Workers stream results back as the test runs.

k6 distributed test

kubectl create configmap my-k6-script \
  --from-file=script.js=./test.js \
  --namespace perf-testing

helm install my-k6-test \
  oci://registry.martkos-it.co.uk/charts/perf-k6 \
  --version 1.0.0 \
  --namespace perf-testing \
  --set test.configMapName=my-k6-script \
  --set runner.replicas=3 \
  --set test.vuCount=3000 \
  --set global.imagePullSecrets[0].name=martkos-registry

Each of the three runners gets RUNNER_INDEX and RUNNERS_TOTAL injected as environment variables and runs 1,000 VUs independently.

Gatling distributed test

kubectl create configmap my-gatling-sim \
  --from-file=MySimulation.scala=./MySimulation.scala \
  --namespace perf-testing

helm install my-gatling-test \
  oci://registry.martkos-it.co.uk/charts/perf-gatling \
  --version 1.0.0 \
  --namespace perf-testing \
  --set test.configMapName=my-gatling-sim \
  --set test.simulationClass=MySimulation \
  --set worker.replicas=4 \
  --set global.imagePullSecrets[0].name=martkos-registry

The chart creates a ServiceAccount with pod get/list permissions. An init container discovers worker pod IPs via kubectl get pods and writes them to a shared volume. The master reads this file to form the Akka cluster.

Scaling

All three charts scale by changing a single Helm value:

helm upgrade my-jmeter-test oci://registry.martkos-it.co.uk/charts/perf-jmeter \
  --version 1.0.0 \
  --reuse-values \
  --set worker.replicas=10

There are no license-enforced caps on pods, workers, or runners. Add nodes to your cluster and scale the replicas.

Security

All three charts ship with production-grade defaults:

  • Non-root containers — all images run as UID 1000
  • Read-only root filesystem — writable paths explicitly mounted as volumes
  • NetworkPolicy — egress scoped to the system under test; inter-pod communication restricted to required ports only
  • RBAC — Gatling’s kubectl access is scoped to pod get/list within the namespace; JMeter and k6 require no cluster permissions

When to use which tool

The distributed model isn’t the only factor in tool choice, but it does affect operational complexity on Kubernetes:

Choose k6 if you want the simplest Kubernetes deployment. Stateless runners, no init containers, no RBAC. Ideal for teams already using JavaScript and CI-first workflows.

Choose JMeter if you have an existing JMX library and need to reuse those scripts at scale. The StatefulSet + RMI model is more complex but well-understood, and JMeter’s GUI for script authoring remains unmatched for complex correlation scenarios.

Choose Gatling if your team is Scala/JVM-native and values the type safety and IDE support of the Gatling DSL. The Akka coordination model adds setup complexity but Gatling’s HTML reports and simulation assertions are best-in-class.

Getting started

The runtime images are the part worth not rebuilding: JMeter with its 17 protocol plugins, k6 built with the xk6 extensions, and Gatling with the JDBC drivers and messaging clients already in place, multi-arch for amd64 and arm64.

Perf Containers ships all of them from a private registry, along with k6-shard.sh for splitting a run across workers and a written-up guide to the two things people get wrong with distributed JMeter — the RMI callback address, and per-ordinal worker addressing, where a normal Service load-balances and the run silently distributes nothing.

A note on what changed: Martkos previously sold a separate perf-distributed-testing product of Helm charts. It was withdrawn — its free tier could not be delivered as documented, because the charts were never published publicly. The parts of it worth keeping moved into Perf Containers, and the manifests in this article stand on their own.

Tags:

#kubernetes #jmeter #k6 #gatling #distributed-testing #helm #performance-testing

The cheat sheets, as printable PDFs

All 52 are free to read on the site — no signup. Want them as A4 PDFs you can actually print? The pack covers JMeter, k6, Gatling, Docker, Kubernetes and observability, and costs nothing but an email address.

Get the PDF pack

Delivered by email. Unsubscribe any time — see our privacy policy.

Stuck on this in your own test suite?

I take on fixed-price, fixed-turnaround performance work — script migrations, HAR-to-script conversion and written performance audits. No calls required.

Get in Touch