Files
home-network-k3s-impl/docs/plans/proxmox-to-n5-air-migration.md
T
jakman85 863af01062
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
docs: the §2 shared-device measurement came back, and it does not hold
infra/PROD-PROVISIONING.md §2 recorded the etcd/Longhorn shared-partition risk as
"accepted to be MEASURED, not assumed", with an exit condition. The measurement
arrived on 2026-09-07 in production, 2 d 7 h after bring-up, as an outage.

§2 now carries "THE MEASUREMENT CAME BACK": the journal evidence, the three-node
IO PSI table (11.36 / 0.75 / 1.29), and the honest verdict that the section's own
premise — "every prod node is SSD or NVMe, where rebuild I/O costs latency rather
than collapsing it" — was right about node 2 and wrong about node 1. A 2011 C400
with 61,440 reallocated sectors and no power-loss protection is not the "SSD" that
reasoning assumed, and §0 had already named the three-way drive asymmetry as the
fault line. The exit condition is OPEN and the second bullet (dedicated device)
applies; that remains the operator's.

Also records what the three software mitigations in this branch do and do not buy,
including the point that no cgroup or scheduler control arbitrates the ext4 journal
both tenants share — and the gap that etcd's own fsync histograms, which the exit
condition literally asks for, are not collected because kubeEtcd is disabled for
k3s. PSI is the signal that exists.

DECISIONS-LOG gains the full entry: the four I/O mechanisms found INERT on these
nodes (IOSchedulingClass, IOSchedulingPriority, io.latency, bare IOWeight), the
finding that concurrentReplicaRebuildPerNodeLimit was already set and already held
during the outage, the third instance of config that looked applied and was not
(orphanAutoDeletion), and the three things still requiring the operator.

infra/ansible/README.md: CORRECTED a stale claim that k3s-prod-2/-3 "stay commented
out" of the inventory. They have been live in [k3s_prod_secondary] since
2026-09-03. That sentence is exactly the kind used to reason about blast radius —
`-e k3s_hosts=k3s_prod` targets THREE hosts, not one — so it is replaced with the
--list-hosts command that settles it. The new etcd-io-priority play is added to the
run order, the layer table and the prod command list.

Co-Authored-By: Claude Opus 5 <[email protected]>
(cherry picked from commit a00a27a2567ea6a805c8c196b75df2c92a7c63a4)
2026-09-07 02:46:26 -05:00

15 KiB

Decommission Proxmox 192.168.2.102 → TrueNAS SCALE on the N5 Air

Status: plan. Drafted 2026-09-06, revised 2026-09-06 after the operator settled the target architecture.

N5 Air TrueNAS SCALE. Storage only — no VMs, no hypervisor.
Compute k3s prod cluster (192.168.2.10/.11/.12). Plex, sonarr, radarr, jackett, qbittorrent, seerr and nextcloud are already modelled in apps/wave-0/.
Dev k3s Does not move to the NAS.
Proxmox host Decommissioned.
Drives Physically relocated. None purchased.

All figures measured on the live host, not inferred.


Already done — 2026-09-06

Reclaimed 3.56 TB. Storage/plex@pre-cleanup-20260828 destroyed after confirming no holds and no clones. Async free drained in ~90 seconds.

before after
Storage allocated 12.3 T (75%) 8.76 T (53%)
Storage free 4.04 T 7.60 T
Storage/plex used 11.9 T 8.33 T (0 B in snapshots)

That free space is not a nicety — it is what lets this whole migration happen with no network transfer at all. See §3.


1. The plan changed shape

The first draft carried the VMs across on their own zvols and put Proxmox on the NAS. With no hypervisor on the far side, that inverts:

Every zvol is now dead weight, and every service's data has to be drained onto a plain dataset before the Proxmox box dies.

Dead-weight zvols, once their contents are extracted:

zvol size after extraction
Storage:vm-101/102/105-disk-0,-disk-1 (dev k3s) 585 G delete
Storage:vm-112-disk-0 (docker-services) 65 G delete
Storage:vm-103-disk-0 (nextcloud boot) 81 G delete
Storage2:vm-103-disk-0 (nextcloud data) 4.06 T extract, then delete

The good news is the shape of the drain. Everything that must be rescued is either already on Storage, or can be copied onto Storage locally, inside the same chassis, onto the very drives that are moving. With 7.60 T free there is room. Nothing crosses the network.


