Skip to content

feat(gcp): slice 4 — node auto-provisioning and the three ComputeClasses - #1822

Merged
Smana merged 7 commits into
mainfrom
worktree-gcp-computeclass
Aug 24, 2026
Merged

feat(gcp): slice 4 — node auto-provisioning and the three ComputeClasses#1822
Smana merged 7 commits into
mainfrom
worktree-gcp-computeclass

Conversation

@Smana

@Smana Smana commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Design slice 4: GKE node auto-provisioning (ADR-0006) and the three ComputeClasses.

Built and measured on a live cluster. Four criteria PASS, one partial, one
blocked on a GCP quota
— each annotated with its evidence in the design doc
rather than left as an intention.

Two halves, both required

OpenTofucluster_autoscaling on the GKE module. Without it a ComputeClass
with nodePoolAutoCreation still schedules, but only onto pools that already
exist: nothing is ever provisioned and the slice's premise goes untested.

Manifests — three classes under a new infrastructure/gcp-mycluster-0/,
mirroring the AWS Karpenter NodePools:

Class Mirrors Notes
general-purpose default-nodepool spot e2n2, no on-demand fallback
io io-nodepool Local SSD; taint key ogenki/io identical to AWS
gpu-l4 gpu-l4-nodepool NVIDIA L4; taint nvidia.com/gpu identical to AWS

Taint keys match AWS deliberately: they're platform conventions, not cloud
details, so a workload's tolerations are the one part of its manifest that
doesn't change between clouds.

The finding the slice existed to produce

Criterion 12 passes — an auto-created node carries node.cilium.io/agent-not-ready
and Cilium clears it. But testing it surfaced something the design did not
anticipate:

Every workload targeting a ComputeClass must tolerate that taint, or nothing
scales up at all.
The autoscaler simulates scheduling against a node that
will carry it, judges an untolerating pod unplaceable, and never provisions.
The symptom is silence: three Pending pods for ten minutes with no
TriggeredScaleUp event of any kind
. Adding only the toleration produced four
nodes and all pods Running.

That's a real divergence from AWS, where the taint is invisible to workloads
because static pools carry it and Karpenter provisions against NodePool
requirements rather than simulating a tainted node.

Decision (yours): keep the taint, require the toleration. It still gates
every pod that doesn't opt in and keeps auto-created nodes behaving like static
ones. Recorded in ADR-0006 as a Negative consequence — and its Positive list,
which claimed the taint is "handled declaratively at the pool level", is amended,
because that is true but incomplete in exactly the way that cost ten minutes of
silent Pending.

Criterion 15 is blocked, not failed

GPUS_ALL_REGIONS on this project is 0, so no GPU node can be created at any
price.

Worth flagging loudly because the obvious diagnostic misleads: the regional
NVIDIA_L4_GPUS quota reads 1, which looks fine. The spot attempt fails with
GCE out of resources — suggesting "retry when capacity frees up" — and only an
on-demand attempt reveals GCE quota exceeded. Running both is what
distinguished capacity from quota.

Everything up to the GPU is proven: the class is selected, NAP resolves it to a
g2-standard-4-gpu1 pool and attempts creation. That also retroactively confirms
cluster_autoscaling.gpu_resources is load-bearing — left empty (as it was until
this PR), no scale-up is attempted at all and the symptom is silence.

Closing it needs a quota request to Google. Recorded in the resume plan.

Two things the API taught us

  • localSSDCount is rejected with machineFamily: "doesn't support local ssd
    without machine type or GPU config"
    . Local SSD attachment is per-machine-type,
    so io names explicit types. Found via --dry-run=server.
  • ComputeClass needed a fourth schema-catalog source. The repo runs
    skipMissingSchemas: false by design, and it's in no public catalog — unlike
    the AI Gateway and Karpenter CRDs which are rendered from pinned charts, GKE
    installs this one and publishes no chart. Vendored under
    scripts/flux-schema/vendored-crds/ with its GKE component version recorded.
    Deliberately not under crds/, which Flux applies: it's GKE addon-managed
    and applying it would fight GKE.

Cost

All three classes are spot-only with whenUnsatisfiable: DoNotScaleUp, so
exhausted spot leaves pods Pending rather than silently going on-demand — which
matters most for L4s.

