mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 00:02:03 +00:00
74b662d8de
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>
106 lines
4.2 KiB
TypeScript
106 lines
4.2 KiB
TypeScript
// Polls /proc/loadavg + nproc on each minikube node every intervalMs and
|
|
// writes one JSONL row per node per tick. Used to compute *saturation* (load /
|
|
// ncpu) and *oversaturation* (max(0, load/ncpu - 1)) in the dashboard. PSI is
|
|
// not available in the minikube kernel and cpu.stat throttling is meaningless
|
|
// without limits.cpu, so loadavg is the only saturation signal we have.
|
|
//
|
|
// Output line: {"ts": ms, "node": "wm-sim-k8s-4node-m02", "load1": 44.2,
|
|
// "load5": 32.1, "load15": 24.4, "ncpu": 4}
|
|
|
|
import { MinikubeProvisioner } from "./k8s_provisioner.ts";
|
|
|
|
export type NodeLoadPoller = {
|
|
cont: { value: boolean };
|
|
done: Promise<void>;
|
|
};
|
|
|
|
export function startNodeLoadPoller(
|
|
prov: MinikubeProvisioner,
|
|
outPath: string,
|
|
opts: { intervalMs?: number } = {},
|
|
): NodeLoadPoller {
|
|
const intervalMs = opts.intervalMs ?? 2000;
|
|
const cont = { value: true };
|
|
const f = Deno.openSync(outPath, { write: true, create: true, truncate: true });
|
|
const enc = new TextEncoder();
|
|
|
|
const done = (async () => {
|
|
// Discover nodes once at startup. The poll loop reuses this list. New
|
|
// nodes joining mid-bench are rare (we don't auto-scale the cluster).
|
|
let nodes: { name: string; ip: string }[] = [];
|
|
try {
|
|
const r = await prov.kubectl([
|
|
"get", "nodes",
|
|
"-o", "jsonpath={range .items[*]}{.metadata.name}{\"|\"}{.status.addresses[?(@.type==\"InternalIP\")].address}{\"\\n\"}{end}",
|
|
]);
|
|
if (r.code === 0) {
|
|
for (const line of r.stdout.split("\n")) {
|
|
if (!line.trim()) continue;
|
|
const [name, ip] = line.split("|");
|
|
if (name && ip) nodes.push({ name: name.trim(), ip: ip.trim() });
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn(`[node-load] node discovery failed: ${(e as Error).message}`);
|
|
}
|
|
|
|
while (cont.value) {
|
|
const startMs = Date.now();
|
|
// Poll all nodes in parallel — one ssh per node per tick.
|
|
await Promise.all(nodes.map(async ({ name, ip }) => {
|
|
try {
|
|
// Each minikube node has its own ssh key under ~/.minikube/machines.
|
|
const keyPath = `${Deno.env.get("HOME")}/.minikube/machines/${name}/id_rsa`;
|
|
const proc = new Deno.Command("ssh", {
|
|
args: [
|
|
"-o", "StrictHostKeyChecking=no",
|
|
"-o", "UserKnownHostsFile=/dev/null",
|
|
"-o", "ConnectTimeout=2",
|
|
"-o", "LogLevel=ERROR",
|
|
"-i", keyPath,
|
|
`docker@${ip}`,
|
|
// procs_running is the runnable count (CPU-bound queue) — does
|
|
// NOT include D-state procs (disk/network wait). loadavg counts
|
|
// both, so loadavg/ncpu was conflating CPU-starved processes
|
|
// with PG backends waiting on disk I/O.
|
|
"cat /proc/loadavg && cat /proc/stat | grep ^procs_running && nproc",
|
|
],
|
|
stdout: "piped",
|
|
stderr: "null",
|
|
});
|
|
const out = await proc.output();
|
|
const text = new TextDecoder().decode(out.stdout).trim();
|
|
const lines = text.split("\n");
|
|
if (lines.length < 3) return;
|
|
const loadParts = lines[0].split(" ");
|
|
const load1 = parseFloat(loadParts[0]);
|
|
const load5 = parseFloat(loadParts[1]);
|
|
const load15 = parseFloat(loadParts[2]);
|
|
// "procs_running N" — instantaneous count of runnable processes
|
|
// (current + queued for CPU). Excludes D-state.
|
|
const procsRunning = parseInt(lines[1].split(/\s+/)[1] ?? "");
|
|
const ncpu = parseInt(lines[2].trim());
|
|
if (!Number.isFinite(load1) || !Number.isFinite(ncpu)) return;
|
|
const row = {
|
|
ts: startMs,
|
|
node: name,
|
|
load1,
|
|
load5,
|
|
load15,
|
|
procs_running: Number.isFinite(procsRunning) ? procsRunning : null,
|
|
ncpu,
|
|
};
|
|
f.writeSync(enc.encode(JSON.stringify(row) + "\n"));
|
|
} catch (_e) { /* skip this node this tick */ }
|
|
}));
|
|
const elapsed = Date.now() - startMs;
|
|
if (cont.value && elapsed < intervalMs) {
|
|
await new Promise((r) => setTimeout(r, intervalMs - elapsed));
|
|
}
|
|
}
|
|
f.close();
|
|
})();
|
|
|
|
return { cont, done };
|
|
}
|