Automatisiertes refresh von gestoppten VMs/LXCs

TErxleben

Renowned Member
Oct 20, 2008
450
57
93
Ich möchte gern das letzte gesicherte Backup einer gestoppten VM/LXC automatisiert auf einem Host wiederherstellen.
Genauso wie ich automatisiert Backups erzeuge.
Das am liebsten von einem PBS.
Geht das?
ich habe nämlich gestoppte VMs als Failover auf einem Host installiert, die nur im Fehlerfall automatisch gestartet werden. Damit es im Fehlerfall keine asbach uralte Maschinen sind, wär es schon schön, diese auch täglich gegen die modernste Sicherung auszutauschen.
Bevor ich nun anfange das per Script zu lösen, frage ich lieber mal nach, ob es auch anders geht.
Es handelt sich um keine fetten DB-Server o.ä.
Eher Mini-Dienste, die aber extrem wichtig sind: DHCP, DNS, GUACAMOLE und GATEWAY.
 
Anders als per script und cronjob geht das ootb nicht. Alternative zu den Backups wäre ZFS Replikation, die aber „einfach“ nur im cluster möglich ist.

Beispielscript:

Bash:
#!/usr/bin/env bash
set -euo pipefail

# ====== CONFIG ======
NODE="$(hostname)"                 # Lokaler PVE-Node, auf dem restored wird
PBS_STORE="pbs"                    # Name des eingebundenen PBS-Storage in PVE
TARGET_STORE="local-zfs"           # Ziel-Storage für Restore (anpassen)
VMIDS=("101" "102" "201" "202")    # IDs der Cold-Standby-Instanzen (VM & LXC gemischt möglich)
DRYRUN="${DRYRUN:-0}"              # DRYRUN=1 -> nur anzeigen, nichts ausführen
KEEP_CONFIG_BACKUP=1               # Legt vor dem Destroy ein qm/pct config-Backup an
# =====================

need() { command -v "$1" >/dev/null || { echo "Fehlt: $1"; exit 1; }; }
need pvesh; need jq

log(){ echo "[$(date +'%F %T')] $*"; }

get_latest_volid() {
  local vmid="$1"
  # holt neueste Backup-VolID (volid wie: pbs:backup/vm/101/2025-08-15T19:01:23Z)
  pvesh get "/nodes/${NODE}/storage/${PBS_STORE}/content" \
    -content backup -vmid "${vmid}" --output-format json \
  | jq -r 'max_by(.ctime) | .volid'
}

detect_type_from_volid() {
  local volid="$1"
  # qemu: vzdump-qemu-<vmid>-*, lxc: vzdump-lxc-<vmid>-*
  if [[ "$volid" == *"vzdump-qemu-"* ]]; then echo "qemu"; else echo "lxc"; fi
}

stop_and_destroy() {
  local type="$1" vmid="$2"
  if [[ "$type" == "qemu" ]]; then
    qm stop "$vmid" || true
    [[ "$KEEP_CONFIG_BACKUP" -eq 1 ]] && qm config "$vmid" >"/root/qm-${vmid}-$(date +%F-%H%M).conf" || true
    qm destroy "$vmid" --purge 1 --destroy-unreferenced-disks 1
  else
    pct stop "$vmid" || true
    [[ "$KEEP_CONFIG_BACKUP" -eq 1 ]] && pct config "$vmid" >"/root/pct-${vmid}-$(date +%F-%H%M).conf" || true
    pct destroy "$vmid" --purge 1
  fi
}

do_restore() {
  local type="$1" vmid="$2" volid="$3"
  if [[ "$type" == "qemu" ]]; then
    # Hinweis: --unique 0 -> behält MAC/SMBIOS wie im Backup (gut für echtes Failover)
    qmrestore "$volid" "$vmid" --storage "$TARGET_STORE" --force 1 --unique 0
    qm set "$vmid" --onboot 0 || true   # sicherheitshalber nicht automatisch starten
  else
    pct restore "$vmid" "$volid" --storage "$TARGET_STORE" --force 1
    pct set "$vmid" -onboot 0 || true
  fi
}

