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
+90
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import json
import logging
import re
import shlex
import shutil
import subprocess
import time
@@ -17,10 +18,14 @@ from dataclasses import dataclass, field
log = logging.getLogger("pvesnap.proxmox")
PVESH = "/usr/bin/pvesh"
ZFS = "/sbin/zfs"
# UPID:node:PID:PSTART:STARTTIME:TYPE:ID:USER:
_UPID_PATTERN = re.compile(r"UPID:[^\s\"']+")
QEMU_DISK_KEY = re.compile(r"^((ide|sata|scsi|virtio)\d+|efidisk0|tpmstate0)$")
LXC_DISK_KEY = re.compile(r"^(rootfs|mp\d+)$")
class ProxmoxError(Exception):
"""Ein pvesh-Aufruf ist fehlgeschlagen."""
@@ -61,6 +66,23 @@ class Snapshot:
return not self.snapstate
def guest_volumes(config, guest_type):
"""Die Volumes (storage:name) der Datentraeger eines Gastes.
Durchgereichte Platten und Bind-Mounts haben kein Storage, ein CD-Laufwerk
bekommt keinen Snapshot.
"""
pattern = LXC_DISK_KEY if guest_type == "lxc" else QEMU_DISK_KEY
volumes = []
for key, value in sorted(config.items()):
if not pattern.match(key) or "media=cdrom" in str(value):
continue
volid = str(value).split(",", 1)[0].strip()
if ":" in volid and not volid.startswith("/"):
volumes.append(volid)
return volumes
def pvesh_available():
return shutil.which(PVESH) is not None or shutil.which("pvesh") is not None
@@ -80,6 +102,7 @@ class Proxmox:
self.retries = retries
self.retry_delay = retry_delay
self._inventory_cache = None
self._storage_cache = {}
@classmethod
def from_config(cls, config):
@@ -207,6 +230,73 @@ class Proxmox:
what = "Loeschen von %s bei %s" % (name, guest.label)
return self._retry(what, lambda: self._task(guest, args, what))
# -- Platz auf den Storages -------------------------------------------
def storage_space(self, node, storage):
"""(frei, gesamt) in Bytes - oder None, wenn es sich nicht sagen laesst.
"Frei" ist bei Ceph MAX AVAIL, also schon nach Replikation.
"""
try:
info = self._json(["get", "/nodes/%s/storage/%s/status" % (node, storage)])
except ProxmoxError as exc:
log.warning("Storage '%s' auf %s nicht abfragbar: %s", storage, node, exc)
return None
# Ein inaktives Storage meldet 0 von 0 - das ist nicht dasselbe wie voll.
if not isinstance(info, dict) or not info.get("total"):
log.warning("Storage '%s' auf %s meldet keine Groesse", storage, node)
return None
return int(info.get("avail") or 0), int(info["total"])
def snapshot_cost(self, node, volid):
"""Was ein Snapshot dieses Volumes sofort belegt, in Bytes.
Fast ueberall nichts. Nur ein dick angelegtes ZFS-Zvol muss seine ganze
Reservierung auch nach dem Snapshot garantieren: Alles seit dem letzten
Snapshot Geschriebene wird dann zusaetzlich belegt. Das laesst sich exakt
ausrechnen, die Proxmox-API liefert die Werte aber nicht - daher `zfs get`.
Klappt das nicht, zaehlt das Volume mit 0: Auf Verdacht wird nichts geloescht.
"""
storage, volname = volid.split(":", 1)
info = self._storage_config(storage)
if info.get("type") != "zfspool" or not info.get("pool"):
return 0
# Bei verknuepften Klonen steht davor noch die Vorlage: base-1-disk-0/vm-2-disk-0
dataset = "%s/%s" % (info["pool"], volname.rsplit("/", 1)[-1])
command = [ZFS, "get", "-Hp", "-o", "value",
"refreservation,usedbyrefreservation", dataset]
if node != self.local_node():
command = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10",
"-o", "HostKeyAlias=%s" % node, "root@%s" % self._node_address(node),
" ".join(shlex.quote(part) for part in command)]
try:
output = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=self.command_timeout, check=True).stdout
reserved, used = (int(line) for line in output.decode().split())
except (OSError, ValueError, subprocess.SubprocessError) as exc:
log.warning("Snapshot-Kosten von %s auf %s nicht ermittelbar: %s", volid, node, exc)
return 0
return max(0, reserved - used)
def _node_address(self, node):
"""Die IP aus dem Clusterstatus. Node-Namen muss nicht jeder Host aufloesen."""
try:
for entry in self._json(["get", "/cluster/status"]) or []:
if entry.get("type") == "node" and entry.get("name") == node and entry.get("ip"):
return entry["ip"]
except ProxmoxError as exc:
log.debug("Clusterstatus nicht lesbar: %s", exc)
return node
def _storage_config(self, storage):
if storage not in self._storage_cache:
try:
self._storage_cache[storage] = self._json(["get", "/storage/%s" % storage]) or {}
except ProxmoxError as exc:
log.warning("Storage '%s' nicht lesbar: %s", storage, exc)
self._storage_cache[storage] = {}
return self._storage_cache[storage]
# -- Gaeste lesen und steuern -----------------------------------------
def guest_config(self, guest, snapshot=None):