mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +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>
135 lines
4.8 KiB
TypeScript
135 lines
4.8 KiB
TypeScript
// Host-side cache for container images that the cluster pulls.
|
|
//
|
|
// Cluster containers run inside the minikube VM, and `minikube delete` destroys
|
|
// the VM's container runtime — next bringup re-pulls everything (windmill
|
|
// alone is ~3.85 GB per node). We work around that with two hooks:
|
|
// - on bringup (after `minikube start`, before `helm install`): for every
|
|
// `.tar` in the host cache dir, `minikube image load <tar>` into the new
|
|
// VM(s).
|
|
// - on teardown (before `minikube delete`): `minikube image save <img> <tar>`
|
|
// for each workload image currently in the cluster, skipping ones already
|
|
// cached.
|
|
//
|
|
// Net effect: first run pulls and caches; subsequent runs reuse the cache,
|
|
// trading a multi-GB network pull for a ~30 s disk-to-VM load per node.
|
|
//
|
|
// Default cache dir: $XDG_CACHE_HOME/wm-sim/images (~/.cache/wm-sim/images).
|
|
|
|
const MINIKUBE = Deno.env.get("SIM_MINIKUBE_BIN") ?? "minikube";
|
|
|
|
// System images we don't cache — they're small, kube-system-only, and minikube
|
|
// itself often pre-stages them via its ISO/preload bundle.
|
|
const SYSTEM_PREFIXES = [
|
|
"registry.k8s.io/",
|
|
"gcr.io/k8s-minikube/",
|
|
"docker.io/flannel/",
|
|
"k8s.gcr.io/",
|
|
"kindest/",
|
|
];
|
|
|
|
function defaultCacheDir(): string {
|
|
const xdg = Deno.env.get("XDG_CACHE_HOME");
|
|
const home = Deno.env.get("HOME") ?? ".";
|
|
return `${xdg ?? `${home}/.cache`}/wm-sim/images`;
|
|
}
|
|
|
|
// Same env shape the provisioner uses — minikube needs the kvm2 driver dir on
|
|
// PATH + LD_LIBRARY_PATH for libvirt.
|
|
function minikubeEnv(): Record<string, string> {
|
|
const base = Deno.env.toObject();
|
|
const driverDir = Deno.env.get("SIM_KVM2_DRIVER_DIR");
|
|
const libDir = Deno.env.get("SIM_LIBVIRT_LIB_DIR");
|
|
if (driverDir) base.PATH = `${driverDir}:${base.PATH ?? ""}`;
|
|
if (libDir) {
|
|
base.LD_LIBRARY_PATH = base.LD_LIBRARY_PATH
|
|
? `${libDir}:${base.LD_LIBRARY_PATH}`
|
|
: libDir;
|
|
}
|
|
return base;
|
|
}
|
|
|
|
async function runMinikube(
|
|
args: string[],
|
|
): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
const p = new Deno.Command(MINIKUBE, {
|
|
args,
|
|
env: minikubeEnv(),
|
|
stdout: "piped",
|
|
stderr: "piped",
|
|
});
|
|
const { code, stdout, stderr } = await p.output();
|
|
return {
|
|
code,
|
|
stdout: new TextDecoder().decode(stdout),
|
|
stderr: new TextDecoder().decode(stderr),
|
|
};
|
|
}
|
|
|
|
function tarballName(image: string): string {
|
|
// ghcr.io/windmill-labs/windmill:1.711.0 -> ghcr.io_windmill-labs_windmill_1.711.0.tar
|
|
return image.replace(/[/:@]/g, "_") + ".tar";
|
|
}
|
|
|
|
async function pathExists(p: string): Promise<boolean> {
|
|
try { await Deno.stat(p); return true; } catch { return false; }
|
|
}
|
|
|
|
function isWorkloadImage(image: string): boolean {
|
|
return !SYSTEM_PREFIXES.some((p) => image.startsWith(p));
|
|
}
|
|
|
|
// Load every .tar in cacheDir into the cluster. Best-effort: errors are
|
|
// logged but don't abort — a corrupted tarball shouldn't break bringup.
|
|
export async function loadCachedImages(
|
|
profile: string,
|
|
cacheDir = defaultCacheDir(),
|
|
): Promise<number> {
|
|
if (!(await pathExists(cacheDir))) return 0;
|
|
let loaded = 0;
|
|
for await (const ent of Deno.readDir(cacheDir)) {
|
|
if (!ent.isFile || !ent.name.endsWith(".tar")) continue;
|
|
const path = `${cacheDir}/${ent.name}`;
|
|
console.log(`[cache] loading ${ent.name}`);
|
|
const r = await runMinikube(["-p", profile, "image", "load", path]);
|
|
if (r.code === 0) loaded++;
|
|
else console.warn(`[cache] failed to load ${ent.name}: ${r.stderr.trim().split("\n").pop()}`);
|
|
}
|
|
if (loaded > 0) console.log(`[cache] loaded ${loaded} image(s) from ${cacheDir}`);
|
|
return loaded;
|
|
}
|
|
|
|
// Save each "workload" image in the running cluster to a tarball in cacheDir.
|
|
// Skips images already cached. Discovers images via the supplied kubectl
|
|
// runner (typically `prov.kubectl`).
|
|
export async function saveImagesToCache(
|
|
profile: string,
|
|
kubectl: (args: string[]) => Promise<{ stdout: string; code: number }>,
|
|
cacheDir = defaultCacheDir(),
|
|
): Promise<number> {
|
|
await Deno.mkdir(cacheDir, { recursive: true });
|
|
|
|
const r = await kubectl([
|
|
"get", "pods", "-A",
|
|
"-o", "jsonpath={range .items[*].spec.containers[*]}{.image}{\"\\n\"}{end}",
|
|
]);
|
|
if (r.code !== 0) {
|
|
console.warn("[cache] could not list images — skipping save");
|
|
return 0;
|
|
}
|
|
const images = new Set(
|
|
r.stdout.split("\n").map((l) => l.trim()).filter((l) => l && isWorkloadImage(l)),
|
|
);
|
|
|
|
let saved = 0;
|
|
for (const image of images) {
|
|
const tarPath = `${cacheDir}/${tarballName(image)}`;
|
|
if (await pathExists(tarPath)) continue;
|
|
console.log(`[cache] saving ${image}`);
|
|
const s = await runMinikube(["-p", profile, "image", "save", image, tarPath]);
|
|
if (s.code === 0) saved++;
|
|
else console.warn(`[cache] failed to save ${image}: ${s.stderr.trim().split("\n").pop()}`);
|
|
}
|
|
if (saved > 0) console.log(`[cache] saved ${saved} new image(s) to ${cacheDir}`);
|
|
return saved;
|
|
}
|