cluster_autoscaling was also sitting on module defaults of 100 GB
pd-standard
, twice the static pool's 50 GB, purely because the field was
unset. Now 50 GB and the cheapest disk type.

Verification

  • ./scripts/validate-manifests.shValid: 1191, Invalid: 0, Skipped: 0
  • kubectl apply --dry-run=server accepts all three against the live GKE API
  • ./scripts/validate-links.sh → all links resolve
  • tofu validate, tofu fmt, shellcheck -x -S warning clean
  • Criteria 12/14/16/17 measured on live infrastructure; probes deleted, cluster
    back to its 2 static nodes, no GPU node was ever created

Smana added 4 commits August 24, 2026 11:43
Narrow on purpose. The design's slice 4 exists to settle ONE question --
criterion 12: does a freshly auto-created node carry
`node.cilium.io/agent-not-ready` at registration, and does Cilium clear it? The
`io` and GPU classes follow once that is answered; writing three now would mean
debugging three variants of the same unknown.

TWO HALVES, BOTH REQUIRED

opentofu: `cluster_autoscaling` on the GKE module. Without it a ComputeClass
with nodePoolAutoCreation still schedules, but only onto pools that already
exist -- nothing is ever provisioned and the premise goes untested. The ceiling
(criterion 16) is set low deliberately: a too-low limit is an Unschedulable pod
and a one-line change, a too-high one is a bill.

manifest: one ComputeClass, spot-only across e2 then n2, with
`whenUnsatisfiable: DoNotScaleUp` -- criterion 14 requires zero on-demand
fallback, so exhausted spot leaves pods Pending rather than quietly becoming
expensive.

THE PART THAT IS ACTUALLY THE SLICE

`spec.nodePoolConfig.taints` carries node.cilium.io/agent-not-ready. The static
pool sets that taint through `node_pools_taints` in OpenTofu; an auto-created
pool has no OpenTofu to set it, because NAP creates the pool. Anything the pool
must carry has to be declared in the ComputeClass or it does not exist.

Without it a new node registers Ready before Cilium owns its networking and
accepts pods it cannot network -- surfacing as FailedCreatePodSandBox
referencing a missing CNI (criterion 13), which points at the CNI rather than at
autoscaling. I nearly shipped the class without it; the schema check is what
surfaced `nodePoolConfig.taints`.

SCHEMA VALIDATION NEEDED A FOURTH CATALOG SOURCE

`flux schema validate` runs with skipMissingSchemas: false by design -- an
unknown Kind FAILS rather than passing unvalidated -- and ComputeClass is in no
public catalog. Unlike the Envoy AI Gateway and Karpenter CRDs, which
gen-catalog.sh renders from pinned Helm charts, GKE installs this one itself and
publishes no chart, so it is vendored under scripts/flux-schema/vendored-crds/
with its GKE component version recorded in the annotations.

It is NOT under crds/ -- Flux applies that tree, and this CRD is reconciled by
GKE's addon manager. Applying it would fight GKE.

COST (test clusters, spot and cheapest by default)

`cluster_autoscaling` left disk on module defaults of 100 GB pd-standard --
TWICE the static pool's 50 GB, purely because the field was unset. Now 50 GB to
match, and pd-standard as the cheapest type, with the pd-balanced asymmetry
against the static pool noted in place so a later performance surprise has a
visible cause.

Verified: ./scripts/validate-manifests.sh -> Valid: 1189, Invalid: 0,
Skipped: 0, all gates passed -- the ComputeClass is genuinely validated, not
skipped; `kubectl apply --dry-run=server` accepts it against the live GKE API;
tofu validate and tofu fmt clean; shellcheck -x -S warning clean on the
generator.

Criterion 12 itself is NOT yet verified -- that needs the cluster to track this
branch and actually scale up. Next step, not a claim.
Slice 4 was built to answer one question and answered a second one nobody had
asked. Both are now written down where they will be hit.

MEASURED, NOT ASSUMED (2026-08-24, on gcp-mycluster-0)

Criterion 12 PASSES: an auto-created node carried node.cilium.io/agent-not-ready
at registration, from spec.nodePoolConfig.taints, and Cilium cleared it. After
scale-up the nodes retained only GKE's own cloud.google.com/compute-class taint.

