- Shell 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Review of the previous commit surfaced one regression it introduced and several gaps that predate it. None is reachable on the current cluster — every HA guest there also needs a feature strip, and none has the config shape required to trigger the was_running gap — but all are latent. Regression, from arming the trap before ha_remove_if_managed: - HA_WAS/HA_OPTS/HA_RULES were only reset inside ha_remove_if_managed, which now runs AFTER the trap is armed. A signal landing in that gap would see the PREVIOUS guest's HA state and re-add this guest using another resource's saved options. Both callers now reset them before arming, mirroring what the CT_* globals already did. - ha_readd now clears HA_WAS itself on success, so the debt is settled in one place and a signal arriving before the caller disarms can't fire a second re-add of a guest that is already back on HA. Pre-existing, found by a deliberately unscoped review pass: - was_running was only ever determined inside the "needs a strip" branch, so for a CT with no feature flags and no bind mounts it stayed 0. But remote-migrate --restart stops the guest itself, so a failure or an interrupt could leave a previously-running container down: no pct start on rollback, no hint in the handler. It is now captured for every CT. - Relatedly, the "source already deleted" branch was gated on the same conditions, so for such a CT a post-transfer failure printed nothing at all about the guest having moved. It now always reports that. - The VM path never mentioned qm unlock, though an interrupted qm remote-migrate leaves 'lock: migrate' on the source config just as readily as the CT path it already warns about. - Neither signal handler printed the stale-target-volume warning that the synchronous failure paths do — despite an interrupt killing the transfer outright, which makes a half-written volume more likely, not less. - SSH calls had no ConnectTimeout or keepalives. On a partition that drops packets rather than refusing them, the restore steps that run after the source config is deleted would hang on the OS-level TCP timeout, silently stalling the batch at the exact moment a guest's fate depends on them. - A timed-out pct shutdown now suggests checking pct status, since a partially-processed shutdown leaves the CT in an unknown state. Also documents two things the script cannot fix: the API token is visible in ps while a migration runs, and re-running after a failed restore will treat the degraded config as the original, since nothing is persisted between runs. Co-Authored-By: Claude Opus 4.8 <[email protected]> |
||
| LICENSE | ||
| migrate-guest.sh | ||
| README.md | ||
homelab-migration
Migrate LXCs and VMs between two Proxmox VE clusters with migrate-guest.sh.
Run it as root on the source node; it auto-detects the guest type per VMID
and drives pct remote-migrate / qm remote-migrate — the same mechanism
Proxmox Datacenter Manager uses, but scriptable and with the container
feature-flag problem handled automatically.
Why this is needed
Cross-cluster migration authenticates against the target with an API
token (e.g. root@pam!migrate). Proxmox only allows the actual
root@pam user — never a token — to set LXC feature flags other than
nesting. Most community-script containers ship with
features: nesting=1,keyctl=1, so migrating them via Datacenter Manager or
plain remote-migrate fails after the full disk copy with:
403 Permission check failed (changing feature flags (except nesting) is only allowed for root@pam)
The script works around this per container:
- Stops the container if running (a feature change on a running CT only
lands in
[pve:pending]and the original flags would still be shipped). - Strips the features line down to
nesting=1(unprivileged CT) or removes it entirely (privileged CT — tokens can't set any features there). - Migrates with
--restart --delete. - Restores the original features on the target over SSH (as real root, which is allowed to set them) and starts the container again.
On failure or Ctrl-C it restores the source container's features and running state, and every error path prints the exact manual command to recover.
HA-managed guests are handled automatically: remote-migrate refuses them outright, and the HA manager would restart a container behind the script's back when it shuts it down for the feature strip. The script removes the guest from HA (saving its settings) before doing anything else, re-adds it on the source if the migration fails, and on success reminds you to configure HA on the target cluster — HA groups don't transfer, so that part stays manual.
Bind mounts on shared storage are handled the same way as the feature flags. A mount like
mp0: /mnt/pve/truenas-media,mp=/data,shared=1
isn't node-local data — /mnt/pve/<storeid> is where PVE mounts an NFS/CIFS
storage, so the target cluster reaches the same share over the network and
there is nothing to copy. Setting a bind mount is root-only, though, so the
token-authenticated target rejects a config carrying one. The script strips
the mpN: lines before migrating and re-adds them over SSH afterwards,
inside the same rollback path as the features.
This only works if the target defines a storage with the same id — that's
what makes /mnt/pve/<storeid> resolve to the same path on both sides:
pvesm add nfs truenas-media --server 10.10.10.10 --export /mnt/tank0/Media/plex --content images
The script checks the target's storages up front — before touching anything —
and skips the guest, naming the storage, if the one it needs isn't there.
Mounts without shared=1, or pointing outside /mnt/pve, are still
refused: that data exists only on the source node and remote-migrate can't
move it.
Two caveats on that check. It requires the storage to be active, not merely defined, so a target storage that exists but isn't currently mounted is skipped the same way a missing one is. And the storage list is fetched once per run and cached — if you bring a storage up on the target mid-batch, the running script won't see it, so re-run rather than expecting later guests to pick it up.
Both rest on assumptions worth knowing. /mnt/pve/<storeid> is the
default path for an NFS/CIFS storage, not a guarantee — it can be
overridden at creation time, which would break the same-id-same-path
premise. And shared=1 is self-asserted: per pct.conf(5), "This option
does not share the mount point automatically, it assumes it is shared
already!" Neither is verified by PVE, so the script trusts what the source
config declares. Both hold for storage created through the GUI.
It also refuses up front — before any shutdown or disk transfer — guests
that cross-cluster migration can't handle: node-local bind mounts, device
passthrough (dev0:), hookscripts, snapshots, and VM args: lines. Raw
lxc.* config lines are silently dropped by Proxmox on the target, so the
script prints them for you to re-add manually. Pool membership doesn't
travel either; re-add guests to pools on the target by hand.
One-time setup
- On the target cluster, create a migration token:
pveum user token add root@pam migrate --privsep 0 - Get the target node's certificate fingerprint (on the target):
pvenode cert info | grep -A1 pve-ssl | grep Fingerprint - Set up SSH key auth from the source node to the target node, using the
same address you put in
TARGET_HOST(host keys are per-IP):ssh-copy-id root@<target> ssh root@<target> # once, to accept the host key - Fill in the
CONFIGblock at the top ofmigrate-guest.sh:TARGET_HOST,TARGET_TOKEN,TARGET_FINGERPRINT,TARGET_STORAGE,TARGET_BRIDGE.
TARGET_HOST decides two things at once: which target node the guests land
on, and which network path the data takes — all disk traffic flows to that
address on port 8006. To move data over a fast link (e.g. a 40G LACP bond),
give the bond an IP on both sides and use the target's bond IP here.
The script preflights everything it can before touching a guest: config placeholders, non-interactive SSH, and API reachability on port 8006.
The token ends up in the argument list of pct/qm remote-migrate, so it is
visible in ps to any other local user for the duration of a migration.
That's fine on a node only admins can log into — worth knowing if yours isn't.
If something fails, act on the printed commands before re-running
Every failure path prints the exact commands to put the guest right. Run them before retrying, because the script keeps no record of the original config between runs — it reads the live config as ground truth each time.
So if a feature or mount restore fails and you re-run without fixing it, the script sees the already-stripped config as the original and migrates the guest that way: the container arrives missing a mount or a feature flag, and nothing warns you, because as far as the second run can tell that's how it always was. This is the one failure mode the rollback logic cannot protect you from.
Usage
./migrate-guest.sh <vmid> [vmid...]
Guests keep their VMID on the target cluster. Running VMs migrate live; stopped VMs migrate offline. Containers use restart-mode migration (brief downtime), and containers that need a feature or bind-mount strip are stopped by the script, arrive stopped, and are started fresh on the target once their config has been restored.
Test on one noncritical guest before running a batch.
Logging to a file
Pipe through tee to keep a record while still watching live progress.
2>&1 matters — the !! error/skip lines go to stderr and would otherwise
be missing from the log:
./migrate-guest.sh 118 120 121 2>&1 | tee migration-$(date +%F).log
Run long batches inside tmux on the source node — if your SSH session drops mid-run, a plain shell HUPs the script and aborts the batch, while a tmux session just detaches and the migration keeps going:
tmux new -s migration
./migrate-guest.sh 118 120 121 2>&1 | tee migration-$(date +%F).log
# if disconnected, later: tmux attach -t migration
Running migrations in parallel
The script migrates its arguments one at a time. To run migrations concurrently, start multiple instances with disjoint VMID lists (never the same VMID twice), each in its own tmux window/pane with its own log:
# window 1
./migrate-guest.sh 118 120 122 2>&1 | tee batch-a.log
# window 2
./migrate-guest.sh 121 123 125 2>&1 | tee batch-b.log
Parallel instances are safe — there's no shared state, and Proxmox's per-VMID locks catch accidental overlap. Two streams is a sensible ceiling: a single migration is one TCP flow and LACP hashes per-flow, so one transfer uses at most one bond member; beyond two you're mostly contending for storage I/O on both ends rather than moving data faster. Watch the first pair complete before launching more, so a config mistake fails once, not everywhere.