Proxmox の corosync ネットワーク設定 — ring1 で heartbeat を冗長化し、3ノードの全ペアを1ホップにする

Proxmox VE のクラスタは、3ノードあれば quorum(過半数)を維持できる。だが「3ノードあるから安全」と考えるのは早い。ノード間の heartbeat が1本のネットワークに依存していれば、その1本が死んだ瞬間にクラスタは判断を失う。私はこの単一障害点(SPOF)を消すために corosync へ2本目のリング(ring1)を追加した。

最初の設計では、3ペアのうち1ペアだけが別ノードを経由する遠回りになった。ただし「3ノードでは必ず1ペアが間接になる」と結論づけるのは早計である。ring1_addr に何を選ぶかという一段の設計判断が残っていたからである。結論から書くと、ring1 のアドレスを Ceph の public/cluster 網へ移したところ、3ペアすべてが1ホップの直接経路になった。

本稿は、その往復を含めた実測の記録である。あわせて、Proxmox VE 8 には作業中に使おうとした指揮系統のコマンドが存在しないという落とし穴も書く。この2点を知らずに設計すると、実装では Playbook を7回作り直すことになった。

この記事で分かること

  • Proxmox VE の corosync ネットワーク設定で、ring0_addr / ring1_addr に何を書くか
  • 3ノード構成で ring1 を追加したとき、なぜ1ペアだけ遠回りになるのか(そして回避できるのか)
  • corosync が使うポート番号(5405 / 5406)と、Ceph 同居時に確認すべきこと
  • Proxmox VE 8 で pvecm expected が使えないという落とし穴と、その代替設計
  • 設定変更を「完了」と判定するための3点(LINK ID 1 / knet MTU / pvecm status の Quorate)
目次

この記事の前提 — 環境と構成

本稿の結論は、次の構成を前提にしている。前提が変われば結論も変わるので、先に固定しておく。数値はすべて実測値である。

項目 実測値・構成
クラスタ Proxmox VE 8 / 3ノード(pve01 / pve02 / pve03)/config_version 9
ring0(既定 heartbeat) 192.168.1.13 / .43 / .49(vmbr0 経由・mtu 1500)
ring0 の物理層 3ノードが 1G スイッチと1本の物理 NIC を共有(10G ポートは vmbr0 に不参加)
ノード間 10G DAC 直結フルメッシュ(3ペア・各 /31・mtu 9000)
ring1 10.20.0.11 / .12 / .13(Ceph public/cluster 網と同一・dummy0)
経路広告 FRR + OSPF で /32 を広告(10.20.0.1x は proto ospf)
Ceph 同一クラスタに同居(mon :3300 / corosync :5405・:5406)

とくに次の2点が、以降の話の土台になる。

  • ノード間が DAC 直結のフルメッシュであること。これが無ければ /31 の3ペアは存在せず、「1ノードにつき1アドレス」という制約も生まれない。
  • FRR + OSPF で /32 を広告していること。これが無ければ、ring1 を Ceph 網(10.20.0.0/24)へ移す案は成立しない。

以降は、この2点を前提に、どこで何が起きたかを実測で追う。

corosync が使うポート番号 — 5405 / 5406 と Ceph mon の 3300

ネットワーク設定を変える前に、どのポートが誰のものかを確定させておく。ここを曖昧にしたまま ring1 を追加すると、「ポートが衝突して corosync が上がらない」という切り分けの難しい障害になる。本環境の実測値は次のとおりである。

ポート 用途
:3300 Ceph monitor(同一クラスタに同居)
:5405 corosync ring0(192.168.1.1x)
:5406 corosync ring1(10.20.0.1x)

ring1 を追加した後、3ノードとも 10.20.0.1x:5406 で LISTEN していることを実測した。Ceph の mon と corosync が同一ホストで同居していても、ポートレンジが重ならないため衝突は起きない。

次に MTU である。実測では ring0 の knet MTU が 1397、ring1 が 8885 だった。ring0 は vmbr0(mtu 1500)経由、ring1 は 10G 直結(mtu 9000)経由なので、物理層の差がそのまま現れている。ring を追加するときは、帯域だけでなく MTU も経路ごとに確認する。

