Skip to main content

k6 Operator: Distributed k6 Tests on Kubernetes

k6 Operator v1.7.0 tested on a 3-node cluster: how parallelism split 10 VUs into 3/3/2/2, why setup() ran four times, and failed runs still marked finished.

k6 Operator: Distributed k6 Tests on Kubernetes

The k6 Operator is Grafana’s Kubernetes operator for running one k6 test across several pods. You install it once, put your script in a ConfigMap, and create a TestRun resource with a parallelism value. The operator starts that many k6 runner pods, gives each a slice of the load, and starts them together so the test behaves like one run from many machines.

We installed operator v1.7.0 (released 15 October 2026) on a three-node kind cluster and ran k6 v2.4.0 through it seven times against a small test API. The happy path worked first time. The surprises were in what the operator does not do: it never merges results, it reported finished for a run where every runner failed its thresholds, and it did not replace a runner we killed.

Key Takeaways

  • Install with the release bundle or the Helm chart, put the script in a ConfigMap, and create a TestRun with parallelism. Each runner is a Kubernetes Job with backoffLimit: 0.
  • The load is split by k6 execution segments. 40 VUs over 4 runners gave 10 each, but 10 VUs over 4 gave 3, 3, 2 and 2, and 1,000 shared iterations over 3 runners gave 400, 300 and 300. Totals were always exact.
  • setup() and teardown() ran once on every runner in our plain TestRun: four runners, four setups.
  • Every runner judges thresholds on its own share. A count>3000 threshold that the whole test passed (4,752 requests) failed on all four runners, and the TestRun still said finished. Judge the run by the runner pods’ exit codes.
  • There is no merged summary. Each runner prints its own, so for one view of the run you send every runner’s metrics to a shared backend.

What does the k6 Operator do?

The operator turns one k6 test into a distributed run inside a Kubernetes cluster. You describe the test in a TestRun resource, and the operator creates an initializer pod that checks the script, one runner Job per unit of parallelism, and a starter pod that tells all runners to begin at once. Each runner gets a share of the virtual users or iterations through k6’s execution segments.

It is open source and free to run, which is the main reason teams pick it over a hosted service. What you take on in exchange is the cluster, the nodes the runners use, and the results pipeline. Our comparison of k6 OSS, Grafana Cloud k6 and a managed platform weighs that trade-off; this guide assumes you have chosen the operator and want it to behave.

How do you install the k6 Operator?

Apply the release bundle with kubectl, or install the Helm chart. The bundle creates a k6-operator-system namespace, the controller Deployment, RBAC, and two custom resource definitions: testruns.k6.io and privateloadzones.k6.io (the second is for Grafana Cloud private load zones). Pin a version tag in production; the docs’ example installs from the main branch.

# bundle, pinned to the release we tested
kubectl apply -f https://raw.githubusercontent.com/grafana/k6-operator/v1.7.0/bundle.yaml

# or Helm (chart 4.7.0 ships controller v1.7.0)
helm repo add grafana https://grafana.github.io/helm-charts
helm install k6-operator grafana/k6-operator --version 4.7.0

kubectl -n k6-operator-system rollout status deploy/k6-operator-controller-manager

We used the bundle; the controller was ready within seconds. For a local lab, kind works: our cluster was one control-plane node plus two workers, on Docker Desktop. Point kubectl at a kubeconfig used only for the lab, so an apply meant for a test cluster cannot land on a real one: KUBECONFIG=./lab.kubeconfig kind create cluster --name k6lab writes there instead of your default file.

How do you run a k6 test with a TestRun?

Store the script in a ConfigMap, then create a TestRun that names the ConfigMap, the file and the number of runners. Our TestRuns moved through initialization, created, started, stopped and finished, or ended at error when the script could not be loaded (the CRD also defines an initialized stage). kubectl get testrun shows the current stage.

kubectl create configmap k6-test --from-file=test.js
apiVersion: k6.io/v1alpha1
kind: TestRun
metadata:
  name: run-a
spec:
  parallelism: 4
  script:
    configMap:
      name: k6-test
      file: test.js
  runner:
    image: grafana/k6:2.4.0

Our script ran 40 constant VUs for 30 seconds. After kubectl apply -f testrun.yaml, the initializer finished in about 4 seconds, the TestRun reached started 8 seconds after the apply, the load ran for 35 seconds including setup() and teardown(), and the TestRun read finished 47 seconds after we created it. The initializer runs k6 archive and k6 inspect --execution-requirements on your script, so a syntax error stops the run before any runner starts.

Each runner is started as k6 run --paused with its own --execution-segment and a REST API on port 6565, and the starter sends each one a “resume” call. That is why the runners begin together instead of whenever their pods happen to come up. Two defaults are worth changing on a real cluster: the runner pods had no CPU or memory requests (resources: {}), so set runner.resources before the scheduler packs them onto one node, and the starter pod ran the latest-starter image tag, which starter.image pins.

How does parallelism split VUs and iterations?

The operator hands each runner a k6 execution segment, such as 1/3:2/3, and k6 itself decides how many VUs and iterations fall in that segment. When the numbers divide evenly, every runner gets the same share. When they do not, some runners get one more VU, and iterations follow the VU split.

