実システムにおける合意形成の実践

このページの目次

合意形成アルゴリズムは、サービスに対して信頼性の高い順序付けとコミットのための基盤を提供しますが、ユーザーが目にするのは API です。API が線形整合性を持つか、ウォッチが回復可能か、リースの期限切れが何を意味するかについては、追加のメカニズムが必要です。システムを判断する際は、「内部で Raft を使用している」ことを「すべてのリクエストが強整合性である」と直接翻訳するのではなく、実際の呼び出し契約から始めるべきです。

サービスを複数の責任に分割する

書き込みリクエストは検証を経てレプリケーションログに入り、プロトコルはコミット前のプレフィックスを決定し、ステートマシンはコマンドを適用して照会可能なデータを更新します。永続化されたログは障害からの復旧をサポートし、インデックスは効率的な検索をサポートし、MVCC は履歴バージョンとスナップショットをサポートし、ウォッチは変更を購読者に伝達します。これらの構造は異なる目的に奉仕しており、アプリケーションのデータベースファイルを合意状態のすべてと見なしてはいけません。

同様に、ビジネス状態の変化と外部効果は同じものではありません。合意は「タスクが A に割り当てられた」と決定しますが、一時停止後に再開された古いワーカープロセスがオブジェクトストレージへの書き込みを自動的に停止させることはありません。

etcd:比較読み取り、リビジョン、およびウォッチ

etcd v3 の通常の KV リクエストは、厳密に直列化可能な契約を提供します。Range リクエストでは、ローカル状態から古い値を返すことを許可する、より弱い serializable 読み取りを明示的に選択できます。ここで使用されるオプション名はデータベースの分離レベルの用語と混同しやすいので、etcd API の定義に従って理解する必要があります。etcd 3.6 API 保証

線形整合性のある読み取りでは、権限を失った古いビューに立っていないことを確認し、ステートマシンが必要な位置まで追いつくのを待つ必要があります。下の図は、一般的な ReadIndex 類の考え方を示しており、具体的な前提条件は合意形成の実装によって責任を負います。

線形整合性読み取り:安全な境界を確立し、適用済み状態を読み取る 読み取りに必要な権威性を確認する 例:quorum による確認;リース方式では時間的仮定が異なる 1 安全な読み取り位置を特定する 分離された古いリーダーのローカルな認識だけを信じてはならない 2 ローカルステートマシンがその位置まで適用されるのを待つ レプリケーション完了と照会可能な状態の間にはギャップがある 3 読み取りを実行して返す API 定義に従ってタイムアウトとリトライを処理する 4 ReadIndex 類のメカニズムは、通常、各読み取り専用リクエストに対してビジネスログを1行追加する必要はありません。

revision は KV 状態の変更のバージョンシーケンスを表し、1つのトランザクションで複数のキーが同じ revision を共有することができます。これは Raft term ではなく、ログインデックスに単純に等しいものでもありません。これらを混在させると、キャッシュの復旧や障害診断に誤りが生じます。

典型的なキャッシュの同期では、まず特定の revision のスナップショットを取得し、その後の revision からウォッチを確立します。クライアントは処理の進捗状況を保存する必要があります。必要な履歴がすでに compact されている場合、スナップショットを再読み取りする必要があります。ウォッチはイベントを継続的に受信するためのインターフェースであり、定期的にクエリを実行する通常のロングポーリングではなく、ビジネスコールバックが決して再試行されないことも保証しません。etcd API:ウォッチとバージョン

Kubernetes クライアントは、通常、etcd に直接接続するのではなく、API サーバーの list/watch インターフェージを通じてリソースを観察します。アプリケーションの契約と基盤となるストレージを区別することは、認証、リソースバージョン、およびキャッシュの位置を理解するのに役立ちます。

権限を確認したのに、なぜ読み取りは待機しているのか?

