Files
home-network-k3s-impl/runbooks/longhorn-degraded.md
T
jakman85andClaude Opus 5 b7341b329b fix(runbooks): declare whole-repo scope, drop the bespoke l0 stage, +cnpg-primary-spread
Three corrections after rebasing onto main.

1. THE GUARD CAUGHT A REAL ONE ON ITS FIRST CONTACT WITH main. main had gained
   `CNPGPrimariesCoLocated` (platform/cnpg/alerts.yaml) pointing at
   /runbooks/cnpg-primary-spread, with no runbook. The check failed the rebase exactly as
   designed — 46 annotations, 35 names, 34 present — naming both the missing file AND the
   missing configMapGenerator entry. runbooks/cnpg-primary-spread.md now exists: what
   co-located primaries actually cost here (blast radius, not an outage, because
   instances: 3 + required anti-affinity), why the drift is uncorrectable-by-declaration
   (CNPG has no primary-placement field), the readiness gate that keeps the rule quiet
   during a real node outage, and the ~9s-per-database switchover rebalance, one cluster
   at a time. The 2026-09-07 outage in which all three primaries sat on one node is the
   reason the alert exists at all.

2. WHOLE-REPO SCOPE, NOT A HAND-WRITTEN STAGE. main's refactor replaced ci/l0.sh's
   per-check "UNCONDITIONAL" stages with a declaration:
   `conformance.home.k3s/scope: "whole-repo"`, unioned into every gate run by Stage 3b's
   `--include-scope`. My +38-line Stage 3f is removed (ci/l0.sh is now byte-identical to
   main) and the annotation is declared instead. Assertion (7) of the check was rewritten
   to match: it no longer asserts its own path appears in ci/l0.sh — it asserts the test
   DECLARES the scope and that ci/l0.sh still passes --include-scope, since neither half
   alone makes the check load-bearing. That matters here because
   ci/touched-requirements.sh maps platform/monitoring/* to "R-OBS-01 R-OBS-02", not
   R-OBS-04, and maps platform/kubevirt/* and runbooks/* to nothing at all.

3. A FACTUAL CORRECTION IN ALL 35 RUNBOOKS. Every page claimed `dev.sh` defaults its
   current-context to k3s-dev. main's `9b4744d fix(devcontainer): dev.sh defaults to
   prod` made that false, and a runbook that misstates which cluster a command talks to is
   worse than one that says nothing. The pages now say what is durably true: the default
   has already changed once, so naming `--context k3s-prod` is what makes a command mean
   the same thing next month, and the per-invocation banner is there to be read. The
   check's own failure message was corrected the same way.

Re-verified after all three: check green at 374 assertions (46 annotations, 35/35 runbooks
present, 35/35 projected), re-applied to prod, and re-probed in-cluster through the
Gateway — node-down, keycloak-down, keycloak-client-secrets, cnpg-archiving,
longhorn-faulted, vpn-down, node-io and cnpg-primary-spread all HTTP 200 with
text/plain; charset=utf-8 and 8.9-12.1 KB of real body; /runbooks/ index 200; an unknown
name 404s with a pointer rather than a blank page; and homepage still answers the apex `/`,
confirming Gateway API longest-path precedence.

Full merge gate: PASS (spec-immutability, lint, l0, render-golden, l1 all green;
prom-rules SKIPPED for want of promtool in the image; L2/L3 SKIPPED, no cluster).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-13 00:00:24 -05:00

9.7 KiB

longhorn-degraded — A Longhorn volume has been degraded for 10 minutes

Alert: LonghornVolumeDegraded (warning, 10m) File: platform/monitoring/alerts.yaml, group platform-health.longhorn Expression: longhorn_volume_robustness == 2 (0=unknown 1=healthy 2=degraded 3=faulted)

What this means here

A volume has fewer healthy replicas than it should. It is still serving I/O — that is the difference between degraded and faulted (/runbooks/longhorn-faulted). What you have lost is redundancy, and on a 3-node cluster with hard node anti-affinity (replicaSoftAntiAffinity: false, numberOfReplicas: 3) one degraded volume means one more node loss away from data loss.

Degraded is the NORMAL state after any node reboot on this cluster, and it is expensive. Recorded plainly in the stabilization notes: "Longhorn on the HDD: every node reboot degrades all 18 volumes → full rebuild (slow; 30%+ IO-delay). Roll reboots one node at a time; wait robustness=healthy before the next." The rebuild is the thing that hurts, not the degradation: rebuild writes land on the same partition as the embedded etcd WAL (/data/k3s and /data/longhorn share one device), and that is the mechanism behind the 2026-09-07 k3s-prod-1 NotReady outage. So the question to answer is never just "why is it degraded" but "is the rebuild about to take a node out". Cross-read /runbooks/node-io.

Verify the alert can even fire (measured 2026-09-13)

Prometheus on prod scrapes 19 jobs: apiserver, cdi-prometheus-metrics, coredns, keycloak-discovery, keycloak-service, keycloak/keycloak-db, kube-prometheus-stack-alertmanager, kube-prometheus-stack-grafana, kube-prometheus-stack-operator, kube-prometheus-stack-prometheus, kube-state-metrics, kubelet, kubevirt-prometheus-metrics, nextcloud-db/nextcloud-db, node-exporter, oauth2-proxy, semaphore-db/semaphore-db, velero, velero/node-agent. There is no Longhorn, cert-manager, Flux-controller or Hubble/Cilium job. Check the series this rule depends on before you trust its silence:

infra/devcontainer/dev.sh run bash -c \
  'kubectl --context k3s-prod -n monitoring port-forward svc/kube-prometheus-stack-prometheus 19090:9090 & \
   sleep 5; curl -s -G --data-urlencode "query=count by (job) (up)" localhost:19090/api/v1/query'

This alert is currently INERT. count(longhorn_volume_robustness) returned 0 series on prod on 2026-09-13: the whole platform-health.longhorn rule group has no data, because Longhorn ships no ServiceMonitor here. That is a separate open defect, and the practical consequence is that you will probably learn about a degraded volume from an application symptom or from volumes.longhorn.io, not from this alert. Do not infer "no alert, so volumes are fine".

Running the commands in this runbook

kubectl does not work on the operator workstation. The toolchain lives only in the pinned dev container, and the host's k3s-oidc context hangs on a browser prompt once its token expires — that hang is not the cluster being down. Every command below runs as:

infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get nodes'

Always pass --context k3s-prod explicitly. dev.sh merges BOTH clusters into one kubeconfig and picks a current-context per invocation, and that default has already changed once — it was k3s-dev until dev was decommissioned on 2026-09-13, and is k3s-prod now. A command that names its context means the same thing next month as it does today; one that relies on the default does not, and the failure is silent (it answers confidently about the other cluster). CLUSTER=prod infra/devcontainer/dev.sh run kubectl ... selects it explicitly, and every invocation prints a banner naming the cluster it is about to talk to — read it. Do not run two dev.sh invocations at once; they share one container and interfere.

USE FULLY-QUALIFIED RESOURCE NAMES, ALWAYS. Three CRDs on this cluster register the kind Backup: longhorn.io/v1beta2, postgresql.cnpg.io/v1 and velero.io/v1. The bare short name resolves to Longhorn's, which holds zero objects — so kubectl get backup -A prints "No resources found" on a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09-13. Reading that as "the backups are gone" and acting on it turns a false negative into real data loss. Write backups.velero.io, backups.postgresql.cnpg.io, clusters.postgresql.cnpg.io, settings.longhorn.io, volumes.longhorn.io, nodes.longhorn.io.

Mutating kubectl (apply, patch, annotate, rollout) and flux reconcile are blocked for agents here: every durable change goes through git (git push origin main is the deploy). Where this runbook gives a mutating command it is for an operator, and it says so.

First commands

The truth about robustness, straight from the CRs (this works whether or not the metric does):

infra/devcontainer/dev.sh run bash -c \
  'kubectl --context k3s-prod -n longhorn-system get volumes.longhorn.io \
     -o custom-columns=NAME:.metadata.name,ROBUST:.status.robustness,STATE:.status.state,NODE:.status.currentNodeID,SIZE:.spec.size'

Which replicas are missing, and where:

infra/devcontainer/dev.sh run bash -c \
  'kubectl --context k3s-prod -n longhorn-system get replicas.longhorn.io \
     -o custom-columns=NAME:.metadata.name,VOL:.spec.volumeName,NODE:.spec.nodeID,RUNNING:.status.currentState | sort -k2'

Which PVC/workload is affected:

infra/devcontainer/dev.sh run bash -c \
  'kubectl --context k3s-prod get pv -o custom-columns=PV:.metadata.name,CLAIM:.spec.claimRef.namespace/.spec.claimRef.name,SC:.spec.storageClassName'

Are the nodes and their disks schedulable, and is a rebuild in flight?

infra/devcontainer/dev.sh run bash -c \
  'kubectl --context k3s-prod -n longhorn-system get nodes.longhorn.io -o wide; echo; \
   kubectl --context k3s-prod -n longhorn-system get settings.longhorn.io concurrent-replica-rebuild-per-node-limit -o custom-columns=NAME:.metadata.name,VALUE:.value'

And the pressure that rebuild is causing — the reason this alert matters:

ssh k3s-prod-1 'cat /proc/pressure/io'

Likely causes, most likely first

1. A node rebooted or was briefly NotReady (overwhelmingly the most common)

Expected, and it self-heals by rebuilding. Your job is to keep the rebuild from taking a node down. Remediation (operator, mutating) — freeze rebuilds while replicas will reattach (an in-place bounce):

infra/devcontainer/dev.sh run bash -c \
  'kubectl --context k3s-prod -n longhorn-system patch settings.longhorn.io \
     concurrent-replica-rebuild-per-node-limit --type=merge -p "{\"value\":\"0\"}"'

Thaw to "1" — never higher — once every volume reads healthy. Use 1 rather than 0 when the replicas are genuinely gone (an OS re-provision), so replenishment can actually proceed. concurrentReplicaRebuildPerNodeLimit: 1 was already in force during the 2026-09-07 outage and held; do not "fix" this by setting a knob that is already set. replicaRebuildingBandwidthLimit is v2/SPDK-only and does nothing here; rebuildConcurrentSyncLimit is already at its floor of 1. What was actually tightened after that incident was every other Longhorn I/O source: concurrentVolumeBackupRestorePerNodeLimit and snapshotHeavyTaskConcurrentLimit 5 → 1.

2. A node is still down

Then the rebuild cannot complete and this will not clear. Go to /runbooks/node-down first; there is nothing to fix in Longhorn.

3. A disk was replaced and Longhorn refuses it

Signature: DiskFilesystemChanged — "record diskUUID doesn't match the one on the disk". Remediation, in exactly this order (the validating webhook enforces it): disable the disk → remove the disk entry → re-add it. Seen during the 2026-09-07 drive swap.

4. No node can hold the replica (anti-affinity or space)

Hard anti-affinity means a 3-replica volume needs three distinct schedulable nodes. With one node cordoned or its disk over storageMinimalAvailablePercentage: 25, the third replica cannot be placed and the volume stays degraded indefinitely. Check nodes.longhorn.io scheduling and /runbooks/longhorn-capacity.

5. replicaAutoBalance: best-effort is shuffling replicas

Normal background behaviour, transient. If degradation follows a pattern of replicas moving and never settling, that is worth an operator's attention.

When to escalate or stop

  • If more than one volume is degraded and I/O-PSI on any node is climbing, freeze rebuilds first and then get the operator. That combination is the 2026-09-07 failure mode in progress.
  • Never reboot, cordon or re-provision a second node while any volume is degraded. This is the single most important rule in this runbook.
  • Do not kubectl drain. This cluster deliberately does not drain: there is no Longhorn nodeDrainPolicy configured anywhere in the repo, the upgrade plan rejects system-upgrade-controller because "its auto-drain fights this cluster's slow-HDD Longhorn", and the node-loss conformance test evicts only its own writer pod.
  • Do not delete a degraded volume's replicas to "force a clean rebuild" unless the operator agrees — with 3 replicas and one already missing you are one mistake from /runbooks/longhorn-faulted.
  • Escalate for: a node that will not come back, a disk replacement, or a volume degraded for more than a few hours with all three nodes Ready (that is a Longhorn bug, not an operational state).

Source of truth: runbooks/longhorn-degraded.md. Index: /runbooks/ Records: DECISIONS-LOG.md "2026-08-31 — Post-upgrade stabilization" (the reboot tax) and "2026-09-07 — k3s-prod-1 NotReady"; docs/plans/cluster-upgrade-plan.md §6.2/§7 (the freeze/thaw).