mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 16:03:21 +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>
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
// Collect OOM kills that fired during the bench window, from two distinct
|
|
// sources:
|
|
//
|
|
// - Node-level VM kernel OOM (`kubectl get events --field-selector
|
|
// reason=SystemOOM`): fires when the sum of pod RSS on a node exceeds
|
|
// the VM's RAM ceiling. The kernel picks a victim by oom_score across
|
|
// all cgroups; the event message names the victim process (e.g. "deno").
|
|
//
|
|
// - Per-container cgroup OOM (pod containerStatus
|
|
// `lastState.terminated.reason == "OOMKilled"`): fires when one pod
|
|
// exceeds its own `limits.memory`. Kills only processes in that pod's
|
|
// cgroup; kubelet marks the container OOMKilled and restarts per policy.
|
|
//
|
|
// Output is a JSON file the renderer turns into a bar chart so heavy-bench
|
|
// reports show "what got killed and how many times" at a glance.
|
|
|
|
import { MinikubeProvisioner } from "./k8s_provisioner.ts";
|
|
|
|
export type OomEvent = {
|
|
ts_ms: number;
|
|
source: "node_kernel" | "cgroup" | "kubelet_eviction" | "scheduler_preemption";
|
|
// process name from SystemOOM, or pod name from cgroup OOM / eviction /
|
|
// preemption
|
|
victim: string;
|
|
node: string;
|
|
// bytes the container was using at eviction time, parsed from the
|
|
// Evicted message. Only present for `kubelet_eviction` source.
|
|
bytes_at_kill?: number;
|
|
};
|
|
|
|
type RawEvent = {
|
|
metadata?: { creationTimestamp?: string };
|
|
lastTimestamp?: string;
|
|
eventTime?: string;
|
|
involvedObject?: { name?: string };
|
|
message?: string;
|
|
reason?: string;
|
|
};
|
|
|
|
type RawPod = {
|
|
metadata?: { name?: string };
|
|
spec?: { nodeName?: string };
|
|
status?: {
|
|
containerStatuses?: Array<{
|
|
restartCount?: number;
|
|
lastState?: {
|
|
terminated?: {
|
|
reason?: string;
|
|
finishedAt?: string;
|
|
};
|
|
};
|
|
}>;
|
|
};
|
|
};
|
|
|
|
function parseTs(s?: string): number {
|
|
if (!s) return 0;
|
|
const ms = Date.parse(s);
|
|
return Number.isFinite(ms) ? ms : 0;
|
|
}
|
|
|
|
// Pull "victim process: deno" out of the SystemOOM message text.
|
|
function parseVictim(msg: string): string {
|
|
const m = msg.match(/victim process:\s*([^\s,]+)/);
|
|
return m ? m[1] : "unknown";
|
|
}
|
|
|
|
// Read dmesg + uptime from a sampler pod (privileged + hostPID, so it sees
|
|
// the host kernel log buffer) and convert each "Memory cgroup out of memory:
|
|
// Killed process N (CMD)" line to an OomEvent. Catches subprocess kills
|
|
// inside a cgroup that don't surface as containerStatus OOMKilled (because
|
|
// PID1 survived) and don't surface as SystemOOM events (because the OOM
|
|
// scope was cgroup, not whole-VM). These are the ones that produce 500+
|
|
// failed jobs + low CPU util while every other source says "0 OOMs".
|
|
async function dmesgOomsFromSampler(
|
|
prov: MinikubeProvisioner,
|
|
samplerNamespace: string,
|
|
samplerPod: string,
|
|
nodeName: string,
|
|
sinceMs: number,
|
|
): Promise<OomEvent[]> {
|
|
// Two outputs in one exec, separated by a sentinel — saves a round-trip.
|
|
const res = await prov.kubectl([
|
|
"-n", samplerNamespace, "exec", samplerPod, "--", "sh", "-c",
|
|
"cat /proc/uptime; echo '---DMESG---'; dmesg 2>/dev/null | grep 'Memory cgroup out of memory'",
|
|
]);
|
|
if (res.code !== 0 || !res.stdout) return [];
|
|
const parts = res.stdout.split("---DMESG---");
|
|
if (parts.length < 2) return [];
|
|
const uptime_s = parseFloat(parts[0].trim().split(/\s+/)[0] || "0");
|
|
if (!Number.isFinite(uptime_s) || uptime_s <= 0) return [];
|
|
const wallNowMs = Date.now();
|
|
const bootWallMs = wallNowMs - uptime_s * 1000;
|
|
|
|
// dmesg line format: `[ 7126.289518] Memory cgroup out of memory: Killed process 354780 (deno) total-vm:...`
|
|
const re = /^\[\s*(\d+(?:\.\d+)?)\]\s+Memory cgroup out of memory:\s+Killed process \d+ \(([^)]+)\)/;
|
|
const out: OomEvent[] = [];
|
|
for (const line of parts[1].split("\n")) {
|
|
const m = re.exec(line);
|
|
if (!m) continue;
|
|
const event_uptime_s = parseFloat(m[1]);
|
|
const cmd = m[2];
|
|
if (!Number.isFinite(event_uptime_s)) continue;
|
|
const ts_ms = Math.round(bootWallMs + event_uptime_s * 1000);
|
|
if (ts_ms < sinceMs) continue;
|
|
out.push({ ts_ms, source: "node_kernel", victim: cmd, node: nodeName });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Pull "was using 14821920Ki" (or similar size suffixes) out of an Evicted
|
|
// event message. kubelet formats this as `<digits><Ki|Mi|Gi>`. Returns bytes.
|
|
function parseEvictedBytes(msg: string): number | undefined {
|
|
const m = msg.match(/was using\s+(\d+)(Ki|Mi|Gi)\b/);
|
|
if (!m) return undefined;
|
|
const n = parseInt(m[1]);
|
|
if (!Number.isFinite(n)) return undefined;
|
|
const mult = m[2] === "Gi" ? 1024 * 1024 * 1024 : m[2] === "Mi" ? 1024 * 1024 : 1024;
|
|
return n * mult;
|
|
}
|
|
|
|
export async function collectOomEvents(
|
|
prov: MinikubeProvisioner,
|
|
outPath: string,
|
|
opts: { sinceMs?: number } = {},
|
|
): Promise<void> {
|
|
const since = opts.sinceMs ?? 0;
|
|
const out: OomEvent[] = [];
|
|
|
|
// 1) Node-level VM kernel OOMs (SystemOOM events).
|
|
const evRes = await prov.kubectl([
|
|
"get", "events",
|
|
"-A",
|
|
"--field-selector", "reason=SystemOOM",
|
|
"-o", "json",
|
|
]);
|
|
if (evRes.code === 0 && evRes.stdout) {
|
|
let evJson: { items?: RawEvent[] } = {};
|
|
try { evJson = JSON.parse(evRes.stdout); } catch { /* ignore parse errors */ }
|
|
for (const e of evJson.items ?? []) {
|
|
const ts = parseTs(e.eventTime ?? e.lastTimestamp ?? e.metadata?.creationTimestamp);
|
|
if (ts < since) continue;
|
|
out.push({
|
|
ts_ms: ts,
|
|
source: "node_kernel",
|
|
victim: parseVictim(e.message ?? ""),
|
|
node: e.involvedObject?.name ?? "unknown",
|
|
});
|
|
}
|
|
}
|
|
|
|
// 1b) Kubelet evictions (L1, fires BEFORE kernel OOM under graceful node
|
|
// pressure). Has a usable memory-at-kill value in the message.
|
|
const evictedRes = await prov.kubectl([
|
|
"get", "events",
|
|
"-A",
|
|
"--field-selector", "reason=Evicted",
|
|
"-o", "json",
|
|
]);
|
|
if (evictedRes.code === 0 && evictedRes.stdout) {
|
|
let evJson: { items?: RawEvent[] } = {};
|
|
try { evJson = JSON.parse(evictedRes.stdout); } catch { /* ignore parse errors */ }
|
|
for (const e of evJson.items ?? []) {
|
|
const ts = parseTs(e.eventTime ?? e.lastTimestamp ?? e.metadata?.creationTimestamp);
|
|
if (ts < since) continue;
|
|
out.push({
|
|
ts_ms: ts,
|
|
source: "kubelet_eviction",
|
|
victim: e.involvedObject?.name ?? "unknown",
|
|
node: "unknown", // event references the evicted pod, not the node;
|
|
// the message has it but parsing is brittle
|
|
bytes_at_kill: parseEvictedBytes(e.message ?? ""),
|
|
});
|
|
}
|
|
}
|
|
|
|
// 1c) Scheduler preemptions (L0, fires when a higher-priority pod can't
|
|
// fit and the scheduler picks a victim to evict). DIFFERENT from kubelet
|
|
// eviction: kubelet evicts due to NODE PRESSURE (memory, disk); scheduler
|
|
// preempts due to a PriorityClass collision. Common case in this cluster:
|
|
// PG/sampler at wm-critical can't fit → workers (priority 0) get
|
|
// preempted. Without this we miss the bulk of "worker dying" events.
|
|
const preemptedRes = await prov.kubectl([
|
|
"get", "events",
|
|
"-A",
|
|
"--field-selector", "reason=Preempted",
|
|
"-o", "json",
|
|
]);
|
|
if (preemptedRes.code === 0 && preemptedRes.stdout) {
|
|
let evJson: { items?: RawEvent[] } = {};
|
|
try { evJson = JSON.parse(preemptedRes.stdout); } catch { /* ignore parse errors */ }
|
|
for (const e of evJson.items ?? []) {
|
|
const ts = parseTs(e.eventTime ?? e.lastTimestamp ?? e.metadata?.creationTimestamp);
|
|
if (ts < since) continue;
|
|
out.push({
|
|
ts_ms: ts,
|
|
source: "scheduler_preemption",
|
|
victim: e.involvedObject?.name ?? "unknown",
|
|
node: "unknown",
|
|
});
|
|
}
|
|
}
|
|
|
|
// 2) Per-container cgroup OOMs (pod containerStatuses).
|
|
// Catches at most ONE OOMKill per container — the most recent. For multi-
|
|
// restart OOM patterns we merge the live poller's JSONL below.
|
|
const podRes = await prov.kubectl([
|
|
"get", "pods", "-A",
|
|
"-o", "json",
|
|
]);
|
|
if (podRes.code === 0 && podRes.stdout) {
|
|
let podJson: { items?: RawPod[] } = {};
|
|
try { podJson = JSON.parse(podRes.stdout); } catch { /* ignore parse errors */ }
|
|
for (const p of podJson.items ?? []) {
|
|
for (const cs of p.status?.containerStatuses ?? []) {
|
|
const term = cs.lastState?.terminated;
|
|
if (term?.reason !== "OOMKilled") continue;
|
|
const ts = parseTs(term.finishedAt);
|
|
if (ts < since) continue;
|
|
out.push({
|
|
ts_ms: ts,
|
|
source: "cgroup",
|
|
victim: p.metadata?.name ?? "unknown",
|
|
node: p.spec?.nodeName ?? "unknown",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3) Merge mid-bench OOMKills captured by the live poller. The poller
|
|
// dedupes by (pod, container, finishedAt) and writes JSONL; we union with
|
|
// the end-of-bench scan above so OOMs that fired AND recovered mid-bench
|
|
// (which the lastState scan misses) still show up on the L2 cgroup panel.
|
|
const liveJsonlPath = outPath.replace(/\.json$/, "_live.jsonl");
|
|
try {
|
|
const text = await Deno.readTextFile(liveJsonlPath);
|
|
const seen = new Set(out.map((e) => `${e.victim}|${e.ts_ms}`));
|
|
for (const line of text.split("\n")) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const r = JSON.parse(line) as OomEvent;
|
|
if (r.ts_ms < since) continue;
|
|
const key = `${r.victim}|${r.ts_ms}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(r);
|
|
} catch { /* skip malformed line */ }
|
|
}
|
|
} catch { /* poller wasn't running or file missing — ignore */ }
|
|
|
|
// 4) Per-node dmesg scan. Catches subprocess OOM kills inside a cgroup
|
|
// (where the container's PID 1 survived → no containerStatus OOMKilled →
|
|
// sources 2/3 miss it, and the OOM was cgroup-scope → no SystemOOM event
|
|
// → source 1 misses it). This is what produces "lots of failed jobs +
|
|
// low CPU util while every panel says 0 OOMs" — workers' deno
|
|
// subprocesses get SIGKILLed mid-job, the worker container keeps polling.
|
|
const samplerNs = "kube-system";
|
|
const samplerSelector = "app=wm-sim-cpu-sampler";
|
|
const samplerListRes = await prov.kubectl([
|
|
"-n", samplerNs, "get", "pods", "-l", samplerSelector,
|
|
"-o", "jsonpath={range .items[*]}{.metadata.name}|{.spec.nodeName}\\n{end}",
|
|
]);
|
|
if (samplerListRes.code === 0 && samplerListRes.stdout) {
|
|
const dmesgEvents: OomEvent[] = [];
|
|
for (const line of samplerListRes.stdout.split("\n")) {
|
|
if (!line.trim()) continue;
|
|
const [samplerPod, nodeName] = line.split("|");
|
|
if (!samplerPod || !nodeName) continue;
|
|
try {
|
|
const evs = await dmesgOomsFromSampler(prov, samplerNs, samplerPod.trim(), nodeName.trim(), since);
|
|
dmesgEvents.push(...evs);
|
|
} catch (e) {
|
|
console.warn(`[oom] dmesg scan on ${nodeName} failed: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
// Dedupe across nodes — extremely close timestamps for the same victim
|
|
// would be rare but defend against double-counting if a future change
|
|
// adds dmesg multi-source overlap.
|
|
const seenDmesg = new Set(out.map((e) => `${e.source}|${e.victim}|${e.ts_ms}`));
|
|
for (const e of dmesgEvents) {
|
|
const key = `${e.source}|${e.victim}|${e.ts_ms}`;
|
|
if (seenDmesg.has(key)) continue;
|
|
seenDmesg.add(key);
|
|
out.push(e);
|
|
}
|
|
}
|
|
|
|
out.sort((a, b) => a.ts_ms - b.ts_ms);
|
|
await Deno.writeTextFile(outPath, JSON.stringify(out));
|
|
const ks = out.filter(e => e.source === "node_kernel").length;
|
|
const cs = out.filter(e => e.source === "cgroup").length;
|
|
const es = out.filter(e => e.source === "kubelet_eviction").length;
|
|
console.log(`[oom] ${out.length} kill event(s) captured (${es} L1 evicted, ${cs} L2 cgroup, ${ks} L2 node-kernel) -> ${outPath}`);
|
|
}
|