Initial commit.

This commit is contained in:
root
2025-10-12 13:23:48 +02:00
commit ca2b6731e0
5 changed files with 863 additions and 0 deletions

395
kvm-lvm-full-backup.sh Executable file
View File

@@ -0,0 +1,395 @@
#!/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