Grafana’s documentation describes the even case: 200 VUs with parallelism: 4 become four runners of 50. These are the splits we measured, each read from the runners’ own summaries and from per-runner request counts on the test API:

ScriptparallelismWhat each runner gotTotal
constant-vus, 40 VUs, 30 s410 VUs each38,139 requests
vus: 10, 15 s43, 3, 2 and 2 VUs (1,425, 1,423, 950 and 954 requests)4,752 requests
shared-iterations, 1,000 iterations, 10 VUs3400, 300 and 300 iterations1,000
per-vu-iterations, 6 VUs x 503100 iterations each300
constant-arrival-rate, 90/s for 10 s3315, 315 and 270 iterations900

The uneven rows matter when you compare runners. A runner with 3 VUs sends half as many requests again as one with 2, so its numbers are not a fault in that pod or its node. Pick a VU count that divides by parallelism when you want runners you can compare directly, and remember that per-runner arrival rates come out uneven too, even though the total rate is exact. For the executors themselves, see k6 scenarios and executors.

Do setup() and teardown() run once with the k6 Operator?

Not in our runs. With a plain TestRun and four runners, our test API logged four setup() calls and four teardown() calls, one from each runner, each tagged with that runner’s hostname. Every runner is a full k6 process, and each ran the whole test lifecycle for its segment.

That is harmless when setup() only fetches a token. It is not harmless when setup() creates test data, resets a database or registers users: you get one copy per runner. Make setup idempotent, have it create data keyed by the runner (the pod hostname is in __ENV.HOSTNAME, run-a-1 to run-a-4 in our run), or prepare the data before the TestRun starts. The TestRun status has a SetupExecuted condition, which stayed False in the runs we inspected; we read it as belonging to the Grafana Cloud modes, where setup is coordinated, and did not test those.

Where do k6 Operator results go, and do thresholds still work?

Results stay where each runner left them: every runner prints its own end-of-test summary to its pod log, and nothing merges them. Thresholds are evaluated per runner on that runner’s share. To see the whole run as one, add an output to the TestRun so every runner streams its metrics to a shared backend, and judge pass or fail from the runner exit codes.

We tested the threshold behaviour with a deliberately count-based rule. The script had http_reqs: ['count>3000'], ran 10 VUs for 15 seconds over four runners, and made 4,752 requests, enough to pass on a single machine. Every runner failed it on its own share:

    ✗ 'count>3000' count=1425
level=error msg="thresholds on metrics 'http_reqs' have been crossed"

All four runner pods exited with code 99 and their Jobs went to Failed, yet the TestRun reached stage finished with no error condition. A pipeline that waits for finished would report success. Check the runners instead:

kubectl get pods -l k6_cr=run-b,runner=true \
  -o 'custom-columns=NAME:.metadata.name,EXIT:.status.containerStatuses[0].state.terminated.exitCode'
NAME            EXIT
run-b-1-slk5m   99
run-b-2-l4vmg   99
run-b-3-j27gd   99
run-b-4-qlnxn   99

Rates and percentiles have the same per-runner scope. A p(95)<500 threshold passes or fails on each runner’s own requests, so four passing runners do not prove the p95 of the combined traffic, although with equal shares the two are usually close. Our k6 checks and thresholds guide covers exit code 99 on a single machine. For the shared view, add arguments: -o opentelemetry (or another output) to the TestRun spec with the output’s environment variables in runner.env; our k6 Grafana dashboard tests show which output to choose now that k6 v2.4.0 has deprecated two of them.

What happens when a runner fails?

The operator does not retry. Runner Jobs have backoffLimit: 0, so a runner that dies stays dead and the others carry on with their own shares. A pod that cannot be scheduled blocks the start of the whole test, and a script error ends the TestRun at the initializer. We tested all three.

A runner killed mid-test. With four runners and 40 VUs for 40 seconds, we deleted runner 2 after about 15 seconds. It received SIGTERM, stopped its VUs, still ran teardown(), and its Job went to Failed with BackoffLimitExceeded. No replacement pod was created. The other three ran to the end, so for the last 25 seconds the test applied 30 VUs instead of 40. The TestRun ended finished; the only hint was an INFO line in the controller log, 3/4 jobs complete, 1 failed. Runner 2 had made 4,173 requests against about 11,550 for each of the others. Grafana’s troubleshooting page warns about a related case: a runner killed for running out of memory leaves the whole test stuck until you delete it, and the fix is runner.resources.

More separated runners than nodes. separate: true adds a pod anti-affinity rule so no two runners share a node. Our cluster had two schedulable workers (the control-plane node carries a taint), and we asked for three runners. Two started; the third stayed Pending with 0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 node(s) didn't match pod anti-affinity rules. The TestRun sat at created for over two minutes and sent no traffic, because the starter waits for every runner. The rule matches any pod labelled app=k6,runner=true, not only this test’s, so a second separated TestRun on the same nodes would compete for them as well.

