リーダー選出とスプリットブレイン回避
二重リーダーによるデータ破壊を、リースとフェンシングトークンで原理から封じる方法がわかる。なぜ単なるロックでは足りず、クォーラムやSTONITHが要るのかを解説。
- 1.スプリットブレインは「リーダーは1人」という仕様が破れて複数ノードが同時に書き込む状態。原因は故障と遅延を区別できないこと(誤検知でのリーダー交代)。
- 2.単なるロックや「時間付きリース」だけでは不十分。リース満了の判定がノードごとの時計に依存し、GCポーズやネットワーク遅延で旧リーダーが生き残るため。
- 3.決定打は単調増加するフェンシングトークン(エポック番号)をストレージ側で検査すること。クォーラムで一意性を担保し、STONITHで旧リーダーを物理的に黙らせる。
リーダー選出は何のためにあるか
分散システムの多くは、ある仕事をちょうど1つのノードだけに担わせたい場面を持ちます。複製の書き込みを受け付けるプライマリ、ジョブを発行するスケジューラ、分散ロックの所有者などです。複数ノードが同時にその役割を担うと、書き込みの二重適用やデータ破壊が起きます。そこで**リーダー選出(leader election)**を行い、生きているノードの中から1つをリーダーに選びます。
問題は選出そのものより、「リーダーはどの瞬間も高々1人」という不変条件を維持し続けることにあります。選出は一瞬で終わりますが、その後ネットワークが乱れたりノードが固まったりすると、システムが「リーダーは複数いる」と誤認しうる。この破綻状態が**スプリットブレイン(split-brain)**です。
リーダーを1人選ぶこと自体は合意(コンセンサス)で解けます。難しいのは、選んだ後に旧リーダーが自分の失効に気づかないまま動き続けることを防ぐ点です。スプリットブレイン対策の本丸はここにあります。
なぜスプリットブレインが起きるのか
根本原因は、FLP不可能性定理が示す通り、非同期な世界では**「故障したノード」と「単に遅いノード」を区別できない**ことです。
リーダーAが応答を返さないとき、他ノードはAがクラッシュしたのか、GC(ガベージコレクション)ポーズで固まっているだけなのか、ネットワークが詰まっているだけなのかを判定できません。やむを得ずタイムアウトで「Aは死んだ」とみなし、Bを新リーダーに選ぶ。ところが実際にはAは生きていて、ポーズから復帰した瞬間、自分がまだリーダーだと信じたまま書き込みを再開する。この瞬間、AとBという2人のリーダーが並立します。
時刻 t0: A がリーダー。書き込みを処理中
時刻 t1: A が長いGCポーズに入る(プロセスは生きているが応答しない)
時刻 t2: 他ノードがタイムアウト判定 → B を新リーダーに選出
時刻 t3: B がリーダーとして書き込みを開始
時刻 t4: A がポーズから復帰。自分の失効を知らず write を再開
→ A と B が同時にストレージへ書き込む(スプリットブレイン)
ここで効くのがフェイルストップではない現実です。プロセスは「きれいに死ぬ」とは限らず、固まる・遅れる・分断される。だから「応答がない=安全に役割を剥奪してよい」とは言い切れません。
なぜ「単なるロック」では不十分か
素朴な対策は分散ロックです。リーダーになりたいノードがロックサービス(etcd・ZooKeeper・Redis 等)から排他ロックを取り、取れた者がリーダーになる。しかしロックには**有効期限(リース)**が要ります。期限がなければ、ロック保持者がクラッシュした瞬間ロックが永久に解放されず、誰もリーダーになれないからです。
そこで「TTL付きリース」を使う。一定時間ごとに更新(lease renewal)し、更新が途絶えたらリースは満了し、別ノードが取得できる。ところがこれにも穴があります。
ロックサーバーが「リースは満了した」と判断して B に渡しても、旧リーダー A 側のプロセスは自分のリースがまだ有効だと信じていることがあります。A が「リースを取った」直後に長いGCポーズに入ると、A の主観では数ミリ秒しか経っていないのに、現実には期限を過ぎている。A はポーズ復帰後、失効に気づかず書き込みます。時計の進みとプロセスの停止は独立なので、リースの相互排他は破れるのです。
つまりリースは「いつ役割を手放すか」を保持者の自己申告に委ねており、保持者が固まると保証が崩れます。ロックを取ったかどうかの判定(取得時点)は守れても、**取った後の有効性(書き込み時点)**は守れません。ここが核心です。
フェンシングトークン:書き込み側で止める
解決策は、ロックの取得時ではなく実際の書き込み時に有効性を検査することです。鍵が**フェンシングトークン(fencing token)**です。
ロックサービスは、ロックを付与するたびに単調増加する整数を一緒に発行します。これがフェンシングトークンであり、リーダーの世代を表す**エポック番号(epoch number / term)**と本質的に同じものです。リーダーがストレージへ書き込むときは、必ずこのトークンを添えます。ストレージ側は、過去に受理した最大トークンより小さいトークンの書き込みを拒否します。
# ロックサービスが付与するトークンは取得のたびに必ず増える
A がロック取得 → token = 33
(A がGCポーズ)
B がロック取得 → token = 34 # 必ず A の 33 より大きい
# ストレージ側の検査
B が write(token=34) → 受理。ストレージは last_token = 34 を記憶
A が復帰し write(token=33) → 33 < 34 なので拒否(フェンスされる)
旧リーダーAは生きていても、トークンが古いために書き込みそのものを跳ね返される。Aが自分の失効に気づく必要はなく、ストレージが機械的に弾きます。これが「フェンシング(柵で囲って締め出す)」の意味です。
Raft の term、Paxos の proposal number、ZooKeeper の zxid のエポック部はすべて同じ役割を果たします。新リーダーが立つたびに番号が増え、古い世代のメッセージや書き込みを番号の大小だけで安全に無効化できる。フェンシングトークンは、この「世代を単調番号で表し、小さい世代を拒否する」原理をストレージ層へ持ち込んだものです。
決定的に重要なのは、トークンを検査する主体がストレージ(書き込みを受ける側)でなければならない点です。リーダー自身に「自分は失効したか」を尋ねても、固まっているリーダーは正しく答えられません。判定を、止まりうる当事者から、書き込みを実際に受け取る側へ移す——これがフェンシングの本質です。
クォーラム:トークンの一意性と選出の整合性を担保する
フェンシングが効くには、トークンが全体で一意かつ単調でなければなりません。これを保証するのが**クォーラム(quorum、過半数)**です。
リーダー選出やトークン発行を単一ノードに任せると、そのノードが分断の両側に対して別々のリーダーとトークンを発行しかねません。そこで「N 台中、過半数 ⌊N/2⌋+1 台の合意がなければ選出もトークン発行も成立しない」と決めます。過半数同士は必ず1台以上重なるため、分断された2つのグループが同時に過半数を集めることは不可能です。よって同時に2人のリーダーが正規に選出されることはありません。
| 手段 | 防ぐ対象 | 限界 |
|---|---|---|
| TTL付きリース | クラッシュ後のロック永久占有 | 保持者の停止で相互排他が破れる |
| フェンシングトークン | 旧リーダーの遅延書き込み | ストレージ側の検査実装が必須 |
| クォーラム(過半数) | 分断両側での二重選出 | 過半数を欠くと可用性を失う |
| STONITH(強制停止) | 制御外ノードの暴走 | 電源制御など外部機構が要る |
クォーラムは、ノード数を奇数にすると効率的です。偶数だと分断で同数に割れ、どちらも過半数を取れず全体が停止しうる。3台なら1台、5台なら2台の故障まで耐えつつ、残りで過半数を維持できます。なお、クォーラムの一意性は整合性モデルでいう線形化可能なリーダー選出を支える土台でもあります。
STONITH:トークンを検査できない相手をどう黙らせるか
フェンシングトークンは強力ですが、ストレージ側がトークン検査を実装していることが前提です。検査できないリソースが相手だと使えません。典型例が、共有ディスクや共有ストレージを直接マウントする旧来のHAクラスタ、あるいはトークンを理解しないネットワーク機器です。
このとき使うのがSTONITH(Shoot The Other Node In The Head)——疑わしい旧ノードを物理的・確実に停止させる手法です。具体的には、電源制御装置(PDU)で旧ノードの電源を切る、IPMI/iLO/iDRAC などのBMCでハードリセットをかける、SANのゾーニングでストレージアクセスを遮断する(ストレージフェンシング)などです。新リーダーは、旧ノードが確実に止まったことを確認してからリソースを引き継ぎます。
両者は同じ目的(旧リーダーの書き込み封じ)を別レイヤで達成します。
・フェンシングトークン:書き込みを受ける側が古い世代を論理的に拒否する。アプリ/ストレージがトークンを理解できる場合に最適。
・STONITH:相手を物理的に停止させる。トークンを理解しないリソース(共有ディスク等)が相手のときの最終手段。
共有リソースが「古い書き込みを自力で弾けない」なら、論理フェンスは使えず STONITH が必要になります。
分断時に両ノードが互いを「死んだ」とみなし、同時に相手をSTONITHしようとすると、両方が落ちる**フェンシング合戦(fencing race / death match)**が起こりえます。遅延を非対称にする、第三者の証人(quorum device / witness)に判定を委ねるなどで回避します。また電源制御装置自体が単一障害点になりうるため、フェンシング経路の冗長化も要ります。
時計を信用しすぎない
リースやタイムアウトはノードの時計に依存しますが、論理時計と happens-before が示す通り、分散環境で物理時計の同期は完全ではありません。NTPのずれ、VMのスケジューリング遅延、長いGCポーズにより、あるノードの「まだ期限内」と別ノードの「もう期限切れ」が食い違います。だからこそ、相互排他の最終的な正しさを時間の経過ではなく、単調増加するトークンの大小という時計に依存しない順序づけに委ねるのが堅牢です。トークンは「絶対時刻」ではなく「世代の前後関係」だけを語るため、時計のずれに影響されません。
この区別不能性は机上の話ではありません。カオスエンジニアリングでプロセス一時停止やネットワーク分断を注入すると、リースだけの構成が二重書き込みを起こし、フェンシングトークンを足した構成がそれを弾く様子を実地で確認できます。
まとめ
- スプリットブレインは「リーダーは高々1人」という不変条件が破れ、複数ノードが同時に書き込む状態。根因は故障と遅延を区別できないこと。
- 単なるロックやTTLリースでは不十分。リースの満了判定が保持者の時計に依存し、GCポーズや遅延で旧リーダーが失効に気づかず書き続けるため。
- 決定打はフェンシングトークン(エポック番号)。単調増加するトークンを書き込みに添え、ストレージ側が古いトークンを拒否する。判定を、止まりうる当事者から書き込みを受ける側へ移すのが核心。
- クォーラム(過半数)がトークンの一意性と二重選出の防止を担保し、トークンを検査できない相手にはSTONITHで物理的に旧ノードを停止させる。
- 相互排他の正しさは時間の経過ではなく単調なトークン順序に委ねるのが堅牢。時計のずれに影響されないからである。
DevOps/インフラ Article
リーダー選出とスプリットブレイン回避を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
分散システム
比較で見る軸
難易度: advanced / カテゴリ: DevOps/インフラ / タグ数: 6
導入後に効く点
単なるロックや「時間付きリース」だけでは不十分。リース満了の判定がノードごとの時計に依存し、GCポーズやネットワーク遅延で旧リーダーが生き残るため。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- DevOps/インフラ
- タグ数
- 6
判断チェックリスト
- 自社の用途が「分散システム / リーダー選出」に近いか確認する。
- 強みである「スプリットブレインは「リーダーは1人」という仕様が破れて複数ノードが同時に書き込む状態。原因は故障と遅延を区別できないこと(誤検知でのリーダー交代)。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。