Snapshot-Laeufe gegen cfs-Sperren des Storages absichern

Auf einem echten Host schlugen Snapshots reihenweise mit
"cfs-lock 'storage-NAME' error: got lock request timeout" fehl.

Ursache: 'pvesh create .../snapshot' lief mit dem allgemeinen
Kommando-Zeitlimit von 60s. Genau so lange wartet Proxmox aber auf den
Storage-Lock. Lief der Aufruf in unser Zeitlimit, ging es mit der naechsten
VM weiter, waehrend der Task noch lief - und die naechste VM scheiterte
dann an derselben Sperre. Eine VM konnte so einen ganzen Lauf umwerfen.

* Snapshot-Aktionen laufen jetzt mit dem langen task_timeout statt mit dem
  kurzen Zeitlimit fuer Lesezugriffe.
* Ohne UPID in der Antwort wird ersatzweise gewartet, bis der Gast nicht
  mehr gesperrt ist, statt sofort weiterzumachen.
* Sperr-Fehler gelten als voruebergehend und werden 'retries'-mal mit
  'retry_delay' Abstand wiederholt; echte Fehler wie "storage does not
  support snapshots" nicht.
* Neu: 'pause_between' fuer eine Pause zwischen zwei Gaesten.

Ausserdem: Kommentare hinter einem Wert ("retries = 2  # ...") wurden nicht
abgeschnitten und machten die Konfiguration ungueltig - das eigene
Beispiel war davon betroffen. 'description' bleibt bewusst unangetastet,
damit ein '#' in der Beschreibung erhalten bleibt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-07-31 01:54:59 +02:00
co-authored by Claude Opus 5
parent 8f1bf037cd
commit e9aeaf9e62
8 changed files with 197 additions and 17 deletions
+78 -8
View File
@@ -61,12 +61,21 @@ def _pvesh_binary():
class Proxmox:
"""Zugriff auf die Proxmox-API ueber das Kommando `pvesh`."""
def __init__(self, dry_run=False, task_timeout=900, command_timeout=60):
def __init__(self, dry_run=False, task_timeout=900, command_timeout=60,
retries=2, retry_delay=60):
self.dry_run = dry_run
self.task_timeout = task_timeout
self.command_timeout = command_timeout
self.retries = retries
self.retry_delay = retry_delay
self._inventory_cache = None
@classmethod
def from_config(cls, config):
globals_ = config.globals
return cls(dry_run=globals_.dry_run, task_timeout=globals_.task_timeout,
retries=globals_.retries, retry_delay=globals_.retry_delay)
# -- unterste Ebene ---------------------------------------------------
def _run(self, args, timeout=None):
@@ -159,29 +168,73 @@ class Proxmox:
if self.dry_run:
log.info("[TESTLAUF] wuerde Snapshot anlegen: %s -> %s", guest.label, name)
return None
stdout, _ = self._run(args)
return self._wait_task(guest.node, stdout, "Snapshot %s fuer %s" % (name, guest.label))
what = "Snapshot %s fuer %s" % (name, guest.label)
return self._retry(what, lambda: self._task(guest, args, what))
def delete_snapshot(self, guest, name):
if self.dry_run:
log.info("[TESTLAUF] wuerde Snapshot loeschen: %s -> %s", guest.label, name)
return None
stdout, _ = self._run(["delete", "%s/%s" % (self._base_path(guest), name)])
return self._wait_task(guest.node, stdout,
"Loeschen von %s bei %s" % (name, guest.label))
args = ["delete", "%s/%s" % (self._base_path(guest), name)]
what = "Loeschen von %s bei %s" % (name, guest.label)
return self._retry(what, lambda: self._task(guest, args, what))
def _task(self, guest, args, what):
"""Fuehrt eine Snapshot-Aktion aus und wartet, bis sie wirklich fertig ist.
Wichtig: hier gilt das lange Task-Zeitlimit, nicht das kurze fuer
Lesezugriffe. Proxmox wartet beim Anlegen bis zu 60s auf den
Storage-Lock; liefen wir vorher in unser eigenes Zeitlimit, wuerde die
naechste VM losgeschickt, waehrend der Task noch laeuft - und genau das
laesst dann alle folgenden Snapshots am cfs-Lock scheitern.
"""
stdout, _ = self._run(args, timeout=self.task_timeout)
return self._wait_task(guest, stdout, what)
# -- Wiederholung bei belegten Sperren --------------------------------
# Solche Meldungen sind voruebergehend - da lohnt ein zweiter Versuch.
_TRANSIENT = ("cfs-lock", "lock request timeout", "trying to acquire",
"can't lock file", "got lock timeout", "unable to acquire lock",
"resource temporarily unavailable", "storage is locked",
"vm is locked", "ct is locked")
@classmethod
def _is_transient(cls, message):
text = str(message).lower()
return any(marker in text for marker in cls._TRANSIENT)
def _retry(self, what, action):
attempt = 0
while True:
try:
return action()
except ProxmoxError as exc:
if attempt >= self.retries or not self._is_transient(exc):
raise
attempt += 1
log.warning("%s: %s - Versuch %d von %d in %ds",
what, exc, attempt + 1, self.retries + 1, self.retry_delay)
time.sleep(self.retry_delay)
# -- Task-Verfolgung --------------------------------------------------
def _wait_task(self, node, output, what):
def _wait_task(self, guest, output, what):
"""pvesh liefert bei Snapshot-Aktionen eine UPID; darauf warten wir."""
upid = self._extract_upid(output)
if not upid:
# Ohne UPID koennen wir den Task nicht verfolgen - dann warten wir
# ersatzweise, bis der Gast nicht mehr gesperrt ist. Sonst wuerde
# die naechste Aktion in eine noch laufende hineinlaufen.
log.debug("%s: keine UPID in der Antwort von pvesh", what)
self._wait_unlocked(guest)
return None
deadline = time.time() + self.task_timeout
delay = 0.5
while True:
status = self._json(["get", "/nodes/%s/tasks/%s/status" % (node, upid)]) or {}
status = self._json(["get", "/nodes/%s/tasks/%s/status"
% (guest.node, upid)]) or {}
if status.get("status") == "stopped":
exit_status = status.get("exitstatus") or "unbekannt"
if exit_status != "OK":
@@ -193,6 +246,23 @@ class Proxmox:
time.sleep(delay)
delay = min(delay * 1.5, 5.0)
def _wait_unlocked(self, guest, timeout=None):
"""Wartet, bis der Gast keine laufende Sperre mehr hat."""
deadline = time.time() + (timeout or self.task_timeout)
path = "/nodes/%s/%s/%d/status/current" % (guest.node, guest.type, guest.vmid)
while True:
try:
status = self._json(["get", path]) or {}
except ProxmoxError:
return # lieber weitermachen als haengen bleiben
if not status.get("lock"):
return
if time.time() > deadline:
raise ProxmoxError("%s ist seit %ds gesperrt (%s)"
% (guest.label, timeout or self.task_timeout,
status.get("lock")))
time.sleep(2.0)
@staticmethod
def _extract_upid(output):
for line in (output or "").splitlines():