Ende September habe ich mein Proxmox-Labor umgebaut: in prox01 und prox02 kamen neue CPUs, und dazu ist als dritter Knoten ein weiterer Optiplex 7050 gewandert. Damit habe ich zum ersten Mal ein echtes Cluster mit Quorum statt zwei Knoten, die sich gegenseitig anschauen. Beim Aufräumen danach ist mir aufgefallen, dass der wichtigste Teil dieses Clusters an einem einzigen Netzwerkkabel pro Knoten hängt — und das möchte ich in diesem Beitrag ändern.

Es geht um Corosync. Das ist die Komponente, die aus drei einzelnen Servern überhaupt erst ein Cluster macht, und sie ist gleichzeitig die empfindlichste im ganzen Aufbau. Wer schon einmal erlebt hat, wie ein Proxmox-Cluster seine Konfiguration auf einmal nur noch lesend anfasst, hat Corosync bei der Arbeit gesehen.

Was Corosync eigentlich tut

Proxmox legt seine Cluster-Konfiguration nicht in einer Datenbank ab, sondern in einem eigenen Dateisystem: /etc/pve ist kein normales Verzeichnis, sondern pmxcfs, ein replizierter Speicher, den alle Knoten identisch sehen. Wenn ihr auf prox01 eine VM anlegt, liegt deren Konfigurationsdatei eine Sekunde später auch auf prox02 und prox03.

Damit das funktioniert, müssen sich die Knoten permanent einig sein, wer überhaupt dazugehört. Genau das ist Corosyncs Aufgabe. Es verschickt dazu über das Totem-Protokoll sehr kleine Pakete in sehr kurzen Abständen und stellt darüber fest, welche Knoten noch antworten. Fallen zu viele aus, ist die Mehrheit weg — und ohne Mehrheit gibt es kein Quorum. Ein Knoten ohne Quorum macht /etc/pve schreibgeschützt. Das ist kein Fehler, sondern Absicht: lieber nichts ändern können, als eine Änderung zu machen, von der die anderen Knoten nichts wissen. Wer HA-Ressourcen konfiguriert hat, erlebt zusätzlich, dass sich ein Knoten ohne Quorum nach einer Weile selbst neu startet, damit seine VMs gefahrlos woanders anlaufen können.

Und jetzt der Punkt, der die ganze Sache heikel macht: Corosync interessiert sich nicht für Bandbreite, sondern für Laufzeit und Gleichmäßigkeit. Ein Gigabit-Link, auf dem gerade ein Backup läuft, hat massig Durchsatz und trotzdem Latenzspitzen von einigen hundert Millisekunden. Für einen Dateitransfer ist das egal. Für Corosync ist es der Unterschied zwischen „alles in Ordnung“ und „der Knoten ist weg“.

Deshalb lautet die Empfehlung seit Jahren: Corosync gehört auf ein Netz, auf dem sonst nichts Großes passiert. Genau das habe ich im Labor auch gemacht — und mir damit ein anderes Problem eingebaut.

Die Ausgangslage in meinem Labor

Datacenter → Cluster in der Proxmox-Oberfläche mit den drei Knoten Drei Knoten, ein Cluster — seit dem Umbau Ende September.

Jeder der drei Optiplex hat zwei Netzwerkanschlüsse: die Onboard-NIC und eine zweite Karte. Aufgeteilt ist es so:

NetzInterfaceWofür
10.66.99.0/24enp0s31f6 in der Bridge vmbr0Management, VM-Verkehr, Backups
10.10.10.0/24zweite NIC, ohne Bridgenur Cluster-Kommunikation

System → Network auf prox01 mit vmbr0 und der zweiten Karte Die Netzwerkkonfiguration eines Knotens: vmbr0 mit der Onboard-Karte fürs Management, daneben die zweite Karte allein fürs Cluster-Netz.

Corosync läuft also auf dem separaten Netz 10.10.10.0/24, prox01 hat dort .1, prox02 die .2 und prox03 die .3. Nachsehen kann man das mit:

pvecm status
Cluster information
-------------------
Name:             prox-cl01
Config Version:   3
Transport:        knet
Secure auth:      on

