Ausgangslage#
Zwei Proxmox-Nodes, eine Firewall-VM, kein Shared Storage. Die Anforderung: bei Ausfall eines Nodes soll die Firewall-VM automatisch auf dem anderen Node starten. Sekunden-genaue Synchronisation ist nicht nötig – der Datenzustand darf etwas älter sein.

Dafür wurde ein Proxmox-Cluster mit QDevice als drittem Quorum-Vote und ZFS-Replikation im 5-Minuten-Takt aufgebaut. Zusätzlich läuft optional zrepl als zweiter Replikationspfad.
Architektur#
- Node 1 + Node 2: identisch konfiguriert, jeweils lokales ZFS (Mirror)
- QDevice: läuft z.B. auf einem Raspberry Pi (nur Quorum, keine VM-Daten)
- VM-Replikation: per Proxmox intern (
pvesr) alle 5 Minuten - Optional: zusätzlicher ZFS-Replica-Stream via
zrepl
Das ist kein synchrones Storage-Cluster. Im Worst Case gehen bis zu 5 Minuten an Änderungen verloren (Recovery Point Objective, kurz RPO). Für eine Firewall, deren Regelwerk sich nicht minütlich ändert, reicht das.
Voraussetzungen#
- Beide Proxmox-Nodes erreichbar, Uhrzeit/NTP synchron
- Gleiche Netzsegmente für Cluster/Management/Replication (produktiv: Corosync und Replikation auf getrennten NICs – Corosync ist latenz-empfindlich, Replikation frisst Bandbreite)
- ZFS auf beiden Nodes eingerichtet (genug Platz für replizierte VM-Disks)
- QDevice-Host stabil erreichbar (z.B. Raspberry Pi an drittem Standort)
- Firewall-VM läuft zunächst primär auf Node 1 (Node 2 ist Replikationsziel)
- E-Mail-Alerts auf den Nodes konfiguriert – sonst bleiben Replikations- und HA-Fehler unbemerkt (alternativ externes Monitoring auf
pvecm status,pvesr status,zpool status)
Proxmox-Cluster bilden#
Auf Node 1 den Cluster erstellen, Node 2 tritt bei:
# Node 1: Cluster erstellen
pvecm create mein-cluster
# Node 2: Cluster beitreten
pvecm add 10.0.0.11
# Quorum prüfen
pvecm status
QDevice anbinden#
Ein klassisches 2-Node-Cluster hat ein Quorum-Problem: fällt ein Node aus, fehlt die Mehrheit. Das QDevice löst das als dritter Vote.
Die Einrichtung des QNetd-Dienstes auf dem externen Host (z.B. einem Raspberry Pi) ist gut dokumentiert – eine kompakte Anleitung findet sich etwa im Proxmox-Wiki unter QDevice .
Auf den Proxmox-Nodes:
# Auf beiden Proxmox-Nodes
apt install corosync-qdevice
# QDevice vom Cluster aus einrichten (einmalig, z.B. von Node 1)
pvecm qdevice setup 10.0.0.120
Quorum prüfen#
pvecm status
Erwartete Ausgabe im Normalbetrieb:
Cluster information
-------------------
Name: prox
Config Version: 5
Transport: knet
Secure auth: on
Quorum information
------------------
Date: Tue Apr 14 13:11:42 2026
Quorum provider: corosync_votequorum
Nodes: 2
Node ID: 0x00000002
Ring ID: 1.123
Quorate: Yes
Votequorum information
----------------------
Expected votes: 3
Highest expected: 3
Total votes: 3
Quorum: 2
Flags: Quorate Qdevice
Membership information
----------------------
Nodeid Votes Qdevice Name
0x00000001 1 A,V,NMW 10.0.0.11
0x00000002 1 A,V,NMW 10.0.0.12 (local)
0x00000000 1 Qdevice
So liest man die wichtigsten Zeilen#
- Nodes: 2 – zwei echte Proxmox-Knoten im Cluster
- Quorate: Yes – der Cluster hat aktuell Quorum und darf normal arbeiten
- Expected votes: 3 – drei Votes eingeplant (Node 1, Node 2, QDevice)
- Total votes: 3 – alle drei Votes sind aktuell verfügbar
- Quorum: 2 – mindestens zwei Votes nötig, damit der Cluster aktiv bleibt
- Flags: Quorate Qdevice – Quorum ist aktiv, QDevice korrekt eingebunden
ZFS-Storage auf beiden Nodes#
Lokalen ZFS-Pool auf beiden Nodes anlegen – identischer Aufbau. Wichtig: Pool-Name und Dataset-Struktur müssen auf beiden Nodes exakt gleich sein. Heißt der Pool auf Node 1 pool1, muss er auch auf Node 2 pool1 heißen – sonst findet pvesr das Replikationsziel nicht und bricht mit “storage not available on target node” ab.
Optional pro Node als Mirror spiegeln, damit ein einzelner Datenträger-Ausfall den Node nicht direkt stoppt:
# Lokalen Mirror-Pool anlegen
zpool create pool1 mirror /dev/sda1 /dev/sdb1
# Status prüfen
zpool status

