Split out of the hardening tracker #131 (architecture plan Phase B + Task C.1). These are the last two items of the post-outage hardening plan; everything else is done. Phase B is held pending a deliberate go-ahead (it costs money + the control-plane step has a short downtime window). C.1 is sequenced after Phase B because it only becomes safe on the bigger nodes.
Phase B — why
- RAM headroom is tight on the 4 GB CAX11s (workers 73–83% used; CP ~70% of physical). Runs today, but no headroom for node-failure rescheduling or growth (squeeze is headroom, not current load — no OOMs).
- Disk: 40 GB roots recurringly fill (containerd images + MicroOS snapshots) — the original outage trigger. 80 GB roots give real margin.
- Target: all 4 nodes CAX21 (8 GB RAM, 80 GB disk). Cost: +€16.68/mo total (cax11 €5.34 → cax21 €9.51 each ×4).
Mechanism (pre-verified)
server_type is not ForceNew in the hcloud provider → changing it is an in-place resize (power off → resize → power on). The node keeps its identity, its attached Cloud Volumes (Postgres PVs), and its Terraform address — no destroy/recreate. Migrate one node at a time via the pool's nodes map (preserves resource addresses; splitting the count=3 pool into three pools would churn addresses — do NOT). Hazards: node is down during its resize (drain first), and Hetzner only grows the disk when resized without "keep disk" → verify the disk actually grew to ~76 GB.
Phase B steps
B.1 — Pre-flight
B.2 — Migrate the 3 workers (one at a time, own PR each)
B.3 — Migrate the control plane (downtime window)
Phase B done when: all 4 nodes CAX21 with ~76 GB disks (growth verified, not assumed); CNPG stayed healthy; etcd snapshot retained.
C.1 — Kubelet eviction + reservation tuning (do AFTER Phase B)
Why after B: reservations carve RAM away from pod-allocatable. On 4 GB nodes that risks OOMKills/unschedulable pods; on 8 GB (post-B) there's headroom.
Why it matters: the outage trigger was a full disk → kubelet abruptly mass-evicting pods → CSI unregister → cascade. C.1 makes the node degrade gracefully: reserve headroom for system/containerd, and evict a few low-priority pods early & softly (with the disk alert firing) before the hard wall triggers the cascade.
File: infrastructure/cluster/main.tf — add to the module "kube-hetzner" block (per Codex finding #3, use k3s_global_kubelet_args, which covers CP and agents; NOT k3s_exec_agent_args):
k3s_global_kubelet_args = [
"kube-reserved=cpu=100m,memory=300Mi,ephemeral-storage=2Gi",
"system-reserved=cpu=200m,memory=350Mi,ephemeral-storage=4Gi",
"eviction-hard=memory.available<150Mi,nodefs.available<10%",
"eviction-soft=nodefs.available<15%",
"eviction-soft-grace-period=nodefs.available=2m",
"eviction-minimum-reclaim=nodefs.available=2Gi",
]
C.1 done when: kubelet args applied on all nodes (verified), no node recreation, no OOM regressions.
Refs: tracker #131 · architecture plan docs/superpowers/plans/2026-05-28-cluster-architecture.md (Phase B = B.1–B.3, Task C.1).
Split out of the hardening tracker #131 (architecture plan Phase B + Task C.1). These are the last two items of the post-outage hardening plan; everything else is done. Phase B is held pending a deliberate go-ahead (it costs money + the control-plane step has a short downtime window). C.1 is sequenced after Phase B because it only becomes safe on the bigger nodes.
Phase B — why
Mechanism (pre-verified)
server_typeis notForceNewin the hcloud provider → changing it is an in-place resize (power off → resize → power on). The node keeps its identity, its attached Cloud Volumes (Postgres PVs), and its Terraform address — no destroy/recreate. Migrate one node at a time via the pool'snodesmap (preserves resource addresses; splitting thecount=3pool into three pools would churn addresses — do NOT). Hazards: node is down during its resize (drain first), and Hetzner only grows the disk when resized without "keep disk" → verify the disk actually grew to ~76 GB.Phase B steps
B.1 — Pre-flight
terraform planafter preparing thenodesmap shows~ update in-place(orhcloud_servermodify), nohcloud_volumechanges, no destroy/replace. If it shows replacement → stop, fix thenodes-map keys.k3s etcd-snapshot save --name pre-cax21-<ts>on the CP; record the name.B.2 — Migrate the 3 workers (one at a time, own PR each)
worker-nbg1pool to an explicitnodes = { "0"={} "1"={} "2"={} }map (noserver_typechange yet) → PR alone first → plan must be no-op (proves the map maps onto the existing 3 nodes)."0"/"1"/"2":kubectl drain→ setnodes."<k>".server_type="cax21"→ PR → read plan: exactly ONE in-placehcloud_serverupdate, no volume change, no replace → merge → verify node rejoins +df -h /shows ~76 GB (not ~38) →kubectl uncordon. CNPG stays 2/2 throughout.B.3 — Migrate the control plane (downtime window)
server_type="cax21"→ PR → verify in-place plan (no etcd volume change / no replace) → merge → watch resize.kubectl get --raw=/readyz= ok; CP Ready with ~76 GB; both CNPG clusters healthy. (If broken:k3s server --cluster-reset --cluster-reset-restore-path=<snapshot>.)Phase B done when: all 4 nodes CAX21 with ~76 GB disks (growth verified, not assumed); CNPG stayed healthy; etcd snapshot retained.
C.1 — Kubelet eviction + reservation tuning (do AFTER Phase B)
Why after B: reservations carve RAM away from pod-allocatable. On 4 GB nodes that risks OOMKills/unschedulable pods; on 8 GB (post-B) there's headroom.
Why it matters: the outage trigger was a full disk → kubelet abruptly mass-evicting pods → CSI unregister → cascade. C.1 makes the node degrade gracefully: reserve headroom for system/containerd, and evict a few low-priority pods early & softly (with the disk alert firing) before the hard wall triggers the cascade.
File:
infrastructure/cluster/main.tf— add to themodule "kube-hetzner"block (per Codex finding #3, usek3s_global_kubelet_args, which covers CP and agents; NOTk3s_exec_agent_args):--kubelet-arglast-wins, and global args append after the module's per-nodepool defaultkube-reserved=cpu=50m,memory=300Mi,ephemeral-storage=1Gi. A partial global value (e.g. ephemeral-only) would silently drop the cpu/memory reservation → always specify the full cpu+memory+ephemeral triple on both reserved flags.kube-reserved(with memory) andsystem-reservedargs present;kubectl get node ... -o jsonpath='{.status.allocatable.memory}'reflects the reservation (not full 8 GiB).C.1 done when: kubelet args applied on all nodes (verified), no node recreation, no OOM regressions.
Refs: tracker #131 · architecture plan
docs/superpowers/plans/2026-05-28-cluster-architecture.md(Phase B = B.1–B.3, Task C.1).