Files
lab/migration/ROLLING-MIGRATION-RUNBOOK.md
Michal 84836cb8f2
Some checks failed
CI/CD / typecheck (push) Failing after 9s
CI/CD / test (push) Failing after 10s
CI/CD / lint (push) Failing after 24s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
v2 partition schema: capability model, fixed sizing, and a proven rolling migration
The fleet's disk layout was decided by literal role-name comparisons at 17
sites across 8 files. That is why `labcontroller` got no rancher LV (it
matched neither "infra" nor "worker"), why labmaster carried an empty 728 GiB
Longhorn LV, and why a 62 GiB container image tipped that node into
DiskPressure and evicted bastion, labd, cockroach and openbao.

Three generators each hardcoded their own sizes and disagreed: the kickstart
said rancher=120G, ubuntu-autoinstall said 20G, asahi-firstboot said 20G under
a comment asserting the sizes matched install.ks.ts. They did not.

ROLE_REGISTRY already carried `k3s` and `parent` and nothing read them. This
makes capabilities authoritative, moves every size into one shared module, and
adds schema v2 (fixed 250G rancher / 300G longhorn, never --grow). Existing
machines stay v1 forever: schema is detected from the on-disk VG tag, so a v1
box stays v1 even when v2 is requested.

Proven on real VMs, not just unit tests: all four topologies installed and had
their layouts read back off the machines; markers in /home, /srv,
/var/lib/longhorn and /var/lib/rancher survived reinstall; and a 4-node cluster
formed, took real data, and had all three servers rolled one at a time through
evacuate / wipe / reinstall / rejoin with checksums verified before and after
each roll. Drill: 10/10, evidence in migration/drill-evidence/.

Production defects the drill found, none of which a unit test could reach:

- sshOpts() capped every operation at 30s while drainNode asked kubectl for
  --timeout=15m, so the transport killed the drain after evicting most pods and
  returned a transcript that read like success. `labctl app k3s evacuate` could
  never have drained a real node.
- generateAgentConfig put node-role.kubernetes.io/worker in the kubelet's
  node-label list, which NodeRestriction rejects, so no agent installed through
  K3sModule could start. systemd reported only "did not take the steps required
  by its unit configuration".
- toOpContext built K3sConfig by enumerating three keys and silently dropped
  the rest, so the k3s version pin did nothing and --allow-single-replica was a
  no-op that claimed to override a data-loss guard.
- The Longhorn disk annotation was mangled by shell quoting; with
  createDefaultDiskLabeledNodes the node then gets no disk at all, joins, goes
  Ready, and silently hosts no replica.
- evacuate drained before evicting, which blocks on Longhorn's PDBs and would
  destroy a single-replica volume's only source.
- etcd member ids were parsed from JSON, losing uint64 precision and emitting
  decimal where `member remove` needs hex -- at the moment a node is stopped
  and about to be wiped.
- get.k3s.io installs latest, two minor versions ahead of the fleet, so a
  reinstalled node would rejoin on a newer k3s than its peers and roll an
  unplanned two-version control-plane upgrade. K3sConfig.k3sVersion now pins it.
- `labctl app k3s install --role x` mapped anything that was not "worker" to
  "infra", so the runbook's own `--role k8s-combo` would have installed a
  different role with different disk expectations and no warning.

Also: /var/lib/longhorn/replicas must never be deleted, only its contents, or
every replica placed on the node dies on a non-recursive mkdir -- for ever,
while the node reports Ready, Schedulable and healthy.

etcd is now in the kickstart's package set for k3s server roles, because
`k3s etcdctl` is not a command and a member that needs to recover its own
membership has no internet.

542 unit tests / 39 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 22:33:59 +01:00

12 KiB

Rolling migration to the v2 disk layout

Moving worker0, worker1 and worker2 from the v1 layout to v2, one at a time, without losing data or quorum.