なぜ ring0 だけでは足りないのか — Proxmox クラスタの heartbeat 経路

まず前提を実測で確認する。私のクラスタ KCloud1 は pve01 / pve02 / pve03 の3ノードで構成されている。ring0(既定の heartbeat 経路)は 192.168.1.13 / .43 / .49 の3アドレスで、いずれも vmbr0 上にある。

問題はその下の物理層にある。各ノードの vmbr0 に参加している物理ポートのリンク速度を実測すると、pve01 は enp8s0 で 1000Mb/s、pve03 は enp89s0 で 1000Mb/s である。10G のポート(pve01 の enp7s0f0 / enp7s0f1、pve03 の enp2s0f0np0 / enp2s0f1np1)は存在するが、いずれも mtu 9000 で vmbr0 には参加していない。つまり 3ノードが同じ 1G のスイッチと1本の物理 NIC を共有している。ケーブル1本、スイッチ1台、NIC1枚のどれが壊れても、3ノードすべてが同時に heartbeat を失う。

これは「ノードが落ちる障害」とは性質が違う。ノードが1台落ちるなら残り2台で quorum(3票中2票)を維持できる。しかし heartbeat 経路そのものが消えると、残ったノード同士が互いを確認できず、3台とも自分を少数派と判断する可能性がある。監視の世界では「split-brain(脳の分離)」と呼ばれる状態で、両側が独立に「自分が正しい」と判断して書き込みを続けるため、データの整合性を壊す。

したがって ring の冗長化は、可用性の話ではなく整合性の話である。ここを混同すると、優先順位を誤る。

corosync の ring1_addr は1ノード1アドレス — ring1 を足す前の制約

corosync のリングは nodelist の中で ring0_addr / ring1_addr として宣言する。私が最初に持っていた理解は「各ノードに ring1 用のアドレスを書けば、ノード間がフルメッシュで冗長化される」というものだった。これは半分正しく、半分間違っている。

重要なのは、ring1_addr はノードごとに1つしか書けないという点である。ノードAとノードBの間に専用線を敷き、ノードAとノードCの間にも別の専用線を敷いていたとしても、ノードAが ring1 として宣言できるアドレスは1つだけになる。

私の環境では、3ノード間はもともと 10G のポートで3ペアすべてが /31 の直接リンクで結ばれていた。実測したインターフェースは次のとおりである。

ノード 直接リンク1 直接リンク2
pve01 enp7s0f1 10.10.0.0/31(対 pve02) enp7s0f0 10.10.0.2/31(対 pve03)
pve02 enp2s0f0np0 10.10.0.1/31(対 pve01) enp2s0f1np1 10.10.0.4/31(対 pve03)
pve03 enp2s0f1np1 10.10.0.3/31(対 pve01) enp2s0f0np0 10.10.0.5/31(対 pve02)

物理的にはフルメッシュが完成している。ところが corosync に宣言できるアドレスは各1つだけである。この「物理はフルメッシュ、宣言は1本」という食い違いが、次の節の失敗を生む。

最初の設計で、1ペアだけ遠回りになった

私は pve01 に 10.10.0.0、pve02 に 10.10.0.1、pve03 に 10.10.0.5 を割り当てた。この選択の結果は次のようになった。

  • pve01 ↔ pve02 … 10.10.0.0 ⇔ 10.10.0.1 で直接
  • pve02 ↔ pve03 … 10.10.0.1 ⇔ 10.10.0.5 で直接
  • pve01 ↔ pve03 … pve01 から見た 10.10.0.5 は enp7s0f0(10.10.0.2 側)ではないため、pve02 の 10.10.0.3 を経由する間接経路

実際に経路を実測すると 10.10.0.5 via 10.10.0.3 dev enp7s0f0 と出た。つまり pve02 がルータの役割を担っている。