A syntax error. We removed a closing parenthesis. The initializer pod failed within about 4 seconds, the TestRun went to stage error, and no runner was created. The TestRun conditions said nothing about why; the reason was in the initializer’s log: could not initialize '/test/broken.js' ... Line 4:35 Unexpected token ;. Running k6 inspect on the script before you apply the TestRun catches this locally.

How do you clean up after a TestRun?

Delete the TestRun when you are done: kubectl delete testrun run-a removed its Jobs and pods in our runs, including a run stuck at created. Or set cleanup: post in the spec, and the operator deletes the TestRun and everything it created as soon as the test finishes. Ours was gone within a few seconds of the 10-second test ending.

cleanup: post keeps the namespace tidy, but it also deletes the runner pods whose logs and exit codes you need to judge the run. Use it only when every runner streams its results to an output, or when something else reads the exit codes first. Without it, finished pods stay until you delete them; ours were still there 20 minutes later, which is what let us read their logs.

Running k6 scripts without a cluster

If the cluster is only there to generate load, a managed service does the same job without one. LoadFocus runs k6 scripts on its own cloud infrastructure with up to 20,000 concurrent virtual users depending on the plan, from 25 AWS regions, with a percentage of the load per region if you want it. You upload the script, set the users and duration, and read the results in one place.

The k6 load testing page describes the setup: for higher loads LoadFocus spreads the test across several cloud instances itself, and a scheduled run emails you when it fails its pass/fail thresholds or does not complete. The operator remains the better fit when the load has to start inside your cluster, for example against services that are not reachable from outside it.

Frequently Asked Questions

What is the k6 Operator used for?

It runs one k6 test as several k6 pods in a Kubernetes cluster. You create a TestRun resource with a script and a parallelism value, and the operator starts that many runners, gives each a share of the virtual users or iterations, and starts them together. It is used to generate more load than one machine can, from inside your own cluster.

How does k6 Operator parallelism split virtual users?

Each runner gets a k6 execution segment, and k6 hands out whole VUs per segment. 40 VUs with parallelism 4 gave 10 VUs per runner in our test, but 10 VUs with parallelism 4 gave 3, 3, 2 and 2. Shared iterations follow the VU split: 1,000 iterations over 3 runners came out as 400, 300 and 300.

Does setup() run once per test in the k6 Operator?

Not in a plain TestRun. In our test with four runners, setup() and teardown() each ran four times, once in every runner pod. If setup creates data or changes state, make it safe to run several times, key the data by runner hostname, or prepare it before the test starts.

Why does my TestRun say finished when thresholds failed?

Because the TestRun stage only records progress: it moves to finished once the runners stop, whatever their exit codes. In our test every runner failed a threshold and exited with code 99, their Jobs were marked Failed, and the TestRun still reached the finished stage with no error condition. Read the runner pods’ exit codes to get the verdict.

Does the k6 Operator merge results from all runners?

No. Each runner prints its own end-of-test summary to its pod log, and thresholds are judged per runner. For one combined view, configure an output in the TestRun, such as OpenTelemetry or Prometheus remote write, so every runner streams its metrics to the same backend, and build the dashboard there.

Sources

  • k6 Operator v1.7.0 release notes (15 October 2026) and bundle.yaml at tag v1.7.0: github.com/grafana/k6-operator, read 14 November 2026.
  • Grafana k6 documentation: Install k6 Operator, Run k6 scripts with TestRun CRD, Configure the TestRun CRD (parallelism, separate) and k6 Operator troubleshooting, grafana.com/docs/k6/latest/set-up/set-up-distributed-k6/, read 14 November 2026.
  • k6 v2.4.0 release notes (12 November 2026): github.com/grafana/k6/releases, read 14 November 2026.
  • LoadFocus k6 capabilities: loadfocus.com/k6-load-testing, read 14 November 2026.
  • Test runs: k6 Operator v1.7.0 (controller image controller-v1.7.0 from ghcr.io, starter image latest-starter), runner image grafana/k6:2.4.0, on kind v0.33.0 with Kubernetes v1.37.0 (one control-plane node and two workers) on Docker Desktop, Apple M1 Max, against a local Node.js API answering in 20 ms, 14 November 2026. Seven TestRuns: 40 VUs over 4 runners, 10 VUs over 4 with a count threshold, three executors over 3 runners, a runner deleted mid-test, separate with 3 runners on 2 nodes, a syntax error, and cleanup post. Counts are from the runners’ summaries and the API’s per-runner request log.

Bogdan

Founder at LoadFocus

Bogdan builds and runs the tools this blog is about. He writes from what the products actually do in production, including the parts that break.

Weekly tips right in your inbox.

Don’t miss out on the latest testing tips and tricks, tools and news related to software testing.

Already +20,000 members in the list.

The Load Testing Platform with AI That Explains Your Results

Load test websites and APIs from 25 cloud regions, monitor page speed and uptime, and get AI analysis that explains your results in plain English.

Cloud load testing, website speed and API monitoring in one place.

Hi there! Can I share a cool thing I'm working on with you?

A software testing newsletter that sparks joy.

Our goal is to create helpful content for software developers and testers.

If you're not into it, you can unsubscribe instantly.