2. Findings

2a. "The path lies" — two confirmed instances, assume a third

Games — 422 GB, not on the Storage pool. Challenged and re-verified; the test is decisive:

findmnt -T /mnt/Storage/Games   →  TARGET /                  SOURCE rpool/ROOT/pve-1
findmnt -T /mnt/Storage/plex    →  TARGET /mnt/Storage/plex  SOURCE Storage/plex

Games is a plain directory on the Proxmox root filesystem that merely sits inside the Storage mount tree. The pool accounting closes on it exactly:

rpool ALLOC 540 G  =  ROOT 431 G  +  data 105 G  +  var-lib-vz 3.75 G
      ROOT 431 G  =  Games 422 G  +  /usr 3.5 G  +  /var 5 G  +  /boot 0.4 G

The two Microns are a mirror, so the pool is ~888 G usable, not ~1.9 T — 422 G is 48% of it, and zpool list agrees at 60% capacity overall. Surprising, but the numbers do match.

Gitea — 13 MB in an anonymous Docker volume, not where its compose file points. /root/docker-compose.yml in LXC 106 bind-mounts /opt/gitea/data:/var/lib/data. That path holds 512 bytes. The real Gitea state is in an unnamed Docker volume:

.../volumes/8a1d2616…/_data/git/repositories/   12 M   home-network.git, home-network-uds.git, jakman85/…
.../volumes/8a1d2616…/_data/data/gitea.db       11 M   SQLite, mtime today 19:00 — live

Copying /opt/gitea would migrate an empty directory and silently lose all three repositories.

Rule for the rest of the drain: for every service, verify where the bytes actually are before trusting the path in a compose file, an export list or a mount table. Two for two so far.

2b. Retiring pfSense is easier than rehoming it

pfsense is VM 100 and is 192.168.1.1. Decommissioning the host takes the entire 192.168.1.0/24 with it:

.1 pfSense · .4 iDRAC · .9 the Proxmox host itself die with the box
.5 nextcloud · .6 plex · .7 gitea · .18 docker-services/Pi-hole data drains, service moves to k3s
.8 unifi · .20 monitoring · .21 auth · .10 semaphore rehome or retire (§2e)
.49 kube-vip VIP · .50/.51/.52 dev k3s dev cluster is not moving; retire with the box

But pfSense exists only to contain that lab LAN. Prod k3s is already on 192.168.2.0/24 — pfSense's WAN side — and the NAS will be too. Once the lab is gone, nothing lives behind pfSense. Retire it; do not rehome it. That is only clean because everything else is leaving 1.0/24 anyway, which is exactly what this migration does.

A documented blocker dissolves with it. infra/PROD-STANDUP.md records that prod k3s cannot reach the S3 backup endpoint at 192.168.1.9:9000 because prod sits on pfSense's WAN side and the crossing rule does not exist. With the NAS on 192.168.2.0/24, prod reaches its storage directly and no pfSense rule is ever needed.

Three things to close before pfSense goes:

  1. The SOHO router NATs inbound 443 to 192.168.2.102 for the legacy stack. It must be repointed at prod's Gateway (192.168.2.20). infra/PROD-PROVISIONING.md §10a already flags that the apex flip and the legacy cutover are the same event — this migration is that event.
  2. VLANs 5 and 123 (minecraft; the arr VM, which reaches Plex media as 10.123.1.10) are pfSense-side constructs. They disappear; the k3s apps need no VLAN.
  3. The OpenVPN split-tunnel egress gateway currently runs on k3s-dev-3 (ADR-0015). It is already modelled for prod in platform/vpn-egress/, but confirm prod's gateway is live before the dev node goes away.

2c. Nextcloud is the critical path

Its user data is inside a filesystem, inside LVM, inside a 4 TB zvol, inside a VM:

Storage2/vm-103-disk-0  →  zd224  →  PV /dev/zd224  →  VG vg_storage  →  LV lv_storage (<4.00 T)

With no hypervisor on the far side this must be copied out at the file level, while the VM is still running — it gates the decommission and it is the longest single task in the plan.

One number is missing and I could not get it. VM 103's QEMU guest agent is not running (qm agent 103 ping → "QEMU guest agent is not running") and its SSH does not accept the home_network key. The zvol shows referenced 3.94 T, but it is attached without discard=on and is thick-provisioned (refreservation 4.06 T), so deletions inside the guest were never returned to ZFS — 3.94 T is an upper bound, possibly far above the truth.

Run this inside the Nextcloud VM and the last open question closes:

df -h /            # or wherever lv_storage is mounted
lsblk -f /dev/mapper/vg_storage-lv_storage

Separately: Storage/nextcloud is 270 G, and 267 G of it is a directory called config_backup (config itself is 103 M). Almost certainly a stale dump — likely another easy reclaim, but confirm before deleting.

2d. Storage2 — and the two oldest drives — can probably be retired

After the drain and the zvol deletions, Storage would hold roughly:

plex 8.33 T
nextcloud data ≤ 3.94 T (real number pending §2c)
Games 0.42 T
docker-volumes, other_containers, k8s-volumes, gitea ~0.02 T
total ≤ 12.7 T of 16.4 T (≤ 77%)

If the real Nextcloud figure comes in under ~2 T — which the missing-TRIM situation makes plausible — everything fits on the 18 TB mirror at a comfortable ~65%, and Storage2 can be retired entirely. That would remove the two 6.0-year-old Seagates, by a wide margin the most failure-prone hardware in the estate, and free two bays. This is the single best safety outcome available, and it costs nothing.

2e. Services that simply end

Decommissioning kills these; none has a home in the target architecture unless you give it one:

guest note
docker-services Pi-hole (192.168.1.18) LAN DNS. Confirm what 192.168.2.0/24 uses for DNS before the box dies.
unifi (VM 121) Network controller — needs a home or a cloud key.
auth (VM 122) Superseded by Keycloak on prod k3s.
monitoring (VM 120) Superseded by the k3s platform monitoring stack.
semaphore (LXC 110) 432 MB resident of an 8 GB reservation; low value.
minecraft (LXC 108) Operator call. Its 3.5 G subvol is on Storage and rides along.
versitygw S3 on 192.168.1.9:9000 Velero + CNPG backup target. Dies with the box. TrueNAS must take this role (MinIO app) or prod loses its backup destination — which infra/PROD-STANDUP.md already records as uncovered.

2f. Still true from the first draft

  • No VM backups exist. /etc/pve/jobs.cfg empty, no PBS, newest dump log April 2025.
  • ata-WDC_…3FGHAB2T has 36 ZFS read errors on pristine SMART (0 reallocated / pending / uncorrectable, PASSED) — points at cable or controller, not platter. Re-verify after the move.
  • Drive ages: WD 18 TB pair 4.75 y, Seagate 8 TB pair 6.0 y.

3. TrueNAS SCALE specifics

ZFS compatibility: verified, not assumed. The only non-default features active on either pool are block_cloning, head_errlog, vdev_zaps_v2, zilsaxattr, zstd_compress, log_spacemap, project_quota, spacemap_v2 — all OpenZFS 2.2 era or older. Nothing 2.3- or 2.4-only is active, even though the pools were last written by zfs-2.4.3-pve1. Any TrueNAS SCALE from 24.10 onward imports them cleanly.

Mountpoints mostly already match TrueNAS convention — a lucky accident worth knowing:

dataset current mountpoint TrueNAS wants
Storage/plex, /nextcloud, /docker-volumes, /other_containers, /k8s-volumes, /k3s-backups /mnt/Storage/… /mnt/Storage/… already correct
Storage (pool root) /Storage /mnt/Storage fix on import
Storage2 (pool root) /Storage2 /mnt/Storage2 fix on import
Storage/subvol-108-disk-0 /Storage/subvol-108-disk-0 — rename or delete

Clear the ZFS sharenfs properties. Storage/plex, Storage/nextcloud and Storage/docker-volumes carry exports as ZFS properties; Storage/k8s-volumes is in /etc/exports. TrueNAS generates exports from its own middleware database and will fight properties it did not set. Wipe them and recreate the shares in the UI.

Fix the export targets while you are there. Today's lists are stale: /mnt/k3s → 192.168.1.25/.26/.27 and k8s-volumes → 192.168.1.40/.41/.42 are hosts that do not answer ARP. The new exports target 192.168.2.10/.11/.12.


4. Sequence

Phase A — drain, locally, no downtime, no network