Status: NOT YET EXECUTED against the fleet. The mechanism is proven by bastion/tests/integration/rolling-drill.test.ts, which passed 10/10 on four VMs on 2026-09-30 — including a backup to S3 and a byte-identical restore. Evidence: drill-evidence/schema-v2-2026-09-30.txt.


The one thing to understand first

You cannot start by evacuating an existing node. Two independent reasons, both read off the live cluster:

  1. Placement. replica-soft-anti-affinity is false, so a volume puts at most one replica per node. There are exactly 3 Longhorn-schedulable nodes (worker0, worker1, worker2 — aitopatom and spark have allowScheduling: false deliberately). 37 volumes want 3 replicas. Evict any one node and the other two already hold a copy each, so the third has nowhere legal to go. Eviction can never reach zero.

  2. Capacity, which blocks it even if placement did not:

    node Longhorn disk used free
    worker0 727.6 GiB 91.5 ~636
    worker1 664.7 GiB 114.3 ~550
    worker2 99.9 GiB 51.1 ~49

    worker2 cannot absorb worker0's 91.5 GiB. No setting changes that.

So: bring the new box up first (N+1), then evacuate an old one onto it.

labctl app k3s evacuate now refuses in seconds with the offending volume named, rather than discovering this an hour in. If it refuses, it is right.


Capacity arithmetic, stated plainly

  • Today: 727.6 + 664.7 + 99.9 = ~1.49 TiB of Longhorn disk.
  • After v2 (fixed 300 GiB per node): 3 x 300 = 900 GiB — a reduction of ~590 GiB.
  • Real usage is ~257 GiB, so it fits with ~640 GiB headroom.
  • But 1550 GiB is already scheduled (794 + 558 + 198), which only works because storageOverProvisioningPercentage is 500. Against 900 GiB real that needs >=172% and leaves no room for the next volume.

Action: before migrating the last node, right-size the oversized PVCs (Prometheus is 376 GiB provisioned against ~8 GiB actual) or set the over-provisioning percentage deliberately. Do not discover this when a disk fills.


Preamble — do these once, before touching any node

  1. Backup target. Configured in deployments/longhorn/ and Pulumi.homelab.yaml (SeaweedFS S3 at 10.0.0.194:8333 — a different machine from the cluster, which is what makes it a backup rather than another copy of the same failure domain). TrueNAS was rejected on evidence: port 2049 is CLOSED, so NFS would need the export creating first.

    defaultSettings.backupTarget DOES NOT WORK on Longhorn 1.8. The target moved onto the backuptargets.longhorn.io/default CR (spec.backupTargetURL, spec.credentialSecret); the helm value is silently ignored, no backup-target Setting object is created, and the chart reports success with no backup target at all. The Pulumi code therefore patches the CR via apiextensions.CustomResourcePatch, and the RecurringJobs depend on that patch so the first scheduled backup cannot fire into nothing.

    Before relying on it:

    • create the longhorn-backup bucket;
    • apply with --target, not a bare pulumi up — the stack carries unrelated drift (at one point 12 resources, including a replacement of the openbao watchdog ConfigMap). Target these four:
      kubernetes:core/v1:Secret::longhorn-backup-credentials
      kubernetes:helm.sh/v3:Release::longhorn
      ...Release$...:BackupTarget::longhorn-backup-target
      ...Release$...:RecurringJob::longhorn-job-daily-backup
      ...Release$...:RecurringJob::longhorn-job-hourly-snapshot
      
      (scripts/pulumi.sh refuses an unclean tree; commit and merge rather than setting PULUMI_ALLOW_UNMERGED=1.)
    • confirm Longhorn accepted it — available: true is the appliance saying it could reach the endpoint AND authenticate:
      kubectl -n longhorn-system get backuptargets.longhorn.io default \
        -o custom-columns=URL:.spec.backupTargetURL,AVAIL:.status.available
      
    • take one backup, then restore it to a new volume and check the contents. An unverified backup is not a backup. The restore is the gate — not the pulumi up, and not available: true.

    Note: this cluster has no CSI VolumeSnapshot API (no snapshot.storage.k8s.io resources at all), so backup tooling must use Longhorn's native CRs — snapshots.longhorn.io, backups.longhorn.io, and a fromBackup StorageClass for restores. The drill does exactly this.

  2. The 21 single-replica volumes. One copy each, and until step 1 is done, no other copy anywhere. For each one on the node you are about to evacuate:

    kubectl -n longhorn-system patch volumes.longhorn.io <pv> \
      --type=merge -p '{"spec":{"numberOfReplicas":3}}'
    

    wait for status.robustness: healthy, then evacuate. Restore the count afterwards if you want it back.

    evacuate refuses single-replica volumes by default. --allow-single-replica overrides it and means "I accept that a failed rebuild is permanent loss".

    Detached volumes are listed separately by the pre-check: Longhorn must attach them to rebuild, so they need attention before, not during.

  3. Pin the k3s version, or the migration upgrades the cluster by accident.

    get.k3s.io installs latest. The fleet runs v1.34.6+k3s1; latest at the time of writing is v1.36.4+k3s1 — two minor versions ahead. A node reinstalled by this procedure rejoins on a newer k3s than the two members it is joining, and Kubernetes supports only one minor of skew between control-plane members. Roll three nodes and you have performed an unplanned, unstaged, two-version upgrade one node at a time.

    Pass --k3s-version v1.34.6+k3s1 (check the real value with kubectl get nodes -o wide). Upgrade deliberately, afterwards, as its own change.

  4. Add the N+1 node. Provision the new box with schemaVersion: 2, join it, and confirm it has a schedulable Longhorn disk:

    kubectl -n longhorn-system get nodes.longhorn.io
    

    A node with no disk hosts no replicas while looking perfectly healthy. If it has none, labctl app k3s admit <node> repairs it.


