Reboot the NAS with zero VM downtime¶
Use when: TrueNAS needs a reboot (or any outage) and VMs are booted from it over ZFS-over-iSCSI. A naive reboot stops every VM in the lab.
Do not use for: an NFS-only problem that does not need a reboot. VM disks are on iSCSI, a separate service, and survive an NFS wedge untouched. Check which service is actually broken first.
Time: about four hours for 14 VMs at the throttle below. Plan accordingly; this is not a quick fix.
Proven on 2026-09-19: 14 disks out, NAS rebooted, 14 disks back, zero VM downtime.
The idea¶
qm disk move is a QEMU drive-mirror. It copies a running VM's disk to
another storage live, then switches the VM over, without stopping it. So:
evacuate every VM disk to node-local NVMe, reboot the NAS while the VMs run
from local disk, then move them all back.
flowchart TB
START["Starting state<br/>14 running VM disks on NAS-backed ZFS over iSCSI<br/>A NAS reboot now would stop every VM"]
subgraph MOVES["Live evacuation and return<br/>Every disk move is serial, one VM at a time, rate limited to 200 MiB/s"]
TARGET{"Step 1, choose node-local NVMe target<br/>Does it share drives with etcd data disks?"}
OUT["Step 1, correct destination<br/>Target does not share drives with etcd<br/>qm disk move uses a QEMU drive mirror<br/>Copy each disk live, VM keeps running"]
LOCAL["Step 2, evacuation complete<br/>All 14 disks are on node-local NVMe<br/>NAS has no VM disks on it"]
REBOOT["Step 3, reboot the NAS<br/>VMs keep running from local NVMe"]
BACK["Step 4, return all 14 disks to the NAS<br/>qm disk move, live<br/>One disk at a time, 200 MiB/s"]
DONE["Return complete<br/>All 14 VM disks back on NAS-backed storage<br/>Zero VM downtime"]
THROTTLE["Reason for the write throttle<br/>Keeps the migration from starving etcd<br/>and wrecking NFS latency"]
end
subgraph TRAP["Wrong destination, hit during step 1"]
WRONG["WRONG PATH<br/>Migration copies written onto QLC drives<br/>that also hold etcd data disks"]
WAL["etcd WAL fsync degraded<br/>74 ms to 7,627 ms"]
DOWN["Kubernetes control plane down<br/>About 16 minutes<br/>VMs still running"]
end
START -->|"Begin live evacuation"| TARGET
TARGET -->|"No, correct destination"| OUT
TARGET -->|"Yes, wrong destination used"| WRONG
WRONG -->|"Shared-drive write contention"| WAL
WAL -->|"etcd latency takes down control plane"| DOWN
OUT -->|"Repeat serially until all 14 disks are local"| LOCAL
LOCAL -->|"No VM disk depends on NAS"| REBOOT
REBOOT -->|"After NAS reboot"| BACK
BACK -->|"Repeat serially until all 14 disks are back"| DONE
THROTTLE -.->|"Explains the migration rate limit"| BACK
classDef neutral fill:#f8fafc,stroke:#475569,color:#0f172a;
classDef healthy fill:#f0fdf4,stroke:#166534,color:#14532d;
classDef degraded fill:#fff7ed,stroke:#9a3412,color:#7c2d12;
classDef failed fill:#fef2f2,stroke:#991b1b,color:#7f1d1d;
class START,TARGET neutral;
class OUT,LOCAL,REBOOT,BACK,DONE healthy;
class THROTTLE,WAL degraded;
class WRONG,DOWN failed;
style MOVES fill:none,stroke:#475569
style TRAP fill:none,stroke:#991b1b,stroke-width:2px
Before you start¶
Pick the target by what else uses the drive, not by free space
This is the step that took the Kubernetes control plane down for sixteen minutes on 2026-09-19.
SSD1 and ssd_1 are QLC drives that also hold the etcd data
disks. Bulk mirror writes there drove etcd WAL fsync from 74 ms to
7,627 ms, leases expired, and quorum churned.
Use local (the node NVMe). Deny-list any storage that shares spindles
with etcd, and put that deny-list in the script rather than in your head.
Checks:
zpool status -xclean, and no recent SATA bus errors: Record this as a baseline and re-check during the run. A rising count means drives are dropping off the bus and you should stop.- Enough free space on each node's local storage for the disks it will host.
- Tell other operators. Concurrent disk moves are how the etcd outage happened, and anyone running heavy I/O lengthens every move.
Procedure¶
1. Evacuate, one VM at a time¶
Serialise this. One move at a time across the whole cluster, not one per node.
pvesh create "/nodes/$node/qemu/$id/move_disk" \
--disk scsi0 \
--storage local \
--delete 1 \
--bwlimit 204800 # KiB/s = 200 MiB/s
--delete 1removes the source copy only after a successful move.-
--bwlimit 204800is a deliberate throttle, roughly 16% of the 10G storage link. It keeps the migration from starving etcd and from pushing NFS latency through the roof for everything else on the pool.It is not protecting the drives, whatever an earlier version of this page said
The throttle was originally justified as protecting the bad-batch SSDs from sustained writes. That causal claim is not supported. See the failure analysis. The contention argument above stands on its own.
- Driving it through
pveshrather thanqmmeans each move runs as a task on its own node and survives your SSH session dying.
- Driving it through
Parsing the UPID from a cross-node pvesh call
A cross-node pvesh call streams the whole task log and puts the UPID at
the end, so tail -1 returns a log line, not the UPID. Extract it:
Include a stop-file check before each VM so you can halt the lane without killing an in-flight move:
2. Confirm the NAS holds no VM disks¶
pvesh get /cluster/resources --type vm --output-format json \
| python3 -c "import sys,json;[print(v['vmid'],v.get('name')) for v in json.load(sys.stdin)]"
scsi0 no longer names the NAS storage. Do not skip
this. A single missed disk means a VM dies at reboot.
3. Reboot the NAS¶
VMs keep running from local NVMe. Expect NFS clients to hang and recover; that is separate from VM disks.
4. Move everything back¶
Identical to step 1 with --storage truenas-iscsi. Same serialisation, same
throttle, same deny-list.
Verify¶
scsi0 should name the NAS storage again. Expect a leftover unused0
entry pointing at the pre-migration volume: that is the old disk, not a
failure. Delete those deliberately afterwards, once you are satisfied.
What to watch while it runs¶
| Signal | Where | Meaning |
|---|---|---|
| SATA errors rising | dmesg on the NAS |
Stop. Drives are dropping off the bus |
| etcd WAL fsync > 50 ms | etcd_disk_wal_fsync_duration_seconds |
Your writes are hurting etcd. Wrong target |
| NFS probe latency | Uptime Kuma | Expect elevation. 170 ms average and ~1.2 s peaks were normal during the 2026-09-19 move, against an 18 ms idle baseline |
| Pool read/write wait | zpool iostat -l tank 5 |
Contention, expected |
Elevated NFS latency during the move is not a fault. It is the cost of the migration and it clears when the lane finishes.
Gotchas¶
grep -c ... || echo 0is broken. On zero matchesgrep -cprints0and exits non-zero, so the||fires and you get"0\n0", which breaks every numeric comparison after it and silently disables your auto-halt. Use; trueandhead -1. This bug made a drive-error guard completely vacuous.- Do not shut VMs down to speed this up. The whole point is that the mirror is live. If you are stopping VMs you have chosen a different, worse procedure.
- A busy VM takes longer. The mirror has to chase dirty blocks while it copies. command-center1, which was running the automation, took 1,644 s for 150 GiB (about 93 MiB/s) against 940 s for an idle VM of the same size.
Related¶
- 2026-09-19 NFSv4 callback deadlock, where this procedure was used and where the etcd trap is written up in full.
- terraform-quasarlab#24, moving the etcd disks off QLC and into Terraform so the constraint stops living only in an operator's memory.