Platz vor jedem Snapshot pruefen, bei Mangel alte loeschen, per Mail warnen

Volle Ceph-Pools legen alle VMs darauf lahm. Vor jedem Snapshot gilt jetzt
je Storage des Gastes: frei >= sofortiger Verbrauch + space_min_free
(Vorgabe 20 %). Die Reserve bleibt also auch nach dem Snapshot frei.

Reicht es nicht, wird der aelteste pvesnap-Snapshot des Gastes geloescht und
neu geprueft, bis es reicht. Sind nur noch die neuesten space_keep (Vorgabe
7) uebrig, entfaellt der Snapshot und der naechste Gast kommt dran.

Gezaehlt wird je Gast ueber alle Gruppen, anders als bei der Vorhaltezeit.
Auf dem Testhost haette eine Grenze je Gruppe nie gegriffen: keep_count = 5
liegt unter 7, und 13 GiB steckten in einer Gruppe, die es nicht mehr gibt.

Sofortiger Verbrauch: Dicke ZFS-Zvols muessen ihre Reservierung auch nach
dem Snapshot garantieren, das kostet alles seit dem letzten Snapshot
Geschriebene. Exakt ist das refreservation - usedbyrefreservation; die Werte
kommen per zfs get, bei anderen Nodes per ssh ueber die IP aus dem
Clusterstatus, weil Node-Namen nicht ueberall aufloesen. Dazu kommt der RAM
bei vmstate. --dry-run zeigt die Rechnung je Storage.

Ceph und ZFS geben Platz erst im Hintergrund frei. Deshalb wird nach jedem
Loeschen gewartet, solange der Platz waechst, und erst nach space_settle
(Vorgabe 30s) ohne Zuwachs weitergemacht. Ohne das wurde auf Ceph ein Snapshot
zu viel geloescht. Laesst sich der Platz nicht feststellen, wird nichts
geloescht und der Snapshot trotzdem versucht.

Neu ist [mail]: eine Alarm-Mail je Gruppe und Lauf, wenn ein Snapshot wegen
Platzmangels ausfaellt. STARTTLS oder SSL/TLS mit Zertifikatspruefung, ohne
Rueckfall auf Klartext. Einzurichten im Editor (Taste m) oder von Hand, Test
mit pvesnap mail-test. Liegt ein Passwort in der Datei, setzt der Editor die
Rechte auf 600, auch fuer die .bak.