ここで「これは設定ミスではなく、3ノード+ring1_addr 1アドレスという条件から導かれる構造的な限界だ」と結論づけることもできる。3ノードのフルメッシュには3本のペアが必要だが、宣言できるアドレスは各ノード1つしかない。だから必ず1本が間接になる、と。ただしこの結論は、次節以降で示すとおり前提に依存する。

間接経路が残るということは、pve02 が落ちた瞬間に pve01 と pve03 の ring1 経路も一緒に失われるということでもある。SPOF が「1本の物理線」から「1台のノード」へ縮小しただけで、完全には消えていない。この認識を持たずに「ring1 を入れたから冗長化完了」と報告すると、後で必ず問題になる。

だが、この結論は条件付きだった。ring1_addr に「各ノードが直結している /31 のアドレス」を選んだから、宣言が1つに絞られて遠回りが出たのである。別のアドレス体系を選べば、この制約は回避できる。

制約の回避 — ring1_addr を Ceph の public/cluster 網へ移す

私のクラスタは Ceph を同居させており、public_network と cluster_network に 10.20.0.0/24 を使っている。各ノードの dummy0 には、この /24 と、OSPF で広告する /32 が同居している。実測値は次のとおりである。

10.20.0.0/24 dev dummy0 proto kernel scope link src 10.20.0.11
10.20.0.12   nhid 126 via 10.10.0.1 dev enp7s0f1 proto ospf metric 20
10.20.0.13   nhid 14  via 10.10.0.3 dev enp7s0f0 proto ospf metric 20

つまり 10.20.0.11 / .12 / .13 は、それぞれ相手ノードの直結 /31 を next-hop とする /32 経路として、3ノードすべてに広告されている。であれば ring1_addr にこの 10.20.0.1x を指定すれば、各ノードが「自分以外の2ノード」への1ホップ経路を持つ状態になり、3ペアすべてが直接になる。

これが C-2b として実施した変更である。corosync.conf の該当部分はこうなった。

nodelist {
  node {
    name: pve01
    nodeid: 1
    quorum_votes: 1
    ring0_addr: 192.168.1.13
    ring1_addr: 10.20.0.11
  }
  node {
    name: pve02
    nodeid: 2
    quorum_votes: 1
    ring0_addr: 192.168.1.43
    ring1_addr: 10.20.0.12
  }
  node {
    name: pve03
    nodeid: 3
    quorum_votes: 1
    ring0_addr: 192.168.1.49
    ring1_addr: 10.20.0.13
  }
}

totem には interface { linknumber: 1 } が必要である。config_version は 8 から 9 へ上げた。

Port の衝突も事前に確認した。Ceph の mon は :3300、corosync は :5405(ring0)と :5406(ring1)を使う。実測では3ノードとも 10.20.0.1x:5406 で LISTEN しており、衝突は無い。dummy0 は NOARP だが、next-hop は相手直結の 10.10.0.x(/31)であるため、L2 のアドレス解決には影響しない。

全ペア1ホップになったことの実測

変更後に、6方向すべての経路を測った。

--- pve01 -> 10.20.0.12 / .13 ---
pve01 -> 10.20.0.12 : 10.20.0.12 via 10.10.0.1 dev enp7s0f1 src 10.10.0.0
pve01 -> 10.20.0.13 : 10.20.0.13 via 10.10.0.3 dev enp7s0f0 src 10.10.0.2
--- pve02 -> 10.20.0.11 / .13 ---
pve02 -> 10.20.0.11 : 10.20.0.11 via 10.10.0.0 dev enp2s0f0np0 src 10.10.0.1
pve02 -> 10.20.0.13 : 10.20.0.13 via 10.10.0.5 dev enp2s0f1np1 src 10.10.0.4
--- pve03 -> 10.20.0.11 / .12 ---
pve03 -> 10.20.0.11 : 10.20.0.11 via 10.10.0.2 dev enp2s0f1np1 src 10.10.0.3
pve03 -> 10.20.0.12 : 10.20.0.12 via 10.10.0.4 dev enp2s0f0np0 src 10.10.0.5