Node order

  1. worker0 — plain Fedora x86, the most ordinary box. Go first.
  2. worker2 — Fedora aarch64. Its 100 GiB Longhorn disk becomes 300 GiB under v2, so this node gains room.
  3. worker1 — LAST, and a different animal. Apple Silicon under Asahi: the partition table belongs to the vendor installer, root is 4.6 GiB, the rancher LV is undersized at 20 GiB, and it carries a containerd bind mount that exists in no template or script:
    /var/lib/longhorn/k3s-containerd /var/lib/rancher/k3s/agent/containerd none \
      bind,x-systemd.requires-mounts-for=/var/lib/longhorn,x-systemd.requires-mounts-for=/var/lib/rancher 0 0
    
    A reinstall destroys that silently. Re-create it, or the node runs out of root space the first time it pulls a large image.

Quorum: there are exactly 3 etcd members, so quorum is 2 and the fleet tolerates exactly one member down with no margin. One node at a time, never two, and never start a roll while anything is already degraded.


Per-node loop

Gate at every step. If one fails, stop — do not roll the next node on top of a half-migrated one.

# 0. GATE: cluster is whole before you start
kubectl get nodes
kubectl -n longhorn-system get volumes.longhorn.io | grep -v healthy   # expect none
# `k3s etcdctl` is NOT a command. Define this first (see the recovery card):
#   e() { local b=/var/lib/rancher/k3s/server/tls/etcd
#         ETCDCTL_API=3 etcdctl --cacert=$b/server-ca.crt \
#           --cert=$b/server-client.crt --key=$b/server-client.key \
#           --endpoints=https://127.0.0.1:2379 "$@"; }
e member list                  # expect 3, all started
e endpoint health

# 1. Will it even work?
labctl app k3s evacuate <node>      # refuses fast if it cannot complete

# 2. (it evacuates: cordon -> check -> evict+wait -> drain -> verify)
kubectl -n longhorn-system get replicas.longhorn.io -o wide | grep <node>   # expect none

# 3. Prove the data is fine BEFORE destroying anything
#    Restart something that uses a Longhorn PVC and check it reads back.