for VMID in "${VMIDS[@]}"; do
  log "== Bearbeite VMID ${VMID} =="
  VOLID="$(get_latest_volid "$VMID" || true)"
  if [[ -z "$VOLID" || "$VOLID" == "null" ]]; then
    log "!! Keine Backups im PBS für VMID ${VMID} gefunden – skip"
    continue
  fi
  TYPE="$(detect_type_from_volid "$VOLID")"
  log "Neueste Sicherung: ${VOLID} (Typ: ${TYPE})"

  if [[ "$DRYRUN" -eq 1 ]]; then
    log "[DRYRUN] Würde ${TYPE} ${VMID} zerstören und aus ${VOLID} nach ${TARGET_STORE} restoren."
    continue
  fi

  stop_and_destroy "$TYPE" "$VMID"
  do_restore "$TYPE" "$VMID" "$VOLID"
  log "✔ Restore fertig: ${TYPE} ${VMID} aus ${VOLID} → ${TARGET_STORE} (gestoppt belassen)"
done

log "Alle Jobs erledigt."
 
  • Like
Reactions: TErxleben
Prima. Tolles Script. Gleich zwei Fragen hinterher.

Warum set-euo pipefail?
Warum stoppst Du Maschinen mit dem Holzhammer? Ich würde das Script in einen Fehler laufen lassen, da es ja wahrscheinlich einen Grund für die laufende Maschine gibt.
Anders als per script und cronjob geht das ootb nicht. Alternative zu den Backups wäre ZFS Replikation, die aber „einfach“ nur im cluster möglich ist.

Beispielscript:

Bash:
#!/usr/bin/env bash
set -euo pipefail

# ====== CONFIG ======
NODE="$(hostname)"                 # Lokaler PVE-Node, auf dem restored wird
PBS_STORE="pbs"                    # Name des eingebundenen PBS-Storage in PVE
TARGET_STORE="local-zfs"           # Ziel-Storage für Restore (anpassen)
VMIDS=("101" "102" "201" "202")    # IDs der Cold-Standby-Instanzen (VM & LXC gemischt möglich)
DRYRUN="${DRYRUN:-0}"              # DRYRUN=1 -> nur anzeigen, nichts ausführen
KEEP_CONFIG_BACKUP=1               # Legt vor dem Destroy ein qm/pct config-Backup an
# =====================

need() { command -v "$1" >/dev/null || { echo "Fehlt: $1"; exit 1; }; }
need pvesh; need jq

log(){ echo "[$(date +'%F %T')] $*"; }

get_latest_volid() {
  local vmid="$1"
  # holt neueste Backup-VolID (volid wie: pbs:backup/vm/101/2025-08-15T19:01:23Z)
  pvesh get "/nodes/${NODE}/storage/${PBS_STORE}/content" \
    -content backup -vmid "${vmid}" --output-format json \
  | jq -r 'max_by(.ctime) | .volid'
}

detect_type_from_volid() {
  local volid="$1"
  # qemu: vzdump-qemu-<vmid>-*, lxc: vzdump-lxc-<vmid>-*
  if [[ "$volid" == *"vzdump-qemu-"* ]]; then echo "qemu"; else echo "lxc"; fi
}

stop_and_destroy() {
  local type="$1" vmid="$2"
  if [[ "$type" == "qemu" ]]; then
    qm stop "$vmid" || true
    [[ "$KEEP_CONFIG_BACKUP" -eq 1 ]] && qm config "$vmid" >"/root/qm-${vmid}-$(date +%F-%H%M).conf" || true
    qm destroy "$vmid" --purge 1 --destroy-unreferenced-disks 1
  else
    pct stop "$vmid" || true
    [[ "$KEEP_CONFIG_BACKUP" -eq 1 ]] && pct config "$vmid" >"/root/pct-${vmid}-$(date +%F-%H%M).conf" || true
    pct destroy "$vmid" --purge 1
  fi
}

