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]>
8.1 KiB
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:
- 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. - CloudNativePG wants hibernation, not pod deletion — and hibernation is imperative state that Flux will neither set nor clear.
- 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:
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.
# 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.
# 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.
# 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.
# 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/19Kustomizations Ready for several minutes. The dependency graph serialises behindplatform-config; 15 of them reportdependency ... is not readyuntil it finishes. Checkplatform-configitself isReady=Trueand itslastAppliedRevisionmatches 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-operatorcrashlooping, 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-proxyreplica stuck in CrashLoopBackOff. It performs OIDC discovery at startup and exits 1 on the503 no healthy upstreamit 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:./infra/devcontainer/dev.sh run bash -c \ 'kubectl --context k3s-prod -n oauth2-proxy delete pod <name>' -
Orphaned
Unknown/ErrorJob pods from CronJobs that were mid-run at poweroff. They never recover; delete them.
Verifying you are actually back
./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.