Da im Allgemeinen gilt “Kein Backup, kein Mitleid” müssen meine VMs auch irgendwie gesichert werden. Da diese auf ZFS Datasets liegen, bietet sich die ZFS Funktion send/receive für das Backup an. Hierbei muss auch nicht jedes Mal das komplette VM-Image gesichert werden, sondern nur die Blöcke, die sich seit dem letzten Snapshot geändert haben. Wenn man jetzt allerdings hingeht und einfach das Image einer laufenden VM kopiert, wird man später auf jeden Fall einen fsck benötigen, da das Filesystem der VM nicht in einem sauberen Zustand ist. Daher beginnt mein Backup-Skript damit einen VM-Snapshot, möglichst mit der Option -quiesce, zu machen. Hierfür muss in der VM der qemu-guest-agent installiert sein und laufen.

VMDISKS=$(virsh domblklist $VM | grep qcow2 | egrep -o [vs]d.)
DISKSPEC=""
for disk in $VMDISKS
do
    DISKSPEC+="--diskspec $disk,file=/srv/snapshots/$VM/$VM-$disk-$SNAPNAME.qcow2,snapshot=external "
done

# create snapshot dataset
zfs create tank/snapshots/$VM

# create virsh xml-dump of vm
virsh dumpxml $VM > /srv/vms/$VM/vm-$VM.xml

echo "$(date +%Y-%m-%d_%H:%M:%S): Create VM snapshot of $VM"
virsh snapshot-create-as --domain $VM --name $SNAPNAME --quiesce --atomic --disk-only $DISKSPEC >/dev/null 2>&1
if [ $? -ne 0 ]
then
    virsh snapshot-create-as --domain $VM --name $SNAPNAME --disk-only $DISKSPEC >/dev/null 2>&1
    if [ $? -ne 0 ]
    then
        echo "VM snapshot creation failed" >&2
        abortFunction
    else
	echo "$(date +%Y-%m-%d_%H:%M:%S): Snapshot created without --quiesce"
    fi
fi

Im ersten Schritt werden die Disks der VM ausgelesen. Von der VM wird dann ein --disk-only Snapshot, also Snapshot ohne RAM der VM, angelegt. Sollte dies nicht funktionieren, da z.B. der qemu-guest-agent nicht läuft, wird ein Snapshot ohne --quiesce probiert. Die Snapshot-Delta-Files werden auf ein anderes ZFS-Fileset abgelegt, da diese nicht mitgesichert werden sollen. Per virsh dumpxml wird auch die Config der VM in das Dataset der VM gelegt, damit diese Config mitgesichert werden kann.

Nach diesen Vorarbeiten kann der ZFS-Snapshot für das spätere zfs send gemacht werden. Das QCOW2-Image sollte jetzt in einem sauberen Zustand sein.

echo "$(date +%Y-%m-%d_%H:%M:%S): Creating ZFS snapshot $SRCZFS@$SNAPNAME"
zfs snapshot "$SRCZFS"@"$SNAPNAME"
if [ $? -ne 0 ]
then
    echo "ZFS snapshot creation failed" >&2
    abortFunction
fi

echo "$(date +%Y-%m-%d_%H:%M:%S): Deleting vm disk snapshots"
for disk in $VMDISKS
do
    virsh blockcommit $VM $disk --pivot --active >/dev/null
done

Sobald der ZFS-Snapshot gemacht ist, kann der VM Snapshot wieder gelöscht werden. Dies läuft über den virsh blockcommit Befehl ab. Hierbei werden die Änderungen im Snapshot-Delta in das Ursprungs-QCOW2 zurück geschrieben und der Pointer für die VM wieder auf das Original-Image gelegt. Da zwischen dem Anlegen des VM-Snapshots und dem Löschen desselben nur der Step des ZFS-Snapshots, der wirklich nur sehr kurz dauert, liegt, ist das Delta nur sehr klein und das blockcommit geht schnell und ohne Probleme.

