Compare commits

..
Author SHA1 Message Date
jakman85andClaude Opus 5 2f2e98aa19 feat(shutdown): configure graceful node shutdown, and never scale a Delete-policy StatefulSet
gate / gate (pull_request) Successful in 27m44s
gate / conformance (pull_request) Has been skipped
All three nodes had to be powered off to move them onto a UPS, and there was
NO procedure: infra/cluster.sh stop is virsh-based and only ever applied to the
decommissioned dev cluster. The sequence was improvised live. Two defects came
out of doing it.

1. GRACEFUL NODE SHUTDOWN WAS UNCONFIGURED ON EVERY PROD NODE. No
   shutdownGracePeriod anywhere in infra/ansible/, so `shutdown -h` stopped k3s,
   containerd went with it, and every pod was SIGKILLed where it stood -- 9 CNPG
   instances mid-checkpoint, the UniFi VM, 25 attached Longhorn volumes.
   roles/k3s_server now sets kubelet-arg shutdown-grace-period=90s and
   shutdown-grace-period-critical-pods=20s.

   The half that silently defeats the other half: this is enforced by LOGIND,
   not the kubelet. The kubelet holds an inhibitor DELAY lock and systemd waits
   at most InhibitDelayMaxSec -- DEFAULT 5 SECONDS -- then proceeds regardless,
   truncating the drain with no error. The role ships a logind.conf.d drop-in
   (120) and ASSERTS cap >= grace period, because the failure is invisible.

2. SCALING A StatefulSet TO 0 DELETED ITS PVC AND VOLUME. monitoring/loki
   carries persistentVolumeClaimRetentionPolicy whenScaled: Delete, so the
   quiesce destroyed storage-loki-0 outright. Loki stores type: filesystem, so
   that PVC was the only copy; 72h retention bounded the loss, the mechanism
   does not. Symptom was the volume count going 25 -> 24, first misread as a
   miscount. Repaired: scale back to the declared 1 recreated the PVC and
   loki-0 is 2/2 Running.

New infra/maintenance/quiesce.sh (quiesce|resume|status) encodes the sequence
that worked and REFUSES to scale any whenScaled: Delete StatefulSet -- it stays
running, its volume hard-detaches, and it rebuilds on restart. A rebuild beats
a deletion. Discovery is computed from the live cluster, so a newly onboarded
service needs no edit here.

runbooks/cluster-maintenance-shutdown.md is the operator page: order in both
directions, TrueNAS-after-nodes (hard NFS mount wedges kubelet), the
un-hibernate-Postgres step Flux will NOT do for you, and a "looks broken but is
fine" section separating the 4/19 Flux cascade, the degraded-and-rebuilding
volume and the self-recovering keycloak-operator from the one thing that needs
a human (a stuck oauth2-proxy replica).

THE KUBELET CHANGE HAS NEVER BEEN APPLIED OR TESTED -- it needs a rolling k3s
restart, which is an operator action. Review config.yaml's kubelet-arg before
applying: a bad config.yaml is how k3s fails to come up. See the handoff entry.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-17 22:26:47 -05:00
12 changed files with 630 additions and 392 deletions

No files matched your search

