rinha2-back-end-k6

Home

operator notes / source-backed load profile

Run the Rinha load suite without guessing what it measures.

A Docker-first Grafana k6 harness for Rinha de Backend 2024/Q1. It runs five banking API scenarios, records per-scenario Trend metrics, and switches between quiet CI-friendly k6 output and live InfluxDB output.

scenarios 5 validation, 404, debit, credit, statement
peak write load 220 + 110 VUs debit and credit ramping scenarios
outputs prod / dev quiet k6 run or InfluxDB stream

What this repository provides

rinha2-back-end-k6 is the shared stress-test harness for the Rinha de Backend 2024/Q1 fictional banking API challenge. It packages a custom k6 binary with xk6-output-influxdb, the scenario script, and a small shell entrypoint into a reusable Docker image.

The docs are intentionally source-backed. Scenario counts, VU targets, metric names, defaults, and run modes should match test/stress-test/rinha-test.js and test/stress-test/run-test.sh.

Quick start

Build and run the image locally:

docker build -t rinha-k6 .

docker run --rm \
  -e MODE=prod \
  -e BASE_URL=http://api:9999 \
  rinha-k6

Use dev mode when you have an InfluxDB endpoint ready for k6 metrics:

docker run --rm \
  -e MODE=dev \
  -e BASE_URL=http://api:9999 \
  -e K6_INFLUXDB_ADDR=http://influxdb:8086 \
  rinha-k6

run-test.sh treats MODE=dev or an empty MODE as InfluxDB export mode. Set MODE=prod explicitly when you want the quiet CI path (k6 run ... --quiet).

Scenario map

validacoes5 VUs, one pass
cliente_nao_encontrado1 VU, 404 check
debitos1 to 220 VUs
creditos1 to 110 VUs
extratos10 VUs, statement read

Repository structure

rinha2-back-end-k6/
├── test/stress-test/
│   ├── rinha-test.js       # Main k6 file, 5 scenarios and 5 Trend metrics
│   └── run-test.sh         # MODE dispatcher plus 15s startup delay
├── Dockerfile              # Go 1.25/xk6 Alpine builder plus Alpine 3.23 runtime
├── docs/wiki/              # Source Markdown for the public docs routes
├── docs/                   # Astro docs site published to GitHub Pages
└── .github/workflows/
    ├── main-release.yml    # Build and push GHCR image
    ├── deploy.yml          # Reusable GitHub Pages deploy workflow
    └── codeql.yml          # JavaScript CodeQL analysis

Docker image

The release workflow publishes a multi-platform image to GitHub Container Registry:

ghcr.io/jonathanperis/rinha2-back-end-k6:latest

Sibling Rinha implementations can run the same test image against their own BASE_URL so the load profile stays consistent across languages and backends.

Source-backed audit notes

Current facts verified against the code and workflows:

  • rinha-test.js exports exactly five scenarios and five Trend metrics.
  • run-test.sh prints a 15-second startup warning before it chooses a mode.
  • prod mode uses k6 run rinha-test.js --quiet; no HTML artifact is generated by this repository entrypoint.
  • dev mode, including empty MODE, uses k6 run rinha-test.js -o xk6-influxdb.
  • .github/workflows/main-release.yml publishes only ghcr.io/jonathanperis/rinha2-back-end-k6:latest for linux/amd64 and linux/arm64/v8.

Key design patterns

  • SharedArray client data: client IDs and limits are loaded once and shared across VUs.
  • Custom Trend metrics: each scenario writes to its own duration metric.
  • Staggered start: validation and 404 probes start at 0s; load and statement scenarios start at 10s.
  • Balance validation: debit and statement checks assert that balances do not exceed the negative limit.
  • Dual output mode: prod is a quiet k6 CLI run for CI logs; dev streams to InfluxDB for Grafana dashboards.

Getting Started

Prerequisites

  • Docker

Use the published image

For most backend implementations, pull the shared GHCR image and run it against your API gateway:

docker pull ghcr.io/jonathanperis/rinha2-back-end-k6:latest

docker run --rm \
  -e MODE=prod \
  -e BASE_URL=http://api:9999 \
  ghcr.io/jonathanperis/rinha2-back-end-k6:latest

Build locally

Build locally when changing this repository or testing an unpublished image:

docker build -t rinha-k6 .
docker run --rm -e MODE=prod -e BASE_URL=http://api:9999 rinha-k6