The unasked question: WITHOUT a toleration, nothing scales up at all. Three
Pending pods, ten minutes, and no TriggeredScaleUp event of any kind -- only
FailedScheduling. The autoscaler simulates scheduling against a node that will
carry the class's taint, judges the pod unplaceable, and never provisions one.
Adding ONLY the toleration produced four nodes and all pods Running.

That is a real divergence from AWS, where the same taint is invisible to
workloads: static pools carry it, and Karpenter provisions against NodePool
requirements rather than simulating a tainted node.

DECISION: keep the taint, require the toleration.

It still gates every pod that does not opt in, and keeps auto-created nodes
behaving like static ones. The accepted cost is that a tolerating pod can land
before the Cilium agent is up and log

    plugin type="cilium-cni" failed (add): unable to create endpoint ... EOF

which is the CNI PRESENT with its agent still starting, not a missing CNI. It
self-heals: 0 restarts, pods reached Running unaided. kube-system/metrics-server
hits the same transient on any fresh node, so it belongs to the platform rather
than to this decision. Criterion 13 therefore holds as worded -- no pod recorded
a MISSING CNI -- while its spirit is only partly met, and saying so is more
useful than claiming a clean pass.

WHERE IT IS RECORDED

- The ComputeClass itself, in a box at the top of nodePoolConfig, with the
  toleration written out ready to copy. A consumer hits this file before they
  hit the ADR.
- ADR-0006, as a Negative consequence. Its Positive list claimed Cilium's taint
  is "handled declaratively at the pool level", which is true but incomplete in
  a way that matters -- declaring the taint works; making it transparent to
  workloads does not. That bullet is now amended to point at the negative rather
  than left to mislead.

Also observed, unprompted: criterion 14 (e2-highcpu-4, spot=true, zero
on-demand -- NAP chose highcpu over standard, cheaper per vCPU) and criterion 17
(all four auto-created pools removed on scale-down; back to 2 nodes with no
intervention).

Verified: ./scripts/validate-manifests.sh -> Valid: 1189, Invalid: 0,
Skipped: 0, all gates passed; ./scripts/validate-links.sh -> all relative links
resolve.
Completes slice 4's three classes. general-purpose was proven on its own first
because it carried the unknown the slice existed to settle; these two follow the
pattern it established, and each inherits the mandatory Cilium toleration.

MIRRORING AWS WHERE IT IS A CONVENTION, DIVERGING WHERE IT IS A FACT

Taint keys are IDENTICAL to the AWS NodePools -- `ogenki/io` and
`nvidia.com/gpu`. Those are platform conventions rather than cloud details, so a
workload's tolerations are the one part of its manifest that does not change
between clouds, even though everything underneath differs.

gpu-l4 needs NO runtimeClassName, and that is a real divergence rather than an
omission. On AWS the Bottlerocket NVIDIA variant requires
`runtimeClassName: nvidia` plus a matching RuntimeClass
(infrastructure/base/runtimeclass-nvidia/). GKE installs the driver via its own
DaemonSet and wires the default runtime, so a pod just requests nvidia.com/gpu --
which is design criterion 15 exactly: "a GPU pod with NO runtimeClassName sees
the device via nvidia-smi".

TWO THINGS THE API TAUGHT US

1. `localSSDCount` is rejected with machineFamily. GKE's webhook:

     compute-class "io" doesn't support local ssd without machine type or GPU
     config

   Local SSD attachment is a per-machine-type property, so io must name explicit
   machineTypes. The cost is a narrower fallback list than general-purpose gets.
   Found by `kubectl apply --dry-run=server`, not by reading docs.

2. `cluster_autoscaling.gpu_resources` was an empty list, deferred when the GPU
   class did not yet exist. It is now REQUIRED: NAP will not create a node with
   an accelerator that has no resourceLimits entry, so leaving it empty caps GPU
   autoscaling at zero -- the class applies cleanly, pods stay Pending, and
   nothing says why. nvidia-l4, maximum 2.

COST POSTURE UNCHANGED