Der Pool zeigt Health ONLINE mit 0 Errors auf beiden Mirror-Disks.
In Proxmox den Pool als Storage registrieren:

Das ist ein lokaler Schutz pro Node (Disk-Redundanz) und ersetzt nicht die Replikation zwischen Node 1 und Node 2.
Replikationsjob anlegen#
In Proxmox für die Firewall-VM Replikation von Node 1 → Node 2 aktivieren. Intervall: alle 5 Minuten.

Status der Replikation prüfen:
pvesr list
pvesr status
Gesunde Ausgabe:
JobID Enabled Target LastSync NextSync Duration FailCount State
101-0 Yes local/prox2 2026-04-14_13:50:03 2026-04-14_13:56:00 5.383416 0 OK
103-0 Yes local/prox2 2026-04-14_13:50:08 2026-04-14_13:55:00 1.910102 0 OK
111-0 Yes local/prox2 2026-04-14_13:50:01 2026-04-14_13:54:00 2.099421 0 OK
Alle Jobs OK, FailCount 0, letzte Sync wenige Minuten her.
Wichtig zum Verständnis: Die VM-Konfiguration (/etc/pve/qemu-server/101.conf) liegt auf dem Proxmox-Cluster-FS und ist ohnehin auf allen Nodes identisch vorhanden. Repliziert werden nur die VM-Disks. Nach einem Failover startet Node 1 die VM mit aktueller Config, aber Disk-Stand der letzten Replikation.
HA-Verhalten definieren#
Watchdog / Self-Fencing#
Proxmox HA braucht einen Watchdog. Verliert ein Node das Quorum, rebootet er sich nach etwa 60 Sekunden über den Watchdog selbst – so kann die VM sicher auf dem anderen Node starten, ohne dass sie doppelt läuft (Split-Brain).
Default ist softdog (Kernel-Software-Watchdog). Ein Hardware-Watchdog (iTCO, IPMI, iLO) ist zuverlässiger, weil er unabhängig vom Kernel auslöst – wer kann, sollte den aktivieren.
VM als HA-Ressource#
Die VM wird als HA-Ressource definiert. Bei Node-Ausfall startet Proxmox sie auf dem verbleibenden Node:

Im Edit-Dialog werden die wichtigsten Parameter gesetzt – Request State started, Max. Restart und Max. Relocate, sowie Failback:

