Files
home-network-k3s-impl/platform/longhorn
jakman85andClaude Opus 5 f663e7c801 fix(longhorn): the I/O caps a runbook promised were never declared — declare them
`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]>
2026-09-13 17:48:42 -05:00
..

platform/longhorn

Longhorn — distributed replicated block storage across the 3 nodes.

What this is

A real, dev-appropriate Flux HelmRelease for Longhorn plus two StorageClasses:

  • longhorn — the cluster's only default StorageClass, 3 replicas with hard node anti-affinity (one replica per node). Durable home for app/config state, databases, and KubeVirt VM disks (via CDI). Replica count is the ${LONGHORN_DEFAULT_REPLICA_COUNT} overlay knob (cluster-vars, =3 in dev).
  • bulk — thin indirection for the external (outside-cluster) media tier: Plex library, download staging, arr handoff. STUB placeholder modelled as an external NFS mount; its backing is OQ-2 (unresolved) and will not provision until answered. Plex's metadata DB stays on the 3-replica longhorn class regardless (docs/04, ADR-0003).

Requirement mapping

Requirement Summary How this component serves it
R-STATE-01 Stateful data survives destroy/rebuild of the workload Volumes are Longhorn PVCs, independent of pod/VM lifecycle; reclaimPolicy: Delete on the class ties data to the PVC, not the workload. Destroy the pod/Deployment/VM → PVC + data persist → rebuild re-attaches. (Per-service rebuild test is the Phase-2 exit gate; T-1.5 proves node-level rebuild.)
R-STATE-02 Persistent storage replicated across the 3 nodes numberOfReplicas=3 on the default class + replicaSoftAntiAffinity: false (hard) → exactly one replica per node, so any single node loss keeps volumes available. Replicas live on the node's data disk at ${LONGHORN_DATA_PATH} (a cluster-vars knob: dev /var/lib/longhorn, a dedicated disk; prod /data/longhorn, a subdirectory of the partition it shares with k3s's data dir). replicaAutoBalance: best-effort re-spreads on node return.
R-STATE-04 Bulk media has a capacity strategy distinct from the replicated tier Separate bulk StorageClass (NON-replicated, external NFS mount) keeps multi-TB media off the 3x tier; only Plex's small metadata DB uses longhorn.

Dev-scoped choices (change for prod)

  • reclaimPolicy: Delete on longhorn — convenient in dev; prod may prefer Retain for accidental-delete protection (backups still cover DR — R-STATE-03).
  • storageOverProvisioningPercentage: 100 — no thin over-provisioning in dev (small disks); prod may raise it.
  • storageReservedPercentageForDefaultDisk: 20 / storageMinimalAvailablePercentage: 25 — headroom guardrails tuned for small dev disks; revisit for prod disk sizes.
  • Replica count = 3 comes from cluster-vars.longhorn_default_replica_count; both dev and prod are 3 today (3 nodes), so the value is identical — the knob exists so it is never forked per env.

Guardrails baked in (docs/04)

  • Reserved disk headroom (never fill a node to 100%).
  • Hard replica anti-affinity across nodes (no two replicas of a volume on one node).
  • Single-zone anti-affinity relaxed (3 mini-PCs = one failure zone).
  • Volume/disk health + degraded-replica alerting is WS-OBS's job (docs/10) — not configured here.

Node prerequisites (already handled, informational)

Each node presents ext4 storage at ${LONGHORN_DATA_PATH} and installs open-iscsi + nfs-common + kernel modules — all via roles/node_baseline (infra/cloud-init/ is a README now; provisioning became API-only). This component assumes that floor. The shape differs per environment, which is why the path is a knob:

dev a dedicated disk (/dev/vdb, LABEL=longhorn) mounted at /var/lib/longhorn — the mount root is the dataPath
prod /data/longhorn, a subdirectory of the ~424 GB partition (LABEL=k3s-data) mounted at /data, whose sibling /data/k3s holds k3s's entire data dir (etcd + the containerd image store, moved off the 11.8 GiB /var — infra/PROD-PROVISIONING.md §2)

[parity]: the value for an environment MUST equal node_baseline_longhorn_data_path in that environment's Ansible group_vars. Ansible creates and mounts the directory; this component tells Longhorn to use it.

Contract (consumed by everyone with state)

Per roadmap/workstreams.md, WS-STORAGE exposes the longhorn (replicated) and bulk (media) StorageClasses; WS-APPS/WS-VM/WS-OBS reference them from ServiceDefinitions. Do not place bulk media on the replicated tier, or app DBs on the bulk tier.

Open decisions / TODO (later phases)

  • OQ-2 (human): decide the bulk-tier backing (NAS/NFS host + share, vs dedicated large disk). Until answered, the bulk class is a non-functional placeholder (server/share = REPLACE-ME). Blocks bulk finalize; does not block the core platform (docs/04).
  • Version pin (WS-STORAGE): >=1.12.0 <1.13.0 is a range; pin exactly at bring-up, Renovate maintains.
  • Phase 4 (WS-STORAGE): off-cluster backup target via defaultSettings.backupTarget + SOPS-encrypted credential secret, wired alongside platform/velero/ (R-STATE-03).

Build check

kubectl kustomize platform/longhorn renders the namespace, HelmRepository, HelmRelease, and both StorageClasses. (Structural only — not applied to a cluster here; the PM validates + deploys.)