Files
home-network-k3s-impl/platform/longhorn
jakman85andClaude Opus 5 4687521d65 fix(monitoring): delete the Longhorn rule that measured allocation and called it capacity
LonghornVolumeCapacityHigh went pending within minutes of the Longhorn scrape
landing today. Re-measured on prod: it read 0.9731 on the Prometheus TSDB volume
(actual_size 52243681280 / capacity 53687091200) while the kubelet reported the
SAME filesystem 20.8% used. actual_size is head + pinned snapshots, and one
user_created=false system snapshot from prod-1's replica rebuild holds 40510484480
bytes (37.7 GiB) of it; Longhorn returns no freed blocks without discard/TRIM, and
retentionSize 45GiB on a 50Gi volume targets 90% by design. The ratio is pinned
near 1.0 and cannot fall back under 0.85 — a permanently-firing alert, the same
shape as CNPGNoRecoverabilityPoint.

DELETED, not re-thresholded. Raising the threshold would tune away a true
statement about allocation. Redundancy proven, not asserted: of 24 Longhorn
volumes on prod, 23 appear in kubelet_volume_stats_inodes > 0 and so fall inside
MonitoringVolumeFillingUp (namespace="monitoring") or PersistentVolumeFillingUp /
PersistentVolumeAlmostFull. The 24th is unifi/unifi-rootdisk, volumeMode Block —
no filesystem for anyone to measure, and the deleted rule was not covering it
either.

AND A SECOND INERTNESS the scrape did not fix. longhorn_volume_robustness on
longhorn 1.12.1 is one-hot over a `state` label with values 0/1, not the 0-3 enum
the rules assumed:
  count(longhorn_volume_robustness)                  => 96  (24 volumes x 4 states)
  count by (state) (longhorn_volume_robustness == 1) => {state="healthy"} 24
  count(longhorn_volume_robustness == 2)             => (empty)
  count(longhorn_volume_robustness == 3)             => (empty)
So a FAULTED volume would still have alerted nobody. Re-keyed to the label; the
"44 series / 72 unknown(0, detached)" note in platform/longhorn/helmrelease.yaml
was the same misreading and is corrected in place.

The allocation question moved to where allocation is a budget. LonghornNodeStorageHigh
fired at 0.85, but storage-minimal-available-percentage is 25 on this cluster, so a
disk leaves the scheduling pool at 75% used — the old threshold could only fire
after scheduling had already been refused. Replaced by:
  LonghornDiskAllocationHigh  (longhorn_disk_usage_bytes / longhorn_disk_capacity_bytes) > 0.70, 30m
  LonghornDiskUnschedulable   longhorn_disk_status{condition="schedulable"} == 0, 15m
Per DISK, not per node: longhorn_node_storage_usage_bytes sums a node's disks, so
one full disk could hide behind a half-empty sibling. Measured prod usage
0.2254 / 0.2306 / 0.2369 on prod-1/2/3.

PROVEN NEITHER PERMANENTLY FIRING NOR INERT. platform/monitoring/tests/longhorn.test.yaml
adds 6 cases pinning every rule quiet on today's measured prod state and firing on
a constructed bad state, with every `for:` asserted, plus regression assertions that
the old `== 2`/`== 3` forms match nothing and that per-disk 0.72 is visible where the
node-summed 0.475 is not. Mutation-checked 11 ways; each turns the suite RED. Live on
prod: all four new expressions return empty, while the sum of their selector
cardinalities is 54. The L0 UNTESTED_ALERTS register shrinks by seven (7/56 -> 14/58
alert rules unit-tested), which also brings MonitoringVolumeFillingUp,
PersistentVolumeFillingUp and PersistentVolumeAlmostFull under test since the
redundancy claim rests on them.

REPORTED, NOT CHANGED (DECISIONS-LOG, owner decisions): retentionSize 45GiB on a
50Gi volume is too tight — recommend 36GiB, with reasoning; and discard/TRIM on the
longhorn StorageClass is feasible but not advisable now — remove-snapshots-during-
filesystem-trim reads false on prod, so trim would not have reclaimed the 37.7 GiB
anyway, and continuous discard on nodes where the etcd WAL shares the spindle points
at this cluster's known failure mode.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-13 17:19:03 -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.)