@@ -0,0 +1,106 @@
---
date: 2026-10-17
branch: feat/graceful-cluster-shutdown
title: "Graceful node shutdown was never configured, and scaling a StatefulSet deleted its PVC"
---
## What changed
All three nodes had to be powered off to move them onto a UPS. There was **no procedure for it**:
`infra/cluster.sh stop` is `virsh`-based and only ever applied to the decommissioned dev cluster, so
the whole sequence was improvised live. Two defects came out of doing it.
**1. Kubelet graceful node shutdown was unconfigured on every prod node.** No `shutdownGracePeriod`
anywhere in `infra/ansible/`, so `shutdown -h` stopped k3s, containerd went with it, and every pod
was SIGKILLed where it stood — 9 CloudNativePG instances mid-checkpoint, the UniFi VM, and 25
attached Longhorn volumes. `roles/k3s_server` now writes `kubelet-arg` into
`/etc/rancher/k3s/config.yaml` with `shutdown-grace-period=90s` and
`shutdown-grace-period-critical-pods=20s`.
The half that is easy to miss and silently defeats the other half: **graceful node shutdown is
enforced by logind, not by the kubelet.** The kubelet holds an inhibitor *delay* lock and systemd
waits at most `InhibitDelayMaxSec` — **default 5 seconds** — before proceeding regardless. A grace
period above that cap is truncated with no error, so the feature looks configured and still
hard-kills pods. The role now ships a `logind.conf.d` drop-in setting it to 120, and **asserts**
`k3s_logind_inhibit_delay_max_sec >= k3s_kubelet_shutdown_grace_period` rather than documenting the
relationship, because the failure is invisible.
**2. Scaling a StatefulSet to 0 DELETED its PVC and Longhorn volume.** `monitoring/loki` carries
`persistentVolumeClaimRetentionPolicy: {whenDeleted: Delete, whenScaled: Delete}`, so the quiesce's
`scale --replicas=0` destroyed `storage-loki-0` outright. Loki stores with `type: filesystem`, so
that PVC was the only copy of its log data. This is the defect in this change set that actually
destroyed something, and it was mine.
New `infra/maintenance/quiesce.sh` (`quiesce` | `resume` | `status`) encodes the sequence that
worked, and **refuses to scale any StatefulSet whose `whenScaled` is `Delete`** — it leaves it
running, accepts that its volume hard-detaches at poweroff, and prints which volumes to expect
`degraded`. A rebuild is a vastly better outcome than a deletion. Workload discovery is computed
from the live cluster (every Deployment/StatefulSet whose pods mount a PVC, Longhorn or TrueNAS NFS)
rather than hardcoded, so a newly onboarded service is covered without anyone remembering to add it.
`runbooks/cluster-maintenance-shutdown.md` (new, registered in `runbooks/kustomization.yaml`) is the
operator page: shutdown order, the TrueNAS-after-nodes constraint, power-on order, the
un-hibernate-Postgres step Flux will not do for you, and a "what looks broken but is fine" section
separating the `4/19`-Kustomizations-Ready cascade, the degraded-and-rebuilding volume and the
self-recovering `keycloak-operator` from the one thing that genuinely needs a human (a stuck
`oauth2-proxy` replica).
## What I verified
* **Graceful shutdown was genuinely absent**, not merely undocumented: `grep -rn
'shutdownGracePeriod|graceful-node-shutdown'` over `infra/ansible/` returns nothing, and no
`logind` or `InhibitDelay` setting exists anywhere in the tree.
* **The whole shutdown/restart cycle was executed end to end on prod 2026-10-17** and the cluster
came back: 3/3 nodes Ready with etcd `healthz` ok, 19/19 Flux Kustomizations Ready,
`platform-config`'s `lastAppliedRevision` equal to the GitRepository artifact revision, all 3
CNPG clusters `healthy` with primaries elected, the UniFi VM `Running`, PiHole resolving
(`home.keithjackson.dev` → `192.168.2.20` via `192.168.2.23`), and the Gateway serving
`200/200/401` for apex/gitea/plex.
* **The PVC-deletion mechanism is confirmed, not inferred:** `kubectl get sts loki -o
jsonpath={.spec.persistentVolumeClaimRetentionPolicy}` returns
`{"whenDeleted":"Delete","whenScaled":"Delete"}`, the PVC was `NotFound` after the cycle, and the
Longhorn volume count had gone 25 → 24.
* **The damage was repaired and verified:** `scale sts loki --replicas=1` (restoring the replica
count the chart declares) recreated `storage-loki-0` as a fresh 50Gi Bound PVC, `loki-0` reached
`2/2 Running`, and the volume count returned to 25.
* **The guard works against the live cluster**, tested both ways: `sts_deletes_pvc_on_scale
monitoring loki` returns true (would be skipped) and `pihole pihole` returns false (safe to
scale).
* **The script's read-only paths run clean on prod:** `status` reports correctly, and the discovery
function returns the 13 workloads currently holding a PVC.
* `bash -n` clean; the two edited Ansible YAML files parse.
## What I did NOT verify
* **The kubelet change has NEVER BEEN APPLIED OR TESTED.** This is the significant gap. I did not
run the role, did not restart k3s, and have **not** observed a node actually drain on
`shutdown -h`. Everything about it is reasoning from kubelet/systemd semantics plus the measured
absence of the setting. Applying it needs a **rolling** k3s restart (`serial: 1` — kube-vip keeps
the API up, etcd holds at 2/3), which is an operator action (CLAUDE.md §5). **Verify by draining
one node for real:** apply, then `shutdown -h now` on a single node and watch whether pods
terminate gracefully instead of vanishing.
* **Whether 90s/20s are the right numbers.** Chosen to comfortably cover a Postgres fast shutdown
and a Longhorn flush; not measured against either. The budget is spent on every reboot, so if a
node takes noticeably longer to go down, this is why.
* **Whether k3s accepts `kubelet-arg` in `config.yaml` as written.** The file already carries
`kube-apiserver-arg` as a list and `kubelet-arg` is the documented sibling, but I did not render
or apply it, so a typo would surface only at k3s restart — and a bad `config.yaml` is exactly the
kind of thing that makes k3s fail to come up. **Review this one before applying.**
* **The `assert` task's `regex_replace` on the grace period** assumes a plain `90s`-style value; a
value expressed as `1m30s` would parse to the nonsense integer `130`. Fine for the committed
defaults, wrong if someone writes a compound duration.
* **`resume`'s Flux-reconcile wait has never run**, and neither has `quiesce` as a script — the
2026-10-17 cycle was done with the equivalent commands by hand, so the script encodes a sequence
that worked but has not itself been executed end to end. Only `status` and the discovery and guard
functions have run.
* **Loki's lost log window is gone for good.** Up to 72h of logs (its `retention_period`), destroyed
by my scale-down. Nothing recovers it; Velero's schedules cover the namespace but the PVC was
deleted, not corrupted, and I did not attempt a restore because the data self-expires anyway.
* **No other `whenScaled: Delete` StatefulSet was audited.** The guard now catches them generically,
but I did not enumerate which other workloads carry the policy, so I cannot say loki was the only
one at risk. The runbook gives the one-liner to list them.
* **TrueNAS and the router are entirely outside this change.** TrueNAS is not in the Ansible
inventory and refuses SSH, so its shutdown stays a web-UI step with no automation and no check.
* **No runtime conformance.** `vars.CLUSTER_AVAILABLE` is still unset, so CI proves the L0/L1
offline subset only.
* I did not re-measure branch protection or runner state (CLAUDE.md §2b).
@@ -1,99 +0,0 @@
---
date: 2026-10-17
branch: fix/oauth2-proxy-oidc-startup-race
title: "oauth2-proxy dies on a cold start because OIDC discovery runs once, before Keycloak serves"
---
## What changed
Exposed by the 2026-10-17 whole-cluster shutdown for the UPS move. oauth2-proxy performs OIDC
discovery **once, at startup**, and `os.Exit(1)`s if it fails. On a cold boot Keycloak is not
serving yet, so the shared Gateway answers 503 and the proxy dies:
```
[provider.go:55] Performing OIDC Discovery...
[main.go:73] ERROR: Failed to initialise OAuth2 Proxy: error initialising provider: ...
failed to discover OIDC configuration: unexpected status "503": no healthy upstream
```
It then sits in **exponential CrashLoopBackOff long after Keycloak is healthy** — one of the two
replicas was still down minutes later and only a manual `kubectl delete pod` cleared it. There is no
retry flag; the process does not survive a cold start. A readinessProbe cannot help (the container
is already dead), and CrashLoopBackOff backoff is not tunable per-pod.
`platform/oauth2-proxy/helmrelease.yaml` gains an `extraInitContainers` gate (`wait-for-idp`) that
polls Keycloak's discovery doc until it answers, then lets the proxy start. A pod in `Init` does not
accrue CrashLoopBackOff backoff and retries promptly, which is the point.
**Bounded, and fails open.** After ~5 minutes it gives up and starts the proxy anyway, so the worst
case is exactly today's behaviour and never something new. An unbounded wait would trade a CrashLoop
for a pod wedged in `Init` forever, which is not an improvement.
## Why this shape and not the alternatives
* **`extraInitContainers` is the chart's own documented hook for precisely this.** Its commented
example in `values.yaml` is literally `- name: wait-for-idp`. The key was **confirmed against the
pinned chart** (`helm show values oauth2-proxy/oauth2-proxy --version 10.7.1` → `extraInitContainers: []`
at line 428), not assumed — a wrong Helm values key is silently ignored, which is the
"reviewed, merged, apparently done, still broken" shape this repo keeps getting bitten by.
* **No new base image.** The chart already runs an `alpine` init container for its built-in
`initContainers.waitForRedis` gate, so alpine is a base this chart uses regardless. Pinned to
`3.22` (verified to exist upstream) rather than the chart's own `latest`.
* **Flux `postRenderers` rejected:** it would work independently of the chart schema, but nothing in
this tree uses postRenderers, so it would introduce a mechanism with no precedent for a problem
the chart already has a hook for.
* **`--skip-oidc-discovery` rejected:** it removes the startup fetch entirely, which is arguably
more elegant, but it means hardcoding four Keycloak endpoints and I could not verify offline that
oauth2-proxy then fetches JWKS lazily rather than at startup. oauth2-proxy gates SSO for PiHole,
the \*arr apps, qBittorrent, Homepage, Prometheus, Alertmanager and Hubble — a wrong guess locks
the operator out of all of them, so the reversible, additive option wins.
The `$$`-free convention matters here: shell variables are written bare (`$i`, `$url`, `$(( ))`)
with **no braced form anywhere**, because `platform` declares `postBuild.substituteFrom cluster-vars`
and envsubst consumes braced expansions — a braced shell variable renders to the empty string and
silently breaks the loop. `${BASE_DOMAIN}` is braced on purpose. Same hazard and same convention as
the gitea and nextcloud OIDC bootstrap hooks, and `ci: flux-postbuild-substitution` enforces it.
## What I verified
* **The failure is measured, not theorised.** The log above is from the live pod
(`oauth2-proxy-5d6c74876b-bzb2p`, `lastState.terminated: {reason: Error, exitCode: 1}`), captured
with `--previous` after the cold boot. The surviving replica's log shows the same startup
succeeding once Keycloak was up, which is what identifies this as a race rather than a config
fault.
* **Deleting the pod is a sufficient workaround** — done on prod 2026-10-17, the replacement came up
clean and both replicas have been healthy since.
* **The chart key is real**, at the pinned version range: `helm search repo` resolves
`>=10.0.0 <11.0.0` to chart `10.7.1` (app `7.15.5`), whose values carry `extraInitContainers: []`,
`extraContainers: []` and the built-in `initContainers.waitForRedis` (which uses `alpine`).
* **`docker.io/library/alpine:3.22` exists** — confirmed against the registry tag list rather than
assumed from memory.
* The edited HelmRelease parses as YAML.
## What I did NOT verify
* **UNAPPLIED. The init container has never run.** I did not reconcile the HelmRelease, so I have not
seen `wait-for-idp` execute, exit 0, or gate the proxy. Applying it rolls both oauth2-proxy
replicas, which is a mutating action (CLAUDE.md §5) — and it rolls the component that gates SSO for
seven apps, so **do it when you can watch it**, not unattended.
* **I did not re-create the cold-start race to prove the fix.** That needs Keycloak down while
oauth2-proxy starts; the honest test is the next whole-cluster boot. Until then this is reasoned
from the measured failure, not demonstrated against it.
* **busybox `wget`'s TLS behaviour here is assumed, not tested.** The claim is that alpine's
`ca-certificates-bundle` lets it validate the Gateway's LE-prod certificate, so a genuine TLS
failure exits non-zero rather than passing falsely. If that is wrong in either direction the gate
degrades safely — a never-succeeding probe just burns the 5-minute budget and then starts the proxy
anyway — but the fast path would be silently dead and this would be doing nothing. **Check the
init container's log on first apply**: it should print "Keycloak discovery reachable" within
seconds on a warm cluster, not the give-up message.
* **Whether a pod can reach the Gateway hostname at all from this namespace is inherited, not
re-proven.** The main container does exactly this fetch today and succeeds, so the path is known
good — but note the standing trap that an arbitrary pod gets 403 from every Gateway hostname
(`decisions/2026-10-04-plex-advertise-plexdirect.md`); oauth2-proxy is exempt only because
Keycloak's ingress policy admits it. An init container in the same pod inherits that identity, so
this should hold — unverified.
* **60 attempts x 5s is not tuned.** It is "comfortably longer than Keycloak's observed cold start",
not a measured bound.
* **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).
-108
View File
@@ -1,108 +0,0 @@
---
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).
@@ -107,3 +107,31 @@ k3s_base_domain: "home.keithjackson.dev"
# Derived, so the realm path is stated once. Override k3s_base_domain, not this.
k3s_oidc_issuer_url: "https://keycloak.{{ k3s_base_domain }}/realms/homelab"
# --- Graceful node shutdown (kubelet) ----------------------------------------------------------
# MEASURED 2026-10-17, during the UPS move: graceful node shutdown was NOT configured on any prod
# node, so `shutdown -h` did NOT drain pods. systemd stops k3s, containerd goes with it, and every
# pod is SIGKILLed where it stands -- including 9 CloudNativePG instances mid-checkpoint, the UniFi
# VM, and 25 attached Longhorn volumes. The whole-cluster shutdown that day had to quiesce
# everything BY HAND first (runbooks/cluster-maintenance-shutdown.md), and the one volume that did
# not finish detaching came back `degraded` and had to rebuild.
#
# With these set, the kubelet takes a systemd inhibitor lock and, on shutdown, evicts ordinary pods
# first and critical ones last, so a plain `shutdown -h`/reboot drains the node. That makes the
# manual scale-down in the runbook a belt-and-braces step for a PLANNED whole-cluster shutdown
# rather than the only thing standing between a reboot and a hard kill.
#
# 90s/20s, not larger: the budget is spent on every reboot and node upgrade, and the kubelet waits
# the FULL ordinary period before it starts on critical pods. Postgres does a fast shutdown in
# well under 90s here, and Longhorn needs only enough time to flush and detach.
k3s_kubelet_shutdown_grace_period: "90s"
k3s_kubelet_shutdown_grace_period_critical_pods: "20s"
# *** THE HALF THAT IS EASY TO MISS, AND SILENTLY BREAKS THE OTHER HALF. ***
# Graceful node shutdown works by the kubelet holding a logind INHIBITOR DELAY lock. systemd caps
# how long any inhibitor may delay shutdown at `InhibitDelayMaxSec`, which DEFAULTS TO 5 SECONDS.
# If that cap is below the grace period above, systemd proceeds anyway and the drain is truncated
# to ~5s -- i.e. the feature appears configured, reports no error, and still hard-kills pods. So
# this MUST be >= k3s_kubelet_shutdown_grace_period; the role asserts it rather than trusting it.
# Set a little above the kubelet's total (90s + 20s) to leave headroom.
k3s_logind_inhibit_delay_max_sec: 120
@@ -15,3 +15,12 @@
port: "{{ k3s_api_port }}"
timeout: 180
listen: Restart k3s
# logind re-reads InhibitDelayMaxSec only on restart. Restarting logind does NOT log users out or
# disturb k3s -- it manages login sessions and inhibitor locks, not workloads. The kubelet picks up
# the new cap when it next takes its lock, so no k3s restart is needed for THIS half.
- name: Restart logind
ansible.builtin.systemd_service:
name: systemd-logind
state: restarted
listen: Restart logind
@@ -86,6 +86,16 @@
# k3s_base_domain (defaults/main.yml), whose [parity] contract with the cluster's
# BASE_DOMAIN is documented there. Dev's default renders the value it already runs, so
# config.yaml is unchanged and the Restart handler does not fire.
# ── Graceful node shutdown ──────────────────────────────────────────────────────────────
# Without these the kubelet takes no systemd inhibitor lock, so `shutdown -h` stops
# containerd and SIGKILLs every pod where it stands -- MEASURED on all three prod nodes
# 2026-10-17 (defaults/main.yml has the detail). The companion logind drop-in below raises
# InhibitDelayMaxSec, WITHOUT WHICH systemd caps the drain at ~5s and these values are
# quietly ignored.
kubelet-arg:
- "shutdown-grace-period={{ k3s_kubelet_shutdown_grace_period }}"
- "shutdown-grace-period-critical-pods={{ k3s_kubelet_shutdown_grace_period_critical_pods }}"
kube-apiserver-arg:
- "oidc-issuer-url={{ k3s_oidc_issuer_url }}"
- "oidc-client-id=kubernetes"
@@ -183,3 +193,46 @@
host: "127.0.0.1"
port: "{{ k3s_api_port }}"
timeout: 180
# ── Graceful node shutdown: the systemd half — tag `shutdown` ──────────────────────────────────
# The kubelet's shutdown-grace-period is ENFORCED BY logind, not by the kubelet: the kubelet holds
# an inhibitor DELAY lock, and systemd will wait at most `InhibitDelayMaxSec` for it (default 5s)
# before proceeding regardless. A grace period above that cap is therefore silently truncated --
# the feature looks configured, logs no error, and still hard-kills pods. Assert the relationship
# instead of documenting it, because the failure is invisible.
- name: Assert the logind inhibitor cap can actually cover the kubelet grace period
ansible.builtin.assert:
that:
- (k3s_logind_inhibit_delay_max_sec | int) >= (k3s_kubelet_shutdown_grace_period | regex_replace('[^0-9]', '') | int)
fail_msg: >-
k3s_logind_inhibit_delay_max_sec ({{ k3s_logind_inhibit_delay_max_sec }}s) is below
k3s_kubelet_shutdown_grace_period ({{ k3s_kubelet_shutdown_grace_period }}). systemd would
stop waiting for the kubelet's inhibitor lock before the drain finished, so pods would be
SIGKILLed anyway and graceful node shutdown would be configured but ineffective.
quiet: true
tags: [shutdown]
- name: Ensure /etc/systemd/logind.conf.d exists
ansible.builtin.file:
path: /etc/systemd/logind.conf.d
state: directory
owner: root
group: root
mode: "0755"
tags: [shutdown]
# A drop-in, not an edit of logind.conf: the distro file stays pristine and `state: absent` here
# would fully revert the change.
- name: Raise the logind inhibitor delay cap so the kubelet can finish draining
ansible.builtin.copy:
dest: /etc/systemd/logind.conf.d/10-k3s-graceful-shutdown.conf
owner: root
group: root
mode: "0644"
content: |
# Managed by Ansible (roles/k3s_server). See defaults/main.yml.
# MUST stay >= kubelet shutdown-grace-period, or the drain is truncated to this value.
[Login]
InhibitDelayMaxSec={{ k3s_logind_inhibit_delay_max_sec }}
notify: Restart logind
tags: [shutdown]
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env bash
# =================================================================================================
# quiesce.sh — bring the prod cluster to a safe standstill before powering nodes off, and bring it
# back afterwards. Companion to runbooks/cluster-maintenance-shutdown.md.
# =================================================================================================
# WHY THIS EXISTS. On 2026-10-17 all three nodes had to be powered off to move them onto a UPS, and
# there was NO procedure for it: `infra/cluster.sh stop` is `virsh`-based and only ever applied to
# the decommissioned dev cluster. The whole sequence was improvised live. This script is that
# sequence, so the next person does not improvise it.
#
# WHY IT IS NOT JUST `shutdown -h`. Graceful node shutdown was unconfigured on every prod node that
# day, so systemd stopped containerd and SIGKILLed every pod where it stood. roles/k3s_server now
# sets shutdown-grace-period (+ the logind InhibitDelayMaxSec cap that actually enforces it), which
# makes a bare reboot safe. This script is still the right tool for a PLANNED whole-cluster
# shutdown, for three reasons the kubelet's drain does not cover:
# 1. The UniFi VM needs an ACPI guest shutdown, not an eviction.
# 2. CloudNativePG wants hibernation, not pod deletion -- and hibernation is IMPERATIVE state
# that Flux will not set or clear for you.
# 3. Draining three nodes one at a time makes pods RESCHEDULE onto the survivors, churning
# Longhorn replicas for no reason when the whole cluster is going down anyway. Scaling in
# place avoids all of it.
#
# SAFE TO RE-RUN. Every step is idempotent and `resume` is the exact inverse of `quiesce`.
#
# WHAT IT DELIBERATELY DOES NOT DO: power anything off. Node poweroff is an operator action
# (CLAUDE.md §5) and needs the become password; the runbook gives the ansible one-liner. It also
# does not touch TrueNAS (not in the inventory, SSH refused -- web UI only) or the router.
set -euo pipefail
CONTEXT="${CONTEXT:-k3s-prod}"
KUBECTL=(kubectl --context "$CONTEXT" --request-timeout=30s)
TIMEOUT="${TIMEOUT:-600}"
# The three CloudNativePG clusters, as namespace/name. Hibernation is per-Cluster.
CNPG=(keycloak/keycloak-db nextcloud-db/nextcloud-db semaphore-db/semaphore-db)
log() { printf '\033[34m[quiesce]\033[0m %s\n' "$*"; }
ok() { printf '\033[32m[ ok ]\033[0m %s\n' "$*"; }
warn() { printf '\033[33m[warn]\033[0m %s\n' "$*"; }
die() { printf '\033[31m[fail]\033[0m %s\n' "$*" >&2; exit 1; }
need() { command -v "$1" >/dev/null || die "$1 not on PATH — run this inside ./infra/devcontainer/dev.sh"; }
# ---------------------------------------------------------------- discovery
# Every Deployment/StatefulSet whose pods mount a PVC — Longhorn OR the TrueNAS NFS bulk tier.
# Computed from the LIVE cluster rather than hardcoded, so a newly onboarded service is covered
# without anyone remembering to add it here. PROTECTED namespaces are excluded because scaling
# them down would remove the very machinery that performs the detach (longhorn-system), keep the
# API reachable (kube-system), or finish the hibernation and the VM shutdown (cnpg-system,
# kubevirt).
pvc_workloads() {
"${KUBECTL[@]}" get pods -A -o json | python3 -c '
import json,sys
PROTECT={"longhorn-system","kube-system","cilium-secrets","cnpg-system","kubevirt","flux-system","cdi"}
out={}
for p in json.load(sys.stdin)["items"]:
ns=p["metadata"]["namespace"]
if ns in PROTECT: continue
if not any(v.get("persistentVolumeClaim") for v in (p["spec"].get("volumes") or [])): continue
ow=(p["metadata"].get("ownerReferences") or [])
if not ow: continue
k,n=ow[0]["kind"],ow[0]["name"]
if k=="ReplicaSet": k,n="deployment",n.rsplit("-",1)[0]
elif k=="StatefulSet": k="statefulset"
else: continue # Jobs, and CNPG Clusters, are handled separately
out[(ns,k,n)]=1
for ns,k,n in sorted(out): print(ns,k,n)
'
}
# ---------------------------------------------------------------- the PVC-deletion guard
# *** DO NOT REMOVE THIS CHECK. IT EXISTS BECAUSE SCALING A StatefulSet DESTROYED DATA. ***
# A StatefulSet may carry `persistentVolumeClaimRetentionPolicy.whenScaled: Delete`, and then
# scaling it to 0 DELETES ITS PVCs -- and with them the Longhorn volumes. MEASURED 2026-10-17: the
# quiesce for the UPS move scaled `monitoring/loki` (whenScaled: Delete) to 0 and its
# `storage-loki-0` PVC and volume were deleted outright. Loki stores with `type: filesystem`, so
# that PVC was the only copy; 72h retention bounded the loss to a rolling window, but nothing about
# the mechanism is bounded -- the same action on a Delete-policy StatefulSet holding real state
# would be unrecoverable. The volume count going 25 -> 24 was the only visible symptom, and it was
# first misread as a miscount.
#
# So: such a StatefulSet is NEVER scaled. It is left RUNNING and its volume hard-detaches at
# poweroff, which costs a replica rebuild on restart (degraded -> healthy, automatic). A rebuild is
# an enormously better outcome than a deletion, and that is the whole trade being made here.
sts_deletes_pvc_on_scale() { # ns name -> 0 if scaling it would delete its PVCs
local policy
policy=$("${KUBECTL[@]}" -n "$1" get statefulset "$2" \
-o jsonpath='{.spec.persistentVolumeClaimRetentionPolicy.whenScaled}' 2>/dev/null || true)
[ "$policy" = "Delete" ]
}
wait_for() { # wait_for <description> <shell-condition>
local desc="$1" cond="$2" waited=0
log "waiting: $desc (up to ${TIMEOUT}s)"
until eval "$cond"; do
(( waited += 10 )); (( waited >= TIMEOUT )) && { warn "TIMED OUT waiting: $desc"; return 1; }
sleep 10
done
ok "$desc"
}
# ---------------------------------------------------------------- quiesce
do_quiesce() {
need kubectl; need python3
# Refuse to start if Longhorn is already unhealthy. Powering off on top of a degraded volume is
# how one replica becomes zero; better to stop here and let it rebuild first.
local unhealthy
unhealthy=$("${KUBECTL[@]}" -n longhorn-system get volumes.longhorn.io \
-o jsonpath='{range .items[*]}{.status.robustness}{"\n"}{end}' | grep -cvE '^(healthy|unknown)$' || true)
[ "$unhealthy" = "0" ] || die "$unhealthy Longhorn volume(s) are not healthy — fix or wait for rebuild BEFORE powering off"
ok "Longhorn: all volumes healthy"
# 1. Flux first, or it reverts every scale-down below within its reconcile interval.
log "suspending Flux Kustomizations"
local k
for k in $("${KUBECTL[@]}" -n flux-system get kustomizations -o name); do
"${KUBECTL[@]}" -n flux-system patch "${k}" --type=merge -p '{"spec":{"suspend":true}}' >/dev/null
done
ok "all Kustomizations suspended"
# 2. The VM needs an ACPI shutdown of the GUEST. KubeVirt does that on running:false; a plain
# pod eviction would be a power cut to the VM.
if "${KUBECTL[@]}" -n unifi get vm unifi >/dev/null 2>&1; then
log "ACPI-stopping the UniFi VM"
"${KUBECTL[@]}" -n unifi patch vm unifi --type=merge -p '{"spec":{"running":false}}' >/dev/null
fi
# 3. Postgres: hibernation is a clean smart shutdown that KEEPS the PVCs. CNPG does one instance
# at a time, primary last-ish, so this is not instant.
log "hibernating CloudNativePG clusters"
local c ns name
for c in "${CNPG[@]}"; do
ns="${c%%/*}"; name="${c##*/}"
"${KUBECTL[@]}" -n "$ns" annotate cluster.postgresql.cnpg.io "$name" \
cnpg.io/hibernation=on --overwrite >/dev/null 2>&1 || warn "could not hibernate $c"
done
# 4. Everything holding a PVC, scaled in place. NFS consumers first so nothing holds a hard
# TrueNAS mount; PiHole last because it is the LAN resolver.
log "scaling down PVC-holding workloads"
local line ns2 kind nm deferred=() skipped_delete_policy=()
while read -r ns2 kind nm; do
[ -z "${ns2:-}" ] && continue
if [ "$kind" = "statefulset" ] && sts_deletes_pvc_on_scale "$ns2" "$nm"; then
warn "SKIPPING $ns2/$nm — whenScaled: Delete would DELETE its PVC (see the guard above)."
warn " Left running; its volume hard-detaches at poweroff and rebuilds on restart."
skipped_delete_policy+=("$ns2/$nm")
continue
fi
if [ "$ns2" = "pihole" ]; then deferred+=("$ns2 $kind $nm"); continue; fi
"${KUBECTL[@]}" -n "$ns2" scale "$kind" "$nm" --replicas=0 >/dev/null 2>&1 \
&& printf ' scaled 0 %s/%s %s\n' "$ns2" "$kind" "$nm" \
|| warn "could not scale $ns2/$kind/$nm"
done < <(pvc_workloads)
for line in "${deferred[@]:-}"; do
[ -z "${line:-}" ] && continue
# shellcheck disable=SC2086
set -- $line
"${KUBECTL[@]}" -n "$1" scale "$2" "$3" --replicas=0 >/dev/null 2>&1 \
&& printf ' scaled 0 %s/%s %s (LAN DNS — expect name resolution to stop)\n' "$1" "$2" "$3"
done
# 5. The only assertion that matters. An attached volume at poweroff comes back degraded and
# rebuilds; this is what we are buying with all of the above.
wait_for "UniFi VMI gone" \
'[ "$('"${KUBECTL[*]}"' -n unifi get vmi --no-headers 2>/dev/null | wc -l)" = "0" ]' || true
wait_for "all Postgres instances stopped" \
'[ "$('"${KUBECTL[*]}"' get pods -A --no-headers 2>/dev/null | grep -cE "(keycloak|nextcloud|semaphore)-db-[0-9]")" = "0" ]' || true
wait_for "all Longhorn volumes detached" \
'[ "$('"${KUBECTL[*]}"' -n longhorn-system get volumes.longhorn.io -o jsonpath="{range .items[*]}{.status.state}{\"\n\"}{end}" 2>/dev/null | grep -c attached)" = "0" ]' || \
warn "some volumes are STILL ATTACHED — they will come back degraded and rebuild; acceptable, but note which"
if [ "${#skipped_delete_policy[@]}" -gt 0 ]; then
echo
warn "NOT scaled down (whenScaled: Delete — scaling them would delete their PVCs):"
printf ' %s\n' "${skipped_delete_policy[@]}"
warn "Expect these volumes to come back 'degraded' and rebuild. That is correct and automatic."
fi
echo
ok "QUIESCED. Safe to power off."
cat <<'NEXT'
Next, from inside the devcontainer (it holds the become password):
cd /work/infra/ansible && ansible -i inventory/hosts.ini k3s_prod -b \
-m shell -a 'shutdown -h +1 "maintenance"'
THEN, in this order and not before:
1. TrueNAS (192.168.2.30) — web UI only; not in the ansible inventory and SSH is refused.
2. Router / switch last.
NEXT
}
# ---------------------------------------------------------------- resume
do_resume() {
need kubectl
# THE STEP THAT IS ALWAYS FORGOTTEN. Hibernation is imperative state: Flux neither sets nor
# clears it, so resuming Flux alone leaves all three databases down and every app that needs
# one looking broken for reasons that point nowhere near Postgres.
log "un-hibernating CloudNativePG clusters"
local c ns name
for c in "${CNPG[@]}"; do
ns="${c%%/*}"; name="${c##*/}"
"${KUBECTL[@]}" -n "$ns" annotate cluster.postgresql.cnpg.io "$name" \
cnpg.io/hibernation=off --overwrite >/dev/null 2>&1 || warn "could not un-hibernate $c"
done
# Flux restores every replica count AND the VM's running:true from git, so nothing here needs to
# undo the scale-downs by hand.
log "resuming Flux Kustomizations"
local k
for k in $("${KUBECTL[@]}" -n flux-system get kustomizations -o name); do
"${KUBECTL[@]}" -n flux-system patch "${k}" --type=merge -p '{"spec":{"suspend":false}}' >/dev/null
done
ok "all Kustomizations resumed"
wait_for "Flux fully reconciled" \
'[ "$('"${KUBECTL[*]}"' -n flux-system get kustomizations --no-headers 2>/dev/null | grep -vc True)" = "0" ]' || \
warn "some Kustomizations are not Ready — the graph serialises behind platform-config, so give it a few minutes before worrying"
echo
ok "RESUMED."
cat <<'NEXT'
Known cold-start race, not a fault in this script: oauth2-proxy performs OIDC discovery at
startup and exits 1 on the 503 it gets while Keycloak is still coming up, then sits in
exponential CrashLoopBackOff. If a replica is stuck, delete the pod once:
kubectl --context k3s-prod -n oauth2-proxy delete pod <name>
NEXT
}
do_status() {
need kubectl
printf 'nodes: %s Ready\n' "$("${KUBECTL[@]}" get nodes --no-headers | grep -c ' Ready')"
printf 'flux: %s/%s Ready\n' \
"$("${KUBECTL[@]}" -n flux-system get kustomizations --no-headers | grep -c True)" \
"$("${KUBECTL[@]}" -n flux-system get kustomizations --no-headers | wc -l)"
printf 'longhorn: %s attached, %s detached, %s not healthy\n' \
"$("${KUBECTL[@]}" -n longhorn-system get volumes.longhorn.io -o jsonpath='{range .items[*]}{.status.state}{"\n"}{end}' | grep -c attached)" \
"$("${KUBECTL[@]}" -n longhorn-system get volumes.longhorn.io -o jsonpath='{range .items[*]}{.status.state}{"\n"}{end}' | grep -c detached)" \
"$("${KUBECTL[@]}" -n longhorn-system get volumes.longhorn.io -o jsonpath='{range .items[*]}{.status.robustness}{"\n"}{end}' | grep -cvE '^(healthy|unknown)$' || true)"
printf 'postgres: %s instance pod(s)\n' \
"$("${KUBECTL[@]}" get pods -A --no-headers | grep -cE '(keycloak|nextcloud|semaphore)-db-[0-9]' || true)"
printf 'unifi VM: %s VMI\n' "$("${KUBECTL[@]}" -n unifi get vmi --no-headers 2>/dev/null | wc -l)"
}
case "${1:-}" in
quiesce) do_quiesce ;;
resume) do_resume ;;
status) do_status ;;
*) cat >&2 <<USAGE
usage: $0 {quiesce|resume|status}
quiesce suspend Flux, ACPI-stop the UniFi VM, hibernate Postgres, scale down every
PVC-holding workload, and wait for all Longhorn volumes to detach.
Does NOT power anything off — see runbooks/cluster-maintenance-shutdown.md.
resume the exact inverse: un-hibernate Postgres, resume Flux, wait for reconcile.
status one-screen readout of nodes / Flux / Longhorn / Postgres / VM.
Run it inside the pinned toolbox, which supplies kubectl and the context:
./infra/devcontainer/dev.sh run bash -c './infra/maintenance/quiesce.sh status'
USAGE
exit 2 ;;
esac
-112
View File
@@ -1,112 +0,0 @@
---
# =================================================================================================
# coredns HA floor — hold in-cluster DNS at >= 2 replicas, which k3s does not.
# =================================================================================================
# MEASURED on prod 2026-10-18: `kube-system/coredns` ran with **replicas: 1**. In-cluster DNS is a
# single pod on a single node, so **draining or rebooting that one node is a cluster-wide
# in-cluster DNS outage** for as long as the pod takes to reschedule and become ready — every pod
# that looks up a Service, every CNPG replica finding its primary, every operator talking to the
# API by name. It is the single cheapest availability defect in the cluster, and it was invisible
# because `1/1 Ready` looks perfectly healthy.
#
# This matters more now than it did: the reboot automation (kured + unattended-upgrades) drains and
# reboots one node at a time on a schedule, which turns a latent single point of failure into a
# recurring one, unattended, in the middle of the night.
#
# -------------------------------------------------------------------------------------------------
# WHY AN HPA, AND NOT JUST `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, not by Flux:
#
# $ kubectl -n kube-system get addon coredns
# NAME SOURCE CHECKSUM
# coredns /data/k3s/server/manifests/coredns.yaml 8cd91595...
# $ kubectl -n kube-system get deploy coredns -o jsonpath='{.metadata.managedFields[*].manager}'
# deploy@k3s-prod-1 k3s
#
# and the Deployment carries an `objectset.rio.cattle.io/hash` label — a wrangler objectset. k3s
# RE-STAGES its bundled manifests on startup, so a `replicas: 2` written into that file (or a
# `kubectl scale`) is reverted the next time k3s restarts or is upgraded. MEASURED the same day: a
# hand `kubectl scale` to 2 was NOT actively reverted (it held, and the AddOn checksum did not
# change) — the deploy controller re-applies on manifest change and on restart, not on a drift
# timer. So the hand fix survives until exactly the event we are protecting against: a reboot.
#
# The two durable alternatives were weighed and rejected:
# * **`coredns.yaml.skip` + our own templated copy** (the pattern roles/kube_vip already uses for
# a manifest in server/manifests). Fully deterministic and never flaps — but it takes OWNERSHIP
# of coredns away from k3s permanently, so k3s upgrades would stop updating it and the version
# would have to be refreshed by hand forever. Too high a price for a replica count.
# * **A Flux-managed patch of the Deployment itself.** Rejected outright: Flux and k3s's deploy
# controller would both own one object and fight continuously. That is the shape of the
# `default-deny` NetworkPolicy drift loop this cluster already suffered (Kyverno generate + Flux
# both owning one resource, ~2500 events per 10 minutes).
#
# An HPA avoids both. It is a SEPARATE object that k3s does not own and will never reconcile away,
# and it drives the Deployment's `/scale` subresource rather than its spec — so there is no
# shared-ownership fight. If k3s re-stages `replicas: 1`, the HPA observes it under `minReplicas`
# and scales back to 2 within one reconcile (~15s by default).
#
# THE COST, STATED PLAINLY: there is a brief window — seconds, after a k3s restart — where coredns
# is back at 1 replica until the HPA notices. That is strictly better than the status quo, where it
# is at 1 replica permanently, but it is not zero. Accepting that window is the trade being made
# here; the `.skip` option above is what to reach for if it ever proves unacceptable.
#
# -------------------------------------------------------------------------------------------------
# THIS IS A FLOOR-HOLDER, NOT A REAL AUTOSCALER
# -------------------------------------------------------------------------------------------------
# `minReplicas: 2` is the entire point. The CPU metric exists because an autoscaling/v2 HPA needs a
# metric to be a valid object, not because coredns is expected to scale on load: measured via
# metrics-server, the two pods use **5m and 4m** against a **100m** request — ~5% utilisation, so
# the 80% target will never be approached and the HPA will sit at `minReplicas` indefinitely.
#
# The CPU request is what makes this legal: coredns ships `requests: {cpu: 100m, memory: 70Mi}`
# (measured), and a Resource/Utilization metric is meaningless without a request. metrics-server is
# installed as a k3s addon and verified serving (`kubectl top pod -l k8s-app=kube-dns` returns
# figures), so the metric resolves rather than leaving the HPA with `ScalingActive=False`.
#
# `maxReplicas: 3` = the node count. coredns' pod anti-affinity prefers one pod per node, so a
# fourth replica would add no failure-domain coverage; the ceiling exists so a metrics glitch can
# never balloon DNS pods across the cluster.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: coredns
# kube-system, because that is where k3s puts coredns. This is the only object this component
# places outside a namespace it owns, and it is additive: it creates nothing in kube-system and
# mutates only the coredns Deployment's scale subresource.
namespace: kube-system
labels:
app.kubernetes.io/name: coredns
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: coredns
minReplicas: 2
maxReplicas: 3
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
# Deliberately high. See "floor-holder" above: this must not scale on normal DNS load.
averageUtilization: 80
behavior:
scaleDown:
# 10 minutes. DNS is the worst thing in the cluster to flap: a scale-down races in-flight
# queries and every client's negative cache. The HPA should be reluctant in this direction.
stabilizationWindowSeconds: 600
policies:
- type: Pods
value: 1
periodSeconds: 600
scaleUp:
# Prompt in the direction that restores redundancy — this is the path taken after a k3s
# restart has re-staged `replicas: 1`.
stabilizationWindowSeconds: 0
policies:
- type: Pods
value: 2
periodSeconds: 60
-4
View File
@@ -7,7 +7,3 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- coredns-custom.yaml
# HA floor for the coredns Deployment itself — a separate concern from the split-horizon server
# block above, and deliberately an HPA rather than a replica edit: k3s owns that Deployment via
# its own deploy controller and re-stages `replicas: 1` on restart. hpa.yaml has the reasoning.
- hpa.yaml
-69
View File
@@ -63,75 +63,6 @@ spec:
# gets oauth2-proxy's HTML sign-in page (HTTP 403) instead of an immediate redirect (docs/15 UX).
skip-provider-button: "true"
scope: "openid email profile"
# ── Cold-start gate: do not start the proxy until Keycloak's discovery doc answers ──────────
# MEASURED on prod 2026-10-17, during the UPS move: oauth2-proxy performs OIDC discovery ONCE at
# startup and EXITS 1 if it fails. On a whole-cluster cold boot Keycloak is not serving yet, so
# the Gateway answers 503 and the proxy dies:
#
# [provider.go:55] Performing OIDC Discovery...
# [main.go:73] ERROR: Failed to initialise OAuth2 Proxy: ... failed to discover OIDC
# configuration: unexpected status "503": no healthy upstream
#
# It then sits in exponential CrashLoopBackOff long after Keycloak is healthy -- one replica was
# still down minutes later and only a manual pod delete cleared it. There is no retry flag; the
# process simply does not survive a cold start. A readinessProbe cannot help (the container is
# already dead), and CrashLoopBackOff backoff is not tunable per-pod.
#
# `extraInitContainers` is the chart's OWN documented hook for this -- its commented example in
# values.yaml is literally `- name: wait-for-idp` -- and the chart already uses an alpine init
# container for its built-in waitForRedis gate, so this adds no new base image to the platform.
# A pod in Init does not accrue CrashLoopBackOff backoff and retries promptly, which is the whole
# point.
#
# BOUNDED, AND FAILS OPEN. After ~5 minutes it gives up and starts the proxy anyway: the worst
# case is then exactly today's behaviour, never something new. An unbounded wait would trade a
# CrashLoop for a pod wedged in Init forever, which is not an improvement.
#
# SHELL VARIABLES ARE WRITTEN BARE ($i, $url, $(( ))), WITH NO BRACED FORM ANYWHERE. This file is
# rendered through Flux envsubst (platform declares postBuild.substituteFrom cluster-vars), which
# CONSUMES braced expansions -- so a braced shell variable renders to the empty string and
# silently breaks the loop. BASE_DOMAIN below is braced ON PURPOSE: it is a real cluster-vars
# knob and must be substituted. Same hazard, and the same convention, as the gitea and nextcloud
# OIDC bootstrap hooks. (ci: flux-postbuild-substitution enforces it.)
extraInitContainers:
- name: wait-for-idp
image: docker.io/library/alpine:3.22
imagePullPolicy: IfNotPresent
command: ["/bin/sh", "-c"]
args:
- |
set -u
url=https://keycloak.${BASE_DOMAIN}/realms/homelab/.well-known/openid-configuration
i=0
while [ "$i" -lt 60 ]; do
# busybox wget validates against alpine's ca-certificates-bundle, so a real TLS
# failure is a non-zero exit here rather than a false pass.
if wget -q -O /dev/null --timeout=5 "$url" 2>/dev/null; then
echo "[wait-for-idp] Keycloak discovery reachable; starting oauth2-proxy"
exit 0
fi
i=$(( i + 1 ))
sleep 5
done
echo "[wait-for-idp] discovery still unreachable after ~5m; starting anyway so this" >&2
echo "[wait-for-idp] gate can never be worse than the un-gated behaviour it replaces" >&2
exit 0
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 32Mi
# Metrics for the ServiceMonitor (../oauth2-proxy servicemonitor in platform-config).
metrics:
enabled: true
+162
View File
@@ -0,0 +1,162 @@
# cluster-maintenance-shutdown — powering the whole cluster off, and back on
**Operator procedure, not an alert page.** Nothing links here from an alert.
**Script:** `infra/maintenance/quiesce.sh` (`quiesce` | `resume` | `status`)
**Related:** `/runbooks/update-rollback` is for rolling node work where the cluster stays up. This
page is for taking **all three nodes down at once**.
First written after the 2026-10-17 UPS move, which was improvised live because no procedure
existed — `infra/cluster.sh stop` is `virsh`-based and only ever applied to the decommissioned dev
cluster, so it does nothing for these three baremetal nodes.
## Why you cannot just run `shutdown -h`
You mostly can now, and could not before.
Until 2026-10-17 **kubelet graceful node shutdown was unconfigured on every prod node**, so systemd
stopped k3s, containerd went with it, and every pod was SIGKILLed where it stood — 9 Postgres
instances mid-checkpoint, the UniFi VM, and 25 attached Longhorn volumes.
`roles/k3s_server` now sets `shutdown-grace-period` (and, critically, the
`InhibitDelayMaxSec` logind cap that actually enforces it — without that systemd stops waiting
after 5s and the drain is silently truncated). A plain reboot of one node is therefore safe.
**A planned whole-cluster shutdown still wants `quiesce`**, for three things the kubelet's drain
does not do:
1. **The UniFi VM needs an ACPI guest shutdown**, not an eviction. KubeVirt does that on
`running: false`; an eviction is a power cut to the guest.
2. **CloudNativePG wants hibernation**, not pod deletion — and hibernation is **imperative** state
that Flux will neither set nor clear.
3. **Draining three nodes one at a time makes pods reschedule onto the survivors**, churning
Longhorn replicas for no reason when everything is going down anyway. Scaling in place avoids it.
## The hazard that bit us — read this before scaling anything by hand
**A StatefulSet can carry `persistentVolumeClaimRetentionPolicy.whenScaled: Delete`, and scaling it
to 0 then DELETES its PVCs and their Longhorn volumes.**
On 2026-10-17 the hand-rolled quiesce scaled `monitoring/loki` (which has that policy) to 0, and
`storage-loki-0` was deleted outright. Loki stores with `type: filesystem`, so that PVC was the only
copy. Its 72h retention bounded the loss to a rolling window — nothing about the *mechanism* is
bounded. The only visible symptom was the Longhorn volume count going 25 → 24, which was first
misread as a miscount.
`quiesce.sh` now refuses to scale any such StatefulSet, leaves it running, and tells you which
volumes to expect `degraded` on restart. **If you quiesce by hand, check the policy first:**
```bash
kubectl --context k3s-prod get statefulset -A \
-o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,WHEN_SCALED:.spec.persistentVolumeClaimRetentionPolicy.whenScaled'
```
A hard-detached volume comes back `degraded` and rebuilds itself automatically. That is a vastly
better outcome than a deleted one — take the rebuild.
## Shutting down
Run everything through the pinned toolbox; it supplies `kubectl` and the context.
```bash
# 0. Look before you leap. Refuses to proceed if any Longhorn volume is unhealthy —
# powering off on top of a degraded volume is how one replica becomes zero.
./infra/devcontainer/dev.sh run bash -c './infra/maintenance/quiesce.sh status'
# 1. Quiesce: suspend Flux, ACPI-stop the VM, hibernate Postgres, scale down every
# PVC-holding workload (NFS consumers first, PiHole last), wait for detach.
./infra/devcontainer/dev.sh run bash -c './infra/maintenance/quiesce.sh quiesce'
```
**Expect DNS to stop** when PiHole scales down — it is the LAN resolver. `kubectl` is unaffected
because the kubeconfig points at the kube-vip IP `192.168.2.9`, not a name.
```bash
# 2. Power the nodes off. Inside the devcontainer, which holds the become password.
./infra/devcontainer/dev.sh run bash -c \
'cd /work/infra/ansible && ansible -i inventory/hosts.ini k3s_prod -b \
-m shell -a "shutdown -h +1 \"maintenance\""'
```
`+1` gives Ansible time to disconnect cleanly. The group resolves to all three nodes; confirm with
`--list-hosts` if you want to see them first.
```bash
# 3. Confirm they are actually down before touching anything else.
for h in 192.168.2.10 192.168.2.11 192.168.2.12; do ping -c1 -W2 $h >/dev/null && echo "$h UP" || echo "$h down"; done
```
**Then, in this order and not before:**
| # | Machine | How |
|---|---|---|
| 4 | **TrueNAS** `192.168.2.30` | **Web UI only.** Not in the ansible inventory, and SSH is **refused** (service disabled). `https://192.168.2.30` → power icon → Shut Down. Safe once the nodes are down, because every NFS consumer (`plex-media`, `*-media`, `qbittorrent-downloads`, `nextcloud-legacy-archive`) is already scaled to zero and nothing holds a hard mount. |
| 5 | **Router / switch** | Last. |
TrueNAS must go **after** the k3s nodes: a hard NFS mount on a live node wedges kubelet in
D-state (`/runbooks/nfs-export-latency`, and the load-average symptom in the 2026-10-?? note).
## Bringing it back
**Power on in the reverse order: router/switch → TrueNAS → all three nodes together.** etcd needs
2 of 3 for quorum, so start the nodes as a group rather than one at a time.
```bash
# 1. Nodes Ready, etcd quorum, storage reachable.
./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get nodes'
./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get --raw=/healthz/etcd'
# 2. Resume: un-hibernate Postgres, then resume Flux.
./infra/devcontainer/dev.sh run bash -c './infra/maintenance/quiesce.sh resume'
```
**Un-hibernating Postgres is the step everyone forgets.** The annotation is imperative and survives
a Flux resume, so resuming Flux alone leaves all three databases down and Keycloak, Nextcloud and
Semaphore looking broken for reasons that point nowhere near Postgres. `resume` does it first, for
exactly that reason.
Flux restores every scaled-down replica count **and** the VM's `running: true` from git, so nothing
needs undoing by hand.
### What "still broken" looks like when it is actually fine
* **`4/19` Kustomizations Ready for several minutes.** The dependency graph serialises behind
`platform-config`; 15 of them report `dependency ... is not ready` until it finishes. Check
`platform-config` itself is `Ready=True` and its `lastAppliedRevision` matches the GitRepository
artifact — if it is, wait.
* **One or more Longhorn volumes `degraded`.** Any volume that was still attached at poweroff
rebuilds its lagging replica automatically. On 2026-10-17 it was Prometheus's 50Gi TSDB; it went
healthy on its own. Worry only if it is still degraded after ~30 minutes.
* **`keycloak-operator` crashlooping, then recovering.** It is the CRD reconciler, not in the
request path; Keycloak's StatefulSet stays 2/2 and auth never goes down. See
`/runbooks/keycloak-*` and the known OOM-at-450Mi behaviour.
### What is actually broken and needs you
* **An `oauth2-proxy` replica stuck in CrashLoopBackOff.** It performs OIDC discovery at startup and
**exits 1** on the `503 no healthy upstream` it gets while Keycloak is still coming up, then sits
in exponential backoff long after Keycloak is fine. It has no startup dependency on Keycloak.
Delete the pod once and it comes up clean:
```bash
./infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n oauth2-proxy delete pod <name>'
```
* **Orphaned `Unknown`/`Error` Job pods** from CronJobs that were mid-run at poweroff. They never
recover; delete them.
## Verifying you are actually back
```bash
./infra/devcontainer/dev.sh run bash -c './infra/maintenance/quiesce.sh status'
# nodes 3 Ready | flux 19/19 | longhorn 0 not healthy | postgres 9 pods | unifi 1 VMI
# DNS, through PiHole's VIP
./infra/devcontainer/dev.sh run bash -c 'nslookup home.keithjackson.dev 192.168.2.23'
# The Gateway actually serving (401 on an auth-gated host is CORRECT)
for h in home.keithjackson.dev gitea.home.keithjackson.dev plex.home.keithjackson.dev; do
curl -s -o /dev/null -w "$h %{http_code}\n" -k --resolve $h:443:192.168.2.20 https://$h/
done
```
Expect `200`, `200`, `401`.
+7
View File
@@ -52,6 +52,13 @@ configMapGenerator:
- backup-failure=backup-failure.md
- backup-missed=backup-missed.md
- backup-partial=backup-partial.md
# Operator procedure, not an alert page — nothing links here from an alert. Served because
# the whole-cluster shutdown had NO written procedure until 2026-10-17 (infra/cluster.sh is
# virsh-based and only ever applied to the decommissioned dev cluster), so the UPS move was
# improvised live, and the one hazard it exposed -- a StatefulSet with
# persistentVolumeClaimRetentionPolicy whenScaled: Delete loses its PVC when scaled to 0 --
# destroyed monitoring/loki's volume before anyone noticed.
- cluster-maintenance-shutdown=cluster-maintenance-shutdown.md
- cdi-import-stuck=cdi-import-stuck.md
- cert-expiry=cert-expiry.md
- cert-issuance=cert-issuance.md