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
+33 -3
View File
@@ -152,9 +152,18 @@ class GlobalConfig:
run_on_start: bool = False
description: str = DEFAULT_DESCRIPTION
lock_file: str = "/run/pvesnap.lock"
retries: int = 2 # Wiederholungen bei belegten Sperren
retry_delay: int = 60 # Wartezeit dazwischen
pause_between: int = 0 # Pause zwischen zwei Gaesten
def validate(self):
problems = []
if self.retries < 0:
problems.append("[global] 'retries' darf nicht negativ sein")
if self.retry_delay < 1:
problems.append("[global] 'retry_delay' muss mindestens 1 Sekunde betragen")
if self.pause_between < 0:
problems.append("[global] 'pause_between' darf nicht negativ sein")
if not re.match(r"^[A-Za-z][A-Za-z0-9]{0,15}$", self.prefix):
problems.append("[global] 'prefix' muss mit einem Buchstaben beginnen und darf "
"nur Buchstaben/Ziffern enthalten (max. 16 Zeichen)")
@@ -228,6 +237,19 @@ _KEY_ALIASES = {
}
# Kommentar am Zeilenende abschneiden ("keep_time = 7d # eine Woche").
# Absichtlich nicht ueber configparser: in Beschreibungs-Vorlagen soll ein '#'
# erhalten bleiben, deshalb wird dieser Schnitt dort nicht angewandt.
_INLINE_COMMENT = re.compile(r"\s+[#;].*$", re.DOTALL)
# Schluessel, deren Wert unangetastet bleibt (freier Text).
_VERBATIM_KEYS = {"description", "description_template"}
def _strip_comment(value):
return _INLINE_COMMENT.sub("", str(value)).strip()
def _canonical_key(key):
key = key.strip().lower().replace("-", "_")
return _KEY_ALIASES.get(key, key)
@@ -288,8 +310,9 @@ def _parse_global(section, errors):
def take(key, parser_fn, target=None):
if key not in section:
return
raw = section[key] if key in _VERBATIM_KEYS else _strip_comment(section[key])
try:
setattr(result, target or key, parser_fn(section[key]))
setattr(result, target or key, parser_fn(raw))
except ValueError as exc:
errors.append("[global] %s: %s" % (key, exc))
@@ -303,6 +326,9 @@ def _parse_global(section, errors):
take("task_timeout", lambda v: parse_duration(v, default_unit="s"))
take("run_on_start", parse_bool)
take("description", lambda v: str(v).strip())
take("retries", lambda v: int(str(v).strip()))
take("retry_delay", lambda v: parse_duration(v, default_unit="s"))
take("pause_between", lambda v: parse_duration(v, default_unit="s"))
# Synonyme
if "description_template" in section:
result.description = str(section["description_template"]).strip()
@@ -315,7 +341,8 @@ def _parse_global(section, errors):
for key in section:
if key not in ("prefix", "check_interval", "state_file", "log_level", "log_file",
"lock_file", "dry_run", "task_timeout", "run_on_start", "description",
"description_template", "testlauf"):
"description_template", "testlauf", "retries", "retry_delay",
"pause_between"):
errors.append("[global] unbekannter Schluessel: %s" % key)
return result
@@ -327,7 +354,7 @@ def _parse_group(name, section, errors):
def take(key, parser_fn, target=None):
if key not in section:
return
raw = section[key]
raw = section[key] if key in _VERBATIM_KEYS else _strip_comment(section[key])
try:
setattr(group, target or key, parser_fn(raw))
except ValueError as exc:
@@ -456,6 +483,9 @@ def dump_config(config):
if g.log_file:
lines.append("log_file = %s" % g.log_file)
lines.append("task_timeout = %s" % format_duration(g.task_timeout, zero="900s"))
lines.append("retries = %d" % g.retries)
lines.append("retry_delay = %s" % format_duration(g.retry_delay, zero="60s"))
lines.append("pause_between = %s" % format_duration(g.pause_between, zero="0"))
lines.append("run_on_start = %s" % format_bool(g.run_on_start))
lines.append("dry_run = %s" % format_bool(g.dry_run))
lines.append("description = %s" % g.description)