Both classes are spot-only with `whenUnsatisfiable: DoNotScaleUp`. That matters
most for gpu-l4: spot GPUs are scarcer than spot CPU, so the class will
sometimes provision nothing -- which is the intended behaviour, because an L4
silently falling back to on-demand is the single most expensive accident
available in this repository.

Verified: `kubectl apply --dry-run=server` accepts both against the live GKE
API; ./scripts/validate-manifests.sh -> Valid: 1191, Invalid: 0, Skipped: 0,
all gates passed; tofu validate and tofu fmt clean.

NOT verified: neither class has actually provisioned a node. general-purpose was
exercised end to end (criteria 12, 14, 17); io and gpu-l4 are schema-valid and
follow a proven pattern, which is not the same as measured. Criterion 15 in
particular needs a real GPU pod.
Every criterion now carries its outcome and the evidence for it, rather than
leaving the design as a list of intentions.

Four PASS, measured on gcp-mycluster-0:

  12  taint applied at registration and cleared by Cilium
  14  e2-highcpu-4, spot=true, cos_containerd -- NAP picked highcpu over
      standard unprompted, cheaper per vCPU
  16  a 64-vCPU pod against a 32-vCPU ceiling held Pending 4 minutes, node count
      never moved, autoscaler logged NotTriggerScaleUp
  17  all four auto-created nodes reaped after the probe was deleted

One PARTIAL (13), one BLOCKED (15).

13 holds as worded -- nothing recorded a MISSING CNI -- but the toleration that
criterion 12 forces means tolerating pods can land before the Cilium agent is up
and log a transient `cilium-cni failed (add) ... EOF`. It self-heals, and
kube-system/metrics-server hits the same on any fresh node. Recording that is
more useful than a green tick, because the two failure modes read almost
identically in logs and only one is benign.

15 is BLOCKED, not failed, and the distinction is the point:
GPUS_ALL_REGIONS on this project is 0, so no GPU node can exist at any price.
Worth writing down loudly because the obvious diagnostic misleads -- the
REGIONAL NVIDIA_L4_GPUS quota reads 1, which looks fine and would send the next
person away to "retry when spot frees up". The spot attempt fails with
`GCE out of resources` and only the on-demand attempt says `GCE quota exceeded`.
Running both is what distinguished capacity from quota.

Everything up to the GPU is proven: the class is selected and NAP resolves it to
a g2-standard-4-gpu1 pool and attempts creation. That also retroactively
confirms cluster_autoscaling.gpu_resources is load-bearing -- left empty, no
scale-up is attempted at all and the symptom is silence.

The on-demand probe cost nothing: the node was never created, and the throwaway
ComputeClass and pod were deleted immediately.

Also adds the quota blocker to the resume plan, where someone rebuilding will
look, with the explicit warning not to diagnose it from the regional quota.

Verified: ./scripts/validate-links.sh -> all relative links resolve.
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

🔍 Rendered manifest diff — this PR vs main (desired state)

0 changed · 3 added · 0 removed

