mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 00:03:07 +00:00
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>
155 lines
6.8 KiB
YAML
155 lines
6.8 KiB
YAML
# 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
|