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]>
109 lines
7.3 KiB
Markdown
109 lines
7.3 KiB
Markdown
---
|
|
date: 2026-10-18
|
|
branch: fix/coredns-ha
|
|
title: "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 pattern `roles/kube_vip` already uses for a
|
|
file in `server/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-deny`
|
|
NetworkPolicy 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/coredns` object exists with source
|
|
`/data/k3s/server/manifests/coredns.yaml`; the Deployment has no `ownerReferences` but carries
|
|
`objectset.rio.cattle.io/hash` and `managedFields` managers `deploy@k3s-prod-1` / `k3s`. So it is
|
|
k3s-managed, not Flux-managed — independently corroborated by another agent, which confirmed
|
|
`platform/coredns/` contains **only** the `coredns-custom` ConfigMap and does not touch the
|
|
Deployment or its replica count.
|
|
* **The hand scale works and holds:** coredns went to `2/2 Running` on two different nodes, and
|
|
in-cluster DNS kept answering throughout (`nslookup kubernetes.default.svc.cluster.local
|
|
10.43.0.10` → `10.43.0.1`). Still `spec.replicas=2 ready=2` after ~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-dns` returns **5m and 4m** against that 100m
|
|
request, i.e. ~5% utilisation, confirming the 80% target will never be approached and the HPA will
|
|
sit at `minReplicas`.
|
|
* **The kustomization edit is additive:** `git diff --stat` is `1 file changed, 4 insertions(+)`,
|
|
and `- coredns` was already registered in `platform/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 hold `minReplicas`.
|
|
Applying is an operator action (CLAUDE.md §5). The failure mode to watch for is
|
|
`ScalingActive=False` with `FailedGetResourceMetric` — the HPA would then be present and inert,
|
|
which looks identical to working because the hand scale already left 2 replicas in place. **Check
|
|
`kubectl -n kube-system describe hpa coredns` after merge**, not just the replica count.
|
|
* **The central claim that k3s reverts `replicas` on 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.yaml` replaced the file wholesale, listing only `hpa.yaml` — which
|
|
would have pruned the existing `coredns-custom` ConfigMap and **broken in-cluster split-horizon
|
|
DNS (R-DNS-01/03)**. It was caught by a guard assertion before commit and restored from `HEAD`, 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 `topologySpreadConstraint` or 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_AVAILABLE` is unset, so CI proves the L0/L1 offline
|
|
subset only.
|
|
* I did not re-measure branch protection or runner state (CLAUDE.md §2b).
|