クラスタ
Wippy はデフォルトでは単一ノードとして動作します。クラスタリングを有効にすると、ゴシップメンバーシップと有界 Raft コンセンサスコアによってノードが接続され、クラスタ全体のプロセス名、分散ロック、プロセスグループメッセージングが利用できます。
cluster.enabled を true に設定するまで、クラスタリングは無効です。
クラスタの機能
- メンバーシップ — すべてのノードがゴシップを通じてライブなピアのセットを把握し、高速な障害検出が行われます。
- クラスタ全体のプロセス名前付け — どのノードからでも解決できる名前でプロセスを登録でき、整合性保証を選択できます(名前付けを参照)。
- 分散ロック —
system.lockは保持者が終了した場合に自動解放されるクラスタ全体の排他制御を提供します(分散ロックを参照)。 - プロセスグループ — 全ノードにまたがる名前付きグループのすべてのメンバーに公開します(プロセスグループを参照)。
- レプリケートされたキーバリューストア —
store.kv.raft(強整合性)とstore.kv.crdt(最終的整合性)がノード間で KV データをレプリケートします(ストアを参照)。 - コンセンサスコア — 小規模で有界な Raft クラスタが、名前付けとロックプリミティブの基盤となる線形化可能なバックボーンを提供します。
アーキテクチャ: 有界 Raft
Wippy は Raft メンバーシップを固定サイズのコアに制限し、リーダーがすべてのログエントリをすべてのノードへレプリケートしないようにします。それ以外のノードはゴシップを通じて参加します。各ノードは Raft 設定で次の 3 つのロールのいずれかを持ちます:
| ロール | 数(デフォルト) | Raft 設定内 | ログレプリケーション受信 | 投票 |
|---|---|---|---|---|
| 投票ノード | 最大5 (max_voters、奇数) |
あり | あり | あり |
| スタンバイ | 最大4 (max_standbys) |
あり | あり | なし |
| クライアント | 無制限 | なし | なし | なし |
- 投票ノードがクォーラムを形成します。過半数の投票ノードが確認すると書き込みがコミットされます。
max_votersは奇数の上限(デフォルト 5)に正規化されます。適格ノードが 3 つ以上ある場合、調整ロジックも奇数の投票ノード数を選択します。適格ノードが 2 つで上限が 1 より大きい場合は両方が投票ノードとなり、max_voters: 1では 1 つだけになります。 - スタンバイは完全にレプリケーションされ温められている非投票メンバーです。投票ノードが離脱すると、リーダーは最上位のスタンバイを空いた投票スロットに昇格させます。これにより新しいノードのキャッチアップを待たずにクォーラムが回復します。
- クライアントは
voters + standbysを超えたノードです。Raft 設定に含まれないため、リーダーはログエントリを送信しません。ゴシップに参加し、書き込みを Raft メンバーにルーティングすることで、Raft レプリケーションを設定済みのコアサイズに制限します。
max_voters と max_standbys の設定により、クラスタ全体のサイズとは独立してコンセンサスコアの上限が決まります。
投票ノードの選択
リーダーは、すべてのメンバーシップ変更時に(raft.reconcile_debounce、デフォルト2sでデバウンスして)どのノードが投票ノードになるべきかを再計算し、最小限のプロモート/デモート操作を適用する調整ロジックを実行します。選択は決定論的です — すべてのノードが同じゴシップビューから同じ順序を導出します — ゴシップで通知される3つのヒントによって駆動されます:
raft.eligible—eligible: falseのノードは投票ノードとスタンバイの両方の選択から除外され、Raft 外のクライアントとなります。スタンバイにする場合は適格のまま、投票ノードの選択順位より下に設定します。raft.priority— 投票スロットを埋める際に値が小さいほど優先されます。同値の場合はノードID で決定します。failure_domain— 投票ノードはまず異なるドメイン(ゾーン/ラック)に分散されるため、1つのドメインが失われても投票ノードの過半数が失われません。
操作はクォーラムを保持する順序で適用されます: 追加と昇格が先、次にデモート、最後に削除。
メンバーシップとゴシップ
メンバーシップには SWIM ゴシップ(HashiCorp memberlist)を使用します。各ノードはゴシップポート(デフォルト 7946)をバインドし、ピアと継続的に小さなメッセージを交換して障害を検出しメタデータを伝播します。
ノードは既存のノードを指定して参加します:
cluster:
enabled: true
name: node-2
membership:
join_addrs: "node-1:7946"
secret_file: /etc/wippy/cluster.key
internode:
identity_key_file: /etc/wippy/node-2.key
trusted_peer_keys:
node-1: "okmamN3PKkMpPwPBurknHy2Wi3dwp/rz+uTM2fF9aD0="
node-2: "PWX+oOYrFdtjUxbgmTkXCFI0KEvG++ZM52HOWfDkqP8="
最初のノードには join_addrs は不要で、シードとして起動します。参加はバックオフ付きで再試行され、孤立したノードは定期的に再参加を試みます。これは Kubernetes で一般的な、新しい IP で再起動するノードにも対応します。
ゴシップは常に共有キーで暗号化されます。インラインの membership.secret_key として、またはファイルから membership.secret_file として指定します。どちらも指定せずに起動したノードは、クラスタコンポーネントの起動に失敗します。値は base64 エンコードされ、すべてのノードで同一です。
メンバーシップ変更(NodeJoined、NodeLeft、NodeUpdated)が Raft ブートストラップ、投票ノード調整、プロセスグループ同期、離脱ノードが所有する名前の自動クリーンアップを駆動するイベントです。
ノード間アイデンティティ
各ノードは ed25519 キーペアを保持し、各ノードは信頼する公開鍵のマップを持ちます。cluster.enabled: true の場合、どちらも必須です。
cluster:
internode:
identity_key_file: /etc/wippy/node-1.key
trusted_peer_keys:
node-1: "okmamN3PKkMpPwPBurknHy2Wi3dwp/rz+uTM2fF9aD0="
node-2: "PWX+oOYrFdtjUxbgmTkXCFI0KEvG++ZM52HOWfDkqP8="
node-3: "QfP0fgllbj4s95VAztTORhy3bv9mst1l0lwuUNvO/hE="
| キー | 内容 |
|---|---|
internode.identity_key |
ノードの秘密鍵(インライン) |
internode.identity_key_file |
その鍵を保持するファイルのパス |
internode.trusted_peer_keys |
自ノードを含むメッシュ内のすべてのノードについての、ノード名から公開鍵へのマッピング |
キー形式: base64(標準またはパディングなしの raw エンコーディング)。秘密鍵は 32 バイトの ed25519 シードまたは 64 バイトの完全な ed25519 秘密鍵にデコードされます。信頼するピアの鍵は 32 バイトの ed25519 公開鍵にデコードされます。鍵生成用のサブコマンドはありません — 任意の ed25519 ツールで鍵を作成し、raw バイトを base64 エンコードしてください:
# 32バイトのシードとその公開鍵をbase64エンコード
openssl genpkey -algorithm ed25519 -out node-1.pem
openssl pkey -in node-1.pem -outform DER \
| tail -c 32 | base64 > node-1.key
openssl pkey -in node-1.pem -pubout -outform DER \
| tail -c 32 | base64
identity_key と identity_key_file は排他的で、いずれか一方が必須です。trusted_peer_keys には、ローカルの cluster.name に対応するエントリが含まれ、その値は自ノード自身の公開鍵でなければなりません。自ノードのエントリが欠けているか一致しない場合、起動は中止されます。これにより、信頼マップはすべてのノードへ変更せずに配布できる単一のアーティファクトになります。
メッシュのハンドシェイクは相互認証です。双方は、両ノードIDと両nonceを束ねたトランスクリプトに対する HMAC によって共有ゴシップシークレットの知識を証明し、そのトランスクリプトを自身のアイデンティティキーで署名します。ピアはその署名を、当該ノードIDに対して保持している公開鍵と、ピアがゴシップで通知している鍵の両方に対して検証します。いずれかの検査に失敗すると接続は閉じられます。
考慮すべき影響:
- メッシュはアイデンティティを持たないノードとは相互運用できません。クラスタ内のすべてのノードにアイデンティティを設定する必要があります。
- ノードIDが
trusted_peer_keysに存在しないピアは拒否されます。ゴシップで通知された公開鍵が信頼エントリと一致しないピアも同様です。ノードの追加とは、その公開鍵を既存のノードへ配布することを意味します。 - 鍵が解決されるにはノードIDが稼働中のゴシップメンバーシップに存在している必要があるため、ゴシップに参加していないピアはメッシュ接続を開けません。
ブートストラップ
初期クラスタは静的なピアリストではなく、ゴシップによって形成されます。Consul/Nomad 方式の bootstrap_expect 設定では、各起動ノードが、自身を含む設定数の適格ノードを確認してからクォーラムを形成します。
bootstrap_expect |
動作 |
|---|---|
0 |
自己ブートストラップしない。既存クラスタへの参加のみ |
1 |
単一ノード。自身を唯一の投票ノードとして即座にブートストラップ |
N |
ローカルノードを含む N 個の適格ノードがゴシップで安定して見えるまで待機し、全員が同じ投票ノードリストを導出してクラスタを形成 |
N ノードブートストラップでは、すべての初期ノードに同じ bootstrap_expect: N を設定します。各ノードはゴシップで「プリブートストラップ」状態を通知します。短い安定ウィンドウの間に自身を含む正確に N 個のそのようなノードが見えると、すべてのノードが独立して同一のソート済み投票ノードセットを計算しクラスタを形成します。安定ウィンドウにより、一時的な部分的ビューがブートストラップを早期にトリガーするのを防ぎます。
後から起動するノードはすでに形成されたクラスタを見てブートストラップをスキップします — リーダーの調整ロジックが投票ノードまたはスタンバイとして追加します。
Raft コンセンサスコア
Raft 状態はデフォルトでファイルシステムに永続化されます: ログとスナップショットは cluster.raft.data_dir(デフォルト ~/.wippy/store、_sys/raft 内)の下に永続化され、store.kv.raft は同じコアを通じてレプリケーションします。再起動するノードはゴシップに再参加し、ピアからキャッチアップするため、クラスタはノードのディスク喪失にも耐えます。耐久性はライブクォーラムとオンディスク状態の両方から得られます。ノードがディスクレスで動作するのは、データディレクトリを解決できない場合(設定パスもホームディレクトリもない場合)のみです。回復を参照してください。
Raft は独自のリッスンポートを開きません。ノード間メッシュ、つまりノード間のリレートラフィックにも使用される同じ TCP 接続上で動作し、メッシュの信頼性のあるクラス別チャネルを介したノード間リクエスト/リプライフレームとして RPC を転送します。ノード間ポートは起動時に自動選択(範囲 7950-7959、その後エフェメラル)、固定され、ゴシップで通知されるためピアが到達できます。ノード同士は、ゴシップポートと通知されたノード間 TCP ポートの両方で相互に到達できなければなりません。
Raft FSM はグローバル名前レジストリを保持します: アクティブな name -> PID バインディングと進行中の Strong 予約。以下で説明する名前付けプリミティブが読み書きするのがこれです。
名前付けと名前スコープ {#naming-and-name-scopes}
プロセスは名前で登録され、生の PID の代わりにその名前で到達できます。重要な決定はスコープで、整合性保証を選択します。最も安価/弱いものから強いものまで4つのスコープがあります:
| スコープ | バックエンド | 可視性 | 保証 |
|---|---|---|---|
| Local | ノードごとのマップ | このノードのみ | 即時、ノードローカル。調整なし |
| Eventual | ゴシップ CRDT | クラスタ全体 | 最終的整合性。ゴシップラウンド後に収束 |
| Consistent | Raft | クラスタ全体 | 線形化可能な書き込み。クラスタ全体で一意なシングルトン |
| Strong | Raft + 全ノード確認 | クラスタ全体 | Consistent かつすべてのライブノードが名前がアクティブになる前に確認 |
選択方法:
- Local — 1 つのノードでのみ意味のある名前(ノードごとのヘルパーなど)。プロセス終了時に解放され、クラスタの調整は不要です。
- Eventual — 短い古い状態ウィンドウが許容される、クラスター全体のサービス名・グループ名・プレゼンス名。バインディング集合は全ノードに完全レプリケートされるため、限定的な名前空間に適しており、セッションごとのプロセスのような高カーディナリティのエンティティごとに名前を持たせる用途には向きません(それらは PID で直接アドレッシングしてください)。2つのオリジンが同じ名前を登録すると、競合解決が勝者を選び、負けたプロセスは
name revoked: <name>という理由を持つキャンセルイベント(process.event.CANCEL)を受信します。実行を継続して再登録できます。所有ノードが離脱すると名前は解放されます。 - Consistent — クラスタ全体の名前付きシングルトンの標準的な選択。先着順: 同じ名前を異なる PID に二度目に登録しようとすると "already exists" で失敗し現在の所有者を返します。書き込みにはクォーラムが必要なため、少数派パーティションでは停止します。読み取りはローカルの Raft レプリカから行われ、書き込みから数ミリ秒遅れる場合があります。
- Strong — 一時的な古い読み取りでさえ危険な、少数のコントロールプレーンシングルトン向けです。Consistent 保証に加え、登録はすべてのライブノードが確認するまで名前が有効にならない予約を開きます。競合するバインディングをすでに保持しているノードは即座に拒否します。期限内に全ノードが確認しない場合、登録は期限切れとなり、欠けていたノードを報告します。
名前は自動的に解放されます: Local はプロセス終了時、Consistent と Strong はプロセス終了時(トポロジー監視経由)とノード離脱時、Eventual はノード離脱時です。メッセージング(process.send、process.terminate など)の名前解決では、最も権威の強いプレーンから Consistent と Strong(Raft)、Eventual(ゴシップ)、Local の順に参照します。そのため、クラスタ全体の名前は同じ文字列のローカル名を隠します。
名前付けの Lua インターフェースは process.registry(スコープ付きの登録/検索/登録解除)にあります。プロセスリファレンスを参照してください。
プロセスグループ {#process-groups}
プロセスグループは Erlang の pg をモデルにしたクラスタ対応のパブリッシュ/サブスクライブとメンバーシップ機能です。プロセスが名前付きグループに参加し、そのグループへのブロードキャストはノード間メッシュを通じて全ノードのメンバーへベストエフォートで配信されます。グループは最終的整合性があり Raft から独立しています — 受信者の選択にはゴシップのメンバーシップビューを使用します — そのためコンセンサスコアが収束中でも動作し続けます。
典型的な操作: グループへの参加/離脱、すべてのメンバーへのブロードキャスト(またはローカルメンバーのみ)、メンバーの一覧取得、グループの参加/離脱イベントの監視。新しいノードが参加すると、グループは直接同期ハンドシェイクを通じてメンバーシップを調整し、バックグラウンドのアンチエントロピーループが時間をかけて発散を修正します。
Lua API についてはプロセスグループを、設定については pg.scope エントリ種別を参照してください。
分散ロック {#distributed-locks}
system.lock は、共有キーバリューストア上の raft リニアライザブルな条件付き書き込みの上に構築されたクラスタ全体の排他制御です。ロックの取得は _sys:lock:<name> に保持者 PID を set-if-absent で書き込みます。解放は、呼び出し側がまだ保持している場合にそのエントリを削除します。条件付き書き込みは Raft を経由する(リーダー以外への書き込みはリーダーへ転送される)ためリニアライザブルであり、クラスタ全体で最大1つの保持者しか存在できません。
local ok, err = system.lock.acquire("orders.migration")
if not ok then
-- err has kind errors.ALREADY_EXISTS when another process holds the lock.
-- Apply the caller's retry and backoff policy for that case if needed.
return nil, err
end
-- critical section: only one holder cluster-wide
local released, release_err = system.lock.release("orders.migration")
if release_err then
return nil, release_err
end
return released
取得はフェイルファスト(ノンブロッキング)です。ロックが保持されている場合は即座に返されるため、呼び出し側が独自のリトライとバックオフを実装します。保持者プロセスが終了またはそのノードが離脱すると、ロックは解放されます。正確なシグネチャについてはシステムリファレンスを参照してください。
設定
関連するクラスタ設定については設定を参照してください。以下の最小構成には、必須のノード間 ID 設定が含まれています。
これらは設定の断片であり、完全なデプロイマニフェストではありません。ノード名、アドレス、障害ドメイン、パス、すべての ${env:...} ID プレースホルダーを、自分のクラスタ用に生成して配布した値に置き換えてください。
単一ノード(開発用):
cluster:
enabled: true
name: dev
membership:
secret_key: "d2lwcHktZG9jcy1nb3NzaXAtc2VjcmV0LTMyYnl0ZXM="
internode:
identity_key: "d2lwcHktZG9jcy1kZXYtbm9kZS1leGFtcGxlc2VlZCE="
trusted_peer_keys:
dev: "rNqImcjOzef28dzvma80mSrCW1px5LBAc5TbaYqAgm0="
raft:
bootstrap_expect: 1
3ノード投票クラスタ(node-2 と node-3 は name、identity_key_file、join_addrs のみが異なります):
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
internode:
identity_key_file: /etc/wippy/node-1.key
trusted_peer_keys:
node-1: "okmamN3PKkMpPwPBurknHy2Wi3dwp/rz+uTM2fF9aD0="
node-2: "PWX+oOYrFdtjUxbgmTkXCFI0KEvG++ZM52HOWfDkqP8="
node-3: "QfP0fgllbj4s95VAztTORhy3bv9mst1l0lwuUNvO/hE="
raft:
bootstrap_expect: 3
ゴシップのみのクライアント(名前付け/メッセージングのために参加、Raft は実行しない)。これにもアイデンティティが必要で、投票ノード側も自身のマップにこのクライアントの公開鍵を持つ必要があります:
cluster:
enabled: true
name: edge-7
membership:
join_addrs: "node-1:7946,node-2:7946"
secret_file: /etc/wippy/cluster.key
internode:
identity_key_file: /etc/wippy/edge-7.key
trusted_peer_keys:
node-1: "okmamN3PKkMpPwPBurknHy2Wi3dwp/rz+uTM2fF9aD0="
node-2: "PWX+oOYrFdtjUxbgmTkXCFI0KEvG++ZM52HOWfDkqP8="
node-3: "QfP0fgllbj4s95VAztTORhy3bv9mst1l0lwuUNvO/hE="
edge-7: "7lzP4jBAkC3P+0jq4vtMsC45571BlVXk3mSlOD/Z0SA="
raft:
role: client
ポート
| 用途 | ポート | プロトコル | 設定キー |
|---|---|---|---|
| ゴシップ(メンバーシップ) | 7946 | TCP + UDP | cluster.membership.bind_port |
| ノード間メッシュ(リレー + Raft) | 自動 | TCP | cluster.internode.bind_port |
Raft は独立したポートを使用せず、ノード間メッシュ上で多重化されます。ノード間ポートは自動的に割り当てられ、ゴシップを通じて通知されます。ゴシップポートはデフォルトで予測可能ですが、ピアは各ノードが通知するノード間 TCP ポートにも到達できなければなりません。
オブザーバビリティ
クラスタのヘルスは標準の 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 ブロードキャスト — プロセスグループサービスがアクティビティ上限までブロードキャストを送受信していない場合に失敗し、停止したサービスや永続的なパーティションを検出します。
回復と障害モード {#recovery-and-failure-modes}
Raft 状態はファイルシステムに永続化されますが、クラスタの主要な耐久性は依然としてライブクォーラムから来ます。実用的なルール:
- 投票ノードの過半数を維持します。5 投票ノードの場合、2 つの同時投票ノード障害を許容します。スタンバイは空いたスロットを補充するために昇格します。過半数を下回ると、書き込み(新しい Consistent/Strong 登録とロック取得)はクォーラムが戻るまで停止します。ローカルにレプリケートされた状態から一部の読み取りには応答できますが、パーティション化されたノードが最新値を保持している証拠として扱わないでください。
- リーダーはハートビート無音かつゴシップデッドの投票ノードを積極的に排除するため、スタンバイが昇格している間にデッドな投票ノードがクォーラムを永続的にブロックしません。
- クォーラムを失ったクラスタを回復するには、失敗したノードを再起動します。ゴシップに再参加し、生き残ったメンバーが折りたたみます。投票ノードを
failure_domainにまたがって分散させることが、単一ゾーン障害でクォーラムを失うことを防ぐ手段です。
ノード間 TLS
cluster.internode.tls でノード間 TCP mesh の相互 TLS を有効にできます。この mesh 上の relay と Raft の通信も保護されます。既存の cluster 設定に次の設定を追加します。
cluster:
internode:
tls:
enabled: true
cert_file: /etc/wippy/node.crt
key_file: /etc/wippy/node.key
ca_file: /etc/wippy/cluster-ca.pem
TLS は既定で無効です。enabled: true の場合、空でない 3 つのファイルパスがすべて必要です。PEM 証明書、対応する秘密鍵、server と client の証明書検証に使用する PEM CA bundle を指定します。最小バージョンは TLS 1.2 です。接続するすべての peer に互換性のある証明書と信頼ルートを設定してください。ed25519 identity と trusted peer key map も引き続き必要です。
不明な TLS 設定、不正な型、欠落または不正な認証情報、不正な CA bundle、enabled: true を指定しないファイルパスは起動エラーになります。不正な明示的設定から平文通信へ暗黙にフォールバックすることはありません。