Quorum information
------------------
Date:             Wed Sep 30 12:53:21 2026
Quorum provider:  corosync_votequorum
Nodes:            3
Node ID:          0x00000001
Ring ID:          1.b2
Quorate:          Yes

Votequorum information
----------------------
Expected votes:   3
Highest expected: 3
Total votes:      3
Quorum:           2  
Flags:            Quorate 

Membership information
----------------------
    Nodeid      Votes Name
0x00000001          1 10.10.10.1 (local)
0x00000002          1 10.10.10.2
0x00000003          1 10.10.10.3

Interessant ist die Zeile mit den Links am Ende der Ausgabe, und noch genauer wird es hiermit:

corosync-cfgtool -s
Local node ID 1, transport knet
LINK ID 0 udp
	addr	= 10.10.10.1
	status:
		nodeid:          1:	localhost
		nodeid:          2:	connected
		nodeid:          3:	connected

Bei mir stand dort genau ein Eintrag: LINK ID 0. Und das ist die Schwachstelle. Das dedizierte Cluster-Netz ist fürs Timing wunderbar — aber es ist eben nur ein Pfad. Geht der Switch aus, an dem diese drei Karten hängen, oder rutscht auf einem Knoten ein Kabel heraus, verliert das Cluster sein Quorum. Dass jeder Knoten über vmbr0 weiterhin bestens erreichbar ist und die VMs munter weiterlaufen, hilft dann nichts: Corosync schaut dort nicht hin.

Corosync 3 kann genau dafür mehrere Links verwalten, bis zu acht. Und mein zweiter Pfad liegt eigentlich schon fertig da — das Management-Netz.

Was ein zweiter Ring bringt, und was nicht

Kurz die Erwartungen sortieren, sonst baut man das Falsche:

Ein zweiter Ring ist Ausfallsicherheit, keine Leistungssteigerung. Corosync bündelt die Links nicht, es verteilt die Last nicht. Standardmäßig steht link_mode: passive, das heißt: es wird genau ein Link benutzt, und wenn der Probleme macht, wird auf den nächsten umgeschaltet. Es gibt auch link_mode: active, bei dem über alle Links gleichzeitig gesendet wird — das schaltet schneller um, kostet aber mehr CPU und erzeugt mehr Verkehr. Für ein Labor und für die meisten produktiven Cluster ist passive richtig.

Der zweite Ring muss ein anderer physikalischer Weg sein. Zwei IP-Adressen auf derselben Karte sind kein zweiter Ring, sondern ein zweiter Eintrag in einer Konfigurationsdatei. Wenn die Karte, das Kabel oder der Switchport ausfällt, fallen beide weg. Das klingt selbstverständlich, ist aber der häufigste Fehler bei dieser Übung — und der gefährlichste, weil das Ergebnis aussieht wie Redundanz.

Die Reihenfolge ist eine Aussage, keine Kosmetik. Link 0 ist der bevorzugte Pfad. Auf meinen soll also weiterhin das ruhige 10.10.10.0/24 liegen, und das Management-Netz wird Link 1 — der Ersatzweg, der zum Zug kommt, wenn der gute Pfad wegbricht. Umgekehrt wäre es unklug: dann läuft der Normalbetrieb dauerhaft über dasselbe Netz, auf dem auch die Backups liegen.

Als Bild sieht der Plan so aus:

Schema: die drei Knoten prox01 bis prox03, verbunden über das Cluster-Netz 10.10.10.0/24 als Ring 0 und das Management-Netz 10.66.99.0/24 als Ring 1 Zwei Wege, zwei Karten, zwei Switches. Solange der obere steht, wird der untere nicht benutzt — er muss nur da sein.

Am Rande noch eine Beobachtung aus meinem Aufbau, die beim Konfigurieren Nerven kostet, wenn man sie nicht kennt: die zweite Karte heißt auf prox01 und prox02 enp2s0, auf prox03 aber nic0. Für Corosync ist das komplett irrelevant, weil in der Konfiguration IP-Adressen stehen und keine Interfacenamen. Beim Einrichten der Netze auf dem Knoten muss man aber genau hinsehen.

Vorher: Netz vorbereiten und einen Rückweg sichern

Bevor irgendetwas an Corosync geändert wird, muss der zweite Weg tatsächlich funktionieren. Prüft von jedem Knoten aus, dass er die beiden anderen über das künftige Ring-1-Netz erreicht:

