クラスタ

Wippyはデフォルトでは単一ノードとして動作します。クラスタを有効にすると、複数のノードが一つの協調システムになり、有界 Raft コンセンサスコアの上でメンバーシップ、クラスタ全体のプロセス名前付け、分散ロック、プロセスグループメッセージングを共有します。

cluster.enabled: true を設定するまでクラスタリングはオフです。以下の内容は単一ノードでは無効です。

クラスタリングで得られるもの

  • メンバーシップ — すべてのノードがゴシップを通じてライブなピアのセットを把握し、高速な障害検出が行われます。
  • クラスタ全体のプロセス名前付け — どのノードからでも解決できる名前でプロセスを登録できます。整合性保証を選択できます(名前付けと名前スコープを参照)。
  • 分散ロックsystem.lock は保持者が死亡した場合に自動解放されるクラスタ全体の排他制御を提供します(分散ロックを参照)。
  • プロセスグループ — 全ノードにまたがる名前付きグループのすべてのメンバーに公開します(プロセスグループを参照)。
  • レプリケートされたキーバリューストアstore.kv.raft(強整合性)と store.kv.crdt(最終的整合性)がノード間で KV データをレプリケートします(ストアを参照)。
  • コンセンサスコア — 小規模で有界な 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.eligibleeligible: false のノードは投票ノードとして選択されません(クライアントやスタンバイに留まらせたいノードに使用)。
  • 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 不要 — シードとして起動します。参加はバックオフで再試行され、孤立したノードは定期的に再参加を試みるため、新しい IP でノードが再起動(Kubernetes では一般的)してもすぐに再収束します。

ゴシップは常に共有キーで暗号化されます。インラインの membership.secret_key として、またはファイルから membership.secret_file として指定します。どちらも指定せずに起動したノードは、クラスタコンポーネントの起動に失敗します。値は base64 エンコードされ、すべてのノードで同一です。

メンバーシップ変更(NodeJoinedNodeLeftNodeUpdated)が 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_keyidentity_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 接続を yamux で多重化しています。ノード間ポートは起動時に自動選択(範囲 7950-7959、その後エフェメラル)、固定され、ゴシップで通知されるためピアが到達できます。通常公開する必要があるのはゴシップポートのみです。

Raft FSM はグローバル名前レジストリを保持します: アクティブな name -> PID バインディングと進行中の Strong 予約。以下で説明する名前付けプリミティブが読み書きするのがこれです。

名前付けと名前スコープ

プロセスは名前で登録され、生の 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.sendprocess.terminate など)の解決は順番に照合します — Consistent、次に Eventual、次に Local — そのため同じ文字列の Consistent 名が Eventual 名を隠します。

名前付けの Lua インターフェースは process.registry(スコープ付き登録/検索/登録解除)にあります — プロセスリファレンスを参照。

プロセスグループ

プロセスグループは Erlang の pg をモデルにしたクラスタ対応のパブリッシュ/サブスクライブとメンバーシップ機能です。プロセスが名前付きグループに参加し、そのグループへのブロードキャストはノード間メッシュを通じて全ノードのメンバーへベストエフォートで配信されます。グループは最終的整合性があり Raft から独立しています — 受信者の選択にはゴシップのメンバーシップビューを使用します — そのためコンセンサスコアが収束中でも動作し続けます。

典型的な操作: グループへの参加/離脱、すべてのメンバーへのブロードキャスト(またはローカルメンバーのみ)、メンバーの一覧取得、グループの参加/離脱イベントの監視。新しいノードが参加すると、グループは直接同期ハンドシェイクを通じてメンバーシップを調整し、バックグラウンドのアンチエントロピーループが時間をかけて発散を修正します。

Lua API についてはプロセスグループを、設定についてはpg.scopeエントリ種別を参照。

分散ロック

system.lock は、共有キーバリューストア上の raft リニアライザブルな条件付き書き込みの上に構築されたクラスタ全体の排他制御です。ロックの取得は _sys:lock:<name> に保持者 PID を set-if-absent で書き込みます。解放は、呼び出し側がまだ保持している場合にそのエントリを削除します。条件付き書き込みは Raft を経由する(リーダー以外への書き込みはリーダーへ転送される)ためリニアライザブルであり、クラスタ全体で最大1つの保持者しか存在できません。

local ok, err = system.lock.acquire("orders.migration")
if ok then
  -- クリティカルセクション: クラスタ全体で保持者は1つだけ
  system.lock.release("orders.migration")
end

取得はフェイルファスト(ノンブロッキング): ロックが保持されている場合は即座に返されるため、呼び出し側は独自のリトライ/バックオフを追加します。保持者プロセスが終了またはそのノードが離脱するとロックは自動解放されるため、クリーンアップは自動的です。正確なシグネチャについてはシステムリファレンスを参照。

設定

キーごとの完全なリファレンスは設定にあります。最小限の形:

単一ノード(開発用):

cluster:
  enabled: true
  name: dev
  membership:
    secret_key: "d2lwcHktZG9jcy1nb3NzaXAtc2VjcmV0LTMyYnl0ZXM="
  internode:
    identity_key: "d2lwcHktZG9jcy1kZXYtbm9kZS1leGFtcGxlc2VlZCE="
    trusted_peer_keys:
      dev: "rNqImcjOzef28dzvma80mSrCW1px5LBAc5TbaYqAgm0="
  raft:
    bootstrap_expect: 1

3ノード投票クラスタ(node-2node-3nameidentity_key_filejoin_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 ポートはありません — 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 ブロードキャスト — グループが長期間ブロードキャストトラフィックを受信しない場合に失敗し、ブロックされたイベントループや永続的なパーティションを検出します。

回復と障害モード

Raft 状態はファイルシステムに永続化されますが、クラスタの主要な耐久性は依然としてライブクォーラムから来ます。実用的なルール:

  • 投票ノードの過半数を維持します。5投票ノードの場合、2つの同時投票ノード障害を許容します。スタンバイが空いたスロットを補充するために昇格します。過半数を下回ると、書き込み(新しい Consistent/Strong 登録とロック取得)はクォーラムが戻るまで停止します。既存の名前と検索はローカルレプリカから引き続き提供されます。
  • リーダーはハートビート無音かつゴシップデッドの投票ノードを積極的に排除するため、スタンバイが昇格している間にデッドな投票ノードがクォーラムを永続的にブロックしません。
  • クォーラムを失ったクラスタを回復するには、失敗したノードを再起動します。ゴシップに再参加し、生き残ったメンバーが折りたたみます。投票ノードを failure_domain にまたがって分散させることが、単一ゾーン障害でクォーラムを失うことを防ぐ手段です。

関連項目