「私はリーダーである」という永続的にキャッシュされたブール値を読み取りの証拠として使用しないでください。以下では、有効な今回の確認とローカルの lastApplied を分離して配置します。適用を単に進めるだけでは権限の根拠を補うことはできず、過半数の確認を受け取っただけでは、まだ適用されていない値が照会ビューに表示されることはありません。図中の位置はすべて同じログ上のインデックスであり、KV revision ではありません。

図を準備しています
権限を確認したのに、なぜ読み取りは待機しているのか?

ReadIndex 類の読み取りは、今回の有効な権限根拠を確立するとともに、ステートマシンが安全な位置まで適用されるのを待つ必要があります。ログがコミットされたからといって、照会ビューがすでに適用されたわけではありません。

Consul:メンバー検出とディレクトリのコミットは異なるプロセス

Consul のサーバーは Raft を通じて共同で決定する必要がある状態を維持し、Gossip はメンバーの発見や障害検出などに使用されます。クライアントエージェントがサーバーにリクエストをコミットしても、Raft の投票メンバーになるわけではありません。

「Gossip によって某ノードが到達可能であると報告された」ことを「そのノード上のすべてのサービスが準備完了である」と理解するのは避けてください。メンバー検出と具体的なサービスチェックは相互に影響を与える可能性がありますが、観察対象は異なります。サービスチェックの結果、ディレクトリ状態、およびクエリキャッシュにはそれぞれ独自の伝播パスがあります。

Consul の読み取りには異なる整合性モードがあり、default、consistent、stale は1つの約束として混同してはいけません。クォラムをまたぐ検証を行うか、フォロワーが値を返すことを許可するか、障害時の動作はどうなるかは、選択されたインターフェースとモードに応じて解釈されます。Consensus and Consistency Modes

したがって、合意形成によって維持されるディレクトリであっても、古くなったクエリモードで読み取られることが許可される場合があります。合意形成は状態の決定を担当し、クエリモードは今回の読み取りでどのビューを使用できるかを決定します。

ZooKeeper:グローバルな更新順序とローカル読み取りの併存

ZooKeeper の ZAB は、更新に対して順序付きブロードキャストと復旧の基盤を提供します。それは「異なるクライアント間の順序に関係なく、同じクライアントからのリクエストの順序のみを保証する」ものではありません。ZooKeeper は更新順序とクライアント順序の両方に関する保証を記述していますが、通常の読み取りは接続されたサーバーによってローカルで処理できるため、異なるクライアントが同じ時刻に異なる進捗状況を見る可能性があります。

これは Raft との違いが「Raft ではすべてのログが常に同じで、ZAB のログは異なることを許容する」というものでもありません。通常のレプリケーション、ネットワーク遅延、および復旧によってログの進捗に差異が生じます。重要なのは、コミットされた履歴と可視性のセマンティクスです。

ZooKeeper のセッションと ephemeral znode は、メンバー登録や協調処理によく使用されます。一時的な切断はセッションの有効期限切れを意味せず、クライアントは接続状態とセッションのライフサイクルを区別する必要があります。従来のオンショットウォッチの場合、通知を受信した後に再読み取りを行い、必要に応じて再登録する必要があります。1回の通知を、中間の変化に対する完全なイベントログとして扱うことはできません。その他のウォッチモードは、具体的なバージョンの API に従って理解してください。ZooKeeper プログラマーズガイド

1回の通知の後に、なぜ直接3番目の値を読み取ったのか

クライアントが getData(path, watch=true) を使用して、バージョンに対応する値 1 を読み取ったと仮定します。その後、サーバーは値を 2 に変更し、1回の NodeDataChanged 通知を発行してこのウォッチを消費します。クライアントが再読み取りする前に、値はさらに 3 に変更されます。このとき、クライアントが受信するのは「データが変更された」という通知であり、再読み取りの結果は直接 3 になります。1回の通知を受信したからといって、書き込みが1回しか発生しなかったと推論することはできません。

