Windmill benchmark sim
Provisions a minikube-backed multi-node Kubernetes cluster, deploys Windmill on it via Helm, runs workload benches against it, and produces a single SVG dashboard with throughput, queue depth, per-node CPU + memory + oversaturation, per-pod CPU/memory peaks, PG response latency + connection counts, OOM and restart events, and pod inventory.
The goal: realistic, reproducible benches at scales where running Windmill
on bare containers (docker run) hides the production failure modes
(scheduler saturation, cgroup_mutex contention, PG backend thrash, kubelet
log rotation, OOM cascades). Everything here exists to make those
production-shape problems observable in 3 minutes from a clean checkout.
What you actually get
Single CLI to bring up the cluster + deploy Windmill:
wm_sim up \
--topology sim/topologies/k8s-4node.json \
--helm ../windmill-helm-charts/charts/windmill \
--helm-values sim/values/smoke.yaml \
--helm-values sim/values/local.yaml # gitignored; carries the EE license
wm_sim up opens its own port-forward to svc/windmill-app on a free local
port and prints the URL — look for a line like:
[helm] API reachable at http://127.0.0.1:38291 (port-forward svc/windmill-app)
The forward dies with the wm_sim process, so leave the wm_sim terminal open. Then in a second shell, fire a bench against that URL:
cd benchmarks && deno run -A main.ts \
--host http://127.0.0.1:<port from wm_sim output> \
--token <admin-token> \
--workload-config workloads/io_150ms_flood.json \
--minikube-profile wm-sim-k8s-4node \
--wait-ready 60
Bench runs ~3 min. Report lands at benchmarks/reports/<timestamp>/, with
dashboard.svg as the entry point. Open it in a browser.
If the port-forward dies mid-bench (kubelet sometimes drops it under heavy load), restart it manually in a second shell with the same port:
kubectl --context wm-sim-k8s-4node port-forward -n default \
svc/windmill-app <same port>:8000
Prerequisites
nix developfrom the repo flake — provisionsminikube,kubectl,helm,pgbadger,deno, and thewm_simwrapper. No host config required beyond the flake with one exception: if you want multi-node KVM clusters on NixOS, libvirtd needs to be enabled at the system level — seesim/nix/kvm2-driver.nix.- Root + libvirtd for the kvm2 driver. Single-node clusters work with the qemu2 driver without root, but multi-node needs kvm2.
- A clone of
windmill-helm-chartsnext to this repo (../windmill-helm-charts). The vendored chart was deleted in favor of using the upstream chart with bench-specific overlays invalues/smoke.yaml. The chart depends on a few knobs (PriorityClass support,oomImmune,maxConnections) that may not be in upstream yet — see the windmill-helm-charts PR.
Topology + values layout
sim/
topologies/
k8s-4node.json # 1 control plane + 3 workers (4 vCPU, 20 GiB each)
k8s-3node.json # control plane + 2 workers
... # several other shapes
values/
smoke.yaml # bench-tuned helm overlay (worker reqs, PG sizing,
# priorityClass=wm-critical, maxConnections, etc.)
local.example.yaml # template — copy to local.yaml, fill in your
# EE license key. local.yaml is gitignored.
smoke.yaml is the source of truth for chart config across benches. Tune
worker replica counts, PG memory, max_connections, etc. there.
Architecture
wm_sim up
│
▼ provision
┌────────────────────────────────────────────────────────────────┐
│ k8s_provisioner: minikube start + add-nodes + per-node sizing │
│ image_cache: pre-load images so bringup is offline-safe │
│ helm_deploy: helm upgrade --install windmill via smoke.yaml│
│ toxiproxy_k8s: per-node toxiproxy DaemonSet for latency inj │
│ cpu_sampler_k8s: per-node sampler DaemonSet (10Hz cpu.stat) │
└────────────────────────────────────────────────────────────────┘
│
▼ benchmarks/main.ts (separate shell)
┌────────────────────────────────────────────────────────────────┐
│ readiness: wait for samplers stable, workers Ready, │
│ PG responsive, queue empty, │
│ *deployment rollout complete* — mid-rollout │
│ pod churn starves the sampler under │
│ cgroup_mutex contention │
│ start pollers: pod_timeline, oom, pg_latency, pg_conn, │
│ node_load (all write JSONL into report dir) │
│ truncate sampler /var/log/wm-sim-cpu-sampler/sampler.tsv on │
│ host log: each node so this run gets a clean file │
│ push jobs: pushers → API → workers pull → PG queue │
│ stop pollers: finalize JSONL │
│ collect samples: scp host log from each node (fallback: │
│ kubectl logs) │
│ collect pg.log: kubectl logs PG pod → pgBadger HTML report │
│ render_report: JSONL + samples → dashboard.svg + report.md │
└────────────────────────────────────────────────────────────────┘
│
▼ output: benchmarks/reports/<ISO timestamp>/
Measurement subsystem
| Signal | Source | Cadence | File in report |
|---|---|---|---|
| Per-pod CPU + memory | DS sampler reading cgroup cpu.stat / memory.current | 10 Hz | cpu_samples.tsv |
| Workers Ready per node | kubectl get pods |
1 Hz | pod_timeline.jsonl |
| OOM events | kubectl get events + node-kernel dmesg |
live | oom_events_live.jsonl |
| PG query latency | psql -c "\timing on" -c "SELECT 1" |
4 Hz | pg_latency.jsonl |
| PG connections by state | SELECT state, count(*) FROM pg_stat_activity |
1 Hz | pg_connections.jsonl |
| Node loadavg + procs_running | ssh /proc/loadavg, /proc/stat |
0.5 Hz | node_load.jsonl |
| Throughput (jobs/s sent + processed) | bench worker postMessage |
sub-second | throughput_samples.json |
| Failed jobs | /api/w/{ws}/jobs/completed/list |
post-bench | failed_jobs.jsonl |
| Pod inventory + node placement | kubectl get pods -o json |
post-bench | pods.json |
| PG slow-query analysis | PG log → pgBadger | post-bench, background | pgbadger.html |
Key reliability fixes (don't undo them):
- Sampler host file: the sampler dual-writes to stdout AND a hostPath log
file.
kubectl logs --since-time=Xloses early data when kubelet rotates log files under heavy benches (m04 was missing the first 90-120s of every busy run). Collector now scp's the host file as primary source. - Readiness rollout check:
kubectl get deploy windmill-workers-defaultis checked forstatus.updatedReplicas == spec.replicas. Mid-rolling-update pod churn floods the kernel'scgroup_mutex, starving every other process on the node — including the sampler. Pod-readiness alone isn't enough. - Oversaturation uses procs_running, not load1. loadavg includes D-state
procs (PG backends waiting on disk I/O, kernel mutex contention) which has
nothing to do with CPU starvation but inflates "oversaturation" by 5-10x.
procs_runningfrom/proc/statis the runnable count.
Dashboard panels
Rendered into dashboard.svg. Layout is roughly:
| Section | Panels |
|---|---|
| Run context | Topology, workload, helm values, bench cmd, report dir |
| Throughput | Jobs sent + processed over time, with push-window shading and phase boundaries |
| Queue depth | Pending jobs in v2_job_queue |
| Node memory | Per-node memory time-series + per-phase average bar chart |
| PG latency | Pure SQL time (psql \timing) + kubectl-exec roundtrip (separate axes) |
| PG connections | active / idle / idle_in_xact over time |
| Util group | One panel per node, 2 per row. Solid blue CPU util (capped 100%) on top of translucent orange oversaturation. 100% reference line. PG-host node flagged [PG] |
| Node CPU | Multi-line per-node CPU, with PG-host node tinted via stroke + label |
| Pod inventory | Bar chart of pod count by node and pod-type donut |
| Failed jobs | Cumulative + categorized bars |
| Restart events | Pod restarts detected from pod_timeline.jsonl |
| OOM events | L0 scheduler preemptions, L1 kubelet evictions, L2 cgroup + node-kernel kills |
All time-series share an x-axis origin (bench_start_ms written into
meta.json) so the same x-coordinate on every panel means the same wall-clock
moment. Tick labels are relative seconds ("0s, 30s, …"), not wall-clock.
Workloads
benchmarks/workloads/*.json. Each is a phased JSON spec the bench reads at
startup. Phases run sequentially, each with its own pusher count, mode mix,
duration range, and ram_mb distribution.
Examples:
| Workload | Shape | Use case |
|---|---|---|
io_4phase.json |
idle → 2.5s → 500ms → 150ms IO jobs | Steady-state vs saturation in one bench |
io_150ms_flood.json |
60 pushers, 150ms IO, 120s | Worker-host CFS saturation regime |
io_300ms_flood.json |
60 pushers, 300ms IO, 120s | Lower context-switch rate |
io_1s_flood.json, io_2s_flood.json |
Same shape, longer jobs | Near-theoretical worker scaling |
ops_day.json |
Mixed phases simulating a day | Realistic load profile |
cpu_heavy.json, mixed.json, burst.json |
Non-IO and bursty shapes | CPU-bound and spikiness |
Agent workers (HTTP+JWT path)
smoke.yaml declares a second worker group agent with mode: agent. Agent
workers reach the windmill API over HTTP+JWT instead of holding a direct
sqlx pool to PG. Useful for measuring how much of the bench cap is PG
contention vs. the HTTP-mediated path.
The JWT lives in a K8s Secret windmill-agent-token (not in any committed
yaml). Create it once:
kubectl --context wm-sim-k8s-4node create secret generic windmill-agent-token \
--from-literal=token='jwt_agent_…'
Tests
cd benchmarks && deno test --allow-import --no-check \
sim/util_metrics_test.ts sim/util_panel_snapshot_test.ts
sim/util_metrics_test.ts— 8 tests forcomputeOversatPct, the formula the util panel uses. Specifically guards the procs_running > load1 fallback, the clamp-at-zero, invalid-ncpu inputs.sim/util_panel_snapshot_test.ts— 5 assertions on the rendered util-panel SVG: orange-behind-blue z-order, 100% reference line, relative-time ticks (no wall-clock leak), phase-boundary dashed verticals, shared-origin override.sim/topology_test.ts,sim/toxiproxy_test.ts— earlier coverage of the validator + proxy planner.
Pre-existing typecheck errors in graph.ts (untyped d3 callback args,
implicit any) trip deno test without --no-check. The functional tests
pass regardless.
Operational notes
- Cluster name / minikube profile is
wm-sim-k8s-4nodeby default. Pass--minikube-profile <name>tomain.tsif you renamed it. - Default kubectl context comes from minikube. Use
kubectl --context wm-sim-k8s-4node …to be explicit. wm_sim upruns the port-forward as a child process. Killing wm_sim (Ctrl-C) kills the forward. The kubelet sometimes drops the forward under heavy bench load; if a bench fails partway with "connection refused", start a replacement forward manually withkubectl port-forwardon the same local port and re-fire.minikube stopis safe — VM disks persist, etcd survives, helm releases- Secrets all come back when you
minikube start.wm_sim updoesminikube deletefirst, so it's destructive — only use it for clean reprovisions.
- Secrets all come back when you
Code layout
sim/
sim.ts # wm_sim CLI entry (cliffy)
k8s_provisioner.ts # minikube up + node sizing
helm_deploy.ts # helm upgrade --install
image_cache.ts # pre-load images so bringup is offline-safe
cpu_sampler_k8s.ts # sampler DaemonSet + collector
toxiproxy_k8s.ts # per-node toxiproxy DaemonSet
pg_logging.ts # ALTER SYSTEM + SIGHUP for verbose PG logs
pgbadger.ts # post-bench PG log → HTML
readiness.ts # pre-bench cluster health check
pod_timeline.ts # pod readiness poller
pod_inventory.ts # post-bench pod inventory snapshot
oom_poller.ts # live OOM event capture
oom_events.ts # post-bench OOM event parsing
pg_latency_poller.ts # 4Hz PG \timing on SELECT 1
pg_conn_poller.ts # pg_stat_activity by state
node_load_poller.ts # /proc/loadavg + /proc/stat procs_running
failed_jobs.ts # post-bench failed-jobs fetch
util_metrics.ts # pure helpers (oversat formula)
render_report.ts # JSONL → SVG dashboard + report.md
dashboard.ts # SVG layout primitives (grid, sections)
svg_to_pdf.ts # dashboard.svg → dashboard.pdf (best-effort)
topology.ts # topology JSON loader + validator
values/ # helm overlay yamls
topologies/ # topology JSON files
nix/kvm2-driver.nix # kvm2 driver overlay for nixpkgs
The bench runner itself is benchmarks/main.ts (one level up). It calls
into sim/ for measurement + report rendering.