Das Handbuch beschreibt, wie man Ceph (Ratios) und ZFS (Notreserve) frueher
"voll" sagen laesst und was im Notfall zu tun ist. Ausserdem war
"pvesnap run --dry-run" ueberall falsch: Die Option ist global und muss vor
dem Unterbefehl stehen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-11-01 10:27:21 +01:00
co-authored by Claude Opus 5.5
parent d9d9345d03
commit 74be5aee59
21 changed files with 1125 additions and 26 deletions
+178 -1
View File
@@ -8,8 +8,11 @@ import time
from dataclasses import dataclass, field
from datetime import datetime
from .mail import send_space_alert
from .naming import build_name, effective_slug, parse_name, render_description
from .proxmox import ProxmoxError
from .proxmox import ProxmoxError, guest_volumes
from .snapfs import human_bytes
from .util import free_space_bytes, parse_free_space
log = logging.getLogger("pvesnap.engine")
@@ -98,6 +101,174 @@ def plan_prune(prefix, group, snapshots, now):
return doomed
def plan_reclaim(config, group, snapshots):
"""Was bei Platzmangel weg darf, in der Reihenfolge des Loeschens.
Anders als die Vorhaltezeit zaehlt das je Gast ueber alle pvesnap-Snapshots,
auch die von Gruppen, die es nicht mehr gibt. Ein voller Pool ist schlimmer
als ein verlorener alter Stand, und gerade die aeltesten halten am meisten
Platz fest. Getrennt gezaehlt koennte eine Gruppe mit keep_count unter
space_keep nie etwas freigeben.
Halbfertige Eintraege zuerst - sie enthalten nichts. Danach die aeltesten,
bis nur noch die neuesten space_keep uebrig sind. Das keep_min jeder Gruppe
bleibt dabei gewahrt.
"""
prefix = config.globals.prefix
managed = []
for snap in snapshots:
parsed = parse_name(prefix, snap.name)
if parsed:
created = (datetime.fromtimestamp(snap.snaptime) if snap.snaptime
else parsed["created"])
managed.append((created, snap))
managed.sort(key=lambda item: item[0], reverse=True) # neueste zuerst
broken = [snap for _created, snap in managed if snap.snapstate]
complete = [snap for _created, snap in managed if not snap.snapstate]
kept = {snap.name for snap in complete[:group.space_keep]}
for other in config.groups:
own = [snap for snap, _created in managed_snapshots(prefix, other, complete)]
kept.update(snap.name for snap in own[:other.keep_min])
return broken[::-1] + [snap for snap in complete[::-1] if snap.name not in kept]
# ---------------------------------------------------------------------------
# Platz vor dem Anlegen
# ---------------------------------------------------------------------------
SPACE_POLL = 5 # Sekunden zwischen zwei Blicken auf das Storage
def _space_demand(proxmox, guest, group):
"""Je Storage des Gastes der Platz, den der Snapshot sofort belegt.
Fuer die meisten Platten ist das nichts - ein Snapshot waechst erst mit dem,
was danach geschrieben wird. Sofort kosten nur dick angelegte ZFS-Zvols und
der mitgesicherte RAM.
"""
config = proxmox.guest_config(guest)
demand = {}
for volid in guest_volumes(config, guest.type):
name = volid.split(":", 1)[0]
demand[name] = demand.get(name, 0) + proxmox.snapshot_cost(guest.node, volid)
if group.vmstate and guest.running and guest.type == "qemu":
ram = int(proxmox.guest_status(guest).get("maxmem") or 0)
target = config.get("vmstatestorage")
for name in ([target] if target else list(demand)):
demand[name] = demand.get(name, 0) + ram
return demand
def _space_shortages(proxmox, guest, group, demand):
"""Die Storages, auf denen es nicht reicht: [(Name, frei, gebraucht)].
Gebraucht wird, was der Snapshot sofort belegt, plus die Reserve, die danach
noch frei bleiben muss.
"""
shortages = []
for name, extra in sorted(demand.items()):
space = proxmox.storage_space(guest.node, name)
if space is None:
continue
free, total = space
reserve = free_space_bytes(group.space_min_free, total)
needed = reserve + extra
log.log(logging.INFO if proxmox.dry_run else logging.DEBUG,
"%s: '%s': %s frei, Snapshot belegt sofort %s, danach frei %s, "
"Reserve %s", guest.label, name, human_bytes(free), human_bytes(extra),
human_bytes(max(0, free - extra)), human_bytes(reserve))
if free < needed:
shortages.append((name, free, needed))
return shortages
def _describe_shortages(shortages):
return ", ".join("'%s': %s frei, %s gebraucht"
% (name, human_bytes(free), human_bytes(needed))
for name, free, needed in shortages)
def _wait_for_space(proxmox, guest, group, demand, settle, limit):
"""Nach dem Loeschen abwarten, bis der Platz wirklich zurueckgekommen ist.
Ceph und ZFS geben ihn erst im Hintergrund frei. Saehe man sofort nach,
fehlte er scheinbar noch - und der naechste Snapshot wuerde umsonst
geloescht. Gewartet wird deshalb, solange der freie Platz noch waechst, und
aufgegeben erst, wenn sich `settle` Sekunden lang nichts mehr tut.
"""
shortages = _space_shortages(proxmox, guest, group, demand)
best = sum(free for _name, free, _needed in shortages)
started = last_growth = time.monotonic()
while (shortages and time.monotonic() - last_growth < settle
and time.monotonic() - started < limit):
time.sleep(min(SPACE_POLL, settle))
shortages = _space_shortages(proxmox, guest, group, demand)
free_now = sum(free for _name, free, _needed in shortages)
if free_now > best:
best = free_now
last_growth = time.monotonic()
return shortages
def _ensure_space(proxmox, config, group, guest, result, may_delete=True):
"""Sorgt vor dem Snapshot fuer Platz. False = dieser Gast wird ausgelassen.
Geloescht wird einzeln und immer nur so viel, bis es reicht. Laesst sich der
Platz nicht feststellen, bleibt alles stehen und der Snapshot wird versucht:
Auf Verdacht wird nichts geloescht.
"""
if parse_free_space(group.space_min_free) == "0":
return True
try:
demand = _space_demand(proxmox, guest, group)
shortages = _space_shortages(proxmox, guest, group, demand)
if not shortages:
return True
snapshots = proxmox.list_snapshots(guest)
except ProxmoxError as exc:
log.warning("Gruppe '%s': Platz fuer %s nicht pruefbar: %s",
group.name, guest.label, exc)
return True
log.warning("Gruppe '%s': %s: zu wenig Platz (%s)",
group.name, guest.label, _describe_shortages(shortages))
candidates = plan_reclaim(config, group, snapshots) if may_delete else []
for snap in candidates:
try:
proxmox.delete_snapshot(guest, snap.name, force=bool(snap.snapstate))
except ProxmoxError as exc:
message = "%s: '%s' nicht loeschbar: %s" % (guest.label, snap.name, exc)
result.errors.append(message)
log.error("Gruppe '%s': %s", group.name, message)
continue
result.deleted.append("%s:%s" % (guest.vmid, snap.name))
log.info("Gruppe '%s': Snapshot '%s' von %s geloescht (Platzmangel)",
group.name, snap.name, guest.label)
if proxmox.dry_run:
continue
shortages = _wait_for_space(proxmox, guest, group, demand,
config.globals.space_settle,
config.globals.task_timeout)
if not shortages:
log.info("Gruppe '%s': %s: Platz reicht jetzt", group.name, guest.label)
return True
if proxmox.dry_run and candidates:
log.info("[TESTLAUF] %s: ein echter Lauf loescht davon nur, bis der Platz "
"reicht", guest.label)
return True
message = ("%s: kein Snapshot angelegt, zu wenig Platz (%s) und nichts mehr "
"loeschbar" % (guest.label, _describe_shortages(shortages)))
result.errors.append(message)
result.no_space.append(message)
log.error("Gruppe '%s': %s", group.name, message)
return False
# ---------------------------------------------------------------------------
# Ausfuehrung
# ---------------------------------------------------------------------------
@@ -110,6 +281,7 @@ class GroupResult:
deleted: list = field(default_factory=list)
skipped: list = field(default_factory=list)
errors: list = field(default_factory=list)
no_space: list = field(default_factory=list) # Abbrueche wegen Platzmangels
@property
def ok(self):
@@ -155,6 +327,9 @@ def run_group(proxmox, config, group, now=None, create=True, prune=True, guests=
continue
if create:
if not _ensure_space(proxmox, config, group, guest, result, may_delete=prune):
continue
description = render_description(template, group, guest, now, prefix)
try:
proxmox.create_snapshot(
@@ -179,6 +354,8 @@ def run_group(proxmox, config, group, now=None, create=True, prune=True, guests=
log.error("Gruppe '%s': %s", group.name, message)
log.info(result.summary())
if result.no_space:
send_space_alert(config, group.name, result.no_space)
return result