do_restore() {
  local type="$1" vmid="$2" volid="$3"
  if [[ "$type" == "qemu" ]]; then
    # Hinweis: --unique 0 -> behält MAC/SMBIOS wie im Backup (gut für echtes Failover)
    qmrestore "$volid" "$vmid" --storage "$TARGET_STORE" --force 1 --unique 0
    qm set "$vmid" --onboot 0 || true   # sicherheitshalber nicht automatisch starten
  else
    pct restore "$vmid" "$volid" --storage "$TARGET_STORE" --force 1
    pct set "$vmid" -onboot 0 || true
  fi
}

for VMID in "${VMIDS[@]}"; do
  log "== Bearbeite VMID ${VMID} =="
  VOLID="$(get_latest_volid "$VMID" || true)"
  if [[ -z "$VOLID" || "$VOLID" == "null" ]]; then
    log "!! Keine Backups im PBS für VMID ${VMID} gefunden – skip"
    continue
  fi
  TYPE="$(detect_type_from_volid "$VOLID")"
  log "Neueste Sicherung: ${VOLID} (Typ: ${TYPE})"

  if [[ "$DRYRUN" -eq 1 ]]; then
    log "[DRYRUN] Würde ${TYPE} ${VMID} zerstören und aus ${VOLID} nach ${TARGET_STORE} restoren."
    continue
  fi

  stop_and_destroy "$TYPE" "$VMID"
  do_restore "$TYPE" "$VMID" "$VOLID"
  log "✔ Restore fertig: ${TYPE} ${VMID} aus ${VOLID} → ${TARGET_STORE} (gestoppt belassen)"
done

log "Alle Jobs erledigt."
 
Warum set-euo pipefail?

Eigentlich ist das "best practice" in bash scripts.

  • set -e -> script bricht ab, wenn exitcode ≠ 0. Lässt man das weg, macht das script stumpf weiter
  • set -u -> nutzt man eine nicht definierte Variable, führt das zum Fehler
  • set -o pipefail -> in der Pipeline (cmd1, cmd2, cmd3, usw.) gilt ja nicht nur EIN exitcode (der des letzten Befehls), sondern auch wenn irgendein Befehl in der "Kette" fehlschlägt
Bedeutet: wenn irgendwas nicht läuft, wird sofort abgebrochen und nicht stumpf weitergemacht.

Warum stoppst Du Maschinen mit dem Holzhammer?

Weil man bei VMs, die nicht laufen (sollen) davon ausgeht, dass diese gerade produktiv nichts machen und sauber runtergefahren werden müssen.

Hier mal angepasst (erst Shutdown-Versuch mit 120 Sekunden Timeout, dann STOP):


Bash:
#!/usr/bin/env bash
set -euo pipefail

# ====== CONFIG ======
NODE="$(hostname)"                # Lokaler PVE-Node
PBS_STORE="pbs"                   # Name des in PVE eingebundenen PBS-Storage (z.B. "pbs" oder "ProxmoxBS")
TARGET_STORE="local-zfs"          # Ziel-Storage für Restore
VMIDS=(101 102 201 202)           # IDs der Cold-Standby-Instanzen (VM & LXC gemischt möglich)
DRYRUN="${DRYRUN:-0}"             # DRYRUN=1 -> nur anzeigen, nichts ausführen
KEEP_CONFIG_BACKUP=1              # Vor Destroy ein qm/pct config-Backup anlegen
SHUTDOWN_TIMEOUT=120              # Sekunden für graceful shutdown
FORCE_STOP=1                      # Nach Timeout hart stoppen (1=ja/0=nein)
LOCK_WAIT=300                     # Sekunden auf Lock warten, bevor abgebrochen wird
CONFIG_BACKUP_DIR="/root/restore-config-backups"
RESTORE_OPTS_VM=""                # z.B. "--unique 0" (Default: Config aus Backup übernehmen)
RESTORE_OPTS_CT=""

# =====================

need() { command -v "$1" >/dev/null || { echo "Fehlt: $1"; exit 1; }; }
need pvesh; need jq; need qm; need pct

log(){ echo "[$(date +'%F %T')] $*"; }

