Files
libvirtbackup/libvirt-backup.sh
2026-07-14 08:07:49 +00:00

300 lines
11 KiB
Bash

#!/usr/bin/env bash
# libvirt-backup.sh
# Offline-Backup persistenter libvirt/QEMU-VMs für eine Host-Neuinstallation.
set -Eeuo pipefail
IFS=$'\n\t'
umask 077
VERSION="1.2"
URI="${LIBVIRT_URI:-qemu:///system}"
BACKUP_ROOT="${1:-}"
RSYNC_OPTS=(-aHAX --numeric-ids --sparse --info=progress2)
ALLOW_RUNNING=0
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
usage() {
cat <<'EOF'
Aufruf:
sudo ./libvirt-backup.sh /pfad/zum/backup
sudo LIBVIRT_URI=qemu:///system ./libvirt-backup.sh /pfad/zum/backup
Umgebungsvariablen:
LIBVIRT_URI libvirt-Verbindung, Standard: qemu:///system
ALLOW_RUNNING=1 Unsicher: Backup trotz laufender VMs erlauben
Das Skript sichert:
- Domain-XMLs
- libvirt-Netzwerk-, Storage-Pool- und Interface-XMLs
- alle file-basierten Disk-/CDROM-/Floppy-Quellen aus den Domain-XMLs
- UEFI-NVRAM-Dateien
- dateibasierte TPM-Backend-Zustände
- qemu-img-Informationen und Prüfsummen
- PAW-Netzwerk aus 50-paw-install.yml inklusive br-lan- und Port-MACs
Blockgeräte wie LVM-LVs werden nur dokumentiert und NICHT kopiert.
EOF
}
die() { echo "FEHLER: $*" >&2; exit 1; }
warn() { echo "WARNUNG: $*" >&2; }
log() { printf '\n==> %s\n' "$*"; }
[[ "${ALLOW_RUNNING:-0}" == "1" ]] && ALLOW_RUNNING=1
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
[[ -n "$BACKUP_ROOT" ]] || { usage; exit 2; }
for cmd in virsh rsync sha256sum python3 qemu-img find sort awk sed findmnt; do
command -v "$cmd" >/dev/null || die "Benötigtes Programm fehlt: $cmd"
done
BACKUP_ROOT="$(readlink -m "$BACKUP_ROOT")"
[[ "$BACKUP_ROOT" != "/" ]] || die "Backup-Ziel darf nicht / sein."
mkdir -p "$BACKUP_ROOT"
[[ -z "$(find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -print -quit)" ]] \
|| die "Backup-Ziel ist nicht leer: $BACKUP_ROOT"
# Früh erkennen, ob das Ziel beschreibbar ist und der Mount read-only ist.
mount_target="$(findmnt -no TARGET -T "$BACKUP_ROOT" 2>/dev/null || true)"
mount_opts="$(findmnt -no OPTIONS -T "$BACKUP_ROOT" 2>/dev/null || true)"
[[ ",$mount_opts," != *,ro,* ]] \
|| die "Backup-Dateisystem ist nur lesbar eingehängt: ${mount_target:-unbekannt}"
probe="$BACKUP_ROOT/.libvirt-backup-write-test.$$"
printf 'libvirt backup write test\n' > "$probe" \
|| die "Backup-Ziel ist nicht beschreibbar: $BACKUP_ROOT"
sync "$probe" 2>/dev/null || sync
grep -qx 'libvirt backup write test' "$probe" \
|| die "Schreib-/Lesetest auf dem Backup-Ziel fehlgeschlagen."
rm -f "$probe"
mkdir -p "$BACKUP_ROOT"/{domains,networks,pools,interfaces,files,metadata,logs}
exec > >(tee -a "$BACKUP_ROOT/logs/backup.log") 2>&1
log "Prüfe libvirt-Verbindung $URI"
virsh -c "$URI" uri >/dev/null
mapfile -t VMS < <(virsh -c "$URI" list --all --name | sed '/^[[:space:]]*$/d')
((${#VMS[@]} > 0)) || warn "Keine persistenten Domains gefunden."
if (( ! ALLOW_RUNNING )); then
running=()
for vm in "${VMS[@]}"; do
state="$(virsh -c "$URI" domstate "$vm" 2>/dev/null | head -n1 | tr '[:upper:]' '[:lower:]')"
case "$state" in
"shut off"|"abgeschaltet"|"ausgeschaltet") ;;
*) running+=("$vm ($state)") ;;
esac
done
if ((${#running[@]})); then
printf 'Nicht ausgeschaltete VMs:\n' >&2
printf ' - %s\n' "${running[@]}" >&2
die "Alle VMs sauber herunterfahren oder ausdrücklich ALLOW_RUNNING=1 setzen."
fi
fi
log "Schreibe Host- und Paketinformationen"
{
echo "backup_format_version=$VERSION"
echo "created_utc=$(date -u +%FT%TZ)"
echo "hostname=$(hostname -f 2>/dev/null || hostname)"
echo "libvirt_uri=$URI"
echo "kernel=$(uname -srmo)"
echo "virsh_version=$(virsh --version)"
echo "qemu_img_version=$(qemu-img --version | head -n1)"
} > "$BACKUP_ROOT/metadata/backup-info.txt"
cp -a /etc/os-release "$BACKUP_ROOT/metadata/os-release" 2>/dev/null || true
virsh -c "$URI" capabilities > "$BACKUP_ROOT/metadata/capabilities.xml" 2>/dev/null || true
virsh -c "$URI" nodeinfo > "$BACKUP_ROOT/metadata/nodeinfo.txt" 2>/dev/null || true
log "Exportiere Domain-Definitionen"
for vm in "${VMS[@]}"; do
safe="$(printf '%s' "$vm" | sed 's#[/[:cntrl:]]#_#g')"
virsh -c "$URI" dumpxml --inactive --security-info "$vm" \
> "$BACKUP_ROOT/domains/$safe.xml" \
|| virsh -c "$URI" dumpxml --inactive "$vm" > "$BACKUP_ROOT/domains/$safe.xml"
printf '%s\t%s\n' "$safe" "$vm" >> "$BACKUP_ROOT/metadata/domains.tsv"
virsh -c "$URI" domblklist "$vm" --details \
> "$BACKUP_ROOT/domains/$safe.domblklist.txt" || true
virsh -c "$URI" domiflist "$vm" \
> "$BACKUP_ROOT/domains/$safe.domiflist.txt" || true
virsh -c "$URI" snapshot-list "$vm" --tree \
> "$BACKUP_ROOT/domains/$safe.snapshots.txt" || true
done
export_objects() {
local kind="$1" list_cmd="$2" dump_cmd="$3" outdir="$4" manifest="$5"
local name safe
while IFS= read -r name; do
[[ -n "$name" ]] || continue
safe="$(printf '%s' "$name" | sed 's#[/[:cntrl:]]#_#g')"
if virsh -c "$URI" "$dump_cmd" "$name" > "$BACKUP_ROOT/$outdir/$safe.xml"; then
printf '%s\t%s\n' "$safe" "$name" >> "$BACKUP_ROOT/metadata/$manifest"
else
warn "$kind konnte nicht exportiert werden: $name"
rm -f "$BACKUP_ROOT/$outdir/$safe.xml"
fi
done < <(
virsh -c "$URI" "$list_cmd" --all --name 2>/dev/null |
sed 's/^[[:space:]]*//; s/[[:space:]]*$//; /^[[:space:]]*$/d'
)
}
log "Exportiere Netzwerke, Storage-Pools und Host-Interfaces"
export_objects "Netzwerk" net-list net-dumpxml networks networks.tsv
export_objects "Storage-Pool" pool-list pool-dumpxml pools pools.tsv
export_objects "Interface" iface-list iface-dumpxml interfaces interfaces.tsv
log "Ermittle referenzierte Dateien und Blockgeräte"
python3 - "$BACKUP_ROOT" <<'PY'
import os, sys, glob, csv
import xml.etree.ElementTree as ET
root = sys.argv[1]
file_paths = set()
block_paths = set()
rows = []
for xml_path in glob.glob(os.path.join(root, "domains", "*.xml")):
vm = os.path.basename(xml_path)[:-4]
tree = ET.parse(xml_path)
dom = tree.getroot()
for disk in dom.findall("./devices/disk"):
dtype = disk.get("type", "")
device = disk.get("device", "")
src = disk.find("source")
if src is None:
continue
if dtype == "file" and src.get("file"):
p = src.get("file")
file_paths.add(p)
rows.append((vm, "disk-file", device, p))
elif dtype == "block" and src.get("dev"):
p = src.get("dev")
block_paths.add(p)
rows.append((vm, "disk-block", device, p))
elif dtype == "dir" and src.get("dir"):
p = src.get("dir")
rows.append((vm, "disk-dir", device, p))
elif dtype == "volume":
rows.append((vm, "disk-volume", device,
f"{src.get('pool','')}/{src.get('volume','')}"))
elif dtype == "network":
rows.append((vm, "disk-network", device,
src.get("name") or src.get("protocol") or ""))
nvram = dom.find("./os/nvram")
if nvram is not None and nvram.text and nvram.text.strip():
p = nvram.text.strip()
file_paths.add(p)
rows.append((vm, "nvram", "", p))
for backend in dom.findall("./devices/tpm/backend"):
for source in backend.findall("source"):
p = source.get("path") or source.get("file")
if p:
file_paths.add(p)
rows.append((vm, "tpm-file", "", p))
with open(os.path.join(root, "metadata", "references.tsv"), "w",
newline="", encoding="utf-8") as f:
w = csv.writer(f, delimiter="\t", lineterminator="\n")
w.writerow(("domain_xml", "kind", "device", "source"))
w.writerows(sorted(rows))
with open(os.path.join(root, "metadata", "file-sources.txt"), "w",
encoding="utf-8") as f:
for p in sorted(file_paths):
f.write(p + "\n")
with open(os.path.join(root, "metadata", "block-sources.txt"), "w",
encoding="utf-8") as f:
for p in sorted(block_paths):
f.write(p + "\n")
PY
: > "$BACKUP_ROOT/metadata/file-map.tsv"
: > "$BACKUP_ROOT/metadata/missing-files.txt"
copy_source() {
local src="$1" rel dst
[[ -n "$src" ]] || return 0
if [[ ! -e "$src" ]]; then
warn "Referenzierte Datei fehlt: $src"
printf '%s\n' "$src" >> "$BACKUP_ROOT/metadata/missing-files.txt"
return 0
fi
# Originalpfad ohne führenden Slash innerhalb files/rootfs/ ablegen.
rel="${src#/}"
dst="$BACKUP_ROOT/files/rootfs/$rel"
mkdir -p "$(dirname "$dst")"
if [[ -d "$src" ]]; then
mkdir -p "$dst"
rsync "${RSYNC_OPTS[@]}" "$src/" "$dst/"
printf '%s\t%s\t%s\n' "directory" "$src" "files/rootfs/$rel" \
>> "$BACKUP_ROOT/metadata/file-map.tsv"
else
rsync "${RSYNC_OPTS[@]}" "$src" "$dst"
printf '%s\t%s\t%s\n' "file" "$src" "files/rootfs/$rel" \
>> "$BACKUP_ROOT/metadata/file-map.tsv"
case "$src" in
*.qcow|*.qcow2|*.qed|*.vmdk|*.vdi|*.raw|*.img)
qemu-img info --backing-chain --output=json "$src" \
> "$dst.qemu-img-info.json" 2>/dev/null || true
;;
esac
fi
}
log "Kopiere referenzierte Dateien"
while IFS= read -r src; do
copy_source "$src"
done < "$BACKUP_ROOT/metadata/file-sources.txt"
if [[ -s "$BACKUP_ROOT/metadata/block-sources.txt" ]]; then
warn "Blockgeräte wurden gefunden und nicht kopiert:"
sed 's/^/ - /' "$BACKUP_ROOT/metadata/block-sources.txt"
warn "Diese LVM-/Block-Datenträger separat sichern, z. B. per LVM-Snapshot, dd oder Storage-Replikation."
fi
log "Sichere PAW-Netzwerk und br-lan-MAC"
if [[ -x "$SCRIPT_DIR/paw-network-backup.sh" ]]; then
"$SCRIPT_DIR/paw-network-backup.sh" "$BACKUP_ROOT/network"
else
warn "paw-network-backup.sh fehlt neben dem Backup-Skript; Netzwerk wurde nicht gesichert."
fi
log "Prüfe Backup-Datenträger vor der Prüfsummenphase"
mount_opts="$(findmnt -no OPTIONS -T "$BACKUP_ROOT" 2>/dev/null || true)"
[[ ",$mount_opts," != *,ro,* ]] \
|| die "Backup-Dateisystem wurde während des Backups nur-lesbar. Backup ist unvollständig."
probe="$BACKUP_ROOT/.libvirt-backup-final-test.$$"
printf 'final write test\n' > "$probe" \
|| die "Abschließender Schreibtest fehlgeschlagen. Backup ist unvollständig."
sync "$probe" 2>/dev/null || sync
grep -qx 'final write test' "$probe" \
|| die "Abschließender Lesetest fehlgeschlagen. Backup ist unvollständig."
rm -f "$probe"
log "Erzeuge Dateiliste und SHA-256-Prüfsummen"
(
cd "$BACKUP_ROOT"
find . -type f \
! -path './metadata/SHA256SUMS' \
! -path './logs/backup.log' \
-print0 | sort -z | xargs -0 sha256sum > metadata/SHA256SUMS
)
sync
log "Backup abgeschlossen: $BACKUP_ROOT"
echo "Vor der Neuinstallation prüfen:"
echo " cd '$BACKUP_ROOT' && sha256sum -c metadata/SHA256SUMS"
[[ ! -s "$BACKUP_ROOT/metadata/block-sources.txt" ]] \
|| echo "ACHTUNG: Blockgeräte laut metadata/block-sources.txt separat sichern."