Files
home-network-k3s-impl/runbooks/cluster-maintenance-shutdown.md
jakman85andClaude Opus 5 2f2e98aa19
gate / gate (pull_request) Successful in 27m44s
gate / conformance (pull_request) Has been skipped
feat(shutdown): configure graceful node shutdown, and never scale a Delete-policy StatefulSet
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

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:

  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:

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/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:

    ./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

./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.