Der Datenstand entspricht der letzten erfolgreichen Replikation. Der Hinweis “At least three quorum votes are recommended for reliable HA” bestätigt noch einmal, warum das QDevice im 2-Node-Setup Pflicht ist.
Failover in der Praxis#
Node-Ausfall simulieren#
Nach dem Ausfall von Node 2 zeigt pvecm status:
Quorum information
------------------
Date: Tue Apr 14 13:37:04 2026
Quorum provider: corosync_votequorum
Nodes: 1
Node ID: 0x00000001
Ring ID: 1.127
Quorate: Yes
Votequorum information
----------------------
Expected votes: 3
Highest expected: 3
Total votes: 2
Quorum: 2
Flags: Quorate Qdevice
Membership information
----------------------
Nodeid Votes Qdevice Name
0x00000001 1 A,V,NMW 10.21.7.11 (local)
0x00000000 1 Qdevice
Quorate: Yes – obwohl nur noch 1 Node da ist. Node 1 + QDevice ergeben 2 von 3 Votes, das reicht. Ohne QDevice wäre der Cluster blockiert.
Die Replikation schlägt fehl – logisch, Node 2 ist ja weg:
JobID Enabled Target LastSync NextSync Duration FailCount State
101-0 Yes local/prox2 - - 4.48416 1 ...exit code 255
103-0 Yes local/prox2 2026-04-14_13:35:01 - 1.308893 1 ...exit code 255
111-0 Yes local/prox2 - - 3.039043 1 ...exit code 255
Sobald der Zielnode wieder da ist, laufen die Jobs von allein an.
Recovery#
Node 2 ist wieder online. Der Cluster zeigt sofort alle 3 Votes:
Votequorum information
----------------------
Expected votes: 3
Highest expected: 3
Total votes: 3
Quorum: 2
Flags: Quorate Qdevice
Membership information
----------------------
Nodeid Votes Qdevice Name
0x00000001 1 A,V,NMW 10.21.7.11 (local)
0x00000002 1 A,V,NMW 10.21.7.12
0x00000000 1 Qdevice
Alle 3 Votes zurück, Replikation wieder OK, FailCount 0. Kein manueller Eingriff nötig.
Optional: Zusätzliche Kopie mit zrepl#
pvesr funktioniert zuverlässig. Wer trotzdem einen zweiten, unabhängigen Replikationspfad will, kann zrepl dazuschalten – ein eigenständiger ZFS-Snapshot-Stream, der nichts mit Proxmox zu tun hat.
Installation#
zrepl ist nicht in den Proxmox-/Debian-Standard-Repos. Über das inoffizielle APT-Repo von C. Schwarz lässt es sich sauber nachziehen:
apt update
apt install -y curl gnupg lsb-release
curl -fsSL https://zrepl.cschwarz.com/apt/apt-key.asc \
| gpg --dearmor \
| tee /usr/share/keyrings/zrepl.gpg >/dev/null
ARCH="$(dpkg --print-architecture)"
CODENAME="$(lsb_release -i -s | tr '[:upper:]' '[:lower:]') $(lsb_release -c -s | tr '[:upper:]' '[:lower:]')"
echo "deb [arch=$ARCH signed-by=/usr/share/keyrings/zrepl.gpg] https://zrepl.cschwarz.com/apt/$CODENAME main" \
| tee /etc/apt/sources.list.d/zrepl.list
apt update
apt install -y zrepl
Auf beiden Nodes installieren.
Beispielkonfiguration auf Node 2#
/etc/zrepl/zrepl.yml:
global:
logging:
- type: syslog
format: human
level: warn
jobs:
- name: src_to_prox1
type: source
serve:
type: tcp
listen: ":8888"
clients:
"10.0.0.11": "prox1"
filesystems:
"pool1/data1<": true
snapshotting:
type: periodic
prefix: zrepl_
interval: 15m
- name: pull_from_prox1
type: pull
connect:
type: tcp
address: "10.0.0.11:8888"
root_fs: pool1/backups/prox1
interval: 15m
recv:
placeholder:
encryption: off
pruning:
keep_sender:
- type: regex
regex: ".*"
keep_receiver:
- type: regex
negate: true
regex: "^zrepl_.*"
- type: last_n
count: 20
Auf Node 1 wird das spiegelbildlich konfiguriert (source/pull-Richtung und Zielpfade entsprechend umdrehen).
Replikationsjob prüfen:
zrepl status

