`runbooks/longhorn-degraded.md` told operators that, after the 2026-09-07 etcd
I/O-starvation incident, `concurrentVolumeBackupRestorePerNodeLimit` and
`snapshotHeavyTaskConcurrentLimit` had been tightened 5 -> 1. Measured live on
prod today: both read **5**, and neither name appeared anywhere in this
repository — so nothing declared them and nothing would have restored them.
Fourth instance of config that looked applied and was not.
Landed in `platform/longhorn/helmrelease.yaml` `defaultSettings` (the path Flux
reconciles; `values.yaml` is the reference mirror), NOT by `kubectl patch` —
a hand-applied Setting is exactly how this gap arose and Helm would silently
rewrite it on its next upgrade.
concurrentVolumeBackupRestorePerNodeLimit 5 -> 1
snapshotHeavyTaskConcurrentLimit 5 -> 1
replicaReplenishmentWaitInterval 600 PINNED, unchanged
backupConcurrentLimit / restoreConcurrentLimit 2 / 2 PINNED, unchanged
concurrentAutomaticEngineUpgradePerNodeLimit 0 PINNED, unchanged
nodeDrainPolicy block-if-contains-last-replica PINNED — it was in
force only as Longhorn's OWN DEFAULT, declared in no manifest and in no
rendered ConfigMap, so an upstream default change would have moved it
silently while two runbooks reasoned about drain safety from it.
EVERY KEY PROVEN TO LAND, not assumed. `helm template longhorn/longhorn
--version 1.12.1` (the version this chart range resolves to, per the
HelmRelease's own lastAttemptedRevision) on this branch's spec.values,
substituted as clusters/prod substitutes them, emits all eight into the rendered
`longhorn-default-setting` ConfigMap in kebab-case. Diffed against the LIVE
ConfigMap the change is purely ADDITIVE: 8 keys added, none altered, none lost.
The chain to the Settings is verified read-only on prod — all 115
settings.longhorn.io carry `longhorn.io/configmap-resource-version: 7917`
(= that ConfigMap's resourceVersion) with `status.applied: true`, and every key
in the ConfigMap equals its Setting's value. The two targets sit at 5 with the
key absent, i.e. Longhorn defaults, as measured.
`orphanAutoDeletion: true` REMOVED, verified inert two independent ways against
the deployed 1.12.1: the render emits no orphan key at all (Longhorn 1.9 renamed
it `orphanResourceAutoDeletion`, a list, and the chart drops unknown
defaultSettings keys with no error), and live, `setting/orphan-auto-deletion` is
NotFound while `orphan-resource-auto-deletion` reads "". Deliberately NOT
replaced — orphan deletion is extra writes on the constrained partition and buys
only space. Orphan cleanup stays manual by decision; the two runbooks that cite
the key by name are corrected rather than left to go stale.
BOTH NEW CAPS ARE 1, AND 1 COSTS SOMETHING — stated, not waved at.
`concurrentVolumeBackupRestorePerNodeLimit: 1` costs nothing TODAY: Longhorn has
no backup target (the `backup-target` Setting does not exist; still the
WS-STORAGE Phase-4 TODO), and velero's nightly run is not a Longhorn backup —
`snapshotVolumes: false` + `defaultVolumesToFsBackup: true` is kopia
file-system backup through velero's node-agent, which this Setting does not
gate; CNPG base backups are barman-level and ungated too. Its only consumer is
an R-STATE-03 restore, where serialising ~24 volumes beats losing etcd
mid-restore. Revisit when a Longhorn backup target lands.
`snapshotHeavyTaskConcurrentLimit: 1` rather than 2 because purge work is
generated by rebuild cleanup (auto-cleanup-system-generated-snapshot true, NO
RecurringJobs, 24 volumes / 24 snapshots) and rebuild is already serialised to
1 per node — so 1 keeps pace with its own generator, where 2 buys backlog drain
at the price of a second heavy writer during the exact rebuild that starved
etcd. The cost: space reclaims serially after a rebuild storm — real here (a
37.7 GiB snapshot once pinned a volume at 97.3% allocated), but instrumented by
LonghornDiskAllocationHigh and recoverable by hand, where a wedged etcd is not.
Headroom on the record: I/O PSI full is 0.30/0.62/0.37 % now, and 3.79 under a
full 18-replica rebuild after node 1's drive replacement, against a 5% warning
threshold. These caps are a BOUND, not a fix — etcd and Longhorn still share one
ext4 filesystem and nothing arbitrates the jbd2 journal fdatasync waits on.
NOT PORTED, deliberately: the same unmerged branch's node-level I/O arbitration
(`roles/etcd_io_priority` — BFQ on the shared device, nr_requests 64 -> 32,
systemd IOWeight drop-ins). The 2011 C400 it was written for was replaced on
2026-09-07 and the same load class measured 11.36 -> 3.79 I/O PSI with the node
Ready throughout; the role was never run against any cluster, its author
recorded it as unproven with a node-3-first sequenced rollout, and its own
README concedes it does not separate the two tenants. Node-wide blast radius,
low ceiling, stale measurements (node 1 reinstalled, founder moved to node 2).
It stays an operator option if I/O PSI climbs back toward 5% — which, unlike in
September, we would now be told about. Nothing under infra/ansible/ is touched.
ALSO ALREADY DONE, so skipped: that branch's NodeIOPressureHigh/Critical and the
dashboard I/O row are on main already (efc6db6) with runbooks/node-io.md.
Verified live, not by reading the file: both rules loaded in
platform-health.nodes, health ok, state inactive, and
ALERTS{alertname=~"NodeIOPressure.*"} returns no samples.
Runbooks corrected in both directions: longhorn-degraded.md's prominent "DO NOT
RELY ON THAT: it was never applied" block becomes an accurate statement of what
is now declared, what to verify before trusting it, what it cannot do, and what
it costs; longhorn-capacity.md / node-disk.md stop citing a key that no longer
exists; node-io.md gains the one line an operator needs mid-incident.
No new ${...} substitution variables and not one dollar sign added anywhere in
the diff, so neither StrictPostBuildSubstitutions nor the L0 doubled-dollar guard
is affected.
Co-Authored-By: Claude Opus 5 <[email protected]>
12 KiB
longhorn-capacity — A Longhorn disk is running out of room for replicas
Alerts: LonghornDiskAllocationHigh (warning, 30m) and LonghornDiskUnschedulable (warning, 15m)
File: platform/monitoring/alerts.yaml, group platform-health.longhorn
Expressions:
(longhorn_disk_usage_bytes / longhorn_disk_capacity_bytes) > 0.70
longhorn_disk_status{condition="schedulable"} == 0
What this means here
This is a POOL question, not a PVC question. One of the three nodes' Longhorn data disks is
filling with replica files, or Longhorn has already stopped placing replicas on it. Neither alert
says anything about how full any single workload's filesystem is — for that, read
/runbooks/pvc-capacity (PersistentVolumeFillingUp, PersistentVolumeAlmostFull) or
/runbooks/monitoring-capacity (MonitoringVolumeFillingUp). Those are keyed on
kubelet_volume_stats_*, which is the filesystem's own accounting.
longhorn_disk_usage_bytes counts bytes ALLOCATED: every replica file plus every snapshot still
pinned to it. Longhorn does not return freed blocks to a replica file without discard/TRIM, and a
system snapshot taken for a replica rebuild keeps its historical blocks alive, so this number only
goes up until something is deleted. That is the correct question here — allocated bytes are what
consumes the node's ~414 GiB data partition — and the wrong question per volume, which is why
LonghornVolumeCapacityHigh was deleted on 2026-09-13 (it read 0.973 on a Prometheus TSDB volume
whose filesystem was 20.8% used, by design, permanently). See the comment block in
platform/monitoring/alerts.yaml for the measurements.
Why 0.70 and not 0.85. storageMinimalAvailablePercentage: 25 means Longhorn itself refuses to
schedule a new replica on a disk with under 25% available — i.e. at 75% used the disk is already
out of the scheduling pool. An 85% threshold could only ever fire after that had happened. 0.70 is
the last point at which there is still lead time. The companion settings are
storageReservedPercentageForDefaultDisk: 20, storageOverProvisioningPercentage: 200,
defaultDataLocality: disabled, hard node anti-affinity.
Measured on prod 2026-09-13: usage 0.2254 / 0.2306 / 0.2369 on prod-1/2/3, with 268.9 GiB of nominal replica size scheduled against (414.5 GiB − 20% reserved) × 200%. So either alert firing means something changed materially, not that the platform slowly grew.
The severe consequence is that a degraded volume cannot finish rebuilding — hard anti-affinity
needs three distinct nodes with room, so one full or unschedulable disk pins a volume at 2 replicas
indefinitely, and R-STATE-02's survive-a-node-loss promise is suspended for as long as that lasts.
Cross-read /runbooks/longhorn-degraded.
Verify the alert can fire (measured 2026-09-13)
These alerts were INERT until 2026-09-13, when metrics.serviceMonitor.enabled was set in
platform/longhorn/helmrelease.yaml; the longhorn_* family was simply absent from the TSDB before
that. Both are now live — verified on prod the same day:
infra/devcontainer/dev.sh run kubectl --context k3s-prod -n monitoring exec \
prometheus-kube-prometheus-stack-prometheus-0 -c prometheus -- \
/bin/promtool query instant http://localhost:9090 \
'count(longhorn_disk_usage_bytes / longhorn_disk_capacity_bytes) + count(longhorn_disk_status{condition="schedulable"})'
returned 6 (3 disks, both selectors resolving), while both alert expressions returned empty.
If that count is ever empty again, the scrape has broken and this pair is silently back to inert —
check up{job="longhorn-backend"} and platform/longhorn/networkpolicy-metrics.yaml.
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
Per-disk allocation and Longhorn's own scheduling verdict — the two things the alerts read:
infra/devcontainer/dev.sh run kubectl --context k3s-prod -n monitoring exec \
prometheus-kube-prometheus-stack-prometheus-0 -c prometheus -- \
/bin/promtool query instant http://localhost:9090 \
'longhorn_disk_usage_bytes / longhorn_disk_capacity_bytes or longhorn_disk_status'
Node-level allocation, from the CRs:
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n longhorn-system get nodes.longhorn.io -o yaml | grep -E "name:|storageAvailable|storageMaximum|storageScheduled|allowScheduling"'
Volume nominal size versus ALLOCATED size. actualSize is head + pinned snapshots, NOT filesystem
usage — a volume can read near 100% here with a nearly empty filesystem (see the deleted-alert note
above), so do not read this column as "full":
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n longhorn-system get volumes.longhorn.io \
-o custom-columns=NAME:.metadata.name,SPEC:.spec.size,ACTUAL:.status.actualSize,ROBUST:.status.robustness'
Which PVC each one is, so you know who is filling it:
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,SIZE:.spec.capacity.storage,SC:.spec.storageClassName'
Which snapshots are holding the allocation (this is the measured top cause on prod — one
user_created=false system snapshot on the Prometheus TSDB volume held 37.7 GiB on 2026-09-13):
infra/devcontainer/dev.sh run kubectl --context k3s-prod -n monitoring exec \
prometheus-kube-prometheus-stack-prometheus-0 -c prometheus -- \
/bin/promtool query instant http://localhost:9090 \
'topk(10, longhorn_snapshot_actual_size_bytes)'
The filesystem view — the separate question, answered by /runbooks/pvc-capacity:
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=topk(10, 1 - kubelet_volume_stats_available_bytes{job=\"kubelet\"} / kubelet_volume_stats_capacity_bytes{job=\"kubelet\"})" localhost:19090/api/v1/query'
On the node itself:
ssh k3s-prod-1 'df -h /data; du -xh --max-depth=1 /data/longhorn/replicas 2>/dev/null | sort -h | tail'
Likely causes, most likely first
0. Pinned snapshots, not live data (check this FIRST for an allocation alert)
An allocation alert can fire with every filesystem in the cluster nearly empty. Run the
longhorn_snapshot_actual_size_bytes query above before anything else. A replica rebuild — which is
what a node reboot or a disk replacement forces, and prod-1's drive was replaced on 2026-09-12 —
leaves a user_created=false system snapshot behind, and it keeps its blocks until it is purged.
Prune from the Longhorn UI (Volume → Snapshots), then re-read the query; nothing else is needed if
this was the cause.
1. A CNPG database is not recycling WAL (check this FIRST for a PVC-fill alert)
The only cause here that grows without bound. archive_mode=on plus a failing archive_command means
Postgres will not recycle unarchived WAL; measured on prod keycloak-db at ~6.8 MiB/h with pg_wal
sharing the data PVC (no walStorage: is configured on any Cluster) — about 29 days from 93% free to
disk-full, ending in a postmaster PANIC. Go to /runbooks/cnpg-archiving. Never delete WAL by
hand.
2. Snapshots and orphaned replicas accumulating
Longhorn snapshots are not free, and cleanup here is manual. orphanAutoDeletion: true used to
sit in platform/longhorn/helmrelease.yaml and had been silently inert since Longhorn 1.12 — it
was renamed orphanResourceAutoDeletion (now a list) and the chart drops unknown defaultSettings
keys with no error. It was removed on 2026-09-13 and deliberately not replaced, so orphan cleanup
is manual by decision, not by accident: prune orphans and stale snapshots from the Longhorn UI.
Note also that snapshot purge and clone now run one at a time per node
(snapshotHeavyTaskConcurrentLimit: 1, declared 2026-09-13 to keep Longhorn from starving etcd's WAL
fsync). Reclaim after a rebuild storm is therefore serial and slower by design. If allocation is
climbing and purge is the bottleneck, the operator may raise the Setting for the duration and watch
NodeIOPressureHigh — see runbooks/longhorn-degraded.md for the tradeoff as recorded.
3. A workload genuinely grew
Plex/Sonarr/Radarr media does not live on Longhorn — it is static NFS from
192.168.2.102:/mnt/Storage/plex, via the bulk tier. If a media volume is on longhorn, that is a
misconfiguration worth fixing, not a capacity problem: the bulk StorageClass is a stub
(provisioner: nfs.csi.k8s.io, server:/share: REPLACE-ME) and provisions nothing, so real bulk
storage is static NFS PVs. Otherwise expand the PVC — all Longhorn classes have
allowVolumeExpansion: true; change the size in git and push.
4. Replicas piled onto one node
replicaAutoBalance: best-effort should prevent this, and hard anti-affinity bounds it to one replica
per volume per node. If one node is much fuller than the others, check whether a rebuild placed
replicas there while another node was down.
5. The cluster has outgrown its disks
Prod's ceiling is ~829 GiB across three nodes. docs/plans/longhorn-ssd-tiering.md is the planned
answer (a ~51 GB hot tier: Prometheus, CNPG, Loki, Gitea). Operator planning work.
When to escalate or stop
- A disk over 70% while any volume is degraded is urgent, because the rebuild cannot complete —
and at 75% Longhorn takes the disk out of the scheduling pool entirely
(
LonghornDiskUnschedulable). Free space or restore the other node before anything else. - Never delete files under
/data/longhornor inside a Postgrespg_wal. Both are irreversible. - Do not expand a PVC as a reflex if the cause is unrecycled WAL — you are buying days and hiding the real fault.
- Escalate for adding disks, the SSD tiering plan, or anything that needs a node reboot (each reboot degrades all ~18 volumes and triggers a rebuild storm).
Source of truth: runbooks/longhorn-capacity.md. Index: /runbooks/
Records: platform/longhorn/helmrelease.yaml (the settings above, the I/O throttle block, and the
removal of the inert orphanAutoDeletion);
platform/cnpg/alerts.yaml header (the WAL disk-full clock); docs/BULK-NFS-STORAGE.md.