オンショットウォッチ:通知によりキャッシュが無効化され、再読み取りで現在の値を取得する 読み取り 1 + 登録 ウォッチは有効 書き込み 2 通知を送信し、ウォッチを消費 書き込み 3 まだ再登録されていない 読み取り 3 + 登録 観察を再開 クライアントの観察:値 1 → 1回の状態変化通知 → 値 3 従来のオンショットウォッチ;図はデータ更新のみを示しており、削除、再構築、セッションイベントは含まれません。

以下に、この交差した処理を順序呼び出しで圧縮した例を示します。read_watch() はウォッチ付きの読み取りを1回表し、通知キューは意図的にイベントタイプのみを保存します。これはネットワーク順序、セッション再接続、または完全な ZooKeeper API をシミュレートしたものではありません。

class OneShot:
    def __init__(self, value):
        self.value = value
        self.watched = False
        self.notifications = []

    def read_watch(self):
        self.watched = True
        return self.value

    def set(self, value):
        self.value = value
        if self.watched:
            self.notifications.append("NodeDataChanged")
            self.watched = False

node = OneShot(1)
assert node.read_watch() == 1
node.set(2)
node.set(3)
assert node.notifications == ["NodeDataChanged"]
assert node.notifications.pop(0) == "NodeDataChanged"
assert node.read_watch() == 3  # 中間の 2 はクライアントにデータとして渡されない
node.set(4)
assert node.notifications == ["NodeDataChanged"]
assert node.read_watch() == 4

これは消費方法を決定します。通知をキャッシュ無効化のシグナルとして使用し、再読み取りして現在の状態を再構築します。1→2→3 の変更を逐次監査する必要がある場合は、再生可能な変更記録を保存する必要があります。オンショットウォッチのコールバックカウントに頼って履歴を補完することはできません。

再読み取りを行う際は、ウォッチパラメータを提供する読み取り操作を使用してください。まず通常の読み取りを行い、その後別の操作で登録すると、2つの操作の間に更新が空窗(くうま・すきま)に落ちる可能性があります。正しいウォッチ付き読み取りを使用した場合でも、2回の読み取りの間の中間値をスキップする可能性があります。これは登録と読み取りの関連性を解決するものですが、すべての履歴を提供するものではありません。永続的なウォッチの登録ライフサイクルは異なるため、データ変更通知を完全な値ログとして解釈することはできません。ZooKeeper ウォッチセマンティクスと注意事項

リースと分散ロック:ロックサービスの成功が外部アクションの安全性を意味しない

ワーカープロセス A がロックを取得した後に一時停止し、リースが期限切れになると、B が新しいロックを取得して作業を開始すると仮定します。A が再開したとき、古い認識を維持し続ける可能性があります。ロックサービス内の記録の有効期限切れだけでは、ロック状態をチェックせずに外部リソースに書き込む A を阻止することはできません。

一般的な保護策は、単調な fencing token を割り当て、実際のリソースがすでに受け入れられた token よりも古い操作を拒否することです。token の比較は関連するリソース範囲に対して行われ、リソースのチェックと変更はアトミックに関連付けられている必要があります。対象の API がこの制約をサポートしない場合は、古い実行を分離できる他の方法を選択する必要があります。

これは、合意形成とリースが調整の根拠を提供するものの、外部システムは依然として権限の境界を実装しなければならないことを示しています。1回ロックを取得したからといって、任意の一時停止、タイムアウト、リトライの下で無条件に実行を継続できるわけではありません。

運用時には quorum の他にも復旧時間を気にする

基本過半数方式では、3つの投票者が必要なら2つ、5つなら3つが必要です。ノードが1つ増えただけでは必ずしも耐障害性が向上するわけではありません。4つ必要なら3つで、依然として1つの投票者のみ障害を許容できます。これらのノードがラック、電源、またはネットワークの障害ドメインを共有しているかどうかを見る必要があります。

