mirror of
https://github.com/imjasonh/terraform-playground
synced 2026-07-07 23:35:16 +00:00
Model node pools on real AWS instance types, prices & spot market
- Replace the generic node pools with realistic AWS EC2 families and their us-east-1 on-demand prices: c5.xlarge/c5.2xlarge (general), c5d.2xlarge (local-NVMe SSD), r5.2xlarge (memory), g4dn.xlarge (1× T4 GPU), plus c5 spot variants. Pod requests were already realistic and are unchanged. - Spot price is now the live fraction of on-demand (mean-reverting ~0.45, clamped <1 since AWS never bills spot above on-demand); spot nodes bill at cost×spotPrice and a hotter market means more interruptions. - GPU nodes are now single-GPU (g4dn.xlarge), so the autoscaler provisions one GPU node per pending GPU pod instead of de-duping to one per burst. - Name nodes with a random k8s-style suffix (node-xugjs) while keeping a stable internal id. Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
This commit is contained in:
parent
218eba3b47
commit
3338b31e1a
3 changed files with 94 additions and 60 deletions
|
|
@ -10,6 +10,7 @@ import {
|
|||
SLA_PENDING_TICKS,
|
||||
DAEMONSETS,
|
||||
effectiveCost,
|
||||
randHash,
|
||||
} from "./types.js";
|
||||
import { bestNodeFor, evaluateFit, selectorMatches, summarizePendingReason } from "./scheduler.js";
|
||||
import { SCENARIOS, makeRng, spawnForTick, createDaemonPod } from "./workload.js";
|
||||
|
|
@ -53,8 +54,9 @@ export class Game {
|
|||
// cluster version: nodes carry a minor version; an upgrade bumps the target.
|
||||
clusterMinor: 30,
|
||||
upgradePending: false,
|
||||
// spot market: a fluctuating multiplier applied to spot node $/hr.
|
||||
spotPrice: 1,
|
||||
// spot market: the live spot price as a fraction of on-demand (<1 means
|
||||
// savings; ~0.45 ≈ 55% off). Drives both billing and interruption risk.
|
||||
spotPrice: 0.4,
|
||||
nextUpgradeTick: scenario.upgradeEvery || 0,
|
||||
// running tallies for the score breakdown panel
|
||||
breakdown: { util: 0, latency: 0, cost: 0, jobs: 0, sla: 0, disruption: 0, upgrade: 0 },
|
||||
|
|
@ -105,9 +107,11 @@ export class Game {
|
|||
if (!spec) throw new Error(`unknown instance type ${typeKey}`);
|
||||
this.state.nodeSeq += 1;
|
||||
const z = zone || ZONES[this.state.nodeSeq % ZONES.length];
|
||||
// Random k8s-style name (e.g. node-xugjs); seq still backs the stable id.
|
||||
const name = `node-${randHash(this.rng, 5)}`;
|
||||
const node = {
|
||||
id: `node-${this.state.nodeSeq}`,
|
||||
name: `node-${this.state.nodeSeq}`,
|
||||
name,
|
||||
type: typeKey,
|
||||
cpu: spec.cpu,
|
||||
mem: spec.mem,
|
||||
|
|
@ -266,17 +270,19 @@ export class Game {
|
|||
}
|
||||
|
||||
/**
|
||||
* Advance the spot market one tick: a mean-reverting random walk with the odd
|
||||
* price spike, then roll interruption dice for each spot node. Warned nodes
|
||||
* count down a short notice (during which you can drain them) before the cloud
|
||||
* reclaims them. Higher prices mean more interruptions.
|
||||
* Advance the spot market one tick. spotPrice is the fraction of on-demand
|
||||
* that spot currently costs: a mean-reverting random walk around ~0.45
|
||||
* (~55% savings), clamped below 1 (AWS never charges spot above on-demand),
|
||||
* with the odd demand spike. Then roll interruption dice for each spot node —
|
||||
* a pricier (hotter) market means more reclaims. Warned nodes count down a
|
||||
* short notice (during which you can drain them) before being reclaimed.
|
||||
*/
|
||||
updateSpotMarket() {
|
||||
const s = this.state;
|
||||
const r = this.rng;
|
||||
let sp = s.spotPrice + (1 - s.spotPrice) * 0.03 + (r() - 0.5) * 0.08;
|
||||
if (r() < 0.004) sp += 0.8 + r(); // occasional price spike
|
||||
s.spotPrice = Math.max(0.2, Math.min(3, sp));
|
||||
let sp = s.spotPrice + (0.45 - s.spotPrice) * 0.03 + (r() - 0.5) * 0.05;
|
||||
if (r() < 0.004) sp += 0.25 + r() * 0.2; // occasional demand spike toward on-demand
|
||||
s.spotPrice = Math.max(0.2, Math.min(0.95, sp));
|
||||
|
||||
for (const node of [...s.nodes]) {
|
||||
if (!node.spot) continue;
|
||||
|
|
@ -358,15 +364,15 @@ export class Game {
|
|||
|
||||
/** The instance type the autoscaler would provision to host this pod. */
|
||||
scaleUpTypeForPod(pod) {
|
||||
if (pod.gpu > 0) return "gpu-xlarge";
|
||||
if (pod.gpu > 0) return "g4dn.xlarge";
|
||||
if (pod.nodeSelector && pod.nodeSelector.disktype === "ssd") {
|
||||
return pod.mem > 8192 ? "mem-xlarge" : "ssd-large";
|
||||
return pod.mem > 8192 ? "r5.2xlarge" : "c5d.2xlarge";
|
||||
}
|
||||
// batch jobs tolerate spot — grab cheap, interruptible capacity for them
|
||||
if (pod.kind === "job" && (pod.tolerations || []).some((t) => t.key === "spot")) {
|
||||
return "spot-medium";
|
||||
return "c5.2xlarge-spot";
|
||||
}
|
||||
return "general-large";
|
||||
return "c5.2xlarge";
|
||||
}
|
||||
|
||||
runAutoScaler() {
|
||||
|
|
@ -393,19 +399,33 @@ export class Game {
|
|||
const unfittable = this.pendingPods().filter(
|
||||
(pod) => !ready.some((n) => evaluateFit(pod, n, this.podsOnNode(n)).ok)
|
||||
);
|
||||
const provisioning = s.nodes.filter((n) => n.status === "Provisioning").length;
|
||||
// ~8 generic pods fit on a fresh node; discount capacity already booting.
|
||||
let budget = Math.min(3, Math.ceil(unfittable.length / 8) - provisioning);
|
||||
|
||||
if (unfittable.length > 0 && budget > 0) {
|
||||
if (unfittable.length > 0) {
|
||||
const added = [];
|
||||
const provisioning = s.nodes.filter((n) => n.status === "Provisioning");
|
||||
|
||||
// GPU pods need a dedicated single-GPU node each: provision one per
|
||||
// uncovered GPU pod (minus GPU nodes already booting), capped per burst.
|
||||
const gpuPending = unfittable.filter((p) => p.gpu > 0).length;
|
||||
const gpuBooting = provisioning.filter((n) => n.gpu > 0).length;
|
||||
const gpuToAdd = Math.min(3, Math.max(0, gpuPending - gpuBooting));
|
||||
for (let i = 0; i < gpuToAdd; i++) {
|
||||
this.addNode("g4dn.xlarge");
|
||||
added.push("g4dn.xlarge");
|
||||
}
|
||||
|
||||
// Everything else bin-packs several pods per node. Add general capacity
|
||||
// sized to the backlog, plus one node per distinct specialized type.
|
||||
const other = unfittable.filter((p) => p.gpu === 0);
|
||||
const otherBooting = provisioning.filter((n) => n.gpu === 0).length;
|
||||
let budget = Math.min(3, Math.ceil(other.length / 8) - otherBooting);
|
||||
const usedSpecial = new Set();
|
||||
// Highest priority first so scarce gpu/ssd workloads aren't starved.
|
||||
const queue = [...unfittable].sort((a, b) => b.priority - a.priority);
|
||||
// Highest priority first so scarce ssd workloads aren't starved.
|
||||
const queue = [...other].sort((a, b) => b.priority - a.priority);
|
||||
for (const pod of queue) {
|
||||
if (budget <= 0) break;
|
||||
const type = this.scaleUpTypeForPod(pod);
|
||||
if (type !== "general-large") {
|
||||
if (type !== "c5.2xlarge") {
|
||||
// one specialized node hosts several such pods — don't over-add
|
||||
if (usedSpecial.has(type)) continue;
|
||||
usedSpecial.add(type);
|
||||
|
|
@ -414,6 +434,7 @@ export class Game {
|
|||
added.push(type);
|
||||
budget -= 1;
|
||||
}
|
||||
|
||||
if (added.length) {
|
||||
this.log("info", `Autoscaler: scaling up (+${added.length}: ${[...new Set(added)].join(", ")}).`);
|
||||
s.scaleCooldown = 3;
|
||||
|
|
|
|||
|
|
@ -11,90 +11,103 @@ export const SLA_PENDING_TICKS = 40; // a pod pending longer than this breaches
|
|||
export const ZONES = ["us-east-1a", "us-east-1b", "us-east-1c"];
|
||||
|
||||
/**
|
||||
* Node instance types ("node pools"). Each carries baked-in labels and taints,
|
||||
* exactly like a managed node group in EKS/GKE. cost is a relative $/hour figure
|
||||
* used purely for scoring.
|
||||
* Node instance types ("node pools"), modeled on real AWS EC2 families. Each
|
||||
* carries baked-in labels and taints, exactly like a managed node group in
|
||||
* EKS/GKE. Specs and `cost` ($/hour) mirror AWS on-demand pricing in us-east-1
|
||||
* (Linux): c5 compute-optimized general purpose, c5d (compute + local NVMe SSD)
|
||||
* for storage-bound apps, r5 memory-optimized, and g4dn (NVIDIA T4) GPU — plus
|
||||
* spot variants of the c5s. CPU is in millicores, memory in MiB. Spot `cost` is
|
||||
* the on-demand reference price; spot nodes are billed at the live spot price
|
||||
* (a fraction of it — see engine).
|
||||
*/
|
||||
export const INSTANCE_TYPES = {
|
||||
"general-medium": {
|
||||
key: "general-medium",
|
||||
"c5.xlarge": {
|
||||
key: "c5.xlarge",
|
||||
family: "general",
|
||||
cpu: 4000,
|
||||
mem: 8192,
|
||||
gpu: 0,
|
||||
cost: 0.16,
|
||||
cost: 0.17,
|
||||
bootTicks: 8,
|
||||
labels: { "node.kubernetes.io/instance-type": "general-medium", disktype: "hdd" },
|
||||
labels: { "node.kubernetes.io/instance-type": "c5.xlarge", disktype: "network" },
|
||||
taints: [],
|
||||
},
|
||||
"general-large": {
|
||||
key: "general-large",
|
||||
"c5.2xlarge": {
|
||||
key: "c5.2xlarge",
|
||||
family: "general",
|
||||
cpu: 8000,
|
||||
mem: 16384,
|
||||
gpu: 0,
|
||||
cost: 0.32,
|
||||
cost: 0.34,
|
||||
bootTicks: 10,
|
||||
labels: { "node.kubernetes.io/instance-type": "general-large", disktype: "hdd" },
|
||||
labels: { "node.kubernetes.io/instance-type": "c5.2xlarge", disktype: "network" },
|
||||
taints: [],
|
||||
},
|
||||
"ssd-large": {
|
||||
key: "ssd-large",
|
||||
"c5d.2xlarge": {
|
||||
key: "c5d.2xlarge",
|
||||
family: "ssd",
|
||||
cpu: 8000,
|
||||
mem: 16384,
|
||||
gpu: 0,
|
||||
cost: 0.42,
|
||||
cost: 0.384,
|
||||
bootTicks: 10,
|
||||
labels: { "node.kubernetes.io/instance-type": "ssd-large", disktype: "ssd" },
|
||||
labels: { "node.kubernetes.io/instance-type": "c5d.2xlarge", disktype: "ssd" },
|
||||
taints: [],
|
||||
},
|
||||
"mem-xlarge": {
|
||||
key: "mem-xlarge",
|
||||
"r5.2xlarge": {
|
||||
key: "r5.2xlarge",
|
||||
family: "mem",
|
||||
cpu: 8000,
|
||||
mem: 65536,
|
||||
mem: 65536, // 64 GiB
|
||||
gpu: 0,
|
||||
cost: 0.55,
|
||||
cost: 0.504,
|
||||
bootTicks: 12,
|
||||
labels: { "node.kubernetes.io/instance-type": "mem-xlarge", disktype: "ssd" },
|
||||
labels: { "node.kubernetes.io/instance-type": "r5.2xlarge", disktype: "ssd" },
|
||||
taints: [],
|
||||
},
|
||||
"gpu-xlarge": {
|
||||
key: "gpu-xlarge",
|
||||
"g4dn.xlarge": {
|
||||
key: "g4dn.xlarge",
|
||||
family: "gpu",
|
||||
cpu: 8000,
|
||||
mem: 32768,
|
||||
gpu: 4,
|
||||
cost: 2.4,
|
||||
cpu: 4000,
|
||||
mem: 16384,
|
||||
gpu: 1, // 1× NVIDIA T4
|
||||
cost: 0.526,
|
||||
bootTicks: 16,
|
||||
labels: {
|
||||
"node.kubernetes.io/instance-type": "gpu-xlarge",
|
||||
"node.kubernetes.io/instance-type": "g4dn.xlarge",
|
||||
disktype: "ssd",
|
||||
accelerator: "nvidia-t4",
|
||||
},
|
||||
taints: [{ key: "nvidia.com/gpu", value: "present", effect: "NoSchedule" }],
|
||||
},
|
||||
"spot-medium": {
|
||||
key: "spot-medium",
|
||||
"c5.xlarge-spot": {
|
||||
key: "c5.xlarge-spot",
|
||||
family: "spot",
|
||||
cpu: 4000,
|
||||
mem: 8192,
|
||||
gpu: 0,
|
||||
cost: 0.05,
|
||||
cost: 0.17, // on-demand reference; billed at the live spot fraction
|
||||
bootTicks: 6,
|
||||
labels: { "node.kubernetes.io/instance-type": "spot-medium", disktype: "hdd" },
|
||||
labels: {
|
||||
"node.kubernetes.io/instance-type": "c5.xlarge",
|
||||
"karpenter.sh/capacity-type": "spot",
|
||||
disktype: "network",
|
||||
},
|
||||
taints: [{ key: "spot", value: "true", effect: "NoSchedule" }],
|
||||
},
|
||||
"spot-large": {
|
||||
key: "spot-large",
|
||||
"c5.2xlarge-spot": {
|
||||
key: "c5.2xlarge-spot",
|
||||
family: "spot",
|
||||
cpu: 8000,
|
||||
mem: 16384,
|
||||
gpu: 0,
|
||||
cost: 0.09,
|
||||
cost: 0.34,
|
||||
bootTicks: 6,
|
||||
labels: { "node.kubernetes.io/instance-type": "spot-large", disktype: "hdd" },
|
||||
labels: {
|
||||
"node.kubernetes.io/instance-type": "c5.2xlarge",
|
||||
"karpenter.sh/capacity-type": "spot",
|
||||
disktype: "network",
|
||||
},
|
||||
taints: [{ key: "spot", value: "true", effect: "NoSchedule" }],
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ export const SCENARIOS = {
|
|||
id: "steady",
|
||||
name: "Steady State",
|
||||
blurb: "A balanced, predictable workload. Great for learning the ropes.",
|
||||
startNodes: ["general-large", "general-large", "ssd-large"],
|
||||
startNodes: ["c5.2xlarge", "c5.2xlarge", "c5d.2xlarge"],
|
||||
seed: 1337,
|
||||
upgradeEvery: 900,
|
||||
arrival: () => 0.55,
|
||||
|
|
@ -117,7 +117,7 @@ export const SCENARIOS = {
|
|||
id: "spike",
|
||||
name: "Traffic Spike",
|
||||
blurb: "Calm baseline punctuated by big frontend/api surges. Scale up fast, scale down after.",
|
||||
startNodes: ["general-large", "ssd-large"],
|
||||
startNodes: ["c5.2xlarge", "c5d.2xlarge"],
|
||||
seed: 7,
|
||||
upgradeEvery: 700,
|
||||
arrival: (tick) => {
|
||||
|
|
@ -135,7 +135,7 @@ export const SCENARIOS = {
|
|||
id: "gpu",
|
||||
name: "GPU Crunch",
|
||||
blurb: "Steady services plus periodic ML training jobs that demand GPU nodes you must provision.",
|
||||
startNodes: ["general-large", "ssd-large"],
|
||||
startNodes: ["c5.2xlarge", "c5d.2xlarge"],
|
||||
seed: 99,
|
||||
upgradeEvery: 750,
|
||||
arrival: (tick) => (tick % 200 < 30 ? 1.4 : 0.5),
|
||||
|
|
@ -150,7 +150,7 @@ export const SCENARIOS = {
|
|||
id: "chaos",
|
||||
name: "Production Chaos",
|
||||
blurb: "Everything, everywhere, all at once. High churn across every workload type. Hard mode.",
|
||||
startNodes: ["general-large", "ssd-large"],
|
||||
startNodes: ["c5.2xlarge", "c5d.2xlarge"],
|
||||
seed: 42,
|
||||
upgradeEvery: 550,
|
||||
arrival: (tick) => 0.9 + (tick % 150 < 40 ? 1.2 : 0),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue