feat(esh-pve-nas): stage the PVE root migration off the USB DOM
Everything but the reboot. Two rerunnable elway playbooks; the host is
still running from the ext4 root and its boot path is byte-identical to
the last 140 days, because grub-install is deliberately held back to the
cutover window.
Phase 1 (esh-pve-nas-stage-zfs-root.yaml): carve a 512 MB /boot LV out
of the 768 MB swap LV, populate it, rsync the 4.3 GB ext4 root into
nvme/ROOT/pve-1, write the copy's fstab.
Phase 2 (esh-pve-nas-stage-bootloader.yaml): ZFS initramfs, grub.cfg,
explicit pve-zfs-root and pve-ext4-rollback entries with stable ids,
grubenv pinned to the rollback so cutover's grub-reboot is a one-shot.
Three landmines the plan did not predict, all caught by verify steps
asserting effective state rather than by reading the plan:
- The /boot LV had nowhere to live. VG pve had 4 MB free and mounted
ext4 cannot shrink; freeing space from root needs a rescue boot, which
costs the one-reboot property. Space came from swap (768M -> 256M).
- The runbook's `zpool set cachefile=... nvme` would have broken the
NAS. Populating a cachefile flips the host from import-by-scan to
import-by-cache, so a one-pool cache leaves ssd and tank unimported --
and CT 103 esh-nas has twelve bind mounts spanning all three pools.
Set on all three instead, verified in the resulting cache.
- update-grub silently emitted a pool-less root=ZFS=/ROOT/pve-1, which
boots to an initramfs prompt. Debian's 10_linux builds ${rpool}${bootfs}
and rpool comes from grub-probe --target=fs_label, which returns empty
because GRUB's ZFS reader cannot open a pool with encryption,
large_dnode and zstd_compress -- the same feature set that forced /boot
to stay ext4. The probe failure is swallowed by `2>/dev/null || true`.
Fixed with a /etc/default/grub.d drop-in plus explicit menu entries.
The transferable lesson: the original verify grepped for the correct
root= string appearing somewhere in grub.cfg, which passes while every
menu entry is still broken. Assert the effective value, not the presence
of a substring.
This commit is contained in:
@@ -1,6 +1,16 @@
|
||||
# esh-pve-nas — moving PVE root off the USB DOM
|
||||
|
||||
**Status: PLANNED, not executed.** Mitigation landed 2026-08-17 (root 90% → 76%).
|
||||
**Status: STAGED — everything but the reboot is done.** Mitigation landed
|
||||
2026-08-17 (root 90% → 76%); staging landed 2026-08-18. The host is still
|
||||
running from the ext4 root on the DOM and its boot path is unchanged; what
|
||||
remains is the cutover window (§ Cutover).
|
||||
|
||||
Staging is two playbooks, both rerunnable:
|
||||
|
||||
| phase | playbook | what it did |
|
||||
|---|---|---|
|
||||
| 1 | `playbooks/esh-pve-nas-stage-zfs-root.yaml` | carved the `/boot` LV out of swap, populated it, rsynced the root into `nvme/ROOT/pve-1`, wrote the copy's fstab |
|
||||
| 2 | `playbooks/esh-pve-nas-stage-bootloader.yaml` | ZFS initramfs, grub.cfg, rollback entry, grubenv — **without** `grub-install` |
|
||||
|
||||
**Plan revised 2026-08-17** from "reinstall to a mirrored-NVMe ZFS root" to
|
||||
**"split the boot chain from the root filesystem"** — operator's proposal, and it
|
||||
@@ -91,9 +101,23 @@ Verified on the host 2026-08-17:
|
||||
on the DOM LV. **Do not put swap on a zvol** (deadlock risk)
|
||||
|
||||
⚠ **`cachefile` is `none` and `/etc/zfs/zpool.cache` is 0 bytes** — pools import
|
||||
by scan today. For root-on-ZFS this must be deterministic: set
|
||||
`zpool set cachefile=/etc/zfs/zpool.cache nvme` and rebuild the initramfs, or the
|
||||
pool may not be imported early enough to find root.
|
||||
by scan today (verified: `zfs-import-scan.service` active,
|
||||
`zfs-import-cache.service` inactive). For root-on-ZFS this must be
|
||||
deterministic, or the pool may not be imported early enough to find root.
|
||||
|
||||
⚠⚠ **Set the cachefile on ALL THREE pools, not just `nvme`.** An earlier draft
|
||||
of this runbook said `zpool set cachefile=/etc/zfs/zpool.cache nvme`, and that
|
||||
one-pool form is a trap. Populating a cachefile flips the host from
|
||||
import-by-scan to import-by-cache — so a cache containing only `nvme` means
|
||||
**`ssd` and `tank` never get imported at boot.** CT 103 `esh-nas` has twelve
|
||||
bind mounts spanning all three pools (`/tank/media`, `/ssd/compose`,
|
||||
`/nvme/nvme-pvestore`, …), so the NAS would come up with every export empty and
|
||||
both NFS clients would hang on `hard` mounts. The scoped-looking command is more
|
||||
dangerous than the broad one.
|
||||
|
||||
Done 2026-08-18 for `nvme`, `ssd` and `tank`; verified all three present in the
|
||||
resulting 11,976-byte cache via `zdb -C -U /etc/zfs/zpool.cache`. Phase 1's
|
||||
third guard step re-asserts this on every run.
|
||||
|
||||
## ⚠ Blast radius — unchanged, and still the gating constraint
|
||||
|
||||
@@ -112,36 +136,154 @@ SQLite risks corruption under `soft`.
|
||||
The reboot in this plan is brief, but it is still a reboot — quiesce the clients
|
||||
first.
|
||||
|
||||
## Where the `/boot` LV came from — the VG was full
|
||||
|
||||
The original step 7 said `/boot` could "stay inside the DOM's existing LVM as
|
||||
its own small ext4 LV, or reuse the freed space once root moves off." Neither
|
||||
was available: **VG `pve` had 4 MB free**, and the 6 GB root is mounted ext4,
|
||||
which cannot shrink online — freeing space from it needs a rescue boot, which
|
||||
would have cost the "one reboot" property the whole design rests on.
|
||||
|
||||
The only space reclaimable live was the **768 MB swap LV** (123 MB in use
|
||||
against 125 GB of RAM). Operator's call 2026-08-17: **shrink swap rather than
|
||||
drop it.** Final layout:
|
||||
|
||||
| LV | size | role |
|
||||
|---|---|---|
|
||||
| `pve-root` | 6.04 G | ext4 — **untouched**, the rollback root |
|
||||
| `pve-boot` | 512 M | ext4 — the new `/boot` (NEW) |
|
||||
| `pve-swap` | 256 M | swap (was 768 M) |
|
||||
|
||||
Rejected alternatives: dropping swap outright (more kernel headroom, no OOM
|
||||
cushion); `proxmox-boot-tool` on the 512 MB ESP (PVE-native and no LVM surgery,
|
||||
but it reformats the ESP and downgrades rollback from "pick a menu entry" to
|
||||
"restore the DOM image"); rescue-boot to shrink root (keeps swap whole, costs a
|
||||
second reboot and an offline resize of the filesystem we are fleeing).
|
||||
|
||||
## Sequence
|
||||
|
||||
**Pre-flight (no downtime)**
|
||||
1. `dd` the DOM to an image, off-box. This is the rollback for the boot chain.
|
||||
**Pre-flight (no downtime)** — done 2026-08-18
|
||||
1. `dd` the DOM to an off-box image. **Crash-consistent, not clean** — the root
|
||||
LV is live during the read, so a restore replays the ext4 journal. That is
|
||||
fine for its purpose (boot-chain insurance) and is what a snapshot backup
|
||||
does anyway. Not fixable with an LVM snapshot: the VG has no free extents.
|
||||
2. Refresh the config snapshot (`nh3-dev:~/backups/esh-pve-nas/`).
|
||||
3. `zpool set cachefile=/etc/zfs/zpool.cache nvme`.
|
||||
3. `zpool set cachefile=/etc/zfs/zpool.cache` on **`nvme`, `ssd` AND `tank`**
|
||||
(see the precondition warning above — the one-pool form breaks the NAS).
|
||||
|
||||
**Build the ZFS root (live, no disruption)**
|
||||
4. `zfs create -o mountpoint=none nvme/ROOT`
|
||||
`zfs create -o mountpoint=/ -o canmount=noauto nvme/ROOT/pve-1`
|
||||
`canmount=noauto` is essential — otherwise ZFS tries to mount it over the
|
||||
running root.
|
||||
5. Mount it somewhere temporary and `rsync -aHAX --numeric-ids` the live root
|
||||
into it, excluding `/proc /sys /dev /run /mnt /nvme /ssd /tank /var/log/journal`.
|
||||
6. In the copy: edit `/etc/fstab` to drop the root line, keep the ESP and swap
|
||||
lines, and add the new `/boot` entry.
|
||||
**Phase 1 — `playbooks/esh-pve-nas-stage-zfs-root.yaml`** (live, no disruption)
|
||||
4. `zfs create -o mountpoint=none nvme/ROOT`, then `nvme/ROOT/pve-1` with
|
||||
`canmount=noauto`, `compression=zstd`, `xattr=sa`, `acltype=posixacl`.
|
||||
Create it with `mountpoint=none` and only set `/` at the very end —
|
||||
`canmount=noauto` alone is the documented guard, but never having a dataset
|
||||
that claims `/` while the ext4 root is live is the guard that cannot misfire.
|
||||
5. Reclaim the swap LV into `pve-boot`, mkfs, populate from `/boot`.
|
||||
6. Mount the dataset at `/mnt/newroot` and rsync the live root in.
|
||||
`--one-file-system` does the exclusion work: every path the old plan listed
|
||||
by hand (`/proc /sys /dev /run /nvme /ssd /tank /var/log/journal /boot`) is
|
||||
already a separate mount, so it is skipped structurally rather than by a
|
||||
list that can drift.
|
||||
7. Write the copy's `/etc/fstab`: no root line (the initramfs mounts it), plus
|
||||
`/dev/pve/boot /boot ext4`, the ESP, and swap.
|
||||
|
||||
**Split `/boot` onto the DOM**
|
||||
7. Shrink/repartition is not needed — `/boot` can stay inside the DOM's existing
|
||||
LVM as its own small ext4 LV, or reuse the freed space once root moves off.
|
||||
Copy `/boot` into it and mount at `/boot`.
|
||||
**Phase 2 — `playbooks/esh-pve-nas-stage-bootloader.yaml`** (live, no disruption)
|
||||
8. Chroot into the copy with the boot LV and ESP mounted, then
|
||||
`update-initramfs -u -k all` + `update-grub`.
|
||||
9. ⚠⚠ **`grub-mkconfig` gets the ZFS root WRONG here, silently. Override it.**
|
||||
See § The pool-name bug below — this is the single most dangerous thing
|
||||
found during staging.
|
||||
10. `GRUB_DEFAULT=saved`, plus `40_custom` carrying **both** boot paths as
|
||||
hand-authored entries with stable ids (`pve-zfs-root`, `pve-ext4-rollback`),
|
||||
with grubenv pinned to the **rollback**, not to ZFS (see § Cutover for why).
|
||||
|
||||
**Bootloader**
|
||||
8. `GRUB_CMDLINE_LINUX="root=ZFS=nvme/ROOT/pve-1"`, `update-initramfs -u -k all`,
|
||||
`update-grub`. **Keep the existing ext4 entry in the menu** and leave
|
||||
`GRUB_TIMEOUT=5` so it is selectable.
|
||||
## ⚠ The pool-name bug — the near-miss worth reading
|
||||
|
||||
**Cut over**
|
||||
9. Quiesce the NFS clients (see blast radius), shut down guests, reboot.
|
||||
10. Verify (below). If it fails, reboot and choose the ext4 entry.
|
||||
Left to itself, `update-grub` on this host produces:
|
||||
|
||||
```
|
||||
linux /vmlinuz-6.8.12-13-pve root=ZFS=/ROOT/pve-1 ro quiet intel_iommu=on
|
||||
```
|
||||
|
||||
**The pool name is missing.** It should be `root=ZFS=nvme/ROOT/pve-1`. That
|
||||
boots to an initramfs prompt — with CT 103 `esh-nas` down and both NFS clients
|
||||
hanging on `hard` mounts, at whatever hour the window happens to be.
|
||||
|
||||
It is not a typo, and it is not random. Debian's `/etc/grub.d/10_linux` builds
|
||||
the ZFS root as `${rpool}${bootfs}`:
|
||||
|
||||
| part | from | value here |
|
||||
|---|---|---|
|
||||
| `rpool` | `grub-probe --device <dev> --target=fs_label` | **empty** |
|
||||
| `bootfs` | `make_system_path_relative_to_its_root /` | `/ROOT/pve-1` |
|
||||
|
||||
`grub-probe --target=fs /` fails outright on this pool — `grub-probe: error:
|
||||
unknown filesystem` — because **GRUB's own ZFS reader cannot open a pool with
|
||||
`encryption`, `large_dnode` and `zstd_compress` enabled.** So `rpool` comes back
|
||||
empty and concatenates to nothing.
|
||||
|
||||
That is the *same* feature set that forced `/boot` to stay ext4 on the DOM. The
|
||||
design already accounted for GRUB being unable to read the pool; what was missed
|
||||
is that the same limitation also corrupts the kernel command line — and does it
|
||||
**without an error**, because `grub-probe`'s failure is swallowed by
|
||||
`2>/dev/null || true`.
|
||||
|
||||
**The fix, in two layers:**
|
||||
|
||||
1. `/etc/default/grub.d/zfs-root.cfg` sets
|
||||
`GRUB_CMDLINE_LINUX="root=ZFS=nvme/ROOT/pve-1 boot=zfs"`. This is appended
|
||||
*after* the bogus value, and both the kernel and the zfs initramfs script
|
||||
take the **last** `root=` on the line — so every auto-generated entry becomes
|
||||
correct. A drop-in, not an edit to `/etc/default/grub`, so a grub package
|
||||
upgrade cannot revert it in a conffile merge.
|
||||
2. `40_custom` carries an explicit `pve-zfs-root` entry with a single clean
|
||||
`root=` and a stable id. That is what cutover's `grub-reboot` targets — the
|
||||
auto-generated ids are derived from pool member device paths
|
||||
(`gnulinux-simple-/dev/nvme0n1p1_/dev/nvme1n1p1`) and would shift if the
|
||||
mirror ever changed.
|
||||
|
||||
**The general lesson, which is the transferable part:** the phase-2 verify step
|
||||
originally grepped for `root=ZFS=nvme/ROOT/pve-1` *appearing somewhere* in
|
||||
grub.cfg. Once the drop-in was added that grep passes — while pool-less entries
|
||||
sit in the menu untouched. The check that actually holds walks every `linux`
|
||||
line, takes the **last** `root=` on it, and asserts it against a known-good set.
|
||||
Assert the effective value, not the presence of a substring.
|
||||
10. **`grub-install` is deliberately NOT run during staging.** The ESP stub
|
||||
still points at the old `/boot` inside the ext4 root, so the host's boot
|
||||
path stays byte-identical to what it has been for 140 days. Everything
|
||||
error-prone is built and verified in advance; the ESP rewrite is a
|
||||
two-second idempotent command held back to the window.
|
||||
|
||||
**Cutover** — the remaining work, § Cutover below.
|
||||
|
||||
## Cutover
|
||||
|
||||
The only remaining work. Everything below the quiesce is minutes.
|
||||
|
||||
1. **Quiesce the NFS clients** (see § Blast radius). On **esh-docker-vm**
|
||||
(10.0.50.45) stop whatever holds `/mnt/books` and `/mnt/backup` and unmount
|
||||
them; on **esh-pve** (10.0.250.35) disable the `esh-nas` and `tank-vmbu`
|
||||
storages. Do this first and confirm it — a `hard` mount left live turns a
|
||||
brief reboot into an unkillable D-state needing a reboot of *that* host too.
|
||||
2. Shut down the five guests.
|
||||
3. Point the ESP at the new `/boot` and arm the one-shot:
|
||||
```
|
||||
chroot /mnt/newroot grub-install --target=x86_64-efi \
|
||||
--efi-directory=/boot/efi --bootloader-id=proxmox
|
||||
chroot /mnt/newroot grub-reboot '<zfs entry id — phase 2's verify prints it>'
|
||||
```
|
||||
4. Set the dataset's final mountpoint, then reboot:
|
||||
```
|
||||
zfs set mountpoint=/ nvme/ROOT/pve-1 # canmount stays noauto
|
||||
reboot
|
||||
```
|
||||
|
||||
**Why `grub-reboot` and not a new default.** `GRUB_DEFAULT=saved` with grubenv
|
||||
pinned to the ext4 rollback means the ZFS entry is tried **exactly once**. If it
|
||||
fails, the next reboot returns to ext4 *by itself* — no console, no hands. That
|
||||
matters more here than on a normal host: a boot that hangs at an initramfs
|
||||
prompt takes CT 103 `esh-nas` down with it, and the NFS clients hang rather than
|
||||
fail. Only after the second successful ZFS boot (§ Verification) should the
|
||||
saved default move to the ZFS entry with `grub-set-default`.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
Reference in New Issue
Block a user