#!/usr/bin/env bash set -Eeuo pipefail ############################################################################### # kvm-lvm-full-backup.sh # # Vollbackup einer KVM/libvirt-VM, deren Disk auf einem LVM-Volume liegt. # # Funktionsweise / Ablauf: # 1. Liest Konfigurationsdatei .conf aus conf/-Unterordner des Skripts. # 2. Optional: Guest-Freeze (qemu-guest-agent/VSS) zur Dateikonsistenz. # → Nur wenn Guest-Agent via 'guest-ping' erreichbar ist. # 3. Erzeugt LVM-Snapshot des Original-LV (Copy-on-Write). # 4. Exportiert die VM-Definition (XML) in den Backup-Ordner. # 5. Liest den Snapshot blockweise mit dd und komprimiert (zstd/xz/gzip/...). # 6. Schreibt Ergebnis als Vollbackup (*.img[.]) in BACKUP_DIR. # 7. Testet Archiv (Integrität) und erzeugt SHA256-Hashdatei. # 8. Entfernt Snapshot wieder. # 9. Rotiert ältere Backups (löscht Archiv + SHA + XML + Log gleicher Generation). # 10. Schreibt Laufzeit & Ergebnis ins Log; im Terminal farbige Statusausgabe & Spinner. # # Backup-Typ: Vollsicherung (kompletter Inhalt des LV wird gelesen/gesichert). # # Aufruf: # kvm-lvm-full-backup.sh -> Startet Backup gemäß Konfiguration # kvm-lvm-full-backup.sh -h|--help|-help|help -> Hilfe anzeigen # ############################################################################### ### ── Script meta ─────────────────────────────────────────────────────────── SCRIPT_PATH="$(readlink -f "$0")" SCRIPT_DIR="$(dirname "$SCRIPT_PATH")" SCRIPT_NAME="$(basename "$SCRIPT_PATH")" SCRIPT_PREFIX="${SCRIPT_NAME%.*}" CONF_DIR="${SCRIPT_DIR}/conf" CONF_FILE="${CONF_DIR}/${SCRIPT_PREFIX}.conf" # Defaults (werden durch .conf überschrieben) VM_NAME="${VM_NAME:-WinServer2025}" LV_ORIG="${LV_ORIG:-/dev/VG-Windows-Server/WinSystem}" SNAP_SIZE="${SNAP_SIZE:-150G}" # 1 TiB LV → Startwert 150G BACKUP_DIR="${BACKUP_DIR:-/data/backup/WinServer2025}" FILE_PREFIX="${FILE_PREFIX:-WinSystem_full}" KEEP="${KEEP:-7}" DD_BS="${DD_BS:-4M}" # größer für Speed COMPRESS="${COMPRESS:-zstd -T0 -3}" # Speed-orientiert QUIET="${QUIET:-0}" # bei Cron/kein TTY wird auto=1 gesetzt # NFS-Checks (optional) REQUIRE_MOUNTED="${REQUIRE_MOUNTED:-1}" # 1 = BACKUP_DIR muss gemountet sein REQUIRE_NFS_TYPE="${REQUIRE_NFS_TYPE:-0}" # 1 = BACKUP_DIR muss nfs/nfs4 sein # Rotation Dry-Run (nur anzeigen, was gelöscht würde) ROTATION_DRY_RUN="${ROTATION_DRY_RUN:-0}" # Timestamps / Log DATE="$(date +%Y%m%d-%H%M%S)" LOG_FILE="${BACKUP_DIR}/${FILE_PREFIX}_${DATE}.log" START_TS=$(date +%s) ### ── Help ────────────────────────────────────────────────────────────────── show_help() { cat <.conf). 2. Guest-Agent Probe ('guest-ping'); optional Freeze/Thaw. 3. Erzeugt LVM-Snapshot (Copy-on-Write). 4. Exportiert VM-XML in BACKUP_DIR. 5. Liest Snapshot blockweise (dd) und komprimiert (zstd/xz/gzip/...). 6. Schreibt Vollbackup (*.img[.]) nach BACKUP_DIR. 7. Testet Archiv und erzeugt SHA256-Hash. 8. Entfernt Snapshot. 9. Rotiert alte Backups inkl. XML & Log gleicher Generation. Ergebnis: - Backup-Image (*.img bzw. *.img.) im BACKUP_DIR - SHA256-Hash (*.sha256) - VM-XML (*.xml) - Logdatei im Skriptordner Restore-Beispiele (Produktiv): lvcreate -L -n WinSystem /dev/VG-Windows-Server sha256sum -c /data/backup/WinServer2025/WinSystem_full_YYYYmmdd-HHMMSS.img..sha256 /data/backup/WinServer2025/WinSystem_full_YYYYmmdd-HHMMSS.img. \\ | dd of=/dev/VG-Windows-Server/WinSystem bs=4M status=progress virsh define /data/backup/WinServer2025/WinSystem_full_YYYYmmdd-HHMMSS.xml virsh start WinServer2025 Probe-Restore (Test ohne Produktiv-Volume zu berühren): lvcreate -L -n WinSystem_TEST /dev/VG-Windows-Server /data/backup/WinServer2025/WinSystem_full_YYYYmmdd-HHMMSS.img. \\ | dd of=/dev/VG-Windows-Server/WinSystem_TEST bs=4M status=progress kpartx -av /dev/VG-Windows-Server/WinSystem_TEST mount -o ro -t ntfs3 /dev/mapper/VG--Windows--Server-WinSystem_TESTp2 /mnt/wintest umount /mnt/wintest && kpartx -dv /dev/VG-Windows-Server/WinSystem_TEST && lvremove -f /dev/VG-Windows-Server/WinSystem_TEST EOF } case "${1:-}" in -h|--help|-help|help) show_help exit 0 ;; esac ### ── Farbe & UI ──────────────────────────────────────────────────────────── # Quiet automatisch bei kein TTY if [[ -t 1 ]]; then QUIET="${QUIET}" else QUIET=1 fi if [[ -t 1 ]]; then RST=$'\033[0m'; WHT=$'\033[37m'; GRN=$'\033[32m'; RED=$'\033[31m'; YEL=$'\033[33m' else RST=""; WHT=""; GRN=""; RED=""; YEL="" fi LBL_OK="${WHT}[ ${GRN}OK${WHT} ]${RST}" LBL_DONE="${WHT}[ ${GRN}Done${WHT} ]${RST}" LBL_ERR="${WHT}[ ${RED}Error${WHT} ]${RST}" LBL_WARN="${WHT}[ ${YEL}warn${WHT} ]${RST}" spinner() { local pid="$1" msg="$2" i=0 marks='|/-\' while kill -0 "$pid" 2>/dev/null; do if [[ "$QUIET" -eq 0 && -t 1 ]]; then printf "\r${WHT}[ %s ${WHT}]%s%s" "${marks:i%4:1}" "${RST} " "$msg" i=$(( (i+1) % 4 )) fi sleep 0.1 done if [[ "$QUIET" -eq 0 && -t 1 ]]; then printf "\r\033[K"; fi } ### ── Logging helpers ─────────────────────────────────────────────────────── have_cmd() { command -v "$1" >/dev/null 2>&1; } log() { echo "[$(date +%F\ %T)] $*" >>"$LOG_FILE"; } out() { [[ "$QUIET" -eq 0 ]] && echo -e "$*"; log "$*"; } warn() { echo -e "${LBL_WARN} $*" >&2; log "WARN: $*"; } die() { echo -e "${LBL_ERR} $*" >&2; log "ERROR: $*"; exit 1; } run_step() { local msg="$1"; shift local cmd="$*" log "STEP: $msg :: $cmd" if [[ "$QUIET" -eq 0 && -t 1 ]]; then echo -n "$msg ... " else log "$msg ..." fi bash -c "$cmd" >>"$LOG_FILE" 2>&1 & local pid=$! spinner "$pid" "$msg" if ! wait "$pid"; then echo -e "${LBL_ERR} $msg" die "$msg fehlgeschlagen. Details: $LOG_FILE" fi if [[ "$QUIET" -eq 0 && -t 1 ]]; then echo -e "${LBL_OK} $msg"; fi log "OK: $msg" } ### ── Cleanup (Snapshot/Thaw) ─────────────────────────────────────────────── cleanup() { # Thaw sicherheitshalber (falls Freeze zuvor OK) if virsh qemu-agent-command "$VM_NAME" '{"execute":"guest-ping"}' >/dev/null 2>&1; then virsh domfsthaw "$VM_NAME" >/dev/null 2>&1 || true fi if [[ -n "${LV_SNAP:-}" ]] && lvdisplay "$LV_SNAP" >/dev/null 2>&1; then lvremove -f "$LV_SNAP" >/dev/null 2>&1 || true fi } trap cleanup EXIT ### ── Start & Config laden ────────────────────────────────────────────────── if [[ -f "$CONF_FILE" ]]; then # shellcheck disable=SC1090 source "$CONF_FILE" fi DATE="$(date +%Y%m%d-%H%M%S)" LOG_FILE="${BACKUP_DIR}/${FILE_PREFIX}_${DATE}.log" # NFS/mount-Prüfungen ensure_backup_target() { mkdir -p "$BACKUP_DIR" || die "Kann BACKUP_DIR nicht anlegen/schreiben: ${BACKUP_DIR}" if [[ "${REQUIRE_MOUNTED}" -eq 1 ]]; then if ! mountpoint -q "$BACKUP_DIR"; then if ! findmnt -n "$BACKUP_DIR" >/dev/null 2>&1; then die "BACKUP_DIR ist nicht gemountet: ${BACKUP_DIR} (NFS/Remote offline?)" fi fi fi if [[ "${REQUIRE_NFS_TYPE}" -eq 1 ]]; then local fstype fstype="$(findmnt -no FSTYPE "$BACKUP_DIR" 2>/dev/null || true)" [[ "$fstype" == "nfs" || "$fstype" == "nfs4" ]] || die "BACKUP_DIR ist kein NFS-Mount (FSTYPE=$fstype)." fi } # Kompressions-Endung & Test case "$COMPRESS" in *xz*) EXT="xz"; TEST_CMD=(xz -t) ;; *zstd*) EXT="zst"; TEST_CMD=(zstd -t) ;; *gzip*|*pigz*) EXT="gz"; TEST_CMD=(gzip -t) ;; *cat*) EXT="img"; TEST_CMD=() ;; # keine Kompression → nur .img *) EXT="bin"; TEST_CMD=() ;; esac OUT_BASENAME="${FILE_PREFIX}_${DATE}" # Dateinamen korrekt bilden: bei COMPRESS='cat' nur .img, sonst .img. if [[ "$EXT" == "img" ]]; then ARCHIVE="${BACKUP_DIR}/${OUT_BASENAME}.img" else ARCHIVE="${BACKUP_DIR}/${OUT_BASENAME}.img.${EXT}" fi SHA="${ARCHIVE}.sha256" XML="${BACKUP_DIR}/${OUT_BASENAME}.xml" ### ── Header ──────────────────────────────────────────────────────────────── if [[ "$QUIET" -eq 0 && -t 1 ]]; then echo -e "${WHT}==>${RST} Starte Vollbackup: VM=${VM_NAME}, LV=${LV_ORIG}" echo -e "${WHT}==>${RST} Ziel: ${ARCHIVE}" echo fi log "Begin Backup: VM=${VM_NAME} LV=${LV_ORIG} -> ${ARCHIVE}" [[ -b "$LV_ORIG" ]] || die "LV nicht gefunden: ${LV_ORIG}" ensure_backup_target ### ── Guest-Agent robust prüfen & ggf. Freeze ─────────────────────────────── agent_ok=0 if virsh qemu-agent-command "$VM_NAME" '{"execute":"guest-ping"}' >/dev/null 2>&1; then agent_ok=1 fi if (( agent_ok )); then if ! virsh domfsfreeze "$VM_NAME" >>"$LOG_FILE" 2>&1; then warn "domfsfreeze fehlgeschlagen – fahre ohne Freeze fort." else if [[ "$QUIET" -eq 0 && -t 1 ]]; then echo -e "${LBL_DONE} Gast-Dateisystem(e) gefreezt"; fi log "Guest-Freeze OK" fi else if [[ "$QUIET" -eq 0 && -t 1 ]]; then echo -e "${LBL_WARN} Guest-Agent nicht erreichbar – ohne Freeze"; fi log "Guest agent ping failed; skipping freeze." fi ### ── Snapshot erstellen ──────────────────────────────────────────────────── SNAP_NAME="$(basename "$LV_ORIG")_snap" LV_SNAP="$(dirname "$LV_ORIG")/${SNAP_NAME}" run_step "LVM-Snapshot erstellen (${SNAP_SIZE})" "lvcreate -L \"$SNAP_SIZE\" -s -n \"$SNAP_NAME\" \"$LV_ORIG\"" # Sofort Thaw (falls Freeze OK) if (( agent_ok )); then if ! virsh domfsthaw "$VM_NAME" >>"$LOG_FILE" 2>&1; then warn "domfsthaw fehlgeschlagen – fahre fort." else if [[ "$QUIET" -eq 0 && -t 1 ]]; then echo -e "${LBL_DONE} Gast-Dateisystem(e) wieder aufgetaut"; fi log "Guest-Thaw OK" fi fi ### ── VM-XML exportieren ──────────────────────────────────────────────────── run_step "VM-XML exportieren" "virsh dumpxml \"$VM_NAME\" > \"$XML\"" ### ── Snapshot sichern & komprimieren ─────────────────────────────────────── SIZE_BYTES="$(blockdev --getsize64 "$LV_ORIG")" if have_cmd pv; then run_step "Snapshot sichern & komprimieren" \ "dd if=\"$LV_SNAP\" bs=\"$DD_BS\" status=none | pv -ptrab -s \"$SIZE_BYTES\" >>\"$LOG_FILE\" 2>&1 | $COMPRESS > \"$ARCHIVE\"" else run_step "Snapshot sichern & komprimieren" \ "dd if=\"$LV_SNAP\" bs=\"$DD_BS\" status=none | $COMPRESS > \"$ARCHIVE\"" fi ### ── LVM Snapshot entfernen ──────────────────────────────────────────────────── run_step "LVM-Snapshot entfernen" "lvremove -f \"$LV_SNAP\"" ### ── Archiv testen & Hash erzeugen ──────────────────────────────────────── if ((${#TEST_CMD[@]})); then run_step "Archiv-Integrität prüfen" "${TEST_CMD[*]} \"$ARCHIVE\"" else warn "Kein integrierter Test für COMPRESS='$COMPRESS' konfiguriert." fi run_step "SHA256 erzeugen" "sha256sum \"$ARCHIVE\" | tee \"$SHA\" >/dev/null" ### ── Rotation (Report + Dry-Run) ─────────────────────────────────────────── # Bewahre nur die letzten $KEEP Generationen (Basis: FILE_PREFIX_YYYYmmdd-HHMMSS) shopt -s nullglob backups=( "${BACKUP_DIR}/${FILE_PREFIX}_"*.img* ) removed_bases=() removed_files=() if ((${#backups[@]}>0)); then IFS=$'\n' backups_sorted=($(ls -1t "${backups[@]}")) ; unset IFS declare -A seen=() basen=() for f in "${backups_sorted[@]}"; do base="$f" base="${base%.sha256}" # evtl. .sha256 abwerfen base="${base%.*}" # letzte Endung (.zst/.xz/.gz/.img) abwerfen base="${base%.img}" # .img abwerfen → reine Generation base="$(basename "$base")" if [[ -z "${seen[$base]:-}" ]]; then seen[$base]=1 basen+=( "$base" ) fi done if ((${#basen[@]}>KEEP)); then for base in "${basen[@]:$KEEP}"; do cand=( \ "${BACKUP_DIR}/${base}.img" \ "${BACKUP_DIR}/${base}.img."* \ "${BACKUP_DIR}/${base}.img."*".sha256" \ "${BACKUP_DIR}/${base}.sha256" \ "${BACKUP_DIR}/${base}.xml" \ "${SCRIPT_DIR}/${base}.log" \ ) for f in "${cand[@]}"; do [[ -e "$f" ]] || continue if [[ "${ROTATION_DRY_RUN:-0}" -eq 1 ]]; then removed_files+=( "(würde löschen) $f" ) log "Rotation (dry-run): würde entfernen: $f" else if rm -f -- "$f"; then removed_files+=( "$f" ) log "Rotation: entfernt: $f" else warn "Rotation: konnte nicht löschen: $f" fi fi done removed_bases+=( "$base" ) done else log "Rotation: nichts zu entfernen (Generationen=${#basen[@]}, KEEP=$KEEP)." fi else log "Rotation: keine Backups gefunden, übersprungen." fi shopt -u nullglob # Zusammenfassung (TTY kurz, Log detailliert) if ((${#removed_bases[@]}>0)); then if [[ "${ROTATION_DRY_RUN:-0}" -eq 1 ]]; then log "Rotation (dry-run): ${#removed_bases[@]} Generation(en) betroffen: ${removed_bases[*]}" if [[ "$QUIET" -eq 0 && -t 1 ]]; then echo -e "${LBL_DONE} Rotation (dry-run): ${#removed_bases[@]} Generation(en) betroffen:" for b in "${removed_bases[@]}"; do echo " - ${b}"; done fi else log "Rotation: ${#removed_bases[@]} Generation(en) entfernt: ${removed_bases[*]}" if [[ "$QUIET" -eq 0 && -t 1 ]]; then echo -e "${LBL_DONE} Rotation: ${#removed_bases[@]} Generation(en) entfernt:" for b in "${removed_bases[@]}"; do echo " - ${b}"; done fi fi else log "Rotation: keine Generation entfernt." if [[ "$QUIET" -eq 0 && -t 1 ]]; then echo -e "${LBL_OK} Rotation: nichts zu entfernen" fi fi ### ── Dauer berechnen & Abschluss ─────────────────────────────────────────── END_TS=$(date +%s) DURATION=$(( END_TS - START_TS )) D_H=$(( DURATION / 3600 )) D_M=$(( (DURATION % 3600) / 60 )) D_S=$(( DURATION % 60 )) log "Backup-Dauer: ${D_H}h ${D_M}m ${D_S}s (${DURATION}s)" if [[ "$QUIET" -eq 0 && -t 1 ]]; then echo echo -e "${LBL_OK} Backup abgeschlossen: ${ARCHIVE}" echo -e "${WHT} XML:${RST} ${XML}" echo -e "${WHT} SHA:${RST} ${SHA}" echo -e "${WHT} Dauer:${RST} ${D_H}h ${D_M}m ${D_S}s" fi exit 0