QEMU/KVM snapshot and backup orchestration for qcow2 images on any filesystem (XFS, ext4, etc.).
qsnap manages external disk-only snapshots via virsh, detects whether a VM disk has changed, enforces retention policies, performs incremental backups to separate storage, and maintains backing chain integrity via blockcommit. Every VM is fully multi-disk aware: each disk target (vda, vdb, ...) owns its own base image, snapshot chain, backup chain, and retention — qsnap never mixes disks.
- External snapshots — disk-only, no-metadata snapshots via
virsh snapshot-create-as, one per disk. - Change detection — skip snapshot creation when a disk hasn't changed (
onchangemode); independent source-disk-based gate for backup transfers (backup_create="onchange"). - Hysteresis retention (default) — grow-to-threshold / collapse-to-floor: the chain grows with no commits while
N ≤ H, then collapses down to floorL. Opt into steady count-based mode viasnapshot_retention_mode = "steady". - Snapshot preservation floor —
snapshot_preserve_minguarantees the newest N snapshots are never blockcommitted. - Incremental backups — NBD bitmap dirty-block transfer to targets, proportional to dirtied data.
- Full backups — standalone qcow2 via
qemu-img convert, with optional zstd/zlib compression. - Verification — three-tier FULL verification (M1/M2/M3) and per-transfer incremental verification.
- Count-based retention — configurable chain lengths and generation counts, evaluated per disk.
- Schedule preview —
--print-schedule/-Sshows current chain lengths and retention counts. - Backing chain integrity — automatic
blockcommit/qemu-img commitper disk, adaptive to VM power state. - State self-healing & reconciliation —
qsnap check --stateaudits,qsnap reconcileactively repairs state-vs-disk drift. - Deferred operations — MAC-blocked or VM-state-blocked blockcommits queued per disk and drained automatically.
- Restore / Fork — flatten a snapshot or backup into a standalone qcow2, replacing a single disk's base image.
git clone https://github.com/baroque-cat/qsnap.git
cd qsnap
makepkg -siInstalls qsnap to the system Python site-packages, the qsnap CLI to /usr/bin/qsnap, systemd units to /usr/lib/systemd/system/, and the config example to /etc/qsnap/qsnap.toml.example. System dependencies (libnbd, libvirt, qemu-utils) are pulled in automatically.
pip install git+https://github.com/baroque-cat/qsnap.gitOr clone and install with Poetry:
git clone https://github.com/baroque-cat/qsnap.git
cd qsnap
poetry installNote: When installing via pip/Poetry in a venv, qsnap appends system site-packages to
sys.pathat runtime so the systemlibnbdbindings are discoverable. For best results create the venv with--system-site-packages, or install via the PKGBUILD.
- Create a configuration file at
/etc/qsnap/qsnap.toml. Each VM declares one or more disks under[[vm.disk]]; each disk carries its ownbase_image:
state_dir = "/var/lib/qsnap/state"
snapshot_chain_length = 72 # hysteresis trigger threshold H (grow to 72)
target_chain_length = 168 # new FULL after 168 incrementals per disk
target_keep_generations = 2 # keep 2 FULL chains per target
snapshot_preserve_min = 24 # hysteresis collapse floor L (keep newest 24)
[[vm]]
name = "debiantest"
snapshot_dir = "/var/lib/libvirt/snapshots/debiantest"
[[vm.disk]]
target = "vda"
base_image = "/var/lib/libvirt/images/debiantest.qcow2"
[[vm.target]]
path = "/mnt/backup/debiantest"
verify = "metadata"- Run the full pipeline (snapshot + backup + retention):
qsnap run debiantest- Preview what retention will keep before running:
qsnap run debiantest --print-schedule- Or run individual steps:
qsnap snapshot debiantest # create snapshots only
qsnap backup debiantest # transfer backups only
qsnap prune debiantest # retention + cleanup only| Command | Description |
|---|---|
qsnap run [vm] |
Full pipeline: snapshot -> backup -> retention |
qsnap snapshot [vm] |
Create snapshots only |
qsnap backup [vm] |
Transfer backups to targets only |
qsnap prune [vm] |
Apply retention policies and cleanup only |
qsnap list snapshots [vm] |
List snapshots with a DISK column (--tree for per-disk backing-chain view) |
qsnap list backups [vm] |
List backups across all targets (--tree for VM -> Target -> Disk -> chain hierarchy) |
qsnap list latest [vm] |
Show most recent snapshot per VM per disk |
qsnap list config |
Show parsed VM configurations (disks and targets) |
qsnap list deferred |
Show deferred blockcommit operations (per VM per disk) |
qsnap stats [vm] |
Show snapshot/backup counts and sizes |
qsnap check [vm] |
Verify backing-chain integrity (--deep for corruption check, --state for state audit) |
qsnap reconcile [vm] |
Actively repair state-vs-disk inconsistencies (--dry-run to preview) |
qsnap restore <name> [vm] |
Replace a stopped VM's disk with a flattened qcow2 from a snapshot/backup (--dry-run, --yes) |
qsnap fork <name> --output <path> [vm] |
Create a standalone qcow2 from a snapshot or backup |
qsnap estimate [vm] |
Preview factual storage data without running the pipeline |
| Flag | Description |
|---|---|
--config, -c |
Path to TOML config file (default: /etc/qsnap/qsnap.toml) |
--dry-run, -n |
Print planned actions without executing |
--preserve |
Skip all deletion (snapshots and backups) |
--preserve-snapshots |
Skip snapshot deletion only |
--preserve-backups |
Skip backup deletion only |
--verbose, -v |
Enable DEBUG-level logging |
--quiet, -q |
Enable ERROR-level logging only |
--loglevel, -l |
Set log level explicitly |
--format |
Output format: table (default), long, raw, col:<columns> |
-L |
Shortcut for --format long |
--lockfile |
Override lockfile path |
The run, snapshot, backup, and prune subcommands additionally accept:
| Flag | Description |
|---|---|
--print-schedule, -S |
Print the retention schedule simulation and exit |
--timer |
Log the schedule summary at INFO level, then continue (for cron/systemd) |
Configuration is TOML. Keys are organized in four levels: global (top-level), per-VM ([[vm]]), per-disk ([[vm.disk]]), and per-target ([[vm.target]]). Values at lower levels override the defaults above them.
| Key | Type | Default | Description |
|---|---|---|---|
state_dir |
string | /var/lib/qsnap/state |
Directory for JSON state files |
lockfile |
string | none | Lockfile path to prevent concurrent runs (unset = no locking) |
snapshot_chain_length |
int | 72 |
Hysteresis trigger threshold H (grow to N before collapsing); steady-mode keep count |
target_chain_length |
int | 168 |
Max incremental backups per disk before a new FULL |
target_keep_generations |
int | 2 |
FULL backup generations (chains) to keep per target |
snapshot_preserve_min |
int | 24 |
Hysteresis collapse floor L (newest N never blockcommitted). 0 = inactive |
snapshot_retention_mode |
string | "hysteresis" |
"hysteresis" (grow-to-threshold / collapse-to-floor, default) or "steady" (count-based keep) |
max_commits_per_run |
int | 12 |
Max snapshots blockcommitted per disk per run (both retention modes). 0 = unlimited |
compress |
bool | true |
Compress FULL backups via qemu-img convert -c |
compression_type |
string | "zstd" |
"zstd" (fast) or "zlib" (smaller). Only when compress = true |
convert_parallel |
int | 4 |
qemu-img convert -m parallel coroutines (1-8) |
convert_out_of_order |
bool | true |
qemu-img convert -W out-of-order writes |
backup_stall_timeout |
string | "30m" |
Kill a transfer if the output file stops growing for this long. "0s" disables |
backup_create |
string | "always" |
"always" or "onchange" (skip transfer when the source disk is unchanged) |
full_verify_after_create |
string | "check" |
FULL verification after creation: "off", "metadata", "check", "compare" |
full_verify_before_delete |
string | "check" |
FULL verification before deletion: "metadata", "check", "off" (M1 always enforced) |
auto_cleanup |
bool | true |
Remove stale .tmp/.partial files and NBD sockets before each run |
chain_verify_before_commit |
bool | true |
Verify backing-chain integrity before blockcommit |
chain_verify_after_commit |
bool | true |
Verify chain length decreased after blockcommit |
deep_check_schedule |
string | "off" |
Deep-check cadence for qsnap check --deep reporting: "off", "weekly", "monthly" |
transaction_log |
string | none | Path to an append-only transaction log (one line per action) |
| Key | Type | Default | Description |
|---|---|---|---|
name |
string | required | VM name as known to libvirt |
snapshot_dir |
string | none | Default directory for this VM's snapshot overlays. Each disk may override it |
snapshot_create |
string | "always" |
"always" or "onchange" (skip if disk allocation unchanged) |
snapshot_chain_length |
int | inherits global | VM-specific snapshot chain length |
target_chain_length |
int | inherits global | VM-specific target chain length |
target_keep_generations |
int | inherits global | VM-specific FULL generations to keep |
snapshot_preserve_min |
int | inherits global | VM-specific snapshot preservation floor |
snapshot_retention_mode |
string | inherits global | VM-specific retention mode: "steady" or "hysteresis" |
snapshot_quiesce |
bool | false |
Use --quiesce for filesystem-consistent snapshots (requires qemu-guest-agent) |
lifecycle_mode |
string | "virsh" |
"virsh" (live blockcommit while running, offline commit when shut off) or "qemu-img" (offline-only) |
change_detection_mode |
string | "allocation-map" |
"allocation-map" (compare qemu-img map regions) or "allocation-size" (compare qemu-img info actual-size) |
blockcommit_deep_verify |
bool | false |
Run qemu-img check on the base image after offline commits |
compress |
bool | true |
Compress FULL backups for this VM (inherited by targets) |
compression_type |
string | "zstd" |
Compression algorithm: "zstd" (faster) or "zlib" (smaller) |
convert_parallel |
int | 4 |
qemu-img convert -m parallel coroutines (1-8) |
convert_out_of_order |
bool | true |
qemu-img convert -W out-of-order writes |
backup_stall_timeout |
string | "30m" |
Stall detection timeout for data-transfer commands |
verify |
string | "metadata" |
Post-transfer verification: "off", "metadata", "check", "compare" |
Each VM must declare at least one disk. Every disk owns its own base image and backing chain.
| Key | Type | Default | Description |
|---|---|---|---|
target |
string | required | libvirt device target name (e.g. "vda", "vdb") |
base_image |
string | required | Path to this disk's base qcow2 image |
snapshot_dir |
string | inherits VM | Optional per-disk snapshot directory override |
| Key | Type | Default | Description |
|---|---|---|---|
path |
string | required | Directory where backup qcow2 files are stored |
target_chain_length |
int | inherits VM/global | Target-specific chain length |
target_keep_generations |
int | inherits VM/global | Target-specific FULL generations to keep |
verify |
string | "metadata" |
"off", "metadata", or "compare" (chain-traversing qemu-img compare) |
compress |
bool | true |
Compress FULL backups. Bitmap incrementals are always uncompressed |
compression_type |
string | "zstd" |
"zstd" or "zlib". Only when compress = true |
convert_parallel |
int | 4 |
qemu-img convert -m parallel coroutines |
convert_out_of_order |
bool | true |
qemu-img convert -W out-of-order writes |
backup_stall_timeout |
string | "30m" |
Stall detection timeout. "0s" disables |
backup_create |
string | "always" |
"always" or "onchange" |
backup_retry_max |
int | 3 |
Retries for transient backup failures (exponential backoff) |
backup_retry_base |
string | "2s" |
Base delay for retry backoff |
Every pipeline stage is keyed by disk target. For a VM with disks vda and vdb:
- Snapshots — one external overlay per disk per run, named
{vm}.{timestamp}_{disk}_{6hex}.qcow2, each backing-chained to that disk's previous active layer. - Blockcommit — each disk's chain is merged into its own
base_image, independently. The active layer / XML tip of each disk is excluded and deferred on its own. - Retention —
snapshot_chain_length,snapshot_preserve_min, and the oldest-prefix filter are evaluated per disk. - Change detection — allocation baselines are stored per
(vm, disk). - Backups — each disk gets its own FULL and incremental chain on each target; checkpoints and NBD sockets are per disk.
- Restore / Fork — resolve the disk from the snapshot/backup name and act on that disk only.
virsh snapshot-create-as --diskspec {disk},file={path},snapshot=external already targets a single disk; qsnap issues one call per disk. Blockcommit passes --path {disk} and --base {that disk's base_image}.
qsnap's snapshot retention is hysteresis by default (snapshot_retention_mode = "hysteresis"); the older steady count-based mode is opt-in. Four keys control
retention:
snapshot_chain_length— in hysteresis mode the trigger threshold H (the chain grows with no commits whileN ≤ H); in steady mode the keep count (blockcommit the excess).snapshot_preserve_min— in hysteresis mode the collapse floor L (a collapse merges down to the newestL); in steady mode the newest N snapshots are never blockcommitted.0disables the floor.target_chain_length— max incrementals per disk before the next backup creates a new FULL.target_keep_generations— FULL chains to keep per target. A FULL plus its incrementals is one generation.
Hysteresis mode implements a grow-to-threshold / collapse-to-floor band instead of committing on every run:
snapshot_chain_lengthis the trigger threshold H: while the snapshot countN ≤ Hno commits happen (the chain just grows).snapshot_preserve_minis the collapse floor L: onceN > H, the oldestN − Lsnapshots are merged down to the floor. The newestLare always kept.- The collapse is a persisted per-disk phase: once triggered it continues across
subsequent runs until
N ≤ L, then the chain grows again. max_commits_per_runcaps how many snapshots one run may merge (default12,0= unlimited).
Validation requires H > L ≥ 1. The default band is snapshot_chain_length = 72,
snapshot_preserve_min = 24: keep hourly snapshots for the last 24 hours and
collapse older layers only occasionally (when the chain reaches 72).
Migration note: a VM already holding a deep chain (say N snapshots above
the floor) converges gradually over ceil((N − L) / max_commits_per_run) runs,
not in one giant batch.
Opting into steady mode restores the pre-existing count-based policy: keep the
newest snapshot_chain_length and blockcommit the excess every run
(snapshot_preserve_min still floors the newest N when it exceeds the chain
length).
Rule of thumb: keep the newest N, remove the oldest. For backups the newest N FULL generations are kept.
Preview the schedule before running:
qsnap run myvm --print-schedule # or -S
qsnap run --timer # for cron/systemd: log summary, then runSnapshots that retention removes are merged back into their disk's base image to keep the backing chain short. qsnap picks the safe mechanism from the VM's current power state on every run, per disk:
| VM state | lifecycle_mode = "virsh" (default) |
lifecycle_mode = "qemu-img" |
|---|---|---|
| running | Non-active snapshots committed live via virsh blockcommit; the active layer is deferred ("vm_running") |
Everything deferred ("vm_running") — writing into a live base is unsafe |
| shut off | Offline qemu-img commit + child pivot + file deletion |
Same as "virsh" |
| paused / other | Everything deferred ("vm_running") |
Everything deferred |
Deferred entries wait in state and drain automatically on a later run once the VM is in a compatible state. Deferral reasons:
"vm_running"— the snapshot was the active layer of a running VM (or the VM was paused / in qemu-img mode)."active_layer"— the snapshot is the XML-referenced tip of a shut-off domain; never deleted offline (it would make the domain unbootable). Drains once it is no longer the tip."apparmor"/"selinux"— the commit was blocked by MAC policy.
After offline commits, qsnap strips stale <backingStore> elements from the domain XML and redefines it, so virsh start re-probes the shortened chain and the VM stays bootable.
Backups use the NBD bitmap pull-model (virsh backup-begin with checkpoint-based dirty-block tracking). libvirt >= 7.2 and python3-libnbd are hard requirements.
A FULL is a standalone qcow2 with no backing dependency, created via qemu-img convert. It is the anchor for subsequent incrementals and a self-contained restore point.
- The first backup to a target always starts with a FULL.
- A new FULL is created when a disk's incremental count exceeds
target_chain_length. - Running VMs:
virsh backup-beginstarts an NBD export andqemu-img convertreadsnbd:unix:<socket>. Stopped VMs: directqemu-img convertfrom the disk's source file. - The FULL is written to a
.tmpfile and atomically renamed on success. - Naming:
{vm}.FULL.{timestamp}_{disk}_{6hex}.qcow2.
Each incremental is a backing-chained qcow2 delta (chained to the previous backup for the same disk, or to the FULL for the first one). An in-process copy loop over the libnbd bindings negotiates the base:allocation and qemu:dirty-bitmap:backup-{disk} meta-contexts and copies only dirty, allocated blocks — so an incremental is proportional to dirtied data, not disk size.
- Every
backup-beginreceives a checkpoint XML, so the successor checkpoint is created atomically at the export's freeze point — the backup chain is gap-free by construction. - Checkpoint naming:
qsnap-{target_hash}-{disk}-{timestamp}-{6hex}. Superseded checkpoints are deleted after a successful export, keeping one baseline per disk. - Naming:
{vm}.{timestamp}_{disk}_{6hex}.qcow2.
First incremental after a FULL contains all blocks written since the FULL's freeze point (the checkpoint baseline is anchored there). Its size is bounded by guest write rate × FULL duration, so schedule FULLs during low write activity.
- Incrementals are uncompressed — compressed qcow2 clusters can only be produced by
qemu-img convert; the dirty-block loop writes via random-accesspwrite.compress/compression_typeapply to FULL backups, where the bulk of the bytes are. - Checkpoints live in libvirt, not in state files — use
qsnap check --stateto detect orphaned checkpoints andqsnap reconcileto delete them.
Per-transfer incremental verification is set per target via verify:
| Tier | Description |
|---|---|
"off" |
No verification |
"metadata" |
Default. qcow2 format + virtual-size; for incrementals also backing-filename and a dirty-size regression barrier |
"compare" |
Metadata + chain-traversing qemu-img compare content verification |
FULL backups get a separate three-tier check at lifecycle points:
| Tier | Checks | When |
|---|---|---|
| M1 | Format is qcow2, no corrupt bit |
Post-create, pre-deletion (always enforced) |
| M2 | qemu-img check — zero errors/leaks |
Post-create / pre-deletion when configured |
| M3 | qemu-img compare content comparison |
Post-create when configured as "compare" |
A FULL that fails post-create verification is deleted and not recorded; a FULL that fails pre-deletion verification blocks deletion of its generation with a CRITICAL log.
The VM pipeline is the atomic unit of execution. A definitive per-disk failure aborts the remaining steps of that VM; qsnap reports the failure and continues with the next VM. Already-completed steps are not rolled back.
| Stage | Failure | Behavior |
|---|---|---|
| Snapshot creation | any disk fails (or its snapshot directory is missing) | VM aborts (exit 1) |
| Pre-commit verification | broken backing chain | CRITICAL + VM aborts — run qsnap check --deep and repair manually; no automatic recovery |
| Blockcommit | commit rejected (non-MAC) | VM aborts (exit 1) |
| Post-commit verification | chain length did not decrease | VM aborts (chain potentially damaged) |
| Blockcommit | AppArmor/SELinux denial | NOT a failure — the operation is deferred and drains on a later run |
| FULL / incremental backup | failure after retries | VM aborts with exit 10 (EXIT_BACKUP_ABORT); successful transfers of the batch are kept and recorded |
Other VMs are always processed normally. Automatic repair of broken chains (partial blockcommit, auto-rebase) was removed — a broken chain requires operator intervention before qsnap resumes blockcommits for that VM.
qsnap keeps per-VM JSON state under state_dir and offers three levels of consistency management:
- Read-only audit —
qsnap check --statereports phantom snapshots/FULLs, stale dependencies, corrupt state files, and orphaned checkpoints, without changing anything. - Automatic self-healing — every pipeline run validates state vs. disk at startup (non-fatal): phantom FULLs are removed from state with their dependencies, and stale allocation baselines are cleared.
- Active repair —
qsnap reconcilefixes both directions: removes phantom state entries and deletes orphan files/checkpoints on disk.
qsnap reconcile --dry-run # preview
qsnap reconcile [vm] # repairNon-qsnap files on a target (those not matching the {vm}.* naming pattern) are never deleted — a WARNING is logged and they are skipped.
qsnap restore replaces a stopped VM's disk with a flattened standalone qcow2 created from the named snapshot or backup. The disk is resolved from the snapshot/backup name.
qsnap restore <snapshot_name> [vm] [--dry-run] [--yes]Steps:
- Resolve the snapshot/backup by name across configured VMs (state + backup targets).
- Verify the VM is stopped — aborts if running.
- Pre-verify source chain integrity — aborts if broken.
- Convert the source to a temporary standalone qcow2.
- Delete that disk's old snapshot overlays.
- Atomically replace the disk's base image (
os.replace). - Update domain XML for that disk only — strip
<backingStore>, update<source file>,virsh define. - Reset state for the VM and its targets.
- Best-effort deletion of all
qsnap-*checkpoints.
--dry-run logs planned actions without executing; --yes skips the confirmation prompt. After restore, the disk is a standalone qcow2 with no backing chain, and the next run creates a fresh snapshot and a new FULL.
qsnap fork creates a standalone qcow2 from any qsnap-managed snapshot or backup. The result has no backing dependencies; creating a VM from it is the operator's responsibility.
qsnap fork <snapshot-name> --output <path> [vm]- Resolve the snapshot/backup by name across configured VMs.
- Estimate total chain size and log it.
- Convert to a standalone qcow2 via
qemu-img convert --force-share -O qcow2.
--force-share allows reading the source even if it is the active layer of a running VM; the resulting image may be inconsistent if the guest is writing — stop the VM or fork a non-active snapshot for consistency. Fork produces a file as large as the full virtual disk (not sparse).
[[vm]]
name = "desktop-vm"
snapshot_dir = "/var/lib/libvirt/snapshots/desktop-vm"
snapshot_create = "onchange"
snapshot_quiesce = true
[[vm.disk]]
target = "vda"
base_image = "/var/lib/libvirt/images/desktop-vm.qcow2"
[[vm.target]]
path = "/mnt/usb-backup/desktop-vm"
verify = "compare"
compress = falseEach disk gets its own base image, chain, and backups:
[[vm]]
name = "prod-server"
snapshot_dir = "/var/lib/libvirt/snapshots/prod-server"
snapshot_quiesce = true
[[vm.disk]]
target = "vda"
base_image = "/var/lib/libvirt/images/prod-server-os.qcow2"
[[vm.disk]]
target = "vdb"
base_image = "/var/lib/libvirt/images/prod-server-data.qcow2"
snapshot_dir = "/fast-nvme/snapshots/prod-server" # per-disk override
[[vm.target]]
path = "/mnt/nas-backup/prod-server"
verify = "compare"
compress = true[[vm]]
name = "test-vm"
snapshot_dir = "/var/lib/libvirt/snapshots/test-vm"
[[vm.disk]]
target = "vda"
base_image = "/var/lib/libvirt/images/test-vm.qcow2"
[[vm.target]]
path = "/tmp/backups/test-vm"- Python 3.11+
- libvirt + virsh (7.2+ — the NBD bitmap incremental backup API is complete since 7.2)
- qemu-img + qemu-nbd
- python3-libnbd (system package, e.g.
apt install python3-libnbd) — hard requirement - QEMU/KVM hypervisor
Incremental backups use the <incremental> XML element inside the <domainbackup> document passed to virsh backup-begin (there is no --incremental CLI flag):
virsh backup-begin --domain <vm> <backup.xml> <checkpoint.xml>backup.xml references the prior checkpoint and the NBD socket; checkpoint.xml names the successor checkpoint created atomically at the export's freeze point. FULL exports omit the <incremental> element.
The qsnap user needs libvirt access. Either add it to the libvirt group (sudo usermod -aG libvirt qsnap, then re-login) or configure polkit. Without access you will see errors like Failed to connect to system bus, unauthorized, or Failed to get shared "write" lock.
- Orphaned checkpoints —
qsnap check --statelists checkpoints whose target hash matches no configured target;qsnap reconciledeletes them (virsh checkpoint-delete --metadata). No space left on device/Permission denied— backup failures that are not retried; fix the target storage.- Broken backing chain —
qsnap checkreports the broken file; blockcommit aborts the affected VM's pipeline with a CRITICAL log (no automatic recovery). Repair the chain, then re-run. See Failure Handling.