k6 tutorial: captured traffic with mocked backends
Point k6 at a service with live dependencies and you end up load testing the dependencies: their rate limits, their shared test data, their bad day. This tutorial captures requests with Speedscale, converts the inbound traffic into a k6 scenario, and uses the recorded outbound traffic to mock the backends. k6 drives the real application, the application calls recorded dependencies, and the numbers you get describe your service.
We’ll use the NGINX demo. Its gateway forwards /payment/ to the payment service. You’ll capture that exchange once, then run the same k6 scenario against the gateway in Kubernetes or in Docker on your laptop. Capture and the k6 conversion are shared. After that, pick one path: mock in Kubernetes with speedctl or mock locally with proxymock. You don’t need both.
flowchart TB
C[Speedscale capture] --> S[Snapshot: inbound and outbound traffic]
S --> E[Export inbound requests to k6]
S --> M[Recorded backend responses]
E --> K[k6 load scenario]
K --> A[Real NGINX gateway]
A --> R[Backend mocks]
M --> R
R --- B[speedctl in Kubernetes or proxymock locally]
Before you start
You need Docker, Git, Python 3, jq, kubectl, k6, and an initialized speedctl. For the local option, install and initialize proxymock for the same Speedscale account. Follow the k6 installation guide, Speedscale Operator installation guide, and proxymock quickstart if these aren’t installed.
Use a development cluster with an active Speedscale inspector. The cluster path requires Speedscale v2.5.789 or newer and speedctl v2.5.833 or newer for the mock readiness check. Use a current proxymock with --no-passthrough support. Port 8080 must be free on your machine for either path. The local path also needs 18081, plus proxymock’s default listener ports 4140 and 4145. The local Docker commands work on Docker Desktop; --add-host also supplies the host gateway mapping on Linux. These examples were tested with Speedscale v2.5.1171, speedctl v2.5.1059, proxymock v2.5.1133, k6 v2.2.0, and NGINX 1.27.5.
Set these variables in the terminal where you run the tutorial. CLUSTER is the cluster name registered in Speedscale; KUBE_CONTEXT is its kubectl context. They can differ. Substitute your own values, then confirm they refer to the same development cluster:
export KUBE_CONTEXT=minikube
export CLUSTER=demo-cluster
export NAMESPACE=k6-tutorial
kubectl --context "$KUBE_CONTEXT" get nodes
speedctl infra inspectors -o pretty
Capture traffic with Speedscale
Clone the pinned demo revision into a new directory. Deploy its services without the demo’s background traffic generator:
git clone https://github.com/speedscale/demo.git k6-demo
cd k6-demo
git checkout 0ad18e83e93008f45e68b5d67879412bb050e8f2
mkdir k6-workflow
cd k6-workflow
kubectl --context "$KUBE_CONTEXT" create namespace "$NAMESPACE"
kubectl --context "$KUBE_CONTEXT" apply -n "$NAMESPACE" \
-f ../nginx/base/gateway.yaml \
-f ../nginx/base/payment.yaml \
-f ../nginx/base/user.yaml
kubectl --context "$KUBE_CONTEXT" annotate -n "$NAMESPACE" deployment/gateway \
sidecar.speedscale.com/inject=true \
sidecar.speedscale.com/capture-mode=proxy \
sidecar.speedscale.com/proxy-type=transparent
kubectl --context "$KUBE_CONTEXT" rollout status -n "$NAMESPACE" deployment/payment --timeout=120s
kubectl --context "$KUBE_CONTEXT" rollout status -n "$NAMESPACE" deployment/user --timeout=120s
kubectl --context "$KUBE_CONTEXT" rollout status -n "$NAMESPACE" deployment/gateway --timeout=120s
NGINX resolves both the payment and user upstreams when it starts, so deploy both even though this test uses only payment. Generate the capture request from inside the cluster. This also avoids recording a loopback port-forward connection as outbound traffic:
kubectl --context "$KUBE_CONTEXT" run k6-capture -n "$NAMESPACE" \
--image=curlimages/curl:8.12.1 --restart=Never --command -- \
curl --fail --silent --show-error http://gateway/payment/
kubectl --context "$KUBE_CONTEXT" wait -n "$NAMESPACE" pod/k6-capture \
--for=jsonpath='{.status.phase}'=Succeeded --timeout=90s
kubectl --context "$KUBE_CONTEXT" logs -n "$NAMESPACE" k6-capture
The response is:
{"status": "ok", "service": "payment", "version": "1.0"}
In Speedscale’s Traffic Viewer, filter to this cluster, namespace, and gateway service. Confirm you see the inbound GET /payment/ and the outbound GET / to payment:80 before creating a snapshot. Both directions must be present: the inbound request becomes the k6 test, and the outbound response becomes the backend mock. If traffic hasn’t arrived yet, wait for collection before continuing.
Save the filtered traffic in the dashboard, or create the same snapshot with the CLI:
SNAPSHOT_ID=$(speedctl create snapshot \
--name k6-gateway-payment \
--service gateway \
--filter "(cluster IS \"$CLUSTER\") AND (namespace IS \"$NAMESPACE\") AND (service IS \"gateway\")" \
--start 5m -o json | jq -er '.snapshot.id')
export SNAPSHOT_ID
speedctl wait snapshot "$SNAPSHOT_ID" --timeout 3m
Keep the snapshot limited to this route for the tutorial’s payment-specific assertions. For your own application, mask sensitive data and review authentication and dynamic values before exporting traffic.
Convert the snapshot into a k6 load scenario
Export the snapshot to k6, including only inbound requests. The snapshot itself keeps both directions:
speedctl export snapshot "$SNAPSHOT_ID" \
--type k6 --inbound-only --output recorded.js
Open recorded.js and confirm it contains a GET for /payment/. An empty iteration function means the snapshot has no eligible inbound HTTP requests. Stop and correct the capture before proceeding. This export handles HTTP and HTTPS requests; captured database calls belong in the backend mocks.
The exporter preserves recorded requests but doesn’t add a load profile or application assertions. Save the following as prepare-k6.py. It keeps recorded.js unchanged and writes k6.js with a configurable target, five virtual users for 30 seconds, and checks for the payment response:
from pathlib import Path
source = Path('recorded.js').read_text()
call = 'http.request(method, url.toString(), body, params);'
if call not in source:
raise SystemExit('Exporter format changed: review recorded.js before adapting it.')
extra = '''
import { check, sleep } from 'k6';
if (!__ENV.BASE_URL) {
throw new Error('Set BASE_URL to the application URL.');
}
const target = new URL(__ENV.BASE_URL);
if (!['http:', 'https:'].includes(target.protocol) || target.pathname !== '/' || target.search || target.hash) {
throw new Error('BASE_URL must be an HTTP(S) origin without a path, query, or fragment.');
}
export const options = {
scenarios: {
recorded_traffic: {
executor: 'constant-vus',
vus: 5,
duration: '30s',
gracefulStop: '5s',
},
},
thresholds: {
http_reqs: ['count>0'],
iterations: ['count>0'],
http_req_failed: ['rate==0'],
checks: ['rate==1'],
http_req_duration: ['p(95)<500'],
},
};
function requestWithChecks(method, recordedURL, body, params) {
const url = new URL(recordedURL);
url.protocol = target.protocol;
url.hostname = target.hostname;
url.port = target.port;
for (const key of Object.keys(params.headers)) {
if (['host', 'connection', 'content-length'].includes(key.toLowerCase())) {
delete params.headers[key];
}
}
params.timeout = '5s';
const response = http.request(method, url.toString(), body, params);
check(response, {
'HTTP 200': (r) => r.status === 200,
'payment response': (r) => {
try {
const data = r.json();
return data.status === 'ok' && data.service === 'payment' && data.version === '1.0';
} catch (_) {
return false;
}
},
});
}
'''
marker = 'export default function () {'
if source.count(marker) != 1:
raise SystemExit('Expected exactly one exported iteration function.')
source = source.replace(call, 'requestWithChecks(method, url.toString(), body, params);')
source = source.replace(marker, extra + '\n' + marker, 1)
last_brace = source.rfind('}')
source = source[:last_brace] + ' sleep(1);\n' + source[last_brace:]
Path('k6.js').write_text(source)
print('Wrote k6.js; recorded.js is unchanged.')
Run the conversion:
python3 prepare-k6.py
k6 inspect --env BASE_URL=http://127.0.0.1:8080 k6.js
The conversion changes each request’s scheme, hostname, and port. It preserves the method, path, query parameters, body, and other headers. Removing the recorded Host header lets k6 address the new target correctly. If your application depends on virtual-host routing, supply its required Host header explicitly instead.
One iteration runs the complete exported request sequence, then sleeps for one second. Five VUs is concurrent activity, not a fixed requests-per-second rate. A larger snapshot sends more requests per iteration. Start small and set your load model deliberately; this export doesn’t reconstruct session correlations or refresh expired credentials.
The thresholds require completed requests and iterations. They also make a failed request, failed body check, or p95 latency of 500 ms or more return a nonzero k6 exit code. Each request has a five-second timeout. That latency budget is an example for this demo; set it to your application’s SLO. k6 checks need thresholds to fail the run.
Mock backends in Kubernetes with speedctl
For k6 to supply all inbound traffic, start a Speedscale replay in responder-only mode. --mock-only selects dependencies and doesn’t disable Speedscale’s traffic generator. Save this as external-driver.json:
{
"id": "k6-tutorial-external-driver",
"protected": false,
"generator": {
"stages": [
{
"virtualUsers": {
"virtualUsers": "1",
"requestDelay": {}
}
}
]
},
"rules": [],
"version": 3,
"assertionGroups": [],
"responder": {
"numReplicas": 1,
"passthrough_mode": false,
"with_report": true
},
"cluster": {
"replayMode": "responder-only",
"logsCollected": false,
"sidecar_tls_out": true,
"cleanup": "inventory",
"logLevel": "info"
}
}
Upload the config:
speedctl put testconfig external-driver.json
passthrough_mode: false makes an unmatched backend request fail instead of reaching the live payment service. The operator attaches the recorded outbound mocks to the gateway. Follow the external driver lifecycle: start mocks, wait for readiness, start a fresh port-forward, run k6, and cancel the replay.
Save this as run-cluster.sh and run it from k6-workflow:
#!/usr/bin/env bash
set -euo pipefail
: "${SNAPSHOT_ID:?Set SNAPSHOT_ID}"
: "${CLUSTER:?Set CLUSTER}"
: "${NAMESPACE:?Set NAMESPACE}"
: "${KUBE_CONTEXT:?Set KUBE_CONTEXT}"
SUT_PORT=${SUT_PORT:-8080}
REPORT_ID=""
FORWARD_PID=""
cleanup() {
status=$?
trap - EXIT
if [[ -n "$FORWARD_PID" ]]; then
kill "$FORWARD_PID" 2>/dev/null || true
wait "$FORWARD_PID" 2>/dev/null || true
fi
if [[ -n "$REPORT_ID" ]]; then
speedctl infra cancel-replay "$REPORT_ID" || true
fi
exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
REPORT_ID=$(speedctl infra replay \
--snapshot-id "$SNAPSHOT_ID" \
--cluster "$CLUSTER" --namespace "$NAMESPACE" --service gateway \
--test-config-id k6-tutorial-external-driver --id-only)
speedctl wait replay "$REPORT_ID" --for mocks-ready \
--cluster "$CLUSTER" --namespace "$NAMESPACE" \
--timeout 10m --poll-interval 5s
kubectl --context "$KUBE_CONTEXT" port-forward -n "$NAMESPACE" \
service/gateway "$SUT_PORT:80" > port-forward.log 2>&1 &
FORWARD_PID=$!
ready=false
for attempt in {1..30}; do
if ! kill -0 "$FORWARD_PID" 2>/dev/null; then
cat port-forward.log
exit 1
fi
if curl --noproxy '*' --max-time 2 --fail --silent \
"http://127.0.0.1:$SUT_PORT/payment/" > /dev/null; then
ready=true
break
fi
sleep 1
done
if [[ "$ready" != true ]]; then
cat port-forward.log
exit 1
fi
k6 run --env "BASE_URL=http://127.0.0.1:$SUT_PORT" k6.js
bash run-cluster.sh
Starting the port-forward after mocks-ready matters: attaching mocks can roll the gateway pod and disconnect an earlier port-forward. The cleanup trap cancels the replay when k6 finishes or fails.
In the Speedscale report, verify that payment requests matched recorded responses and that no backend requests passed through. k6’s successful payment body check verifies the gateway’s response; the mock match data verifies where the backend response came from.
Mock backends locally with proxymock
Stay in k6-demo/k6-workflow. Pull the same snapshot into local files:
proxymock cloud pull snapshot "$SNAPSHOT_ID" --out ./mocks
Start the mock server in this terminal and leave it running:
proxymock mock --in ./mocks --out ./mock-results \
--map 18081=http://payment:80 --no-passthrough
NGINX doesn’t use HTTP_PROXY or http_proxy. The reverse mapping listens on port 18081 and associates requests with the recorded payment:80 backend. --no-passthrough prevents unmatched calls from reaching a real dependency.
In a second terminal, change to the same k6-demo/k6-workflow directory. Save this as local-gateway.conf:
server {
listen 8080;
location /payment/ {
proxy_pass http://host.docker.internal:18081/;
proxy_set_header Host payment;
}
}
Run the gateway locally, with Docker connecting to the mock port on your host:
docker run --rm --name k6-local-gateway \
--add-host host.docker.internal:host-gateway \
-p 8080:8080 \
--mount "type=bind,src=$PWD/local-gateway.conf,dst=/etc/nginx/conf.d/default.conf,readonly" \
nginx:1.27
The gateway keeps the original /payment/ route and sends Host: payment to the mock. There is no local payment service. In a third terminal, change to k6-demo/k6-workflow and run:
curl --noproxy '*' --max-time 10 --fail --silent --show-error http://127.0.0.1:8080/payment/
k6 run --env BASE_URL=http://127.0.0.1:8080 k6.js
Expect the same payment JSON and all k6 checks to pass. In the proxymock terminal and mock-results, confirm payment calls are marked HIT (a successful match), with no MISS or PASSTHROUGH results. A 502 from NGINX usually means it can’t reach the mock listener; a mock miss means the outgoing request differs from the recording.
Read the results and clean up
In the validation run, each path completed 150 requests and 300 successful checks with no failed HTTP requests. The local responder recorded backend hits, and the cluster report recorded mock matches with no misses or passthrough. Your latency and request counts will depend on the machine and recording.
Compare k6’s request count, failure rate, and p95 latency across runs. Require passing body checks and confirmed mock matches before treating a run as valid. The local example measures a Docker gateway and host mock server; the cluster example includes the port-forward and cluster responder. Those are useful workflow checks, but port-forward results aren’t a measure of cluster capacity. Run k6 inside the cluster or through your normal test ingress for capacity testing.
Mocking isolates the gateway’s behavior from the real payment backend. Backend performance, end-to-end capacity, and the effect of recorded dependency latency require separate test configurations. The local responder returns immediately by default; use --mock-timing recorded if you want to include the captured backend delay.
Stop the local mock server with Ctrl+C and stop the Docker gateway:
docker stop k6-local-gateway
The cluster script already cancels its replay. Once that cleanup has finished, remove only the namespace created for this tutorial:
kubectl --context "$KUBE_CONTEXT" delete namespace "$NAMESPACE"
For your own service, keep the same flow: capture inbound and outbound traffic, export the inbound requests to k6, and mock the recorded dependencies while k6 drives the application. Adapt response assertions and dynamic values to your API before increasing the load.
Ready to try it on your own service? Install proxymock and record your first traffic, then export your service’s snapshot to k6 and adapt its response assertions before running a load test.