Everything below is a copy from somewhere else in the chassis onto Storage, which is moving.

  1. Games — rpool → new dataset.
    zfs create Storage/games
    rsync -aHAX --info=progress2 /mnt/Storage/Games/ /mnt/Storage/games/
    
  2. Gitea — copy the Docker volume, not /opt/gitea (§2a). Stop the container first so gitea.db is quiesced.
    pct exec 106 -- docker stop gitea
    zfs create Storage/gitea
    rsync -aHAX /rpool/data/subvol-106-disk-0/var/lib/docker/volumes/8a1d2616*/_data/ /mnt/Storage/gitea/
    
  3. Nextcloud — the long one. Copy lv_storage's contents to a new dataset at file level from inside the running VM, over its existing NFS mount of the host. Verify §2c's size first so you know whether it fits (it will, in 7.60 T free).
  4. Confirm config_backup (267 G) is disposable; drop it if so.
  5. Verify each service's real data location before copying it — the §2a rule.

Phase B — prove the compute side before the storage moves

Bring the apps/wave-0 manifests up on prod k3s while the old box is still alive, so the cutover is a storage swap against known-good workloads rather than two migrations at once.

Wrinkle: prod cannot reach 192.168.1.9 today (§2b). Either add a temporary pfSense rule for the test, or accept that app bring-up happens after the NAS is live on 192.168.2.0/24 — in which case stage the app configs now and keep the window short.

Phase C — cutover

  1. Stop all guests; reverse startup: order, pfSense last.
  2. Export cleanly — never just cut power:
    zpool export Storage && zpool export Storage2
    zpool status                  # both must be gone before power-off
    
  3. Label every drive with pool and serial before it leaves the chassis (3FGJ6M9T/3FGHAB2T = Storage; WKD1S0TR/WKD1S10M = Storage2).
  4. Move the four HDDs — cold, upright. The 6-year-old Seagates are the fragile ones.
  5. Install TrueNAS SCALE on the shipped 64 GB M.2 (Slot A). The five bays take the four HDDs with one spare; the Micron SSDs are not needed in the target — leave them in the old chassis.
  6. Import read-only first, verify, then import for real:
    zpool import                          # both pools listed
    zpool import -o readonly=on Storage
    zfs list                              # verify datasets, sizes
    zpool export Storage && zpool import Storage
    

Phase D — repoint, reclaim, retire

  1. Recreate NFS shares in the TrueNAS UI targeting 192.168.2.10/.11/.12; clear the old sharenfs properties.
  2. Point the apps/wave-0 PVs at the NAS; bring Plex, the arr stack and Nextcloud up on prod k3s.
  3. Stand up MinIO on TrueNAS to replace versitygw, and re-enable Velero + CNPG backups (§2e).
  4. Only once everything is verified from k3s: delete the dead zvols (§1) — ~731 G on Storage, 4.06 T on Storage2.
  5. Re-evaluate §2d. If it fits, evacuate Storage2, retire the 6-year-old Seagates, keep two bays free.
  6. Repoint the SOHO 443 NAT, then power off the Proxmox host.
  7. zpool status — confirm the 36 read errors did not follow 3FGHAB2T into the new chassis. Full scrub on the new hardware.

5. Rollback, and the one-way door

Until Phase D step 4, the old chassis is intact: rpool, the Proxmox install, both Microns and every /etc/pve config untouched. Put the four HDDs back and it boots into the previous world.

Do not run zpool upgrade

zpool status Storage2 already nags — "Some supported and requested features are not enabled… action: Enable all features using 'zpool upgrade'" — and TrueNAS will offer it in the UI after import. It is the only irreversible step here: run it and the pools can never go back to the Proxmox host. Leave it until the NAS has been serving k3s for weeks.

Note the asymmetry that makes this safe: TrueNAS's OpenZFS reads these pools happily, because newer ZFS always reads older pools. The reverse is not true.


6. Open items

  1. The Nextcloud number (§2c) — one df -h inside VM 103. It decides whether Storage2 and the two oldest drives can be retired.
  2. Is config_backup (267 G) disposable?
  3. What serves DNS on 192.168.2.0/24 once Pi-hole dies?
  4. UniFi controller — rehome, cloud key, or drop?
  5. Minecraft — keep or retire?
  6. Gitea — its whole state is 3 repos and an 11 MB SQLite DB, and prod k3s already runs its own Gitea. Is the legacy one worth migrating at all, or does it just end?