故障ドメインごとの分布で計算し、マシン総数だけを見ない

以下は、固定構成の通常の過半数に基づいて計算したもので、1つのアベイラビリティゾーンが完全に失敗し、他の投票者が互いに通信可能であると仮定しています。A、B、C はアベイラビリティゾーンであり、表内の数は投票者のみを含みます。投票に参加しない読み取り専用レプリカは quorum を補完できません。

投票者レイアウト過半数の閾値A 損失後の残り任意の1ゾーン完全失敗に耐えられるか
A:2,B:121いいえ、A 障害で過半数を失う
A:1,B:1,C:122はい
A:3,B:1,C:132いいえ、A 依然是集中的な故障点
A:2,B:2,C:133はい、B 損失で残り3、C 損失で残り4
def zone_survival(layout):
    if not layout or any(n <= 0 for n in layout.values()):
        raise ValueError("各故障ドメインには少なくとも1つの投票者が存在する必要があります")
    total = sum(layout.values())
    quorum = total // 2 + 1
    return quorum, {
        zone: total - count >= quorum for zone, count in layout.items()
    }

assert zone_survival({"A": 2, "B": 1}) == (2, {"A": False, "B": True})
assert all(zone_survival({"A": 1, "B": 1, "C": 1})[1].values())
assert not zone_survival({"A": 3, "B": 1, "C": 1})[1]["A"]
assert all(zone_survival({"A": 2, "B": 2, "C": 1})[1].values())

例えば A:2、B:2、C:1 の構成で A を失った場合、残りの投票者はちょうど3つです。この状態で B のノードを1つメンテナンスで停止すると、残り2つになります。故障許容予算は、現在の利用不可ノードを差し引いて計算する必要があり、「最大2つの故障を許容できる」から毎回再計算してはいけません。ゾーン間デプロイメントでは、一部のゾーン間往復がコミットパスに含まれるため、耐障害性レイアウトと書き込みレイテンシは一緒に衡量する必要があります。

上記の計算は、残りの票数が十分であることを示しているだけです。リーダーを回復できるか、ディスクが利用可能か、ネットワークが最終的に安定するかが、サービスが実際に進行するかどうかを決定します。メンバー変更時の構成ルールを、この固定構成の表で代替することはできません。

quorum の生存は、レイテンシの健全性を意味するものではありません。遅いディスク、長時間の停止、およびアプリケーションのバックログは、リクエストのタイムアウト、頻繁な選挙、または読み取りの待機を引き起こす可能性があります。プロセスが実行中かどうかだけでなく、リーダーの変化、永続化レイテンシ、レプリケーション進捗、コミット済みと適用済みのギャップ、およびクライアントの失敗率を観察する必要があります。

バックアップ復旧およびメンバー変更では、製品プロトコルを特に遵守する必要があります。複数の古いデータディレクトリを同時に再起動することは、明確なクラスター復旧プロセスに取って代わるものではありません。

用途クイックリファレンス

要件合意形成の関与依然として必要なもの
設定、ディレクトリ、メタデータ更新順序の共同決定読み取りモード、キャッシュ、ウォッチ復旧
シャーディングデータベース各レプリカグループがログを決定シャーディングルーティング、トランザクション、スナップショット、フロー制御
タスク割り当てまたはロック現在の保持者の決定fencing、リトライ、結果の重複排除
メンバー健康観察一部のコントロール決定をコミット可能検出は依然として不確実性を伴う観察を提供するのみ

大容量ストレージでも、通常は複数のレプリカグループを通じて拡張することで、合意形成によるレプリケーションを使用できます。データが多いからといって「Gossip に切り替えるべき」ではありません。Gossip は情報伝播の方式であり、ユーザーが要求するコミットおよび読み取りセマンティクスを代替できるものではありません。

参考

Keywords: etcd, Consul, ZooKeeper, ZAB, ReadIndex, revision, watch, lease, fencing