ping -c 100 -i 0.2 10.66.99.202
ping -c 100 -i 0.2 10.66.99.203

Worauf ich dabei schaue, ist nicht der Mittelwert, sondern die Ausreißer und mdev am Ende der Zusammenfassung. Bei mir sah es über alle sechs Pfade so aus — je 60 Pakete, zum Vergleich auch über das bestehende Cluster-Netz:

von → nachVerlustmin/avg/max/mdev in ms
prox01 → prox02, Management0 %0,133 / 0,347 / 0,543 / 0,080
prox01 → prox03, Management0 %0,189 / 0,377 / 0,563 / 0,100
prox02 → prox03, Management0 %0,141 / 0,347 / 0,491 / 0,077
prox01 → prox02, Cluster-Netz0 %0,071 / 0,254 / 0,443 / 0,095
prox01 → prox03, Cluster-Netz0 %0,051 / 0,319 / 0,486 / 0,103
prox02 → prox03, Cluster-Netz0 %0,082 / 0,283 / 0,467 / 0,104

Kein Paketverlust, alles weit unter einer Millisekunde. Das Management-Netz ist durchgehend ein wenig langsamer als das dedizierte Cluster-Netz — nicht dramatisch, aber es bestätigt die Rollenverteilung: das ruhige Netz bleibt Link 0.

Dann eine Sicherung anlegen. Die kostet nichts und ist der Unterschied zwischen einer ruhigen und einer sehr unruhigen Stunde:

cp /etc/pve/corosync.conf /root/corosync.conf.$(date +%F)

Und abschließend die Lage prüfen: alle Knoten online, Cluster quorat, kein HA-Vorgang und kein Backup unterwegs.

pvecm status
ha-manager status

Die Änderung

Bearbeitet wird /etc/pve/corosync.conf — die Datei im replizierten Dateisystem, nicht /etc/corosync/corosync.conf. Das ist wichtig: die Datei unter /etc/pve wird auf alle Knoten verteilt, und Corosync lädt die Änderung von selbst nach. Die Datei unter /etc/corosync ist die Kopie, die der Dienst beim Start liest; dort editiert man nur im Notfall, dazu unten mehr.

Zwei Dinge sind zu tun. Erstens bekommt jeder Knoten in der nodelist neben ring0_addr ein ring1_addr:

  node {
    name: prox01
    nodeid: 1
    quorum_votes: 1
    ring0_addr: 10.10.10.1
    ring1_addr: 10.66.99.201
  }

… und entsprechend prox02 mit 10.10.10.2 / 10.66.99.202 sowie prox03 mit 10.10.10.3 / 10.66.99.203.

Zweitens wird der neue Link im totem-Abschnitt bekannt gemacht, und — das ist der Schritt, den man nicht vergessen darf — config_version wird um eins erhöht:

totem {
  cluster_name: prox-cl01
  config_version: 4
  interface {
    linknumber: 0
  }
  interface {
    linknumber: 1
  }
  ip_version: ipv4-6
  link_mode: passive
  secauth: on
  version: 2
}

Unterm Strich sind es acht neue Zeilen und eine geänderte. So sieht die fertige Änderung gegen die Sicherung aus:

@@ -9,18 +9,21 @@
     nodeid: 1
     quorum_votes: 1
     ring0_addr: 10.10.10.1
+    ring1_addr: 10.66.99.201
   }
   node {
     name: prox02
     nodeid: 2
     quorum_votes: 1
     ring0_addr: 10.10.10.2
+    ring1_addr: 10.66.99.202
   }
   node {
     name: prox03
     nodeid: 3
     quorum_votes: 1
     ring0_addr: 10.10.10.3
+    ring1_addr: 10.66.99.203
   }
 }
 
@@ -30,10 +33,13 @@
 
 totem {
   cluster_name: prox-cl01
-  config_version: 3
+  config_version: 4
   interface {
     linknumber: 0
   }
+  interface {
+    linknumber: 1
+  }
   ip_version: ipv4-6
   link_mode: passive
   secauth: on

Bei mir stand vorher config_version: 3, also wird daraus die 4. Über diese Zahl entscheiden die Knoten, welche Fassung der Konfiguration die neuere ist. Vergisst man sie, verwerfen die anderen Knoten die Änderung — im besten Fall passiert einfach nichts, im schlechteren habt ihr unterschiedliche Konfigurationen mit gleicher Versionsnummer im Cluster.

Gespeichert wird einmal, auf einem Knoten. Den Rest macht pmxcfs.

Kontrolle

Jetzt die eigentliche Prüfung:

corosync-cfgtool -s

Erwartet werden nun zwei Blöcke, LINK ID 0 und LINK ID 1, und in beiden für jeden der anderen Knoten der Status connected.

Local node ID 1, transport knet
LINK ID 0 udp
	addr	= 10.10.10.1
	status:
		nodeid:          1:	localhost
		nodeid:          2:	connected
		nodeid:          3:	connected
LINK ID 1 udp
	addr	= 10.66.99.201
	status:
		nodeid:          1:	localhost
		nodeid:          2:	connected
		nodeid:          3:	connected

Dazu ein Blick ins Protokoll, das bei dieser Aktion erstaunlich gesprächig ist:

journalctl -u corosync -n 50

Was ihr dort sehen wollt, ist das Nachladen der neuen Konfiguration und das Hinzufügen des neuen Links — und was ihr nicht sehen wollt, sind Meldungen über verlorene und wiedergefundene Mitgliedschaften. Wenn das Cluster bei dieser Änderung kurz die Mitgliedschaft neu bildet, ist meist die Versionsnummer oder eine der Adressen nicht korrekt.

2026-09-30 12:53:57+02:00 prox01 corosync[27485]:   [CFG   ] Config reload requested by node 1
2026-09-30 12:53:57+02:00 prox01 corosync[27485]:   [TOTEM ] Configuring link 0
2026-09-30 12:53:57+02:00 prox01 corosync[27485]:   [TOTEM ] Configured link number 0: local addr: 10.10.10.1, port=5405
2026-09-30 12:53:57+02:00 prox01 corosync[27485]:   [TOTEM ] Configuring link 1
2026-09-30 12:53:57+02:00 prox01 corosync[27485]:   [TOTEM ] Configured link number 1: local addr: 10.66.99.201, port=5406
2026-09-30 12:53:57+02:00 prox01 corosync[27485]:   [KNET  ] host: host: 2 (passive) best link: 0 (pri: 1)
2026-09-30 12:53:57+02:00 prox01 corosync[27485]:   [KNET  ] host: host: 2 (passive) best link: 0 (pri: 1)
2026-09-30 12:53:57+02:00 prox01 corosync[27485]:   [KNET  ] host: host: 2 (passive) best link: 0 (pri: 1)
2026-09-30 12:53:57+02:00 prox01 corosync[27485]:   [KNET  ] host: host: 3 (passive) best link: 0 (pri: 1)
2026-09-30 12:53:57+02:00 prox01 corosync[27485]:   [KNET  ] host: host: 3 (passive) best link: 0 (pri: 1)
2026-09-30 12:53:57+02:00 prox01 corosync[27485]:   [KNET  ] host: host: 3 (passive) best link: 0 (pri: 1)
2026-09-30 12:53:57+02:00 prox01 corosync[27485]:   [KNET  ] pmtud: MTU manually set to: 0
2026-09-30 12:53:59+02:00 prox01 corosync[27485]:   [KNET  ] rx: host: 3 link: 1 is up
2026-09-30 12:53:59+02:00 prox01 corosync[27485]:   [KNET  ] link: Resetting MTU for link 1 because host 3 joined
2026-09-30 12:53:59+02:00 prox01 corosync[27485]:   [KNET  ] host: host: 3 (passive) best link: 0 (pri: 1)
2026-09-30 12:53:59+02:00 prox01 corosync[27485]:   [KNET  ] rx: host: 2 link: 1 is up
2026-09-30 12:53:59+02:00 prox01 corosync[27485]:   [KNET  ] link: Resetting MTU for link 1 because host 2 joined
2026-09-30 12:53:59+02:00 prox01 corosync[27485]:   [KNET  ] host: host: 2 (passive) best link: 0 (pri: 1)
2026-09-30 12:54:00+02:00 prox01 corosync[27485]:   [KNET  ] pmtud: PMTUD link change for host: 3 link: 1 from 469 to 1397
2026-09-30 12:54:00+02:00 prox01 corosync[27485]:   [KNET  ] pmtud: PMTUD link change for host: 2 link: 1 from 469 to 1397
2026-09-30 12:54:00+02:00 prox01 corosync[27485]:   [KNET  ] pmtud: Global data MTU changed to: 1397

Drei Dinge stehen da, die die Sache erklären. Config reload requested — es wurde nichts neu gestartet, Corosync hat die Datei im Betrieb nachgeladen. Configured link number 1: local addr: 10.66.99.201, port=5406 — der neue Link bekommt einen eigenen UDP-Port, Link 0 bleibt auf 5405. Und best link: 0 (pri: 1) — genau das, was link_mode: passive verspricht: beide Links sind da, benutzt wird der erste.

Der Test, der es beweist

Alles bisher war Konfiguration. Ob sie etwas taugt, weiß man erst, wenn der bevorzugte Pfad wegbricht — und diesen Test würde ich nicht überspringen. Ich habe ihn auf prox03 gemacht, dem Knoten, auf dem am wenigsten liegt. Statt am Kabel zu ziehen, schalte ich die Karte ab; das ist dasselbe Ergebnis, lässt sich aber aus der Ferne machen und wieder einschalten:

ip link set nic0 down

Wichtig: Das ist die Karte des Cluster-Netzes. Meine Verbindung zum Knoten läuft über vmbr0 und bleibt bestehen. Und damit ich mich nicht darauf verlassen muss, die Karte hinterher auch wieder einzuschalten, habe ich das Abschalten mit einem festen Zeitfenster versehen, das sie von selbst zurückholt:

setsid nohup bash -c "ip link set nic0 down; sleep 25; ip link set nic0 up" &

Während dieser 25 Sekunden sieht prox01 das:

Local node ID 1, transport knet
LINK ID 0 udp
	addr	= 10.10.10.1
	status:
		nodeid:          1:	localhost
		nodeid:          2:	connected
		nodeid:          3:	disconnected
LINK ID 1 udp
	addr	= 10.66.99.201
	status:
		nodeid:          1:	localhost
		nodeid:          2:	connected
		nodeid:          3:	connected

Link 0 zu Knoten 3 ist disconnected, Link 1 trägt. Und prox03 selbst, von innen betrachtet — für ihn sind beide Nachbarn auf Link 0 weg:

Local node ID 3, transport knet
LINK ID 0 udp
	addr	= 10.10.10.3
	status:
		nodeid:          1:	disconnected
		nodeid:          2:	disconnected
		nodeid:          3:	localhost
LINK ID 1 udp
	addr	= 10.66.99.203
	status:
		nodeid:          1:	connected
		nodeid:          2:	connected
		nodeid:          3:	localhost

Das Entscheidende steht aber im Protokoll, weil dort die Zeit mitläuft:

2026-09-30 12:55:03+02:00 prox03 corosync[2479]:   [KNET  ] link: host: 2 link: 0 is down
2026-09-30 12:55:03+02:00 prox03 corosync[2479]:   [KNET  ] link: host: 1 link: 0 is down
2026-09-30 12:55:03+02:00 prox03 corosync[2479]:   [KNET  ] host: host: 2 (passive) best link: 1 (pri: 1)
2026-09-30 12:55:03+02:00 prox03 corosync[2479]:   [KNET  ] host: host: 1 (passive) best link: 1 (pri: 1)
2026-09-30 12:55:37+02:00 prox03 corosync[2479]:   [KNET  ] rx: host: 2 link: 0 is up
2026-09-30 12:55:37+02:00 prox03 corosync[2479]:   [KNET  ] rx: host: 1 link: 0 is up
2026-09-30 12:55:37+02:00 prox03 corosync[2479]:   [KNET  ] host: host: 2 (passive) best link: 0 (pri: 1)
2026-09-30 12:55:37+02:00 prox03 corosync[2479]:   [KNET  ] host: host: 1 (passive) best link: 0 (pri: 1)
2026-09-30 12:55:38+02:00 prox03 corosync[2479]:   [KNET  ] pmtud: Global data MTU changed to: 1397

Um 12:55:03 fällt Link 0 aus, in derselben Sekunde steht best link: 1 — die Umschaltung passiert so schnell, dass sie in der Protokollauflösung keinen Abstand hat. Um 12:55:37 ist die Karte zurück und Corosync wechselt von selbst wieder auf Link 0. Dazwischen: kein Mitgliedschaftswechsel, keine Neubildung des Clusters, keine Meldung über verlorene Knoten. Alle drei Knoten melden während des ganzen Vorgangs Quorate: Yes.

Genau dafür war die Übung. Vorher hätte derselbe Ausfall prox03 aus dem Cluster geworfen und auf allen drei Knoten /etc/pve schreibgeschützt.

Wenn es schiefgeht

Der unangenehme Fall: die Konfiguration ist fehlerhaft, das Cluster verliert das Quorum, und damit ist /etc/pve schreibgeschützt — also genau das Verzeichnis, in dem die Datei liegt, die ihr korrigieren müsstet. Das ist der Moment, in dem man froh ist, den Weg vorher gelesen zu haben.

Der Ausweg führt über pmxcfs im lokalen Modus. Auf dem betroffenen Knoten:

systemctl stop pve-cluster corosync
pmxcfs -l

Damit ist /etc/pve wieder beschreibbar, allerdings nur lokal und ohne Replikation. Jetzt die corosync.conf reparieren — entweder aus der Sicherung zurückholen oder den Fehler beheben — und anschließend zurück in den Normalbetrieb:

killall pmxcfs
systemctl start pve-cluster corosync

Deswegen die Sicherung ganz oben. Und deswegen macht man diese Änderung nicht an einem Freitagabend.

Fallstricke, die ich mir notiert habe

  • Nicht auf ein Netz legen, über das große Datenmengen laufen. Auch nicht als Link 1, nach dem Motto „ist ja nur der Ersatzweg“. Wenn Link 0 ausfällt, schaltet Corosync genau in dem Moment um, in dem euer Backup läuft.
  • MTU einheitlich halten. Unterschiedliche MTU auf demselben Ring ist eine Fehlerquelle, die sich als sporadischer Mitgliedschaftsverlust äußert und sich entsprechend schlecht sucht.
  • Firewall: der neue Ring braucht einen eigenen Port. Das ist die Stelle, an der ich selbst falsch geraten hätte — ich hatte angenommen, beide Links nutzen dieselbe Freigabe. Tun sie nicht: Link 0 läuft auf UDP 5405, Link 1 auf UDP 5406, und so weiter nach oben. Steht im Protokoll, siehe oben. Wer die Proxmox-Firewall benutzt, muss den zweiten Port also gesondert freigeben.
  • Nicht beides gleichzeitig ändern. Erst den zweiten Ring einbauen und prüfen, dann bei Bedarf über link_mode nachdenken. Wenn hinterher etwas klemmt, wollt ihr wissen, welche der beiden Änderungen es war.
  • Drei Knoten bleiben drei Knoten. Ein zweiter Ring schützt den Pfad, nicht die Mehrheit. Fällt ein Knoten von drei aus, ist das Cluster weiter quorat; fallen zwei aus, hilft auch der schönste Ring nichts mehr.

Fazit

Der zweite Corosync-Ring ist eine der Maßnahmen mit dem besten Verhältnis zwischen Aufwand und Wirkung: ein paar Zeilen in einer Datei, kein Neustart, keine Ausfallzeit — und danach übersteht das Cluster den Ausfall eines Switches oder eines Kabels, ohne in den Schreibschutz zu fallen. Vorausgesetzt, der zweite Weg ist tatsächlich ein anderer Weg.

Was mich an der Sache gereizt hat, ist dass man dabei versteht, warum Proxmox sich in bestimmten Situationen so verhält, wie es sich verhält. „Das Cluster ist plötzlich read-only“ ist keine Macke, sondern die logische Folge eines Protokolls, das lieber nichts tut als etwas Falsches.

Im nächsten Teil der Serie schaue ich mir an, ob auf denselben drei Knoten ein Ceph-Cluster sinnvoll ist — die Antwort ist interessanter, als ich erwartet hatte, und sie hat weniger mit dem Netzwerk zu tun als mit Rechnen.

Wie immer: Fragen, Anregungen und Widerspruch gerne in die Kommentare oder per Mail.