すべて「相手ノードに直結した /31 対向」を next-hop にしている。中継ノードが入っていない。

経路だけでなく、TTL=1 の ping で「1ホップで届くか」を直接測った。TTL を1にすると、ルータを1台でも経由した時点で破棄されるため、これが通れば中継が無いことの証明になる。

--- 10.20.0.11 -> 10.20.0.12 (TTL=1) ---
3 packets transmitted, 3 received, 0% packet loss
--- 10.20.0.11 -> 10.20.0.13 (TTL=1) ---
3 packets transmitted, 3 received, 0% packet loss
(pve02・pve03 発の4方向も、すべて 3/3 受信・0% loss)

corosync 側のリンク状態も揃った。

nodeid: 2 reachable
   LINK: 0 udp (192.168.1.13->192.168.1.43) enabled connected mtu: 1397
   LINK: 1 udp (10.20.0.11->10.20.0.12) enabled connected mtu: 8885

nodeid: 3 reachable
   LINK: 0 udp (192.168.1.13->192.168.1.49) enabled connected mtu: 1397
   LINK: 1 udp (10.20.0.11->10.20.0.13) enabled connected mtu: 8885

3ペアすべて LINK: 1 が connected、mtu 8885 である。ring0 は mtu 1397 なので、物理層の差(vmbr0 は mtu 1500、10G 直結は mtu 9000)がそのまま現れている。

Proxmox VE 8 には expected votes を下げる手段が無い

設計上のもう一つの落とし穴が、作業手順そのものにある。リングを追加するとき、一時的に quorum の要求票数を下げて、設定変更中の事故を避けたくなる。一般には pvecm expected 1 のようなコマンドで票数を下げられると説明されている。

私は実際に実行して確認した。結果は次のとおりである。

# pvecm expected 1
Unable to set expected votes: CS_ERR_INVALID_PARAM

# corosync-quorumtool -e 1
(RC=1 / CS_ERR_INVALID_PARAM / Expected votes は 3 のまま不変)

Proxmox VE 8(corosync 3.x)には、expected votes を動的に下げる手段が存在しない。 参照系の corosync-quorumtool -s は正常に動くが、値を変える側は受け付けられない。したがって「一時的に下げて、作業後に戻す」という工程は、私の環境では設計から削除するしかなかった。

代替として採ったのが、「反映した直後に quorate を実測し、失っていれば自動でバックアップへ戻す」という設計である。Playbook の構造はこうなる。

block:
  - copy: { content: "{{ planned_conf }}", dest: /etc/pve/corosync.conf }
  - pause: { seconds: 10 }
  - command: pvecm status
    register: st1
  - assert:
      that:
        - st1.stdout_lines | select('search','Quorate:') | first | trim | regex_search('Yes') is not none
      fail_msg: "quorate を喪失。自動ロールバックを実行する。"
rescue:
  - copy: { remote_src: true, src: "{{ conf_backup }}", dest: /etc/pve/corosync.conf }
  - pause: { seconds: 10 }
  - command: pvecm status
  - fail: { msg: "ring1 反映に失敗したためバックアップから復元した。" }

block / rescue を使うことで、「変更 → 実測 → 失敗なら自動復帰」が1タスクのまとまりとして実行される。人間が監視していなくても、quorate を失った設定は残らない。

この設計に至るまでに、私は Playbook を7回作り直した。主な原因は、いずれも「正しいはずだと思い込んだ記述」が実測で否定されたことだった。

  • lookup('file', '/etc/pve/corosync.conf') はコントロールノード側のファイルを探すため、リモートの実体を読めず必ず失敗する。command: cat と register に変える必要がある。
  • 反映前の設定を template → cat → copy の3段で組み立てると、--check(ドライラン)で停止する。set_fact で先にメモリ上にレンダリングし、assert で検証してから copy: content で流し込む2段にする必要がある。
  • assert の that に {{ }} を付けると ansible-core 2.23 では非推奨警告になる。素の式で書く。
  • Playbook の name に半角コロン+半角スペースを書くと YAML のパースエラーになる。

いずれも「動くはず」という推測が外れた結果である。この種の作業では、推測で書いた行がそのまま失敗の原因になる。

反映後の実測 — 何をもって「確立した」と判定するか

設定を反映しただけでは、ring1 が機能しているかは分からない。私は次の3点を実測して判定した。

第一に、LINK ID 1 のセクションが存在し、その配下に connected があること。これが最も基本的な判定条件である。LINK ID 1 が無ければ設定は読まれていない。今回の実測では、3ノードすべてで addr = 10.20.0.1x の LINK ID 1 が現れ、自分以外の2ノードが connected になっていた。

第二に、knet のリンク状態(前掲の cfgtool -n)で3ペアすべてが connected であること。cfgtool -s は自分の視点しか出さないが、-n はペアごとの状態を出すため、片方向だけ落ちるような非対称障害の検出に効く。

第三に、quorum の状態を確認する。

# pvecm status
Name:             KCloud1
Config Version:   9
Nodes:            3
Quorate:          Yes
Expected votes:   3

Config Version が 8 から 9 へ上がり、Quorate: Yes が維持されている。ここまで揃って初めて「ring1 が確立した」と判断できる。私は最初の作業で LINK ID 1 の存在だけで完了報告しかけ、MTU と quorate を後から測り直した。設定変更の完了判定は、設定ファイルの差分ではなく稼働状態の実測で行うべきである。