# 4. The irreversible part — as late as possible
ssh <node> systemctl stop k3s
e member remove <id-of-node>   # member names are "<node>-<8 hex>", not "<node>"
kubectl delete node <node>
e member list                  # expect 2, both healthy
kubectl get --raw /readyz      # from each survivor: ok

# 5. Wipe and reinstall
labctl provision reprovision <node>    # the DISK decides the schema, not you

# 6. Rejoin — against a SURVIVOR, not against itself, PINNED
labctl app k3s install <node> --role k8s-combo \
  --k3s-server https://<survivor-ip>:6443 --k3s-token <token> \
  --k3s-version v1.34.6+k3s1      # MUST match the cluster; see preamble step 3
e member list                  # expect EXACTLY 3

# 7. Back into service
labctl app k3s admit <node>

# 8. Wait for the rebuild, then verify the data again
kubectl -n longhorn-system get volumes.longhorn.io    # all healthy, 3 replicas
#    And check the node actually took replicas BACK — running, not merely assigned:
kubectl -n longhorn-system get replicas.longhorn.io -o wide | grep <node>

Step 6's "exactly 3" is not a formality. A reinstalled server rejoins as a new etcd member. If the old record survives you get 4 members, quorum rises from 2-of-3 to 3-of-4, and a cluster that tolerated one failure now tolerates none — while every dashboard still says green. That is why step 4 removes the member explicitly instead of trusting the rejoin to clean up.

Step 8's "running, not merely assigned" is also not a formality. A replica can sit at currentState=stopped / desireState=running with no failedAt indefinitely. The node looks Ready and Schedulable and hosts nothing, and the next node's eviction then has nowhere to drain and stalls for its full hour.


What pulumi up puts back, and what it does not

Restored automatically. Node labels and taints — both NodePatch sites use pulumi.com/patchForce, so they genuinely self-heal.

NOT restored — re-apply by hand:

  • aitopatom's Longhorn allowScheduling: false
  • the Prometheus 2-replica pin
  • the multus 512Mi memory limit
  • worker1's containerd bind mount (above)
  • every local-path PVC. Model caches re-download; the rest do not come back.

If it goes wrong

  • Evacuation refused. Believe it. Read the named volume; it is telling you either "no node can take this replica" or "the disk does not have room". Add capacity rather than forcing it.
  • Evacuation timed out. The node is left schedulable again and the eviction cancelled automatically; the error names which volumes were stuck and in what state. If that cleanup itself failed, the error says so — run labctl app k3s admit <node>.
  • Aborted midway, node still cordoned. labctl app k3s admit <node> uncordons, clears the eviction and restores the Longhorn disk.
  • Replicas land on a node but stay stopped. /var/lib/longhorn/replicas is missing while longhorn-disk.cfg exists, so Longhorn considers the disk already initialised and never recreates the directory; the replica process then fails on a non-recursive mkdir every time, permanently. mkdir -p /var/lib/longhorn/replicas. Never delete that directory — delete its contents. labctl app k3s admit <node> now ensures it exists.
  • Rejoined node hosts no replicas. It has no Longhorn disk. admit repairs it; kubectl -n longhorn-system get nodes.longhorn.io <node> -o yaml confirms.
  • Node wiped and cannot rejoin. This is the case the backup target exists for. Restore the volumes from backup. If step 1 of the preamble was skipped, there is no recovery path — which is precisely why it is step 1.

Known gaps

  • Every node reports RequiredPackages=False — Missing packages: [nfs-utils cryptsetup] and KernelModulesLoaded=False. Uniform across the fleet and not blocking today, but it would block an NFS backup target and encrypted volumes. The S3 target does not need nfs-utils.
  • The drill ran on VMs with ~3 GiB of test data; production has ~257 GiB. It proves mechanism, not timing. Keep the 60-minute eviction timeout generous.
  • worker1 has never been VM-tested and cannot be — it needs Apple Silicon.