VMDISKS=$(virsh domblklist $VM | egrep [vs]d.)
for disk in $VMDISKS
do
    if [[ $disk == *"snapshot"* ]]
    then
	echo "Snapshot deletion failed for $disk" >&2
	abortFunction
    fi
done

virsh snapshot-delete $VM $SNAPNAME --metadata > /dev/null

Wenn diese Aktion fertig ist, wird noch kurz geprüft ob die Pointer wirklich wieder auf den normalen Disks sind. Daraufhin kann der Snapshot auch aus libvirt gelöscht werden.

SUCCESS=$(zfs get backup:success -H -o value $SRCZFS)
LASTBACKUP=$(zfs get backup:date -H -o value $SRCZFS)
if [[ $SUCCESS == "true" ]]
then
    LASTSNAP="backup_$LASTBACKUP"
    echo "$(date +%Y-%m-%d_%H:%M:%S): Sending incremental snapshot from $LASTSNAP"
    ssh $DESTHOST sudo zfs rollback "$DESTZFS"@"$LASTSNAP"
    zfs send -R -i "$SRCZFS"@"$LASTSNAP" "$SRCZFS"@"$SNAPNAME" | pv -L $LIMIT | ssh $DESTHOST sudo zfs recv $DESTZFS
else
    if [[ $LASTBACKUP != "-" ]]
    then
        LASTSNAP="backup_$LASTBACKUP"
	echo "$(date +%Y-%m-%d_%H:%M:%S): Trying to send incremental snapshot from $LASTSNAP"
	ssh $DESTHOST sudo zfs rollback "$DESTZFS"@"$LASTSNAP"
	zfs send -R -i "$SRCZFS"@"$LASTSNAP" "$SRCZFS"@"$SNAPNAME" | pv -L $LIMIT | ssh $DESTHOST sudo zfs recv $DESTZFS
    else 
        echo "$(date +%Y-%m-%d_%H:%M:%S): Sending full snapshot"
        zfs send -R "$SRCZFS"@"$SNAPNAME" | pv -L $LIMIT | ssh $DESTHOST sudo zfs recv -F $DESTZFS
    fi
fi

Daraufhin beginnt der eigentliche Backup-Step. Wenn es schon ein vorheriges Backup gibt, das erfolgreich übertragen wurde, wird nur der Diff zum letzten Snapshot übertragen, ansonsten der komplette Snapshot.

if [ $? -eq 0 ]
then
    zfs set backup:success="true" $SRCZFS
    zfs set backup:date="$DATE" $SRCZFS
    zfs set backup:failed="-" $SRCZFS
    if [ -n $LASTSNAP ]
    then
	echo "$(date +%Y-%m-%d_%H:%M:%S): Deleting old ZFS snapshot $LASTSNAP"
        zfs destroy "$SRCZFS"@"$LASTSNAP"
    fi
else
    abortFunction
fi

zfs destroy tank/snapshots/$VM

# cleanup old backups

OLDSNAPS=$(ssh $DESTHOST zfs list -r -t snapshot -o name $DESTZFS 2>/dev/null | grep $DATEOLD)
if [ ${#OLDSNAPS} -gt 0 ]
then
    for oldsnap in $OLDSNAPS
    do
	echo "$(date +%Y-%m-%d_%H:%M:%S): Deleting old snapshots on target"
	ssh $DESTHOST sudo zfs destroy $oldsnap
    done
else
    echo "$(date +%Y-%m-%d_%H:%M:%S): No old snapshots to cleanup on target"
fi

echo "$(date +%Y-%m-%d_%H:%M:%S): Backup Completed"

Die letzten Steps sorgen noch dafür, dass der Timestamp des erfolgreichen Backups als ZFS Variable gespeichert wird. Außerdem werden auf dem Live-System alle, außer der letzte und auf dem Backup-System alle Snapshots, die älter sind als die konfigurierte Aufbewahrungsdauer, gelöscht.

Das komplette Skript ist in meinem Git zu finden.