Created
March 11, 2026 16:31
-
-
Save tomesparon/586a704f2202e5d142267b12cdaf8f6c to your computer and use it in GitHub Desktop.
KVM backup script - #libvirt #virtnbdbackup
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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