クラスタ
Wippyはデフォルトでは単一ノードとして動作します。クラスタを有効にすると、複数のノードが一つの協調システムになり、有界 Raft コンセンサスコアの上でメンバーシップ、クラスタ全体のプロセス名前付け、分散ロック、プロセスグループメッセージングを共有します。
cluster.enabled: true を設定するまでクラスタリングはオフです。以下の内容は単一ノードでは無効です。
クラスタリングで得られるもの
- メンバーシップ — すべてのノードがゴシップを通じてライブなピアのセットを把握し、高速な障害検出が行われます。
- クラスタ全体のプロセス名前付け — どのノードからでも解決できる名前でプロセスを登録できます。整合性保証を選択できます(名前付けと名前スコープを参照)。
- 分散ロック —
system.lockは保持者が死亡した場合に自動解放されるクラスタ全体の排他制御を提供します(分散ロックを参照)。 - プロセスグループ — 全ノードにまたがる名前付きグループのすべてのメンバーに公開します(プロセスグループを参照)。
- コンセンサスコア — 小規模で有界な Raft クラスタが、名前付けとロックプリミティブの基盤となる線形化可能なバックボーンを提供します。
アーキテクチャ: 有界 Raft
すべてのノードを Raft ピアにするとスケールが悪くなります。リーダーはすべてのログエントリをすべてのピアにレプリケーションするため、クラスタサイズが増えるとリーダーのアイドルコストが増加します。Wippyは Raft を固定サイズのコアに制限し、残りのクラスタはゴシップで動作します。各ノードは Raft 設定において3つのロールのいずれかを持ちます:
| ロール | 数(デフォルト) | Raft 設定内 | ログレプリケーション受信 | 投票 |
|---|---|---|---|---|
| 投票ノード | 最大5 (max_voters、奇数) |
あり | あり | あり |
| スタンバイ | 最大4 (max_standbys) |
あり | あり | なし |
| クライアント | 無制限 | なし | なし | なし |
- 投票ノードがクォーラムを形成します。過半数の投票ノードが確認すると書き込みがコミットされます。投票ノード数は常に奇数なので過半数が明確に定義されます。
- スタンバイは完全にレプリケーションされ温められている非投票メンバーです。投票ノードが離脱すると、リーダーは最上位のスタンバイを空いた投票スロットに昇格させます。これにより新しいノードのキャッチアップを待たずにクォーラムが回復します。
- クライアントは
投票ノード + スタンバイを超えたすべてのノードです。Raft 設定にまったく含まれないため、リーダーはログエントリを送信しません。ゴシップに参加し、書き込みを Raft メンバーにルーティングします。これによりアイドルリーダー CPU がクラスタサイズに関係なくフラット(O(1))に保たれます。
スタンバイとクライアントがフリートの残りを吸収できるため、数百ノードのクラスタでも5投票ノードのコンセンサスコアを維持できます。max_voters/max_standbys の上限がこの設計を「有界」にしているものです。
投票ノードの選択
リーダーは、すべてのメンバーシップ変更時に(raft.reconcile_debounce、デフォルト2sでデバウンスして)どのノードが投票ノードになるべきかを再計算し、最小限のプロモート/デモート操作を適用する調整ロジックを実行します。選択は決定論的です — すべてのノードが同じゴシップビューから同じ順序を導出します — ゴシップで通知される3つのヒントによって駆動されます:
raft.eligible—eligible: falseのノードは投票ノードとして選択されません(クライアントやスタンバイに留まらせたいノードに使用)。raft.priority— 投票スロットを埋める際に値が小さいほど優先されます。同値の場合はノードID で決定します。failure_domain— 投票ノードはまず異なるドメイン(ゾーン/ラック)に分散されるため、1つのドメインが失われても投票ノードの過半数が失われません。
操作はクォーラムを保持する順序で適用されます: 追加と昇格が先、次にデモート、最後に削除。
メンバーシップとゴシップ
メンバーシップには SWIM ゴシップ(HashiCorp memberlist)を使用します。各ノードはゴシップポート(デフォルト 7946)をバインドし、ピアと継続的に小さなメッセージを交換して障害を検出しメタデータを伝播します。
ノードは既存のノードを指定して参加します:
cluster:
enabled: true
name: node-2
membership:
join_addrs: "node-1:7946"
最初のノードは join_addrs 不要 — シードとして起動します。参加はバックオフで再試行され、孤立したノードは定期的に再参加を試みるため、新しい IP でノードが再起動(Kubernetes では一般的)してもすぐに再収束します。
ゴシップはインラインまたはファイルから提供される共有キーで暗号化できます:
cluster:
membership:
secret_file: /etc/wippy/cluster.key
メンバーシップ変更(NodeJoined、NodeLeft、NodeUpdated)が Raft ブートストラップ、投票ノード調整、プロセスグループ同期、離脱ノードが所有する名前の自動クリーンアップを駆動するイベントです。
ブートストラップ
初期クラスタは静的なピアリストではなく、ゴシップによって形成されます。これは Consul/Nomad の bootstrap_expect パターンに従います: 各起動ノードに期待するノード数を伝え、互いを確認してからクォーラムを共同で形成します。
bootstrap_expect |
動作 |
|---|---|
0 |
自己ブートストラップしない。既存クラスタへの参加のみ |
1 |
単一ノード。自身を唯一の投票ノードとして即座にブートストラップ |
N |
N個の適格ピアがゴシップで安定して見えるようになるまで待機し、全員が同じ投票ノードリストを導出してクラスタを形成 |
N ノードブートストラップでは、すべての初期ノードに同じ bootstrap_expect: N を設定します。各ノードはゴシップで「プリブートストラップ」状態を通知します。短い安定ウィンドウの間に正確に N 個のそのようなピアが見えると、すべてのノードが独立して同一のソート済み投票ノードセットを計算しクラスタを形成します。安定ウィンドウにより、一時的な部分的ビューがブートストラップを早期にトリガーするのを防ぎます。
後から起動するノードはすでに形成されたクラスタを見てブートストラップをスキップします — リーダーの調整ロジックが投票ノードまたはスタンバイとして追加します。
Raft コンセンサスコア
コンセンサスコアはディスクレスです: Raft ログとスナップショットはメモリ内のみに存在するため、データディレクトリをプロビジョニングする必要はありません。再起動時にノードはゴシップに再参加し、ピアから状態をリプレイします。これにより、オンディスク Raft が引き起こす永続性対クォーラム障害モードが意図的に排除され、インメモリ協調システム(Erlang global、Akka distributed data)のモデルに合致します。トレードオフ: クラスタの耐久性はディスクではなくライブクォーラムから来ます — 回復と障害モードを参照。
Raft は独自のリッスンポートを開きません。ノード間メッシュ上で動作します — ノード間のリレートラフィックに使用されるのと同じ TCP 接続を yamux で多重化しています。ノード間ポートは起動時に自動選択(範囲 7950-7959、その後エフェメラル)、固定され、ゴシップで通知されるためピアが到達できます。通常公開する必要があるのはゴシップポートのみです。
Raft FSM はグローバル名前レジストリを保持します: アクティブな name -> PID バインディングと進行中の Strong 予約。以下で説明する名前付けプリミティブが読み書きするのがこれです。
名前付けと名前スコープ
プロセスは名前で登録され、生の PID の代わりにその名前で到達できます。重要な決定はスコープで、整合性保証を選択します。最も安価/弱いものから強いものまで4つのスコープがあります:
| スコープ | バックエンド | 可視性 | 保証 |
|---|---|---|---|
| Local | ノードごとのマップ | このノードのみ | 即時、ノードローカル。調整なし |
| Eventual | ゴシップ CRDT | クラスタ全体 | 最終的整合性。ゴシップラウンド後に収束 |
| Consistent | Raft | クラスタ全体 | 線形化可能な書き込み。クラスタ全体で一意なシングルトン |
| Strong | Raft + 全ノード確認 | クラスタ全体 | Consistent かつすべてのライブノードが名前がアクティブになる前に確認 |
選択方法:
- Local — 1つのノードでのみ意味のある名前(ノードごとのヘルパー)。プロセス終了時に即座に解放。コストゼロ。
- Eventual — 短い古い状態ウィンドウが許容される大量のプレゼンス/セッション名。非常に大きな名前カウントにスケールします。2つのオリジンが同じ名前を登録すると、競合解決が勝者を選び、負けたプロセスは
name revoked: <name>という理由を持つキャンセルイベント(process.event.CANCEL)を受信します。実行を継続して再登録できます。所有ノードが離脱すると名前は解放されます。 - Consistent — クラスタ全体の名前付きシングルトンの標準的な選択。先着順: 同じ名前を異なる PID に二度目に登録しようとすると "already exists" で失敗し現在の所有者を返します。書き込みにはクォーラムが必要なため、少数派パーティションでは停止します。読み取りはローカルの Raft レプリカから行われ、書き込みから数ミリ秒遅れる場合があります。
- Strong — 一時的な古い読み取りでさえ危険なコントロールプレーンシングルトンの小さなセット。Consistent 保証に加え、登録はすべてのライブノードが確認するまでアクティブにならない予約を開きます。競合するバインディングを既に保持しているノードは即座に拒否します。期限内に全ノードが確認しない場合、登録は期限切れとなりどのノードが欠けていたかを報告します。これが分散ロックの基盤です。
名前は自動的に解放されます: Local はプロセス終了時。Consistent と Strong はプロセス終了時(トポロジー監視経由)とノード離脱時。Eventual はノード離脱時。メッセージング(process.send、process.terminate など)の解決は順番に照合します — Consistent、次に Eventual、次に Local — そのため同じ文字列の Consistent 名が Eventual 名を隠します。
名前付けの Lua インターフェースは process.registry(スコープ付き登録/検索/登録解除)にあります — プロセスリファレンスを参照。
プロセスグループ
プロセスグループは Erlang の pg をモデルにしたクラスタ対応のパブリッシュ/サブスクライブとメンバーシップ機能です。プロセスが名前付きグループに参加し、そのグループへのブロードキャストがすべてのノードのすべてのメンバーに届きます。グループはゴシップに裏付けられており最終的整合性があります — Raft から独立しているため、コンセンサスコアが収束中でも動作し続けます。
典型的な操作: グループへの参加/離脱、すべてのメンバーへのブロードキャスト(またはローカルメンバーのみ)、メンバーの一覧取得、グループの参加/離脱イベントの監視。新しいノードが参加すると、グループは直接同期ハンドシェイクを通じてメンバーシップを調整し、バックグラウンドのアンチエントロピーループが時間をかけて発散を修正します。
Lua API についてはプロセスグループを、設定についてはpg.scopeエントリ種別を参照。
分散ロック
system.lock は Strong 名前スコープ上に直接構築されたクラスタ全体の排他制御です。ロックの取得は呼び出しプロセスが所有する名前を Strong スコープで登録します。解放は登録を解除します。Strong はすべてのライブノードの確認を要求するため、クラスタ全体で最大1つの保持者しか存在できません。
local ok, err = system.lock.acquire("orders.migration")
if ok then
-- クリティカルセクション: クラスタ全体で保持者は1つだけ
system.lock.release("orders.migration")
end
取得はフェイルファスト(ノンブロッキング): ロックが保持されている場合は即座に返されるため、呼び出し側は独自のリトライ/バックオフを追加します。保持者プロセスが終了またはそのノードが離脱するとロックは自動解放されるため、クリーンアップは自動的です。正確なシグネチャについてはシステムリファレンスを参照。
設定
キーごとの完全なリファレンスは設定にあります。最小限の形:
単一ノード(開発用):
cluster:
enabled: true
name: dev
raft:
bootstrap_expect: 1
3ノード投票クラスタ:
cluster:
enabled: true
name: node-1
failure_domain: us-east-1a
membership:
join_addrs: "node-2:7946,node-3:7946"
secret_file: /etc/wippy/cluster.key
raft:
bootstrap_expect: 3
ゴシップのみのクライアント(名前付け/メッセージングのために参加、Raft は実行しない):
cluster:
enabled: true
name: edge-7
membership:
join_addrs: "node-1:7946,node-2:7946"
raft:
role: client
ポート
| 用途 | ポート | プロトコル | 設定キー |
|---|---|---|---|
| ゴシップ(メンバーシップ) | 7946 | TCP + UDP | cluster.membership.bind_port |
| ノード間メッシュ(リレー + Raft) | 自動 | TCP | cluster.internode.bind_port |
独立した Raft ポートはありません — Raft はノード間メッシュ上で多重化されています。ノード間ポートは自動割り当てされゴシップで通知されるため、ゴシップポートのみ予測可能な公開が必要です。
オブザーバビリティ
クラスタのヘルスは標準の Prometheusエンドポイントとライブネスヘルスチェックを通じて公開されます。
監視すべき主要メトリクス:
| メトリクス | 意味 |
|---|---|
raft_state |
0 = フォロワー、1 = 候補、2 = リーダー |
raft_term |
現在の Raft タームで、急速な増加は選挙の乱れを示す |
raft_voters / raft_non_voters |
設定内のライブな投票ノードとスタンバイ |
raft_leader_changes_total |
リーダー遷移。健全なクラスタではほぼフラット |
raft_voter_churn_burst_total |
投票ノードの追加/削除操作のバースト。持続的な乱れは警告サイン |
gossip_members{state} |
状態(alive/suspect/dead/left)ごとのカウント |
gossip_convergence_seconds |
ゴシップイベント間の時間 |
組み込みのライブネスチェック(ライブネスエンドポイントに接続):
- ゴシップ — ノードのゴシップヘルススコアが低い間は健全。再参加するノードが早期に終了しないよう起動グレースウィンドウあり。
- raft last-contact — 投票フォロワーは最近リーダーから連絡がない場合に失敗します。スタンバイはより長いギャップを許容します。リーダーは常に通過。
- process-group ブロードキャスト — グループが長期間ブロードキャストトラフィックを受信しない場合に失敗し、ブロックされたイベントループや永続的なパーティションを検出します。
回復と障害モード
コンセンサスコアはディスクレスなため、耐久性はディスクではなくライブクォーラムから来ます。実用的なルール:
- 投票ノードの過半数を維持します。5投票ノードの場合、2つの同時投票ノード障害を許容します。スタンバイが空いたスロットを補充するために昇格します。過半数を下回ると、書き込み(新しい Consistent/Strong 登録とロック取得)はクォーラムが戻るまで停止します。既存の名前と検索はローカルレプリカから引き続き提供されます。
- リーダーはハートビート無音かつゴシップデッドの投票ノードを積極的に排除するため、スタンバイが昇格している間にデッドな投票ノードがクォーラムを永続的にブロックしません。
- クォーラムを失ったクラスタを回復するには、失敗したノードを再起動します。ゴシップに再参加し、生き残ったメンバーが折りたたみます。投票ノードを
failure_domainにまたがって分散させることが、単一ゾーン障害でクォーラムを失うことを防ぐ手段です。