Skip to content

Instantly share code, notes, and snippets.

@tomesparon
Created March 11, 2026 16:31
Show Gist options
  • Select an option

  • Save tomesparon/586a704f2202e5d142267b12cdaf8f6c to your computer and use it in GitHub Desktop.

Select an option

Save tomesparon/586a704f2202e5d142267b12cdaf8f6c to your computer and use it in GitHub Desktop.
KVM backup script - #libvirt #virtnbdbackup
#!/usr/bin/env bash
# Tom Esparon - 2026
# KVM backup operations cycle for active/running kvm machines(only).
#
# Backup captures:-
# File-backed disks: virtnbdbackup (stream) with lz4 compression
# LVM-backed disks (/dev/...): live lvm based snapshot -> pv -> xz -> file (once per month)
# VM XML config : saved with xmldump for LVM-backed VMs, virtnbdbackup handles file-backed VMs
#
# The first backup of every vm on every new month is forced to be full size,
# the rest backups on the month are incremental.
# There is logic to handle Ctrl+C cancellation when ran interactively on cmdline.
#
# Tested with virtnbdbackup Version 2.45 and libvirtd 10.0
# @see https://github.com/abbbi/virtnbdbackup for requirements and options of virtnbdbackup tool
set -euo pipefail
# Run as root check (re-exec via sudo)
if [[ ${UID} -ne 0 ]]; then
exec sudo bash "$(realpath "${0}")" "${@}"
fi
# --- Settings ---
uri="qemu:///system"
vmburoot="/data/vm-storage/testbackups"
type="stream"
moye="$(date +'%m-%Y')"
# virtnbdbackup compression (lz4)
compress_level="2"
# xz compression for LVM images
xz_level="3"
install -d -m 0755 -- "${vmburoot}"
# Get running VMs robustly
mapfile -t vmlist < <(virsh list --name --state-running | sed '/^$/d')
if (( ${#vmlist[@]} == 0 )); then
echo "No running VMs found; nothing to back up." >&2
exit 0
fi
dump_vm_xml() {
local vm_name="${1}"
local out_dir="${2}"
local xml_path="${out_dir}/vmconfig.${vm_name}.xml"
virsh dumpxml -- "${vm_name}" > "${xml_path}"
}
# Separate function for snapshotting LVMs
backup_lvm_lv() {
local vm_name="${1}"
local lv_path="${2}"
local out_dir="${3}"
local lv_base
lv_base="$(basename -- "${lv_path}")"
# Only 1 LVM backup per month per LV (skip if exists)
if compgen -G "${out_dir}/${lv_base}"_*.img.xz > /dev/null; then
echo "[${vm_name}] LVM monthly backup already exists for '${lv_base}' in '${out_dir}', skipping."
return 0
fi
# Query LV info
local lv_line
lv_line="$(lvs -a --noheadings -o vg_name,lv_attr,pool_lv -- "${lv_path}" | awk '{$1=$1};1')"
local vg_name lv_attr pool_lv
vg_name="$(awk '{print $1}' <<< "${lv_line}")"
lv_attr="$(awk '{print $2}' <<< "${lv_line}")"
pool_lv="$(awk '{print $3}' <<< "${lv_line}")"
if [[ -z "${vg_name}" || -z "${lv_attr}" ]]; then
echo "[${vm_name}] ERROR: Could not determine LV info for '${lv_path}'" >&2
return 1
fi
local snap_name snap_dev out_file
snap_name="backup_${vm_name}_${lv_base}_snap"
snap_dev="/dev/${vg_name}/${snap_name}"
out_file="${out_dir}/${lv_base}_$(date +'%Y%m%d').img.xz"
# Ensure snapshot is removed on function return if created
local created_snap="0"
cleanup_snap() {
if [[ "${created_snap:-0}" == "1" ]]; then
lvremove -f -- "${snap_dev}" >/dev/null 2>&1 || true
created_snap="0"
fi
}
# Save existing signal traps (so we can restore them)
local old_int_trap old_term_trap
old_int_trap="$(trap -p INT || true)"
old_term_trap="$(trap -p TERM || true)"
restore_traps() {
if [[ -n "${old_int_trap}" ]]; then
eval "${old_int_trap}"
else
trap - INT
fi
if [[ -n "${old_term_trap}" ]]; then
eval "${old_term_trap}"
else
trap - TERM
fi
}
# Always cleanup + restore traps when the function returns
trap 'cleanup_snap; restore_traps' RETURN
# On Ctrl+C or TERM, cleanup then re-raise signal with default behavior
trap 'cleanup_snap; restore_traps; trap - INT; kill -s INT $$' INT
trap 'cleanup_snap; restore_traps; trap - TERM; kill -s TERM $$' TERM
# Standard snapshot: size = max(10% LV size, 2GB)
local lv_size_bytes snap_size_bytes
lv_size_bytes="$(lvs --noheadings -o lv_size --units b --nosuffix -- "${lv_path}" | awk '{$1=$1};1')"
if [[ -z "${lv_size_bytes}" || ! "${lv_size_bytes}" =~ ^[0-9]+$ ]]; then
echo "[${vm_name}] ERROR: Could not read LV size for '${lv_path}'" >&2
return 1
fi
snap_size_bytes="$(( lv_size_bytes / 10 ))"
if (( snap_size_bytes < 2147483648 )); then
snap_size_bytes="2147483648"
fi
echo "[${vm_name}] Creating standard snapshot '${snap_name}' size ${snap_size_bytes}B for '${lv_path}'"
lvcreate --snapshot --size "${snap_size_bytes}B" --name "${snap_name}" -- "${lv_path}" >/dev/null
created_snap="1"
# MAIN CMD FOR LVM
echo "[${vm_name}] Backing up snapshot '${snap_dev}' -> '${out_file}'"
pv -- "${snap_dev}" | xz -T0 -"${xz_level}" > "${out_file}"
echo "[${vm_name}] LVM backup complete: ${lv_path} -> ${out_file}"
}
echo "VMs to backup ${vmlist[*]}"
# Main loop of vms
for vm in "${vmlist[@]}"; do
vm_dir="${vmburoot}/${vm}"
out_dir="${vm_dir}/${moye}"
# Decide level based on whether this month directory already exists
level=auto
install -d -m 0755 -- "${vm_dir}" "${out_dir}"
# Collect disk sources
mapfile -t disk_sources < <(
virsh domblklist "${vm}" --details \
| awk 'NR>2 {print $4}' \
| sed '/^-\s*$/d' \
| sed '/^$/d'
)
# Split into LVM vs file-based
lvm_disks=()
file_disks=()
for src in "${disk_sources[@]:-}"; do
if [[ "${src}" == /dev/* ]]; then
lvm_disks+=("${src}")
else
file_disks+=("${src}")
fi
done
# Save VM XML only if this VM has LVM-backed disks
if (( ${#lvm_disks[@]} > 0 )); then
dump_vm_xml "${vm}" "${out_dir}"
echo "[${vm}] Saved XML to '${out_dir}/vmconfig.${vm}.xml'"
fi
# PART 1 --- LVM disks snapshot backup (/dev/...) (monthly)
for disk in "${lvm_disks[@]:-}"; do
[[ -n "${disk}" ]] || continue
backup_lvm_lv "${vm}" "${disk}" "${out_dir}"
done
# PART 2 --- virtnbdbackup only if there are file-backed disks
if (( ${#file_disks[@]} > 0 )); then
virtnbdbackup \
-U "${uri}" \
--domain "${vm}" \
--level "${level}" \
--type "${type}" \
--output "${out_dir}" \
--compress="${compress_level}"
sleep 1
echo "[${vm}] Verifying virtnbdbackup set in '${out_dir}'"
virtnbdrestore -i "${out_dir}" -o verify
else
echo "[${vm}] No file-backed disks detected; skipping virtnbdbackup."
fi
done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment