MEASURED on prod 2026-10-18: kube-system/coredns had replicas: 1. In-cluster DNS was a single pod on a single node, so draining or rebooting that node was a CLUSTER-WIDE in-cluster DNS outage until the pod rescheduled -- every Service lookup, every CNPG replica finding its primary, every operator addressing the API by name. It was invisible because 1/1 Ready looks healthy. It is urgent rather than latent now: the scheduled reboot automation on feat/automated-upgrades drains and reboots one node at a time, unattended, overnight -- turning a single point of failure into a recurring one. WHY AN HPA AND NOT replicas: 2 -- the whole decision. k3s OWNS the coredns Deployment: AddOn/coredns, source /data/k3s/server/manifests/coredns.yaml, managedFields managers deploy@k3s-prod-1 + k3s, objectset.rio.cattle.io/hash label. k3s re-stages its bundled manifests on startup, so replicas: 2 written there -- or a kubectl scale -- is reverted on the next restart or upgrade. Rejected alternatives: * coredns.yaml.skip + our own templated copy (the roles/kube_vip pattern). Deterministic, but takes ownership of coredns from k3s permanently so k3s upgrades stop updating it. Too high a price for a replica count. * A Flux patch of the Deployment. Rejected outright: Flux and k3s's deploy controller would both own one object and fight -- the default-deny NetworkPolicy drift-loop shape (~2500 events/10min) this cluster already hit. An HPA is a separate object k3s does not own, driving /scale rather than the Deployment spec, so there is no shared-ownership fight; if k3s re-stages replicas: 1 it is re-asserted within a reconcile. Disclosed cost: a brief window at 1 replica after a k3s restart. It is a FLOOR-HOLDER, not an autoscaler -- the CPU metric exists only so the autoscaling/v2 object is valid. Measured: 5m/4m against a 100m request (~5%), so the 80% target is never approached. scaleDown stabilization 600s because DNS is the worst thing to flap; scaleUp immediate, since that restores redundancy. The kustomization edit is ADDITIVE: platform/coredns/ already existed and holds the coredns-custom split-horizon ConfigMap (R-DNS-01/03), which is untouched. UNAPPLIED. The HPA has never run, and the claim that k3s reverts replicas on restart is reasoned, not observed. After merge check `describe hpa coredns` for ScalingActive=True -- an inert HPA looks identical to a working one because the hand scale already left 2 replicas. See the handoff entry. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
7.3 KiB
date, branch, title
| date | branch | title |
|---|---|---|
| 2026-10-18 | fix/coredns-ha | coredns ran at one replica, so any node drain was a cluster-wide DNS outage |
What changed
MEASURED on prod 2026-10-18: kube-system/coredns had replicas: 1. In-cluster DNS was a
single pod on a single node, so draining or rebooting that node was a cluster-wide in-cluster DNS
outage until the pod rescheduled and became ready — every Service lookup, every CNPG replica
finding its primary, every operator addressing the API by name. It was invisible because 1/1 Ready looks perfectly healthy.
It also became urgent rather than latent: the scheduled reboot automation on
feat/automated-upgrades drains and reboots one node at a time, unattended, overnight — turning a
single point of failure into a recurring one.
New platform/coredns/hpa.yaml: a HorizontalPodAutoscaler on the coredns Deployment with
minReplicas: 2, maxReplicas: 3, registered in the existing platform/coredns/kustomization.yaml
alongside the split-horizon ConfigMap already there.
Why an HPA and not replicas: 2 — this is the whole decision. k3s owns the coredns Deployment
and we cannot durably edit it: it is applied by k3s's own deploy controller from a bundled manifest
(AddOn/coredns, source /data/k3s/server/manifests/coredns.yaml, managedFields managers
deploy@k3s-prod-1 and k3s, plus an objectset.rio.cattle.io/hash label). k3s re-stages its
bundled manifests on startup, so a replicas: 2 written there — or a kubectl scale — is reverted
on the next k3s restart or upgrade. Two alternatives were weighed and rejected:
coredns.yaml.skip+ our own templated copy (the patternroles/kube_vipalready uses for a file inserver/manifests). Deterministic and never flaps, but it takes ownership of coredns away from k3s permanently — k3s upgrades would stop updating it and the version would need refreshing by hand forever. Too high a price for a replica count.- A Flux-managed patch of the Deployment. Rejected outright: Flux and k3s's deploy controller
would both own one object and fight continuously — the exact shape of the
default-denyNetworkPolicy drift loop this cluster already suffered (~2500 events/10min).
An HPA avoids both: a separate object k3s does not own, driving the /scale subresource rather than
the Deployment spec, so there is no shared-ownership fight. If k3s re-stages replicas: 1, the HPA
sees it below minReplicas and scales back within one reconcile.
It is a floor-holder, not an autoscaler: the CPU metric exists only because an autoscaling/v2
HPA needs one to be valid. scaleDown.stabilizationWindowSeconds: 600 because DNS is the worst
thing in the cluster to flap; scaleUp is immediate, since that is the path that restores redundancy
after a k3s restart.
Immediate action taken outside this PR: kubectl -n kube-system scale deploy coredns --replicas=2 was run at the operator's explicit request, so the cluster has two replicas now
rather than after merge. That hand scale is what this PR makes durable.
What I verified
- The defect:
kubectl -n kube-system get deploy coredns→replicas=1 available=1. - k3s ownership: the
AddOn/corednsobject exists with source/data/k3s/server/manifests/coredns.yaml; the Deployment has noownerReferencesbut carriesobjectset.rio.cattle.io/hashandmanagedFieldsmanagersdeploy@k3s-prod-1/k3s. So it is k3s-managed, not Flux-managed — independently corroborated by another agent, which confirmedplatform/coredns/contains only thecoredns-customConfigMap and does not touch the Deployment or its replica count. - The hand scale works and holds: coredns went to
2/2 Runningon two different nodes, and in-cluster DNS kept answering throughout (nslookup kubernetes.default.svc.cluster.local 10.43.0.10→10.43.0.1). Stillspec.replicas=2 ready=2after ~2 minutes, with the AddOn checksum unchanged — so k3s's deploy controller does not revert on a drift timer; it re-applies on manifest change and on restart. - The HPA's preconditions hold: coredns ships
requests: {cpu: 100m, memory: 70Mi}(so a Resource/Utilization metric is meaningful), and metrics-server is installed as a k3s addon and verified serving —kubectl top pod -l k8s-app=kube-dnsreturns 5m and 4m against that 100m request, i.e. ~5% utilisation, confirming the 80% target will never be approached and the HPA will sit atminReplicas. - The kustomization edit is additive:
git diff --statis1 file changed, 4 insertions(+), and- corednswas already registered inplatform/kustomization.yaml, so no change was needed there. - All three touched/added YAML files parse.
What I did NOT verify
- The HPA has never been applied. I did not reconcile it, so I have not observed it create,
reach
ScalingActive=True, resolve its metric against real coredns data, or holdminReplicas. Applying is an operator action (CLAUDE.md §5). The failure mode to watch for isScalingActive=FalsewithFailedGetResourceMetric— the HPA would then be present and inert, which looks identical to working because the hand scale already left 2 replicas in place. Checkkubectl -n kube-system describe hpa corednsafter merge, not just the replica count. - The central claim that k3s reverts
replicason restart is REASONED, NOT OBSERVED. It rests on k3s re-staging bundled manifests at startup plus the measured ownership evidence above. I did not restart k3s to watch the revert happen — doing so is a rolling control-plane restart, an operator action. If k3s in fact preserves a modified replica count, this HPA is unnecessary (though harmless). - I did not confirm the HPA wins the race against k3s's deploy controller after a restart. Both write the scale; the expectation is the HPA re-asserts within a reconcile, leaving a brief window at 1 replica. Neither the window's length nor the outcome has been measured.
- I nearly shipped a destructive change. My first write of
platform/coredns/kustomization.yamlreplaced the file wholesale, listing onlyhpa.yaml— which would have pruned the existingcoredns-customConfigMap and broken in-cluster split-horizon DNS (R-DNS-01/03). It was caught by a guard assertion before commit and restored fromHEAD, and the diff is now additive. Recorded because the near-miss is the useful part:platform/coredns/already existed and I assumed it did not. No conformance check would obviously have caught the removal — it is valid YAML and a legitimate-looking prune. - Whether 2 is enough. Three nodes, and coredns' anti-affinity only prefers spreading, so both
replicas could in principle land on one node and a single drain would still take DNS down. I did
not add a
topologySpreadConstraintor a PodDisruptionBudget — both arguably belong here and neither is in this change. - No alert fires if coredns drops to 1 replica, so a future silent regression (k3s upgrade changing the manifest, the HPA going inert) would not be reported. That gap is not closed here.
- No conformance test asserts this, so a future edit could remove the HPA with no gate stage noticing.
- No runtime conformance.
vars.CLUSTER_AVAILABLEis unset, so CI proves the L0/L1 offline subset only. - I did not re-measure branch protection or runner state (CLAUDE.md §2b).