run(){
  if [[ "$DRYRUN" = "1" ]]; then
    echo "[DRYRUN] $*"
  else
    eval "$@"
  fi
}

ensure_dirs(){
  [[ -d "$CONFIG_BACKUP_DIR" ]] || run "mkdir -p '$CONFIG_BACKUP_DIR'"
}

storage_exists(){
  pvesh get "/nodes/$NODE/storage" | jq -e --arg id "$PBS_STORE" '.[]|select(.storage==$id)' >/dev/null
}

get_type(){
  local vmid="$1"
  if qm config "$vmid" >/dev/null 2>&1; then
    echo "vm"
  elif pct config "$vmid" >/dev/null 2>&1; then
    echo "ct"
  else
    echo "none"
  fi
}

status_vm(){
  local vmid="$1"
  qm status "$vmid" 2>/dev/null | awk '{print $2}'
}

status_ct(){
  local vmid="$1"
  pct status "$vmid" 2>/dev/null | awk '{print $2}'
}

wait_no_lock(){
  local kind="$1" vmid="$2" waited=0
  while : ; do
    if [[ "$kind" == "vm" ]]; then
      if ! qm config "$vmid" 2>/dev/null | grep -q '^lock:'; then break; fi
    else
      if ! pct config "$vmid" 2>/dev/null | grep -q '^lock:'; then break; fi
    fi
    [[ $waited -ge $LOCK_WAIT ]] && { log "Lock auf $kind/$vmid Timeout nach ${LOCK_WAIT}s"; return 1; }
    sleep 2; waited=$((waited+2))
  done
  return 0
}

unlock_if_possible(){
  local kind="$1" vmid="$2"
  if [[ "$kind" == "vm" ]]; then
    if qm config "$vmid" 2>/dev/null | grep -q '^lock:'; then
      log "Versuche qm unlock $vmid"
      run "qm unlock $vmid"
    fi
  else
    if pct config "$vmid" 2>/dev/null | grep -q '^lock:'; then
      log "Versuche pct unlock $vmid"
      run "pct unlock $vmid"
    fi
  fi
}

shutdown_safe(){
  local kind="$1" vmid="$2"
  if [[ "$kind" == "vm" ]]; then
    local s; s="$(status_vm "$vmid" || true)"
    if [[ "$s" == "running" ]]; then
      log "VM $vmid: graceful shutdown (${SHUTDOWN_TIMEOUT}s)…"
      run "qm shutdown $vmid --timeout $SHUTDOWN_TIMEOUT"
      s="$(status_vm "$vmid" || true)"
      if [[ "$s" == "running" && "$FORCE_STOP" = "1" ]]; then
        log "VM $vmid: Timeout – hartes Stop."
        run "qm stop $vmid"
      fi
    fi
  else
    local s; s="$(status_ct "$vmid" || true)"
    if [[ "$s" == "running" ]]; then
      log "CT $vmid: graceful shutdown (${SHUTDOWN_TIMEOUT}s)…"
      # forceStop: 1 = hart nach Timeout
      local fopt=""; [[ "$FORCE_STOP" = "1" ]] && fopt="--forceStop 1"
      run "pct shutdown $vmid --timeout $SHUTDOWN_TIMEOUT $fopt"
      s="$(status_ct "$vmid" || true)"
      if [[ "$s" == "running" && "$FORCE_STOP" = "1" ]]; then
        log "CT $vmid: Timeout – hartes Stop."
        run "pct stop $vmid"
      fi
    fi
  fi
}

backup_config(){
  local kind="$1" vmid="$2"
  [[ "$KEEP_CONFIG_BACKUP" = "1" ]] || return 0
  ensure_dirs
  local ts; ts="$(date +'%F_%H%M%S')"
  if [[ "$kind" == "vm" ]]; then
    run "qm config $vmid > '$CONFIG_BACKUP_DIR/qm-$vmid-$ts.conf'"
  else
    run "pct config $vmid > '$CONFIG_BACKUP_DIR/pct-$vmid-$ts.conf'"
  fi
}