Dev mode with InfluxDB

Dev mode is the default when MODE is empty, and it requires an InfluxDB output target:

docker run --rm \
  -e MODE=dev \
  -e BASE_URL=http://api:9999 \
  -e K6_INFLUXDB_ADDR=http://influxdb:8086 \
  ghcr.io/jonathanperis/rinha2-back-end-k6:latest

Environment Variables

Variable Default Purpose
MODE dev Execution mode (dev or prod)
BASE_URL http://localhost:9999 Target API endpoint
K6_INFLUXDB_ADDR InfluxDB address (dev mode)

Modes

  • dev (default): Exports metrics to InfluxDB for real-time monitoring in Grafana dashboards
  • prod: Runs k6 run rinha-test.js --quiet for a quieter CI/log path. The entrypoint does not write an HTML artifact by itself.

Run with a backend

Sibling backend implementations can include this image as their k6 service in docker-compose.yml. Point BASE_URL at the service name and port used by the API gateway or load balancer:

services:
  k6:
    image: ghcr.io/jonathanperis/rinha2-back-end-k6:latest
    environment:
      MODE: prod
      BASE_URL: http://nginx:9999
    depends_on:
      - nginx

Start the backend stack, then run the k6 service using that implementation’s Compose workflow.

Test Scenarios

The suite defines 5 source-backed k6 scenarios in test/stress-test/rinha-test.js. They all target BASE_URL, which defaults to http://localhost:9999.

Scenario overview

Scenario Executor VUs / stages Start Purpose
validacoes per-vu-iterations 5 VUs, 1 iteration each 0s Baseline statement, credit, debit, recent-transaction order, and invalid-request checks
cliente_nao_encontrado per-vu-iterations 1 VU, 1 iteration 0s GET /clientes/6/extrato must return 404
debitos ramping-vus 1 to 220 VUs over 2m, then hold 220 for 2m 10s High-concurrency debit transactions with overdraft validation
creditos ramping-vus 1 to 110 VUs over 2m, then hold 110 for 2m 10s High-concurrency credit transactions
extratos per-vu-iterations 10 VUs, 1 iteration each 10s Statement reads and balance-limit consistency

validacoes

Runs one virtual user per configured client in saldosIniciaisClientes. Each VU checks the full client workflow:

  1. GET /clientes/{id}/extrato, expecting status 200, the configured limit, and initial balance 0.
  2. POST /clientes/{id}/transacoes with credit { valor: 1, tipo: 'c', descricao: 'toma' }.
  3. POST /clientes/{id}/transacoes with debit { valor: 1, tipo: 'd', descricao: 'devolve' }.
  4. GET /clientes/{id}/extrato, expecting recent transactions in the debit-then-credit order.
  5. Invalid transaction requests, expecting 422 or 400 depending on implementation behavior.

Invalid request cases:

Payload issue Expected status
Decimal valor 422 or 400
Invalid tipo 422 or 400
Description longer than 10 characters 422 or 400
Empty description 422 or 400
null description 422 or 400

cliente_nao_encontrado

Runs a single statement request against client 6:

GET /clientes/6/extrato

The expected result is 404. This keeps missing-client behavior visible as a separate metric instead of hiding it inside the general validation flow.

debitos

The debit workload ramps from 1 to 220 VUs, holds that target, and posts random debit transactions to clients 1 through 5.

stages: [
  { duration: '2m', target: 220 },
  { duration: '2m', target: 220 },
]

Accepted response statuses are 200 or 422, because debit operations can be rejected when a client would exceed the overdraft limit. Successful debit responses are checked with:

saldo >= limite * -1

creditos

The credit workload ramps from 1 to 110 VUs, holds that target, and posts random credit transactions to clients 1 through 5.

stages: [
  { duration: '2m', target: 110 },
  { duration: '2m', target: 110 },
]

Credits are expected to return 200 and preserve the same balance-limit consistency contract.

extratos

Runs 10 VUs, one iteration each, against:

GET /clientes/{id}/extrato

The response must be 200, and saldo.total must still respect the configured limit:

saldo.total >= saldo.limite * -1

Source-backed constants

The current script uses these fixed helper values:

Constant Current value Source
Random client IDs 1 through 5 randomClienteId()
Random transaction value integer 1 through 10000 randomValorTransacao()
Random generated description 10 alphanumeric characters randomDescricao()
Validation delay sleep(1) before the second statement check validacoes()

