Files
kvm/kvm-lvm-full-backup.sh
2025-10-12 13:23:48 +02:00

396 lines
15 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 <prefix>.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[.<ext>]) 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 <<EOF
Usage: $(basename $0) [options]
Vollbackup einer libvirt-VM, deren Disk auf einem LVM-Volume liegt.
Ohne Parameter wird ein vollständiges Backup gemäß Konfigurationsdatei erstellt.
Optionen:
-h, --help, -help, help Zeigt diese Hilfe und beendet das Programm.
Ablauf:
1. Liest Konfigurationsdatei (conf/<skriptprefix>.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[.<ext>]) 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.<ext>) im BACKUP_DIR
- SHA256-Hash (*.sha256)
- VM-XML (*.xml)
- Logdatei im Skriptordner
Restore-Beispiele (Produktiv):
lvcreate -L <size> -n WinSystem /dev/VG-Windows-Server
sha256sum -c /data/backup/WinServer2025/WinSystem_full_YYYYmmdd-HHMMSS.img.<ext>.sha256
<decompress> /data/backup/WinServer2025/WinSystem_full_YYYYmmdd-HHMMSS.img.<ext> \\
| 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 <size> -n WinSystem_TEST /dev/VG-Windows-Server
<decompress> /data/backup/WinServer2025/WinSystem_full_YYYYmmdd-HHMMSS.img.<ext> \\
| 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.<ext>
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