Files
DuckbootandClaude Opus 5.5 82902edfc8 Restore a container backup with a smaller root disk
Proxmox only grows disks. The restore dialog gets an optional "Root
disk GiB" field for container backups; with a size, the backup is
restored as a new guest with --rootfs <storage>:<N> (Proxmox's advanced
restore mode).

- A smaller disk is only allowed for a new VMID, checked in the dialog
  and again in cli.restore(): with force, Proxmox deletes the old disks
  before it unpacks, and a failed unpack then removes the guest.
- The size must be a positive integer (0 gives an unlimited subvol on
  ZFS/dir storage) and above the used root disk space of a running
  guest.
- The confirm screen is a separate DANGER screen with a red border: it
  says mount points are dropped and their backed-up files land in the
  root disk, raw lxc.* lines can come back under root@pam, root disk
  options are not carried over, and the used space is no fit check.
- Never --unique or ignore-unpack-errors.

Design: docs/plans/lxc-shrink.md (section A).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:58:02 +01:00

6.7 KiB

Shrink a container's root disk

Problem

Proxmox only grows disks (resize_vm dies with "unable to shrink disk size"). A container whose rootfs was sized too large cannot be made smaller from the web UI or this TUI. Two routes exist, verified against pve-container 6.1.18 (master) source:

  1. API: restore a backup with an explicit rootfs=<storage>:<GiB> ("advanced restore mode", pct.adoc). Reachable through cv4pve-cli (do restore ct ... --rootfs S:N forwards the key; checked with --dry-run).
  2. Node shell: copy the files into a new, smaller volume and swap it in.

Both are dangerous; the TUI must say so plainly.

Approach

A. TUI: smaller rootfs on restore (container backups only)

  • RestoreScreen gets an optional field "Root disk GiB" for lxc backups. Empty = today's behaviour, unchanged.
  • With a size set, only New guest is allowed. Overwrite + size is refused: with force=1 PVE destroys every old volume before it extracts, and a failed extraction then deletes the new disks and the config too; the container is gone.
  • cli.restore() gets rootfs_gb: int | None; when set it checks it with _positive (a size of 0 makes PVE allocate an unlimited subvol on ZFS/dir) and adds --rootfs <storage>:<N> (same storage as the dialog's choice). Never ignore-unpack-errors.
  • The confirm screen for a shrink restore is a separate, red warning:
    • every mpX of the backup is dropped from the new config; files of backup=1 mount points land inside the root disk and count against its size; backup=0 mount points are not in the backup at all;
    • raw lxc.* lines (including lxc.mount.entry bind mounts) may come back when cv4pve-cli runs as root@pam: do not run both guests at once;
    • rootfs options (acl, quota, mountoptions, replicate) are not carried over;
    • names the guest's current mount points, saying the backup may differ;
    • shows rootfs used space when the guest is running (status/current disk; 0 when stopped, shown as "unknown"), and says this is not a fit check: the backup may be older, mount point data and ext4 overhead (5 % reserve, inodes) come on top;
    • a too-small disk makes the restore task fail; the original is untouched because this is always a new VMID.
  • Hard refusal only where it is certain: size not a positive integer, or size <= used rootfs GiB of a running guest.
  • Known limit, unchanged from today's restore: run(timeout=3600) can give up while the PVE task still runs.

B. Node script scripts/pct-shrink-rootfs (bash, run as root on the node)

Usage: pct-shrink-rootfs [--yes] <vmid> <new-size-GiB>. Without --yes it only measures and prints the plan; measuring runs pct mount/pct unmount, which sets and clears lock: mounted (so even a dry run touches the config briefly).

Refuses (exit non-zero, nothing changed) when: not root; pct missing; CT not stopped; config has any [...] section (snapshots, [pve:pending]); lock: set; template: 1 or protection: 1; HA-managed (ha-manager status); a replication job exists (pvesr list); rootfs is not a volume; any mpN or lxc.mount.entry (rootfs-only CTs: no shadowed files, df measures rootfs alone, pct mount cannot fail on another storage); new size >= current size; size not a positive integer.

Fit check after pct mount (df -B1 and df -i on the rootfs): used bytes * 1.1 <= N GiB * 0.93 (5 % root reserve + ~2 % ext4 metadata), and used inodes * 1.1 <= the new disk's inodes (N * 65536, mkfs.ext4 default ratio). On any error after pct mount the trap runs pct unmount; if that fails it stops and prints the manual cleanup (pct unmount, pct unlock), touching nothing else.

Steps (copy route, any storage):

  1. pct set <id> --mp9 <storage>:<N>,mp=/.shrink-new - PVE allocates and formats; the script reads back mp9's volid and size=.
  2. pct mount <id>; rsync -aHAX --numeric-ids --whole-file --sparse --one-file-system --exclude=/.shrink-new old rootfs -> new; pct unmount <id>. On rsync failure: unmount, then free the new volume explicitly - pct set --delete mp9 only moves it to unusedN, so the script then deletes the unusedK whose value is exactly the new volid (found by value, never by index).
  3. Swap, under PVE's config flock (flock /run/lock/lxc/pve-config-<id>.lock): copy the config to /root/pct-shrink-<id>-<timestamp>.conf; rewrite the main section with awk (exact string compare, no regex on volids) into <conf>.tmp.<pid> and rename it, as PVE's file_set_contents does on pmxcfs; refuse if rootfs or mp9 changed since the copy: rootfs: gets the new volid with the old options and mp9's size=, the mp9: line goes, and unusedK: <old volid> is added at the first free K. No pct rescan: the script knows both volids and the size, so rescan's side effects (every orphan of the VMID added as unused, size=0 on pve-container < 6.1.9) are avoided.
  4. Print the exact unusedK key and value of the old rootfs: start the CT, check it, then pct set <id> --delete unusedK. The script never deletes the old volume.

The in-place ZFS refquota shortcut is left out: one route for every storage is less code to get wrong, and the copy keeps the original until the user deletes it. chattr flags (+i, +a) are not copied by rsync; the script says so.

Tests: pytest runs the script with stub pct, df, rsync, flock, ha-manager, pvesr on PATH and a temp config dir (PCT_CONF_DIR override, default /etc/pve/lxc; the plan output prints the directory used). bats is not installed and CI only runs pytest. ShellCheck via the edit hook.

Out of scope

  • Shrinking mpX volumes, and containers that have any mount point (script).
  • Overwrite-in-place restore with a smaller disk.
  • Restoring back to the original VMID (second backup/restore cycle).
  • Running the node script from the TUI (it needs a root shell on the node).
  • Moving backup=0 mount points to the new guest.

Steps

  • cli.restore(rootfs_gb=...) adds --rootfs S:N; tests in test_cli.py.
  • RestoreScreen: size field for lxc, refuse with overwrite, refuse <= used of a running guest; shrink confirm text with the warnings above; tests in test_app.py.
  • scripts/pct-shrink-rootfs + tests/test_pct_shrink.py: each refusal, dry run changes no config, happy path (exact new config, unusedK at first free K with existing unused0), dir-style volid with / and ., rsync failure frees the new volume by value, unmount failure stops.
  • README: key table row and a warning paragraph; script usage.
  • Full uv run pytest, ruff, shellcheck green.

Open questions

  • Should a shrink restore pass --unique 1 (new MACs)? Today's restore does not, it warns instead. Proposal: keep that, the warning already covers it.