Files
windmill/benchmarks/sim/sim.ts
pyranota 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

226 lines
8.8 KiB
TypeScript

// Windmill benchmark sim — CLI entry, provisioning ONLY (no benchmarking).
//
// Provisions a minikube cluster from a topology and deploys Windmill onto it
// via helm (bundled PG). Leaves the cluster running until Ctrl-C, then tears
// it down. Benchmarking is a separate concern — point the bench at the URL
// this prints (`benchmarks/main.ts --host <url> ...` / `wm-bench`).
//
// Usage (wired via the `wm_sim` flake helper):
// wm_sim --topology sim/topologies/k8s_smoke.json
// wm_sim --topology <t>.json --helm <local-chart-path>
// wm_sim --topology <t>.json --helm <chart> --helm-values values.yaml
// wm_sim --topology <t>.json --helm-values v.yaml \
// --helm-set-override windmill.workerGroups[0].replicas=250
//
// The helm chart's `values.yaml` is the primary source of deployment config
// (replicas, resources, image). `--helm-set-override` is for surgical CLI
// tweaks on top — naming reflects "override, not primary source". The values
// files are archived under results/<stamp>_<topology>/ so each run records
// exactly what was deployed.
//
// Requires minikube + the kvm2 driver on PATH and libvirtd reachable. The
// `wm_sim` wrapper sets SIM_KVM2_DRIVER_DIR / SIM_LIBVIRT_LIB_DIR.
import { Command } from "https://deno.land/x/cliffy@v0.25.7/command/mod.ts";
import { loadTopology, validateTopology } from "./topology.ts";
import { MinikubeProvisioner } from "./k8s_provisioner.ts";
import {
helmDeployWindmill,
portForwardApi,
waitPgReady,
waitWindmillAppReady,
type ApiEndpoint,
} from "./helm_deploy.ts";
import { buildToxiproxyManifests } from "./toxiproxy_k8s.ts";
import { enableVerbosePgLogging } from "./pg_logging.ts";
import { applyCpuSampler } from "./cpu_sampler_k8s.ts";
import { loadCachedImages, saveImagesToCache } from "./image_cache.ts";
// minikube profile names allow only alphanumerics + dashes; topology names
// often contain underscores, so sanitise.
function profileFor(prefix: string, name: string): string {
const slug = name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
return `${prefix}-${slug || "topology"}`;
}
async function loadValidTopology(path: string) {
const topology = await loadTopology(path);
const issues = await validateTopology(topology);
for (const w of issues.filter((i) => i.severity === "warn")) {
console.warn(`[topology] WARN: ${w.message}`);
}
const errors = issues.filter((i) => i.severity === "error");
if (errors.length > 0) {
for (const e of errors) console.error(`[topology] ERROR: ${e.message}`);
Deno.exit(1);
}
return topology;
}
type DeployArgs = {
helm?: string;
helmValues?: string[];
helmSetOverride?: string[];
};
async function deployWindmill(prov: MinikubeProvisioner, args: DeployArgs): Promise<ApiEndpoint> {
await helmDeployWindmill({
profile: prov.profile,
chart: args.helm,
valuesFiles: args.helmValues,
set: args.helmSetOverride,
});
// helm runs without --wait — gate PG + app readiness ourselves so port-forward
// doesn't race startup. PG max_connections is templated into the chart now
// (postgresql.maxConnections value), no post-install patching needed.
await waitPgReady(prov.profile);
await waitWindmillAppReady(prov.profile);
return await portForwardApi(prov.profile);
}
type UpOpts = DeployArgs & { topology: string };
async function upMain(opts: UpOpts) {
const topology = await loadValidTopology(opts.topology);
// Per-run reports dir — archives the inputs (topology + values files) so
// each cluster bring-up records exactly what was deployed.
const isoStamp = new Date().toISOString().replace(/[:.]/g, "-").replace(/Z$/, "");
const outDir = `reports/${isoStamp}_${topology.name}`;
await Deno.mkdir(outDir, { recursive: true });
await Deno.copyFile(opts.topology, `${outDir}/topology.json`);
for (const v of opts.helmValues ?? []) {
const base = v.split("/").pop() || "values.yaml";
await Deno.copyFile(v, `${outDir}/${base}`);
}
console.log(`[sim] inputs archived to ${outDir}/`);
const prov = new MinikubeProvisioner({ profile: profileFor("wm-sim", topology.name) });
// Clean any leftover state (crashed prior run, leaked libvirt domains)
// before provisioning. teardown() now sweeps both minikube + libvirt.
console.log(`[sim] cleaning stale state for profile=${prov.profile}...`);
await prov.teardown();
await prov.provision(topology);
try {
await loadCachedImages(prov.profile);
} catch (e) {
console.warn(`[sim] image cache preload failed: ${(e as Error).message}`);
}
// Toxiproxy: single Deployment + Service, no nodeSelector. Worker count +
// resources + DATABASE_URL live in the user's values.yaml.
const toxManifestPath = `${outDir}/toxiproxy.yaml`;
await Deno.writeTextFile(toxManifestPath, buildToxiproxyManifests(topology));
console.log(`[sim] applying toxiproxy...`);
const applyRes = await prov.kubectl(["apply", "-f", toxManifestPath]);
if (applyRes.code !== 0) {
console.warn(`[sim] toxiproxy apply non-zero: ${applyRes.stdout}`);
}
// helm: user values are the only source of truth (workerGroups +
// DATABASE_URL routing live there now).
const endpoint = await deployWindmill(prov, opts);
// PG max_connections bump + readiness gates are inside deployWindmill now.
// Enable verbose PG logging so a pgBadger report can be generated on
// teardown — best-effort; if the chart's PG layout differs, log and
// continue (the cluster is still usable for ad-hoc work).
try {
await enableVerbosePgLogging(prov);
} catch (e) {
console.warn(`[sim] could not enable verbose PG logging: ${(e as Error).message}`);
}
// Apply the sampler DaemonSet once per provision — it runs continuously
// between bench runs; wm-bench scopes its output via `--since-time` so each
// report only sees rows from its own window. No rollout-restart on reuse.
try {
await applyCpuSampler(prov, outDir);
} catch (e) {
console.warn(`[sim] CPU sampler not applied: ${(e as Error).message}`);
}
await Deno.writeTextFile(
`${outDir}/meta.json`,
JSON.stringify(
{
topology: topology.name,
profile: prov.profile,
api_host: endpoint.host,
helm_chart: opts.helm ?? "windmill/windmill",
helm_values_files: opts.helmValues ?? [],
helm_set_overrides: opts.helmSetOverride ?? [],
started_at: new Date().toISOString(),
},
null,
2,
),
);
console.log("\n" + "=".repeat(56));
console.log(` Topology "${topology.name}" is up.`);
console.log(` Windmill API: ${endpoint.host}`);
console.log(` minikube profile: ${prov.profile}`);
console.log(` Login: admin@windmill.dev / changeme`);
console.log("");
console.log(` To bench against this cluster:`);
console.log(` wm-bench --host ${endpoint.host} --minikube-profile ${prov.profile} ...`);
console.log("");
console.log(` Ctrl-C to tear down the cluster.`);
console.log("=".repeat(56));
await new Promise<void>((resolve) => {
const handler = () => {
Deno.removeSignalListener("SIGINT", handler);
Deno.removeSignalListener("SIGTERM", handler);
resolve();
};
Deno.addSignalListener("SIGINT", handler);
Deno.addSignalListener("SIGTERM", handler);
});
// Save images BEFORE minikube delete — they're gone with the VM otherwise.
try {
await saveImagesToCache(prov.profile, (args) => prov.kubectl(args));
} catch (e) {
console.warn(`[sim] image cache save failed: ${(e as Error).message}`);
}
console.log("\nTearing down...");
endpoint.stop();
await prov.teardown();
Deno.exit(0);
}
if (import.meta.main) {
// `up` subcommand mirrors the root action — both forms work:
// wm_sim --topology X (root action)
// wm_sim up --topology X (explicit subcommand)
const upSub = new Command()
.description("Provision the cluster + deploy Windmill, leave running (Ctrl-C tears down).")
.option("--topology <path:string>", "Topology JSON.", { required: true })
.option(
"--helm <chart:string>",
"Helm chart for Windmill: local path (preferred) or remote 'repo/name'. Default 'windmill/windmill'.",
)
.option(
"--helm-values <path:string>",
"Helm values.yaml — primary source of deployment config (replicas/resources/image). Repeatable; later files take precedence. Copied into results/ as the run record.",
{ collect: true },
)
.option(
"--helm-set-override <kv:string>",
"Inline key=value override on top of --helm-values (repeatable). For surgical CLI tweaks; the values file is the source of truth.",
{ collect: true },
)
.action(upMain);
await new Command()
.name("wm_sim")
.description(
"Windmill benchmark sim: provision a minikube cluster from a topology and deploy Windmill on it. Provisioning only — no benchmarking.",
)
.command("up", upSub)
.default("up")
.parse();
}