Phase 2 of making LAN DNS survive a node failure. Pihole was one pod on one node; measured 2026-10-04, losing that node meant roughly 6-10 minutes with no LAN resolution (tolerationSeconds 300 + NotReady detection + a Longhorn RWO force-detach), while losing the node holding the pihole-dns L2 lease -- a DIFFERENT node -- meant tens of seconds. Two of three nodes were single points of failure with wildly different blast radii. Adds `availability.replicas` to the ServiceDefinition schema (1.10.0). Intent only; the mechanism is render.py's, documented in the renderer contract. 1 or absent renders a Deployment exactly as before -- verified byte-identical across the whole apps/ tree. >1 renders a StatefulSet (a longhorn PVC is RWO, so a multi-replica Deployment never schedules the second pod), per-replica volumeClaimTemplates, REQUIRED hostname anti-affinity, podManagementPolicy Parallel, a minAvailable:1 PodDisruptionBudget, and an SLI that follows the rendered kind (a Deployment-shaped rule against a StatefulSet absent()s forever). Pinned by ~20 new --selftest assertions rather than a conformance change. ALSO FIXES A LIVE BUG. Pihole's config PVC was mounted at the renderer default /data/config, but pihole writes to /etc/pihole. Measured: /etc/pihole (7.1M -- gravity.db, pihole.toml, query DB) was on the OVERLAY, while the 2Gi Longhorn volume held lost+found and 24K of nothing. So every restart discarded all state and re-downloaded 72k gravity domains, and backup.policy has been backing up an empty volume while reporting success. Fixed here because otherwise the new per-replica volumes would hold nothing while state stayed ephemeral -- and because persisting gravity.db is what makes a restart cheap. The /etc/pihole mount is safe on the image's own evidence: start.sh is PID 1 as uid 0 and chowns the directory recursively (no fsGroup needed), and it calls migrate_gravity which creates the database when absent. The gate caught a real regression on the first run: the wave-0 alert unit test fed pihole a Deployment-shaped series. Updated; promtool SUCCESS. Touches no sensitive paths, so this one needs no override. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
11 KiB
date, branch, title
| date | branch | title |
|---|---|---|
| 2026-10-04 | feat/pihole-multi-replica | PiHole runs two replicas on different nodes — and its config PVC was mounted where PiHole never writes |
What changed
Phase 2 of making LAN DNS survive a node failure (phase 1:
decisions/2026-10-04-pihole-declare-state.md). PiHole was one pod on one node, which for a
whole-house resolver cost more than "until it reschedules" — measured 2026-10-04:
| Failure | Recovery |
|---|---|
| the node running the pod dies | ~40s to mark NotReady + tolerationSeconds: 300 before eviction + reschedule + a Longhorn RWO force-detach → roughly 6-10 minutes with no LAN DNS |
the node holding the pihole-dns-* L2 lease dies |
Cilium re-elects an announcer, tens of seconds |
Those were different nodes (lease on k3s-prod-2, pod on k3s-prod-1), so two of three nodes were single points of failure for LAN DNS with wildly different blast radii.
A new schema field, because this is intent and not a primitive. availability.replicas
(schema 1.10.0, mechanism in schemas/renderer-contract.md ### availability). 1 or absent
renders a Deployment exactly as before — every pre-existing golden is byte-identical, verified. >1
renders, together:
- a StatefulSet instead of a Deployment. This is forced, not preferred: a longhorn PVC is ReadWriteOnce, and one RWO claim cannot attach on two nodes, so a 2-replica Deployment does not degrade — the second pod never schedules.
- longhorn storage as per-replica
volumeClaimTemplates(config-pihole-0,config-pihole-1); the standalone PVC is no longer emitted, since it would be an orphan nothing mounts. Abulkentry stays a standalone PVC — it is a shared static NFS export, not per-replica state. - required
podAntiAffinityonkubernetes.io/hostname.preferredwould let both replicas land on one node and deliver none of the intended tolerance while reporting healthy. podManagementPolicy: Parallel, so a cold start costs the max of the replicas' first-boot work rather than the sum —apps-wave-0hastimeout: 5m.- a PodDisruptionBudget,
minAvailable: 1, so a node drain cannot take the last replica. observability.livenessWorkload.kinddefaulting toStatefulSet, so theupSLI readskube_statefulset_status_replicas_ready. A Deployment-shaped rule against a StatefulSetabsent()s forever — an alert that can never fire.
catalog/pihole.yaml sets replicas: 2, not 3: two tolerates any single node loss and leaves a
spare node for the evicted replica under the hard anti-affinity. Three would pin one per node with no
spare, so a single node failure would leave a replica permanently Pending and — apps-wave-0 being
wait: true — stall the app tier for as long as the node was down.
The bug found on the way, and fixed here
PiHole's config PVC was mounted at a path PiHole never writes, so it held nothing and all of its
state was ephemeral. With no explicit mountPath the renderer defaults to /data/<name>; PiHole
keeps everything in /etc/pihole. Measured on live prod 2026-10-04: /etc/pihole (7.1 M —
gravity.db, pihole.toml, the query DB) was on the overlay filesystem, while the 2 Gi Longhorn
volume sat at /data/config holding lost+found and 24 K of nothing. Consequences, all live until
now:
- every restart silently discarded PiHole's entire state and re-downloaded 72 k gravity domains;
backup.policy: daily-offclusterhas been faithfully backing up an empty volume, which is worse than no backup because it reports success;- it also means phase 1 was understated: that state was not merely undeclared, it was ephemeral.
Same class of bug catalog/plex.yaml documents for its own config entry. Fixed in this branch
rather than its own because with the wrong path the new per-replica volumes would be two 2 Gi
Longhorn volumes (×3 Longhorn replicas each) holding only lost+found while all state stayed
ephemeral — paying for persistence and getting none. It also removes this change's main operational
risk: with gravity.db persisted, a restart is cheap instead of re-downloading 72 k domains inside a
wave with a 5 m timeout.
What I verified
- Back-compatibility, the thing most likely to break quietly: after the renderer change and
before
catalog/pihole.yamldeclared anything, the canonicalrender.py --all catalog -o apps --wave-layout --no-image-automationproduced zero diff across the wholeapps/tree.render-goldenpasses. - The new behaviour is pinned by ~20 new
render.py --selftestassertions (soci/render-golden.shhard-fails on a regression, and noconformance/**change was needed):replicas: 1still renders a Deployment + standalone PVC + no PDB;replicas: 2renders a StatefulSet and no standalone longhorn PVC, carries the replica count, omitsstrategy, setsParallelandserviceName, names the claim template exactly as the mounted volume, keeps RWO/longhorn on it, does not duplicate that volume inpod.volumes, still mounts it on the container, renders required hostname anti-affinity, renders exactly oneminAvailable: 1PDB, and switches the SLI tokube_statefulset_status_replicas_ready. A mixed longhorn+bulk case asserts the bulk share stays a standalone PVC and an ordinary pod volume. - Rendered pihole output, read back:
replicas: 2,serviceName: pihole,podManagementPolicy: Parallel, nostrategy, claim templateconfig(RWO/longhorn/2Gi), anti-affinitykubernetes.io/hostname,pod.volumes: [], mountconfig -> /etc/pihole, PDBminAvailable: 1, SLIkube_statefulset_status_replicas_ready. - The
/etc/piholemount is safe, evidenced from the image's own init rather than assumed:/usr/bin/start.shis PID 1 and runs as uid 0; its line 5 ischown -R pihole:pihole /etc/pihole/ /var/log/pihole/, so a fresh root-owned volume has its ownership fixed before anything else starts (nofsGroupneeded, and one would be inert); it callsmigrate_gravity, whosemigrate_to_database()creates the gravity database when[ ! -e "$gravityDBfile" ]; FTL writespihole.tomlitself from theFTLCONF_*env; and the image's own default forfiles.macvendorwas moved out of that directory, which is what a maintainer does to a path they expect to be a volume. - The PVC really is unused:
dfshowed/etc/piholeonoverlayand/dev/longhorn/pvc-849edb86-...on/data/config;ls -A /data/config→lost+foundonly. - The gate caught a real regression and it is fixed. The first run came back rc 1:
alert-rules-unit-testedfailed becauseapps/wave-0/tests/app-slis.test.yamlfed pihole akube_deployment_status_replicas_availableseries and expected adeployment: piholelabel. Three input series, the firing expectation and the file header were updated;platform/monitoring/tests/run.sh apps/wave-0/tests/app-slis.test.yaml→ SUCCESS (6 rules). - Schema:
check-jsonschemapasses over all ofcatalog/*.yaml. - Toolbox gate: rc 3, with
spec-immutability,decisions-log,lint,l0,render-golden,l1,approval-policyall pass. The rc 3 is the two environmental skips —gitea-settings(no localSETTINGS_API_TOKEN) andconformanceL2/L3 (CLUSTER_AVAILABLEunset). Not green; reported as rc 3. - No sensitive paths touched, so unlike the previous two branches this one needs no
/approve-override— confirmed againstinfra/gitea/approval-policy.json.
What I did NOT verify
- Nothing has been reconciled. No StatefulSet exists, no second replica has ever run, the
anti-affinity has never been evaluated by the scheduler, the PDB has never been consulted, and the
/etc/piholemount has never been exercised on a real pod. Everything above is the rendered artifact plus the image's own source. - THE ROLLOUT WILL BRIEFLY DROP LAN DNS, and I have not measured for how long.
postStartmust complete before a container is reported Running, so a pod is not Ready — and the Service has no endpoints — until phase 1's adlist hook finishes. On this transition both replicas start with an empty/etc/pihole, so the hook finds a freshgravity.dbwith zero adlists and runs a realpihole -g(72 k domains, network download) before either pod becomes Ready. Do this at a quiet time. Two mitigating facts: every pihole restart has already paid this cost (state was ephemeral), and after this changegravity.dbpersists so later restarts should not. If it turns out slow, backgrounding the gravity rebuild inside the hook would decouple readiness from it, at the cost of a window where DNS resolves but does not block — not done here. - Whether
pihole -gfinishes insideapps-wave-0's 5 mtimeout.Parallelwas chosen to make this the max rather than the sum of two first boots, but the real duration is unmeasured (it was stubbed during phase 1's testing). If the wave times out, the Kustomization reports failed even though the pods may come up seconds later. - The old PVC's deletion.
apps-wave-0hasprune: true, so removingpihole-configfrom the rendered output should delete it. That destroys nothing (it holdslost+found), but I have not observed the prune, and I did not check whether Longhorn retains the backing volume afterwards. - The transition overlap. For a moment the pruned Deployment's pod and the new StatefulSet pods may both match the Service selector. Both are configured identically from git so this should be benign — unobserved.
- Longhorn capacity. Two 2 Gi claims at 3 Longhorn replicas each is ~12 Gi of real storage where there was ~6 Gi. Not checked against free space.
- Whether two independent query databases are acceptable to the operator. Stats and query history now split per replica, and the admin UI shows only the pod that answered. Adlists and password are identical (phase 1), so blocking and auth are consistent; the reporting is not.
- The residual risk is unchanged and is the larger one. Replicas address node failure. LAN DNS
still depends on the cluster — etcd quorum, Cilium, Longhorn, a bad reconcile — and only a resolver
outside the cluster carrying the same
address=lines removes that. Considered and not chosen (operator decision, 2026-10-04). tolerationSeconds: 300is untouched. With two replicas the surviving one serves immediately, so the 5-minute eviction delay no longer gates recovery — but it still delays the replacement replica, and during that window there is no redundancy left.