Rendered with kustomize build + helm template (source of truth = git), so Helm-expanded workloads are included. Shows what Flux will apply — not a diff against live cluster state (drift is alerted on separately), and not CRD-defaulted / webhook-mutated output. Secret values are redacted; per-render noise (webhook caBundles, checksum/* annotations, render timestamps) is normalized out.

🟢 added — cloud.google.com/v1/ComputeClass/-/general-purpose
--- a/cloud.google.com/v1/ComputeClass/-/general-purpose
+++ b/cloud.google.com/v1/ComputeClass/-/general-purpose
@@ -0,0 +1,19 @@
+apiVersion: cloud.google.com/v1
+kind: ComputeClass
+metadata:
+  name: general-purpose
+spec:
+  nodePoolAutoCreation:
+    enabled: true
+  nodePoolConfig:
+    imageType: cos_containerd
+    taints:
+    - effect: NoSchedule
+      key: node.cilium.io/agent-not-ready
+      value: 'true'
+  priorities:
+  - machineFamily: e2
+    spot: true
+  - machineFamily: n2
+    spot: true
+  whenUnsatisfiable: DoNotScaleUp
🟢 added — cloud.google.com/v1/ComputeClass/-/gpu-l4
--- a/cloud.google.com/v1/ComputeClass/-/gpu-l4
+++ b/cloud.google.com/v1/ComputeClass/-/gpu-l4
@@ -0,0 +1,25 @@
+apiVersion: cloud.google.com/v1
+kind: ComputeClass
+metadata:
+  name: gpu-l4
+spec:
+  nodePoolAutoCreation:
+    enabled: true
+  nodePoolConfig:
+    imageType: cos_containerd
+    taints:
+    - effect: NoSchedule
+      key: node.cilium.io/agent-not-ready
+      value: 'true'
+    - effect: NoSchedule
+      key: nvidia.com/gpu
+      value: 'true'
+  priorities:
+  - gpu:
+      count: 1
+      type: nvidia-l4
+    machineFamily: g2
+    spot: true
+    storage:
+      bootDiskType: pd-balanced
+  whenUnsatisfiable: DoNotScaleUp
🟢 added — cloud.google.com/v1/ComputeClass/-/io
--- a/cloud.google.com/v1/ComputeClass/-/io
+++ b/cloud.google.com/v1/ComputeClass/-/io
@@ -0,0 +1,26 @@
+apiVersion: cloud.google.com/v1
+kind: ComputeClass
+metadata:
+  name: io
+spec:
+  nodePoolAutoCreation:
+    enabled: true
+  nodePoolConfig:
+    imageType: cos_containerd
+    taints:
+    - effect: NoSchedule
+      key: node.cilium.io/agent-not-ready
+      value: 'true'
+    - effect: NoSchedule
+      key: ogenki/io
+      value: 'true'
+  priorities:
+  - machineType: n2-standard-4
+    spot: true
+    storage:
+      localSSDCount: 1
+  - machineType: n2-standard-8
+    spot: true
+    storage:
+      localSSDCount: 1
+  whenUnsatisfiable: DoNotScaleUp

Smana added 3 commits August 24, 2026 13:21
Six findings from the review, all applied. The first is a flat contradiction and
the second is a failure this session already fixed once elsewhere.

1. general-purpose.yaml opened with "the FIRST and, for now, the ONLY one" and
   "Deliberately one class, not three". Both false in this branch -- io and
   gpu-l4 sit beside it, and kustomization.yaml lists all three. That was true
   when written and never updated when the other two landed. The first thing a
   reader saw was wrong, so the paragraph is gone; kustomization.yaml already
   records the general-purpose-proven-first history, once and better.

2. The taint story was told FOUR times in general-purpose.yaml -- why it
   matters, the measurement, why it is there, the trade -- and a fifth time in
   ADR-0006, near-verbatim down to the `cilium-cni ... EOF` line. That is
   exactly the cilium.yaml failure corrected earlier in this branch's history:
   overlapping accounts of one decision, where the next amendment updates some
   and silently strands the rest.

   Now: the file states the REQUIREMENT (the toleration snippet, ready to copy,
   plus one sentence that without it nothing scales up), and the ADR explains
   it. One account, one place to amend. io and gpu-l4 keep only their own
   class-specific taint sentence -- ogenki/io and nvidia.com/gpu are genuinely
   local facts -- and cross-reference the rest.

3. imageType had three different treatments across three files: five comment
   lines, three, and none. Now one identical line in each, pointing at the
   image_type note in main.tf where the reasoning lives.

4. The criterion-16 ceiling rationale was stated in main.tf and again on the
   variables in variables.tf. Kept on the variables, where the default sits.

5. gen-catalog.sh listed "Three sources" with the new entry numbered 4 and
   inserted between 2 and 3. Now four sources, in order.

6. infrastructure.yaml argued carefully about the dependsOn it OMITS (crds) and
   left the one it keeps unexplained. Now says why: currently inert, since a
   ComputeClass is cluster-scoped and this path holds nothing namespaced, but
   kept because the first namespaced resource added here would otherwise race
   its namespace -- a failure that presents as a dependency cascade rather than
   as a missing namespace.

Deliberately NOT changed: the taint declarations are duplicated across all three
classes and stay that way. The review's own recommendation, and it is right --
the taints are what a reader opens the file to learn, and a kustomize patch
injecting them would make io.yaml look like it needs one toleration when it
needs two. Roughly ten shared YAML lines is below where indirection pays.

Verified: ./scripts/validate-manifests.sh -> Valid: 1191, Invalid: 0,
Skipped: 0, all gates passed; tofu validate and tofu fmt clean; bash -n clean on
gen-catalog.sh; the API still accepts all three classes.
Upgrades io from 'schema-valid, unexercised' to verified. It provisioned an
n2-standard-4 spot node with a 375 GiB Local SSD attached -- 368 GiB ephemeral
storage on a 50 GB boot disk is the giveaway -- and the probe pod reached
Running.

Tested because the GPU result made a second quota blocker look likely:
PREEMPTIBLE_LOCAL_SSD_GB reads 0 in europe-west4, and io is spot +
localSSDCount 1, which appeared to be exactly the gpu-l4 situation again.

It is not. GKE-managed Local SSD on spot nodes provisions regardless of that
metric. The prediction was wrong, and the false alarm is recorded in the design
so the next person does not re-derive it and 'fix' a class that works -- the
regional LOCAL_SSD_TOTAL_GB reads effectively unlimited, so the two quotas
together are genuinely confusing.

Per-class status is now explicit in the design: general-purpose and io VERIFIED,
gpu-l4 proven up to provisioning and blocked on GPUS_ALL_REGIONS: 0.

Verified: ./scripts/validate-links.sh -> all relative links resolve.
…orted on G2

Correctness review found a blocker that no gate here could have caught, plus two
smaller real issues. One of its suspicions is refuted by measurement.

BLOCKER: gpu-l4 was unprovisionable.

cluster_autoscaling sets disk_type = "pd-standard" as the boot disk for EVERY
auto-created pool, and gpu-l4 overrode nothing. The G2 series does not support
pd-standard -- Google's PD matrix allows only pd-ssd, pd-balanced and hyperdisk
there, and the ComputeClass CRD says so itself: "available boot disk types
depend on the machine family".

The failure mode is the bad one: NAP either filters g2 out as unsatisfiable or
the pool create fails on disk type, and with whenUnsatisfiable: DoNotScaleUp no
event names the disk. A GPU pod just stays Pending -- exactly the silent mode
the gpu_resources comment warns about. `kubectl apply --dry-run=server` provably
cannot catch it: it never reaches VM creation. Nor could tofu validate, or the
manifest gate, or the live test, because GPUS_ALL_REGIONS: 0 stopped us one step
earlier. Two independent blockers were stacked and only the outer one was
visible.

Fixed with `storage.bootDiskType: pd-balanced` on the g2 priority rather than by
changing the cluster-wide default: e2 and n2 both support pd-standard and are
the common case, so only GPU nodes pay for the dearer disk.

CONFIRMED: the autoscaling ceiling is cluster-wide, not per-class.

GKE counts manually created pools toward max_cpu_cores. With the static pool at
its max of 3 x e2-standard-4 (12 vCPU), only 20 of the 32 vCPU remain for all
three classes combined -- so gpu_resources maximum 2 (8 vCPU) is reachable only
while general-purpose and io stay under 12. A GPU scale-up can be starved by CPU
scale-up, and the events do not distinguish the two. Documented on the variable.

SUSPECTED, recorded not fixed: GKE applies its own
nvidia.com/gpu=present:NoSchedule to GPU nodes, and Kubernetes forbids two
taints sharing key+effect. Either GKE's value wins -- making our declared "true"
inaccurate -- or the pool create errors. The toleration this file recommends
uses operator: Exists, which survives both, so the block now says so explicitly
and asks whoever sees the first GPU node to confirm and correct it. Guessing
would be worse than flagging.

REFUTED by measurement: the review suspected localSSDCount attaches raw NVMe
block rather than pod ephemeral storage, which would have broken the AWS parity
claim. The io node provisioned earlier reported 385926528Ki (~368 GiB) of
ephemeral-storage on a 50 GB boot disk -- that is the Local SSD and nothing
else. Recorded in io.yaml so the question is not reopened.

Verified: ./scripts/validate-manifests.sh -> Valid: 1191, Invalid: 0,
Skipped: 0; the API accepts the modified gpu-l4; tofu validate and fmt clean.
@Smana
Smana merged commit 791aa9a into main Aug 24, 2026
9 checks passed
@Smana
Smana deleted the worktree-gcp-computeclass branch August 24, 2026 11:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant