Files
windmill/benchmarks/sim/values/smoke.yaml
pyranotaandClaude Opus 4.7 74b662d8de feat(benchmarks): k8s sim mode + util-group dashboard + reliability fixes
Stand up a minikube-backed simulation subsystem for benching Windmill under
realistic multi-node load, with a per-bench measurement pipeline and a
dashboard renderer that consolidates throughput, queue depth, per-node CPU,
PG latency/conns, OOM events, and per-node CPU-util-vs-oversaturation into
one SVG report.

Sim infrastructure (sim/):
- k8s_provisioner: minikube up + heterogeneous node sizing from topology JSON
- helm_deploy: helm install Windmill with smoke.yaml + local.yaml overlays
- image_cache: pre-load required images so bench bringup is offline-safe
- toxiproxy_k8s: per-node toxiproxy DaemonSet for cross-node latency injection
- cpu_sampler_k8s: privileged DS reading per-cgroup cpu.stat at 10Hz, dual-
  writes to stdout AND a host-mounted log file (/var/log/wm-sim-cpu-sampler/
  sampler.tsv) so heavy benches no longer lose early samples to kubelet log
  rotation
- pg_logging: ALTER SYSTEM + SIGHUP to enable verbose PG logging without restart
- pgbadger: post-bench PG log analysis HTML report
- readiness: pre-bench cluster health check (samplers stable ≥30s, workers
  ready, PG responsive, queue empty, **deploy.status rollout-complete**) —
  the rollout-complete check catches mid-rolling-update fires that previously
  starved m04's sampler under cgroup_mutex contention

Per-bench JSONL pollers, started/finalized alongside the bench loop:
- pod_timeline: 1Hz workers-per-node Ready counts (used for the workers panel)
- oom_poller: live OOM event capture (kernel + kubelet evictions + cgroup)
- pg_latency_poller: 4Hz psql \\timing on SELECT 1 vs kubectl-exec roundtrip
- pg_conn_poller: 1Hz pg_stat_activity by state (active/idle/idle_in_xact)
- node_load_poller: 2Hz /proc/loadavg + /proc/stat procs_running per node

Dashboard renderer (sim/render_report.ts + graph.ts):
- Util group: one panel per node with translucent orange oversaturation area
  BEHIND solid blue CPU-util area, 100% reference line, phase-boundary verticals.
  cols:2 grid wraps after 2 panels per row.
- PG node tinted with [PG] flag in legend across the dashboard.
- Phase-boundary verticals + push-window shaded zones layered consistently.
- All x-axes switched from wall-clock HH:MM to relative seconds-from-bench-
  start. Shared origin sourced from meta.json's bench_start_ms so 0s on every
  panel = the same wall-clock moment (previously each chart picked its own
  earliest sample as origin, causing drift between panels).

Oversaturation metric, with explicit fallback:
- Primary: (procs_running - ncpu) / ncpu × 100 — true CPU run-queue pressure.
- Fallback to load1 when procs_running is missing (older reports).
- load1 overcounted previously because it includes uninterruptible D-state
  procs (PG backends in disk I/O, cgroup_mutex waits), inflating "saturation"
  by 5-10x under load.
- Pure helper extracted to sim/util_metrics.ts; 8 unit tests cover the
  procs_running > load1 preference, the clamp-at-zero, invalid-ncpu cases.

Sampler reliability:
- HostPath log file in addition to stdout so the bench's scp-based collector
  bypasses kubelet log rotation entirely.
- main.ts truncates the host log file on every node before pushers start
  (parallel ssh, best-effort) so it doesn't grow unbounded across runs.
- Collector falls back to kubectl-logs when scp fails for any node.

Workloads (workloads/):
- io_4phase: four-phase IO step (idle → 2.5s → 500ms → 150ms jobs)
- io_150ms_flood / io_300ms_flood / io_1s_flood / io_2s_flood: single-phase
  flood configs to isolate the worker-host CFS context-switch storm vs PG
  contention regime
- burst, ops_day, cpu_*, etc. for other scenarios

Tests:
- sim/util_metrics_test.ts — 8 cases for computeOversatPct
- sim/util_panel_snapshot_test.ts — 5 assertions guarding util-panel SVG
  invariants (orange behind blue, 100% ref line, relative-time ticks NOT
  wall-clock, phase-boundary verticals, shared-origin override)

Helm values:
- sim/values/smoke.yaml — bench-tuned: workers w/ no CPU limit & low mem
  request, PG w/ 3-core request + wm-critical priorityClass + oomImmune +
  maxConnections, app w/ wm-critical + oomImmune + no resource limits.
- sim/values/local.example.yaml — template for the gitignored local.yaml
  that carries the EE license key.
- Depends on the wm-critical PriorityClass + oomImmune + maxConnections
  knobs landing in windmill-helm-charts (separate PR).

graph.ts additions:
- areaFills param: ordered list of per-kind translucent area fills drawn
  before lines, used by the util panel for orange-behind-blue layering
- lineColorOverrides: pin per-kind line colors so oversaturation reliably
  renders orange regardless of d3 ordinal-color insertion order
- highlightKindToken: substring-match flag for the PG-node tint in Node CPU
- xRelativeOriginMs: shared bench-start origin for the relative-time x-axis
- DataPointMulti is now exported for downstream tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 11:43:47 +02:00

155 lines
6.8 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Helm values for the k8s_4node bench topology (1 small CP + 3 × 4vCPU/20GiB workers).
#
# Critical-pod safety pattern:
# 1. Chart creates a `wm-critical` PriorityClass (value 2_000_000_000) — this
# hits kubelet's IsCriticalPodBasedOnPriority special case, giving any pod
# assigned to it oom_score_adj=-997. Burstable QoS, but kernel-OOM-immune.
# 2. App and PG both reference wm-critical via priorityClassName.
# 3. App and PG have NO `limits.memory` — combined with the priority class,
# they cannot cgroup-OOM (no own ceiling) and the kernel won't pick them.
# Workers are the designated OOM victims (adj~984) under node pressure.
# 4. App has a podAntiAffinity preference vs PG so they land on different
# worker nodes when capacity allows.
#
# Pass at the wm_sim invocation:
# wm_sim --topology benchmarks/sim/topologies/k8s-4node.json \
# --helm-values benchmarks/sim/values/smoke.yaml
# EE / enterprise settings live in `local.yaml` (gitignored). Layer that file
# alongside smoke.yaml with `-f smoke.yaml -f local.yaml` to enable EE + the
# license key. This file stays committable; license keys must never land here.
windmill:
appReplicas: 1
extraReplicas: 0
indexer:
enabled: false
# Declare wm-critical, which is referenced by app.priorityClassName and
# postgresql.priorityClassName below.
#
# NOTE: k8s caps user-defined PriorityClass `value` at 1_000_000_000. The
# kubelet's IsCriticalPodBasedOnPriority shortcut (which would set
# oom_score_adj = -997) requires value >= 2_000_000_000, which is reserved
# for the built-in system-cluster-critical/system-node-critical classes
# (kube-system namespace only). So we use the max user-allowed value: that
# still gives us scheduling priority + eviction protection. For OOM ranking,
# we lean on big memory requests below to pull the Burstable-formula adj
# well below the workers'.
priorityClasses:
enabled: true
classes:
- name: wm-critical
value: 1000000000
description: "Windmill critical pods (app, PG) — scheduled/evicted last."
app:
priorityClassName: wm-critical
# postStart hook writes -999 to /proc/1/oom_score_adj — kernel cannot
# pick this pod for OOM. The k8s PriorityClass route to -997 is blocked
# for non-kube-system pods, so we do it directly inside the container.
oomImmune: true
resources:
requests:
cpu: 1 # 1024 CFS shares — ~20× any worker's 50m
# 1 GiB request leaves room for workers on the same node; oom_score_adj
# = -999 (via postStart hook) keeps app immune to OOM regardless of
# request size.
memory: 1Gi
limits: {} # no cpu cap, no memory cap. App takes what it
# needs; workers on the same node lose CFS share
# → process fewer jobs → less load on app → balance.
# Spread app away from PG when possible. PG carries its PVC and stays
# pinned to its first-scheduled node; app should land elsewhere.
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: kubernetes.io/hostname
labelSelector:
matchExpressions:
- key: app
operator: In
values:
- windmill-postgresql-demo-app
workerGroups:
- name: default
replicas: 200
mode: worker
# No priorityClassName — workers are intentionally the disposable
# layer that absorbs OOM pressure on a node where PG or app is heavy.
resources:
requests:
# Tiny CPU request — workers are mostly idle (poll PG, run sleep
# jobs) so 50m was over-reserving and starving m02 (where PG sits)
# of worker capacity. 5m means ~80 workers can fit anywhere CPU-
# budget-wise; the scheduler spreads them evenly across nodes.
# Actual CPU burst is bounded by the host's CFS rather than this
# floor.
cpu: 5m
memory: 128Mi
limits:
# No cpu limit — workers can burst into idle cores.
# Memory ceiling at 8 GiB (was 2 GiB): the ops_day etl_storm phase
# has lognormal ram_mb cap=4096 MiB, so individual deno subprocesses
# legitimately need >2 GiB and were being kernel-cgroup-OOM-killed
# (visible as L2 node-kernel "deno" entries via dmesg parsing).
# 8 GiB swallows the tail without over-committing 20 GiB nodes too
# aggressively — realistic concurrent peak is well under 3 workers.
memory: 8Gi
extraEnv:
- name: DATABASE_URL
value: postgres://postgres:windmill@toxiproxy.default.svc:15400/windmill?sslmode=disable
- name: DATABASE_CONNECTIONS
value: "1"
# Agent workers — connect to the app via HTTP+JWT instead of directly to
# PG. Useful for measuring how much of the throughput cap is PG contention
# vs. the app's HTTP-mediated path. Token lives in K8s Secret
# `windmill-agent-token` (not in this file), created via kubectl.
- name: agent
replicas: 5
mode: agent
resources:
requests:
cpu: 5m
memory: 128Mi
limits:
memory: 1Gi
extraEnv:
- name: MODE
value: agent
- name: BASE_INTERNAL_URL
value: http://windmill-app.default.svc:8000
- name: AGENT_TOKEN
valueFrom:
secretKeyRef:
name: windmill-agent-token
key: token
postgresql:
maxConnections: 2000
priorityClassName: wm-critical
# postStart hook → /proc/1/oom_score_adj = -999. See app.oomImmune note.
oomImmune: true
resources:
requests:
cpu: 3 # 3 cores — fits on a worker node where ~33
# workers reserve 1.65 cores leaving ~2.35
# free (cpu=4 was Pending forever). PG still
# bursts to all 4 cores when workers are idle
# (limits empty). Under full contention CFS
# gives PG 3072/(3072+1683) ≈ 65% of cores.
# No memory request/limit. PG is free to grow as large as the node
# has memory for. OOM-immunity comes from priorityClassName +
# postStart oom_score_adj=-999 (not from request-driven Burstable
# ranking). The previous 12Gi request was just a scheduling fence
# the cluster doesn't need — PG should land on whichever worker
# node has the most free RAM at deploy time.
limits: {} # uncapped — PG takes whatever it needs; only
# the node's physical memory ceiling applies.
persistence:
enabled: true
size: 10Gi