Custom Trend metrics

The script exports five Trend metrics. These names are the canonical labels to use in reports and dashboards:

Metric Scenario
debitos_duration debitos
creditos_duration creditos
extratos_duration extratos
validacoes_duration validacoes
cliente_nao_encontrado_duration cliente_nao_encontrado

Each request path adds its observed duration to the scenario-specific Trend so Grafana dashboards and k6 summaries can separate bottlenecks by operation type.

Configuration

The suite is configured through environment variables and the exported options object in test/stress-test/rinha-test.js. Keep operational values in code, not in the docs, then use this page as the readable map.

Environment variables

Variable Default / required value Description
BASE_URL http://localhost:9999 Base URL for the API under test, usually the NGINX/load-balancer endpoint.
MODE empty value behaves like dev in run-test.sh Run mode selector. Use prod for quiet CI/log runs or dev for InfluxDB export.
K6_INFLUXDB_ADDR required by the xk6 output when using dev mode InfluxDB endpoint, for example http://influxdb:8086.

Docker run examples

Build the local image:

docker build -t rinha-k6 .

Run against an API endpoint in production/CI mode:

docker run --rm \
  -e MODE=prod \
  -e BASE_URL=http://api:9999 \
  rinha-k6

Run with InfluxDB export in development mode:

docker run --rm \
  -e MODE=dev \
  -e BASE_URL=http://api:9999 \
  -e K6_INFLUXDB_ADDR=http://influxdb:8086 \
  rinha-k6

Direct k6 invocation

If you have the custom k6 binary available locally, pass variables with -e:

k6 run \
  -e BASE_URL=http://localhost:9999 \
  test/stress-test/rinha-test.js

For dev output, include the xk6 InfluxDB output:

k6 run \
  -e MODE=dev \
  -e BASE_URL=http://localhost:9999 \
  -e K6_INFLUXDB_ADDR=http://localhost:8086 \
  -o xk6-influxdb \
  test/stress-test/rinha-test.js

SharedArray client data

rinha-test.js defines five clients and their credit limits in cents. k6 loads this data through SharedArray so VUs share one read-only copy:

Client Limit
1 100000
2 80000
3 1000000
4 10000000
5 500000

Those values drive the validation scenario and the balance-limit checks after debit and statement requests.

Thresholds

The current script does not define hard k6 thresholds. It measures and reports behavior; sibling implementations or CI jobs can decide whether to fail a pipeline based on their own policy.

If thresholds are added later, keep the metric names aligned with the exported Trend metrics:

export const options = {
  scenarios: { /* ... */ },
  thresholds: {
    debitos_duration: ['p(95)<500'],
    creditos_duration: ['p(95)<500'],
    extratos_duration: ['p(95)<200'],
    http_req_failed: ['rate<0.01'],
  },
};

Entrypoint behavior

run-test.sh waits 15 seconds before starting k6 so a backend stack launched in the same Compose project can finish booting. It accepts only three effective states:

MODE value Command
prod k6 run rinha-test.js --quiet
dev k6 run rinha-test.js -o xk6-influxdb
empty same as dev

Any other value exits with Invalid MODE specified. Set MODE=dev or MODE=prod.

Resource boundaries

The Rinha challenge constrains the backend services, not the k6 runner. Treat k6 as the external pressure source. The API stack under test is responsible for staying inside the challenge budget.

Run Modes

test/stress-test/run-test.sh selects the k6 output path from the MODE environment variable. The scenario profile is the same in both modes; only the output destination changes.

prod quiet CI/log run

Use in CI, release jobs, and repeatable local checks where lower-noise k6 logs matter more than live telemetry.

dev InfluxDB stream

Use while tuning an implementation and watching latency or throughput in Grafana.

prod: quiet CI/log run

Set MODE=prod to run k6 quietly through the bundled entrypoint:

docker run --rm \
  -e MODE=prod \
  -e BASE_URL=http://api:9999 \
  rinha-k6

The entrypoint executes:

k6 run rinha-test.js --quiet

Use this mode when the caller is collecting stdout or CI logs outside the container. The current entrypoint does not create an HTML report file; it only passes --quiet to k6.

dev: InfluxDB export

Set MODE=dev, or leave MODE empty, to stream k6 metrics through the xk6-output-influxdb extension:

docker run --rm \
  -e MODE=dev \
  -e BASE_URL=http://api:9999 \
  -e K6_INFLUXDB_ADDR=http://influxdb:8086 \
  rinha-k6

The entrypoint executes:

k6 run rinha-test.js -o xk6-influxdb

Use this mode when an InfluxDB target is available and you want Grafana-style feedback during a tuning loop.

Mode behavior

MODE value Behavior
prod Runs k6 run rinha-test.js --quiet.
dev Runs k6 run rinha-test.js -o xk6-influxdb.
empty Treated as dev mode by run-test.sh.
anything else Fails fast with Invalid MODE specified. Set MODE=dev or MODE=prod.

Startup delay

The entrypoint prints Tests will start in 15 seconds... and sleeps before launching k6. That delay gives the API stack under test time to finish booting when k6 is started alongside other services.

Docker image shape

The Dockerfile uses two stages:

  1. golang:1.25-alpine3.21 builds a custom k6 binary with github.com/grafana/xk6-output-influxdb.
  2. alpine:3.23 copies the binary, rinha-test.js, and run-test.sh into /app.

The Dockerfile creates /reports, but the current script does not write report files there. The image entrypoint is:

/app/run-test.sh

Published image:

ghcr.io/jonathanperis/rinha2-back-end-k6:latest

Supported platforms are published by the release workflow as linux/amd64 and linux/arm64/v8.

CI/CD

The repository uses GitHub Actions for the k6 image, the GitHub Pages documentation site, CodeQL analysis, and docs/source drift checks.

Workflow map

Workflow Trigger Purpose
main-release.yml Push to main, manual dispatch Build and publish the multi-platform k6 image to GHCR.
deploy.yml Push to main, manual dispatch Call the shared Pages deploy workflow for the Astro docs site.
codeql.yml Push, pull request, weekly schedule Run JavaScript CodeQL analysis.
docs-drift.yml Push and pull request paths that touch code/docs facts Verify README, agent notes, and Pages docs against source-backed k6/workflow facts.

Release workflow

main-release.yml builds the Docker image and publishes it to GitHub Container Registry. The current workflow publishes this tag:

ghcr.io/jonathanperis/rinha2-back-end-k6:latest

Sibling backend implementations can pull the same image so comparisons use the same stress-test profile. No SHA tag is configured in main-release.yml today. The workflow publishes linux/amd64 and linux/arm64/v8 platforms.

Pages deploy workflow

deploy.yml delegates publishing to the shared workflow:

jobs:
  deploy:
    uses: jonathanperis/.github/.github/workflows/pages-docs-deploy.yml@main
    secrets: inherit
    with:
      package-manager: bun

That workflow builds the Astro 7 static site under docs/ with Bun and publishes it to GitHub Pages after changes land on main. Astro 7’s Rust compiler, default Rust/Sätteri Markdown pipeline, Vite 8/Rolldown bundling, and queued rendering are adopted by the framework upgrade. Route caching, CDN cache providers, and src/fetch.ts advanced routing are not configured because this repository publishes static Pages output rather than an SSR/edge runtime.

Public documentation route:

https://jonathanperis.github.io/rinha2-back-end-k6/docs/

Docs drift check

docs-drift.yml runs python3 scripts/check_docs_source_drift.py. The script checks the documented scenario names, Trend metrics, run modes, Docker image tag, release platforms, and homepage metric copy against the current source files.

CodeQL

codeql.yml scans the JavaScript test code on pull requests, pushes to main, and a weekly Monday 03:00 UTC schedule. Treat CodeQL failures as blocking unless the finding is understood and explicitly waived.

Branch protection

The repository is configured for rebase merges and required status checks on main. Operationally, changes should go through pull requests before landing on main, then GitHub Pages deploys from the updated branch.

Deployment checklist

Before merging docs or test-profile changes:

  1. Build locally from docs/ with Bun.
  2. Smoke-check /docs/ and the section routes: /docs/getting-started/, /docs/configuration/, /docs/run-modes/, /docs/test-scenarios/, and /docs/ci-cd/.
  3. Confirm Docs Drift, CodeQL, and review-bot status are clean.
  4. Merge through the PR.
  5. Watch the Pages deploy run for the merge commit.
  6. Fetch the live /docs/ route and verify a distinctive updated phrase.