1
0
Fork 0
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:
Cursor Agent 2026-06-26 14:04:08 +00:00
parent 218eba3b47
commit 3338b31e1a
No known key found for this signature in database
3 changed files with 94 additions and 60 deletions

View file

@ -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;

View file

@ -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" }],
},
};

View file

@ -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),