なお MTU の話は VPN の世界でも同じ罠がある。別稿で WireGuard の MTU 不一致を切り分けた記録を書いている( https://kaneshige.org/wireguard-mtu-1280-pmtu-blackhole/ )。リングを追加するときは、帯域だけでなく MTU も経路ごとに確認したほうがよい。

トレードオフ — ring1 は OSPF に依存する

全ペア1ホップになった代わりに、受け入れた条件がある。10.20.0.1x への /32 経路は proto ospf、つまり frr が広告している経路である。実測でも3ノードとも frr は active、OSPF neighbor は全ペア Full(2日10時間22分)で安定していた。

10.20.0.12 nhid 126 via 10.10.0.1 dev enp7s0f1 proto ospf metric 20
10.20.0.13 nhid 14  via 10.10.0.3 dev enp7s0f0 proto ospf metric 20

したがって frr を停止すると、ring1 は3ペア全滅する。これは ring1 を「OSPF に依存しない経路」にした場合(10.10.0.x 案)には無かった性質である。

ただし、冗長化の本質は損なわれない。ring0(192.168.1.0/24)は proto kernel の directly connected 経路であり、frr に依存しない。だから frr が落ちても quorum は ring0 だけで維持される。冗長経路が2本から1本に減るだけで、クラスタが判断を失うことはない。

この構造を正しく理解しておくと、障害時の切り分けが速くなる。ring1 が落ちたときに「corosync の設定が壊れた」と疑う前に、「frr は動いているか」を先に見るべき場面がある、ということである。

この構成で消えた SPOF と、消えていない SPOF

実測を踏まえて、現状を正確に整理する。

障害点 ring1 追加前 ring1 追加後(C-2b 後)
ring0 の物理 NIC 故障 quorum 喪失 quorate 維持(ring1 が生存)
ring0 のスイッチ故障 quorum 喪失 quorate 維持(ring1 が生存)
pve02 の停止 残り2台で quorate 維持 残り2台で quorate 維持(ring1 の pve01–pve03 も直接経路のまま)
frr(OSPF)の停止 影響なし ring1 は全滅。ただし quorate は ring0 で維持
3ノード同時の電源断 全停止 全停止(変わらず)

1行目と2行目が、今回の作業で得たものだ。heartbeat 経路が2本になったことで、物理層の単一障害では quorum を失わなくなった。

3行目も改善した。10.10.0.x 案では pve02 が落ちると pve01–pve03 の ring1 が失われたが、C-2b 後はそのペアも直接経路なので、pve02 が落ちても残り2ノード間の ring1 は生きている。

代わりに4行目という新しい依存が生まれた。frr が止まると ring1 は無くなる。ただし ring0 は frr 非依存なので、クラスタが quorum を失うことはない。「冗長性が1段減る」であって「冗長性が消える」ではない。この差を正確に書いておかないと、次の担当者が「frr を止めたらクラスタが落ちる」と誤解する。

学びの整理 — 制約を先に測り、制約の回避策も測る

本件から得た判断の型を整理する。

  1. 冗長化の設計前に、宣言できるパラメータの制約を実測する。 ring1_addr が1ノード1アドレスであることを知らずに物理配線だけを見ると、「フルメッシュだから大丈夫」と誤認する。物理層と設定層は別物である。
  2. 制約に当たったら、そこで止めずに「アドレス体系の選択」を疑う。 「3ノードでは必ず1ペア間接になる」という結論は、ring1_addr に直結 /31 を選ぶ前提を固定したときにだけ成り立つ。Ceph 網のように「各ノードが相手への1ホップ経路を持つアドレス体系」を選べば、同じ制約下でも全ペア直接にできる。
  3. コマンドの存在を前提にしない。 pvecm expected は一般に流通している情報だが、PVE8 では動作しない。手順書を信じる前に、対象環境で1回実行して確認する。
  4. 完了判定は稼働状態で行う。 LINK ID 1 の存在、knet の MTU、pvecm status の Quorate。3点が揃って初めて完了である。設定ファイルが書き換わったことは完了の証拠にならない。
  5. 自動ロールバックを設計に含める。 票数を下げられない環境では、変更直後に quorate を実測して失敗時に戻す仕組みが唯一の安全弁になる。
  6. 残ったトレードオフを明示して閉じる。 「冗長化しました」ではなく「物理層の SPOF は消えたが、ring1 は OSPF に依存し、frr 停止時は ring0 の1本に戻る」と書く。書かないと、次の担当者が同じ誤解をする。

設計は、最初「ring1 を追加すれば3ノードがフルメッシュで冗長化される」という理解から始まり、次に「3ノードでは必ず1ペア間接になる」という制約に当たり、最終的に「アドレス体系を変えれば全ペア直接になる」へ到達した。制約は設計の後ではなく設計の前に測る。そして制約に当たったら、制約の外側に解が無いかを測る。 これが今回いちばん高くついた学びである。

同じクラスタ基盤の話として、Ceph の RBD へ移行する際に「実効容量・複製係数・戻し道」の順序を決めた記録( https://kaneshige.org/proxmox-lxc-ceph-rbd-staged-migration/ )や、OSD を安全に削除する条件を実測した記録( https://kaneshige.org/ceph-osd-removal-safe-to-destroy/ )も書いている。ストレージ層と heartbeat 層は別の設計だが、「先に制約を測る」という姿勢は共通している。

ring1 を載せた 10G 直結網そのものの作り方は、RDMA の導入手順として別稿にまとめている( https://kaneshige.org/proxmox-rdma-setup-guide/ )。本稿の環境では、この 10G フルメッシュが /31 の3ペアとして存在することが前提になっている。バックアップ運用との干渉(vzdump と iowait、sanoid のスナップショット)を実測した記録( https://kaneshige.org/proxmox-vzdump-iowait-sanoid-snapshot/ )も、同じクラスタで得た知見である。

なお、今回の作業は OpenProject の WP#1030(10G 化の残課題を引き継いだ追跡チケット)として記録し、Playbook は corosync_ring1_to_1020_v8_20261031.yml(v8、撤回手順をヘッダに明記)を正本としている。撤回は各ノードの /root/corosync.conf.bak_ring1to1020_20261031_1740 を戻すことで行える。可逆性を確保したうえで実行した点も、記録として残しておきたい。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

金重総合研究所の主席研究員。
子供の頃から研究者を目指し、ライフワークとして日々様々な研究をしています。
経営・マネジメント・金融・DXあたりが本職です。
私を採用したい人、私と一緒に働きたい人、一緒に知識を肥やしていきたい人はぜひお声がけ下さい。

目次