Die interaktive Statusansicht zeigt laufende Pull-/Push-Jobs, aktuelle Snapshot-Schritte und den Fortschritt pro Dataset.
Auto-Repair bei Replikationskonflikten#
In der Praxis läuft zrepl parallel zu Proxmox – und genau da entsteht ein typisches Problem: Sobald eine VM per Proxmox-Bordmitteln migriert wird (manuelle Migration, HA-Failover, pvesr-Umkonfiguration), ändert Proxmox die ZFS-Datasets und Snapshots der VM-Disk. Damit reißt die Snapshot-Kette, auf der zrepl aufbaut, ab. Folge: zrepl findet keinen gemeinsamen Snapshot mehr und die Replikation muss für das betroffene Dataset komplett von vorne beginnen – inklusive vollem Initial-Sync und frischem Speicherverbrauch auf dem Ziel.
Um das nicht jedes Mal von Hand auflösen zu müssen, gibt es ein kleines Repair-Skript. Es erkennt betroffene Datasets, löst ZFS-Holds, räumt das kaputte Ziel-Dataset auf und triggert den Job erneut.
Beispiel-Cronjob auf Node 2:
4,19,34,49 * * * * /root/zrepl-autorepair.py pull_from_prox1 >/dev/null 2>&1
zrepl-autorepair.py – vollständiges Skript, klicken für Details
#!/usr/bin/env python3
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
CONFIG = Path("/etc/zrepl/zrepl.yml")
ERROR_PATTERNS = [
"destination already exists",
"no common snapshot or suitable bookmark",
"cannot restore to",
"validate `to` exists: dataset",
"validate 'to' exists: dataset",
"dataset does not exist",
"cannot resolve conflict",
"the receiver's latest snapshot is not present on sender",
]
RESTORE_DEST_RE = re.compile(
r"cannot restore to (\S+@\S+): destination already exists",
re.IGNORECASE,
)
def run(cmd, check=True, capture=True):
res = subprocess.run(cmd, text=True, capture_output=capture, check=False)
if check and res.returncode != 0:
raise RuntimeError(f"{' '.join(cmd)}: {res.stderr.strip()}")
return res.stdout.strip(), res.stderr.strip(), res.returncode
def get_root_fs(job: str) -> str:
found = False
for line in CONFIG.read_text().splitlines():
s = line.strip()
if s == f"- name: {job}":
found = True
continue
if found and s.startswith("root_fs:"):
return s.split(":", 1)[1].strip()
raise RuntimeError(f"Could not detect root_fs for job {job}")
def walk(obj):
if isinstance(obj, dict):
yield obj
for v in obj.values():
yield from walk(v)
elif isinstance(obj, list):
for v in obj:
yield from walk(v)
def collect_strings(obj):
if isinstance(obj, str):
yield obj
elif isinstance(obj, dict):
for v in obj.values():
yield from collect_strings(v)
elif isinstance(obj, list):
for v in obj:
yield from collect_strings(v)
def extract_restore_target(text: str):
m = RESTORE_DEST_RE.search(text)
if m:
return m.group(1)
return None
def extract_node_path(node: dict):
path = node.get("Path")
if path:
return path
info = node.get("Info")
if isinstance(info, dict) and isinstance(info.get("Name"), str):
return info["Name"]
if isinstance(node.get("Filesystem"), str):
return node["Filesystem"]
return None
def get_broken_items(job: str):
stdout, stderr, rc = run(
["zrepl", "status", "--mode", "raw"], check=False
)
if rc != 0 or not stdout:
raise RuntimeError(f"Could not read zrepl raw status: {stderr}")
data = json.loads(stdout)
jobs = data.get("Jobs", data)
if job not in jobs:
return []
broken = {}
for node in walk(jobs[job]):
if not isinstance(node, dict):
continue
path = extract_node_path(node)
if not path:
continue
texts = list(collect_strings(node))
blob = "\n".join(texts)
blob_lower = blob.lower()
if any(p in blob_lower for p in ERROR_PATTERNS):
entry = broken.setdefault(
path,
{"fs": path, "restore_snapshot": None, "messages": []},
)
entry["messages"].append(blob)
snap = extract_restore_target(blob)
if snap:
entry["restore_snapshot"] = snap
return [broken[k] for k in sorted(broken)]
def dataset_exists(ds: str) -> bool:
_, _, rc = run(["zfs", "list", "-H", "-o", "name", ds], check=False)
return rc == 0
def snapshot_exists(snap: str) -> bool:
_, _, rc = run(
["zfs", "list", "-H", "-o", "name", "-t", "snapshot", snap],
check=False,
)
return rc == 0
def release_holds_on_snapshot(snap: str):
stdout, _, _ = run(["zfs", "holds", "-H", snap], check=False)
if not stdout:
return
for line in stdout.splitlines():
parts = line.split()
if len(parts) >= 2:
snapname, tag = parts[0], parts[1]
print(f" releasing hold: {tag} on {snapname}")
run(["zfs", "release", tag, snapname], check=False)
def release_holds_recursive(dataset: str):
stdout, _, _ = run(
["zfs", "list", "-H", "-o", "name", "-t", "snapshot", "-r", dataset],
check=False,
)
if not stdout:
return
seen = set()
for snap in stdout.splitlines():
holds, _, _ = run(["zfs", "holds", "-H", snap], check=False)
if not holds:
continue
for line in holds.splitlines():
parts = line.split()
if len(parts) >= 2:
snapname, tag = parts[0], parts[1]
key = (snapname, tag)
if key in seen:
continue
seen.add(key)
print(f" releasing hold: {tag} on {snapname}")
run(["zfs", "release", tag, snapname], check=False)
def destroy_snapshot_first(snapshot: str, dry_run: bool):
print(f" -> conflicting snapshot: {snapshot}")
if not snapshot_exists(snapshot):
print(" -> snapshot does not exist, skipping")
return
if dry_run:
print(f' -> dry run: zfs destroy "{snapshot}"')
return
_, stderr, rc = run(["zfs", "destroy", snapshot], check=False)
if rc == 0:
print(" -> snapshot destroyed")
return
if "it's being held" in stderr.lower():
print(" -> snapshot is held, releasing holds")
release_holds_on_snapshot(snapshot)
_, stderr2, rc2 = run(["zfs", "destroy", snapshot], check=False)
if rc2 == 0:
print(" -> snapshot destroyed after releasing holds")
return
raise RuntimeError(
f"Failed to destroy snapshot after releasing holds: {stderr2}"
)
raise RuntimeError(f"Failed to destroy snapshot: {stderr}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("-n", "--dry-run", action="store_true")
ap.add_argument("job")
args = ap.parse_args()
root_fs = get_root_fs(args.job)
items = get_broken_items(args.job)
print(f"Detected receiver root: {root_fs}")
if not items:
print(f"No affected filesystems found for job {args.job}")
return
print("\nAffected filesystems:")
for item in items:
print(f" {item['fs']}")
for item in items:
fs = item["fs"]
target = f"{root_fs}/{fs}"
print(f"\nTarget replica: {target}")
if not target.startswith(root_fs + "/"):
print(f" -> refusing unsafe target path: {target}")
continue
if item["restore_snapshot"]:
destroy_snapshot_first(item["restore_snapshot"], args.dry_run)
if not dataset_exists(target):
print(" -> target dataset does not exist, skipping")
continue
if args.dry_run:
print(f' -> dry run: would release holds under "{target}"')
print(f' -> dry run: zfs destroy -r "{target}"')
continue
print(f" -> releasing holds under: {target}")
release_holds_recursive(target)
print(f" -> destroying dataset recursively: {target}")
_, stderr, rc = run(["zfs", "destroy", "-r", target], check=False)
if rc != 0:
raise RuntimeError(
f"Failed to destroy dataset {target}: {stderr}"
)
if not args.dry_run:
print(f"\nTriggering replication: {args.job}")
_, stderr, rc = run(
["zrepl", "signal", "wakeup", args.job], check=False
)
if rc != 0:
raise RuntimeError(
f"Failed to wake up job {args.job}: {stderr}"
)
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
Grenzen und Erwartungen#
✅ Was diese Lösung kann#
- Node-Ausfall überstehen: VM startet automatisch auf dem anderen Node
- Quorum ohne dritten Server: QDevice reicht als dritter Vote
- Einfacher Betrieb: kein SAN, kein Shared Storage
- Automatische Recovery: nach Node-Rückkehr läuft die Replikation von allein wieder an
⚠️ Was diese Lösung nicht kann#
- Synchrone Zustandsübernahme: Datenverlust bis zum letzten Replikationslauf möglich (RPO)
- Sofortiger Failover: vom Node-Ausfall bis die VM auf dem anderen Node wieder erreichbar ist vergehen in der Praxis ca. 3 Minuten (Watchdog-Reboot + HA-Übernahme + VM-Boot)
- Split-Brain-Schutz ohne QDevice: ohne den dritten Vote blockiert das Quorum
- Kein Backup-Ersatz: HA schützt gegen Hardware-Ausfall, nicht gegen gelöschte VMs, Ransomware oder Fehlkonfiguration – die repliziert sich nämlich auch sauber auf den zweiten Node
Für eine Firewall, deren Regelwerk nicht ständig mutiert, passt das. Als Backup-Lösung läuft bei uns zusätzlich Proxmox Backup Server – er hält inkrementelle, deduplizierte Snapshots der VMs unabhängig vom Cluster vor.
Alternativen: PegaProx#
Wer mehrere Proxmox-Cluster zentral verwalten will, sollte sich PegaProx anschauen. PegaProx bietet unter anderem ein eigenes 2-Node-HA-Feature, das über die native Proxmox-HA hinausgeht. Im Lab-Test hat das bei uns allerdings nur mit Einschränkungen funktioniert – vermutlich, weil PegaProx für das 2-Node-HA Shared Storage erwartet und lokales ZFS mit Replikation nicht vollständig unterstützt wird. Für den hier beschriebenen Ansatz (lokales ZFS + pvesr) bleiben die Proxmox-Bordmittel die bessere Wahl.
Weiterführende Links#
- Proxmox Cluster Manager & QDevice – Corosync External Vote Support, QDevice-Setup
- Proxmox Storage Replication (pvesr) – ZFS-Replikation zwischen Nodes
- Proxmox High Availability – HA-Manager, Ressourcen, Failover
- Proxmox HA-Manager Referenz – Detaillierte Dokumentation
- pvesr Manpage – CLI-Referenz für Replikationsjobs
- PegaProx Dokumentation – Multi-Cluster-Management für Proxmox VE
Fazit#
Zwei Nodes, ein QDevice, ZFS-Replikation. Kein Shared Storage, kein SAN, trotzdem Failover. Die Proxmox-Bordmittel (pvesr, HA-Manager) reichen dafür aus. Wer paranoid ist, schaltet zrepl als zweiten Replikationspfad dazu.
Wim Bonis ist CTO bei Stylite AG und beschäftigt sich schwerpunktmäßig mit Storage, Virtualisierung und Open-Source-Infrastruktur.