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>
226 lines
8.8 KiB
TypeScript
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();
|
|
}
|