destroy_safe(){
  local kind="$1" vmid="$2"
  log "$kind $vmid: destroy (purge)…"
  if [[ "$kind" == "vm" ]]; then
    run "qm destroy $vmid --purge 1"
  else
    run "pct destroy $vmid --purge 1"
  fi
}

get_latest_volid(){
  local vmid="$1" kind="$2"
  local pathtype="vm"; [[ "$kind" == "ct" ]] && pathtype="ct"
  # Liste aller PBS-Backups filtern: gleiche VMID + richtiger Typ, nach ctime sortiert, letztes nehmen
  pvesh get "/nodes/$NODE/storage/$PBS_STORE/content" --content backup \
    | jq -r --argjson id "$vmid" --arg pt "$pathtype" '
        map(select(.vmid == $id and (.volid|test("/\($pt)/"))))
        | sort_by(.ctime) | last | .volid // empty
      '
}

restore_from_pbs(){
  local vmid="$1" kind="$2" volid="$3"
  if [[ -z "$volid" ]]; then
    log "$kind $vmid: Kein Backup im PBS gefunden – SKIP."
    return 0
  fi
  log "$kind $vmid: Restore von $volid -> $TARGET_STORE"
  if [[ "$kind" == "vm" ]]; then
    run "qmrestore '$volid' $vmid --storage '$TARGET_STORE' --force 1 $RESTORE_OPTS_VM"
  else
    run "pct restore $vmid '$volid' --storage '$TARGET_STORE' --force 1 $RESTORE_OPTS_CT"
  fi
}

main(){
  storage_exists || { log "PBS-Storage '$PBS_STORE' nicht gefunden auf Node '$NODE'."; exit 1; }

  for vmid in "${VMIDS[@]}"; do
    log "==== Bearbeite VMID $vmid ===="
    local kind; kind="$(get_type "$vmid")" # vm|ct|none

    if [[ "$kind" == "none" ]]; then
      # Nicht vorhanden -> direkt nach passendem Typ im PBS suchen: erst VM, dann CT
      for trykind in vm ct; do
        local volid; volid="$(get_latest_volid "$vmid" "$trykind")"
        if [[ -n "$volid" ]]; then
          log "VMID $vmid existiert nicht – finde Backup-Typ: $trykind"
          restore_from_pbs "$vmid" "$trykind" "$volid"
          kind="$trykind"
          break
        fi
      done
      [[ "$kind" == "none" ]] && { log "VMID $vmid: kein Backup gefunden – SKIP."; continue; }
      continue
    fi

    # Falls vorhanden: Locks handhaben, sauber beenden, Config sichern, destroy, restore
    unlock_if_possible "$kind" "$vmid" || true
    wait_no_lock "$kind" "$vmid" || { log "$kind $vmid: Lock konnte nicht aufgehoben werden – SKIP."; continue; }

    shutdown_safe "$kind" "$vmid"
    backup_config "$kind" "$vmid"
    destroy_safe "$kind" "$vmid"

    local volid; volid="$(get_latest_volid "$vmid" "$kind")"
    restore_from_pbs "$vmid" "$kind" "$volid"
  done
  log "Fertig."
}

main "$@"
 
  • Like
Reactions: UdoB and TErxleben
Noch ein zwar erheblich fetteres aber leistungsfähigeres scriot.
Danke. Ich glaube nicht deine ersten scripte.
Über dies set bin ich in 20J nicht gestolpert und es treibt mir die Schamesröte ins Gesicht..
 
  • Like
Reactions: cwt
jq, eine weitere Sache, die an mir vorbeigegangen ist, nutzt du um die Kacksyntax von bash zu zähmen oder gibt es einen anderen Hintergrund?
 
Der Hauptgrund ist folgender:

Bash:
pvesh get "/nodes/${NODE}/storage/${PBS_STORE}/content" \
  -content backup -vmid "${vmid}" --output-format json \
| jq -r 'max_by(.ctime) | .volid'

fragt die neueste Backup-Volid ab. Das Filtern und Parsen geht mit jq wesentlich einfacher und sauberer, als mit grep/sort/awk da rumzubasteln.