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:
co-authored by
Claude Opus 5.5
parent
d9d9345d03
commit
74be5aee59
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user