サービスディスカバリ

このページの目次

サービス名は安定しているが、インスタンスのアドレスはスケーリング、再起動、移行に伴って変化する。サービスディスカバリは「この論理サービスのカレントな候補インスタンスは何か」を管理し、ロードバランサは候補の中から接続先を選択する。アドレスのグループを発見することと、それらがビジネスを処理できるかどうかを判断することは、関連しているが異なる責務である。

インスタンスの起動から終了まで

インスタンスのライフサイクル:登録成功はトラフィックを受信できることを意味しない 起動と初期化 設定の読み込み、必要な依存関係の確立 1 インスタンスとヘルスステータスの登録 ID、アドレス、バージョン、リースは個別に管理される 2 準備完了後、候補セットに入る クライアント/プロキシがルーティングと接続を更新 3 シャットダウン時に新しいトラフィックを停止 準備完了状態を解除し、伝播を待ってから在来リクエストを排空 4 ディスカバリ状態には伝播遅延があり、旧接続の排空には追加の処理が必要。

プロセスが生きていることは、プログラムがまだ実行中であることを示すだけです。準備完了(Ready)とは、特定のタイプのビジネストラフィックを受信できることを意味します。サービスがポートをリスニングしていても、キャッシュがまだ読み込まれていない場合や、データベースマイグレーションが完了していない場合は、生きているが準備完了ではない状態になり得ます。シャットダウン時も、まず新しいトラフィックを停止し、在来リクエストと接続に対して排空の機会を与える必要があります。

登録レジストリ内のアドレスは陳腐化している可能性があります。呼び出し元は、接続失敗時の処理、リトライの予算、更新メカニズムを必要とし、レジストリが healthy を返したからといって、そのリクエストが必ず成功する証明にはなりません。

DNS と DNS-SD

通常の A/AAAA クエリではアドレスが取得できますが、SRV レコードはターゲットホスト、ポート、優先度、重み付けを表現できます。DNS-SD はさらに PTR を使用してサービスインスタンスを閲覧し、SRV/TXT を通じてインスタンスの位置と属性を取得します。これは単に「SRV を数行書く」という狭い定義とは異なります。RFC 6763 にレコードの整理方法が示されています。

_api._tcp.example.com. 30 IN SRV 10 60 8080 api-1.example.com.
_api._tcp.example.com. 30 IN SRV 10 40 8080 api-2.example.com.

これは SRV を使用してエンドポイントを表現する例です。クライアントは対応するクエリと重み付けのセマンティクスをサポートしている必要があります。通常の HTTP クライアントは SRV を自動的に検索するとは限りません。

TTL は DNS キャッシュの有効期間を制限しますが、実際のトラフィックの切り替えは、クライアント側のキャッシュ、再解決のタイミング、接続プール、コネクションプーリング(長接続)の影響も受けます。TTL=30秒としても、インスタンス削除後30秒後にトラフィックが完全に止まることを保証するものではありません。DNSプロトコル自体はビジネスレベルのヘルスチェックを定義せず、ヘルスステータスは追加のコントローラによってレコードを更新する必要があります。

レジストリ:ストレージプリミティブと完全な製品の差

Consul はサービス登録とヘルス関連機能を提供します。etcd は一貫性のあるキーバリュー、トランザクション、Lease(リース)、Watch(ウォッチ)などのプリミティブを提供し、アプリケーションはその上でサービス登録の規約とヘルスポリシーを実装する必要があります。これらを「health URL を送信するだけで自動的にチェックされる」と記述してはいけません。

etcd の一つのアプローチでは、インスタンスキーを lease にバインドします。アクティブなインスタンスは定期的にリースを更新し、リースが失効するとキーが削除されます。Watch により呼び出し元は変更を取得します。初回読み込みと後続のリスニングは、正しい revision に接続する必要があり、その間で更新を見逃さないようにします。履歴が圧縮されている場合は、スナップショットを再取得してリスニングを続ける必要があります。インターフェースの保証については etcd API を参照してください。

リースのタイムアウトは、リース更新が間に合わなかったことを示すだけで、ネットワーク分離によるものかもしれません。また、旧インスタンスの実行が停止したことを証明するものではありません。ディスカバリメカニズムが排他的なタスク割り当ての役割も果たす場合、フェンシング(fencing)をさらに考慮する必要があります。

スナップショットと Watch:revision で接続し、「できるだけ早くリスニング開始」に頼らない

サービス接頭辞に対して完全な読み取りを実行し、revision 20 の {A,B} を取得したとします。読み取りが返された後、Watch の確立前に、21 で A が削除され、22 で C が追加されたとします。Watch の確立時の「現在」から開始し、後続のイベントのみを受信する場合、ローカルキャッシュは A を永遠に残し、C を見逃す可能性があります。

スナップショットの revision の次の位置である 21 から Watch を開始し、サーバーに中間履歴を再生させてから、新しい変更の受信を続ける必要があります。もし 21 がすでに圧縮されている場合は、完全なスナップショットを再取得して接続し、圧縮を「変化がない」として扱ってはなりません。etcd の開始 revision と圧縮応答については Watch API を参照してください。

スナップショット 20 の後、21 から履歴を継承し、接続確立のギャップをカバー スナップショット r=20 を読み取る 候補:A、B その間に更新が発生 21 で A を削除;22 で C を追加 Watch の起点 21 再生後の候補:B、C 履歴が圧縮されている場合:スナップショットを再取得し、新しい revision + 1 から再接続 同じ revision の関連イベントがすべて適用され、ローカルのリカバリチェックポイントを進めてから。

以下はキャッシュの更新をシミュレートしたものです。イベントは revision ごとにグループ化されており、同じ revision の関連イベントがすべて揃って初めて apply_revision に渡されます。ここでは put/delete をインスタンスの追加と削除として使用し、etcd への接続は行いません。

class DiscoveryCache:
    def __init__(self, revision, snapshot):
        self.revision = revision
        self.endpoints = dict(snapshot)

    def apply_revision(self, revision, events):
        if revision <= self.revision:
            return False  # すでに完全に適用された履歴の再生
        updated = dict(self.endpoints)
        for operation, instance, address in events:
            if operation == "put":
                updated[instance] = address
            elif operation == "delete":
                updated.pop(instance, None)
            else:
                raise ValueError("不明なイベントタイプ")
        # モデルはスレッドセーフな単一スレッド呼び出しを前提;新しいビュー全体を公開してから、適用済みの進行状況を進める。
        self.endpoints, self.revision = updated, revision
        return True

cache = DiscoveryCache(20, {"A": "addr-A", "B": "addr-B"})
assert cache.apply_revision(21, [("delete", "A", None)])
assert cache.apply_revision(22, [("put", "C", "addr-C")])
assert cache.endpoints == {"B": "addr-B", "C": "addr-C"}
# revision 23 は他の接頭辞を変更したものであり、現在の Watch には対応するイベントがない。これは正当なギャップである。
change = [("delete", "B", None), ("put", "D", "addr-D")]
assert cache.apply_revision(24, change)
assert not cache.apply_revision(24, change)
assert cache.endpoints == {"C": "addr-C", "D": "addr-D"}
assert cache.revision == 24

# イベントタイプが誤っている場合、部分的なビューを公開せず、リカバリ位置も進めない。
try:
    cache.apply_revision(25, [("delete", "C", None), ("invalid", "E", "addr-E")])
except ValueError:
    pass
assert cache.endpoints == {"C": "addr-C", "D": "addr-D"}
assert cache.revision == 24

# 誤った使い方:同じ revision で行ごとにチェックポイントを更新すると、2行目を重複として扱う。
bad = DiscoveryCache(22, {"B": "addr-B", "C": "addr-C"})
bad.apply_revision(24, [change[0]])
assert not bad.apply_revision(24, [change[1]])
assert "D" not in bad.endpoints

この実装は、上位から完全かつ順序通りに配信されることに依存しており、22 から 24 の間にこの接頭辞のイベントが見逃されていないことを自ら証明することはできません。revision が整数で連続している必要がないのは、他のキーへの書き込みもクラスターの revision を進めるためです。実際のクライアントは、Watch の履歴と順序の保証に従い、接続断、キャンセル、圧縮エラーを処理する必要があります。etcd Watch 保証

同じトランザクションで複数のインスタンスキーが変更され、同じ revision を持つことがあります。リカバリチェックポイントは、その revision の関連イベントがすべて適用された後に進める必要があり、最初のイベントだけで「24 は処理済み」として永続化してはいけません。レスポンスのシャーディングが有効な場合、完全な内容を組み立てる必要があります。イベントの修正 revision、レスポンスヘッダー内の revision、プログレス通知を区別せずに「ローカルキャッシュが追いついた位置」として扱うことはできません。

さらに、大きな接頭辞でページ読み込みが必要な場合、同じスナップショット revision を固定する必要があります。さもなければ、1ページ目が revision 20、2ページ目が revision 22 となり、初期セット自体が一貫したビューになりません。キャッシュおよびリカバリチェックポイントをディスクに書き込む場合、それらを一緒に永続化する必要があります。または、プロセス再起動時にスナップショットを再取得し、進行状況が実際に保存されたキャッシュよりも新しくならないようにする必要があります。

スナップショット後の変化、リスニングはどこから再接続するか?

キャッシュを比較する際、最終的なアドレスリストだけでなく、それがどのスナップショットとどの履歴で構成されているかを見る必要があります。以下では、スナップショット後に A の取り消しと C の登録を独立して行い、旧スナップショットの次の revision から再生するか、現在からのリスニングのみを選択するかを比較します。履歴の圧縮により、前者の方式は明確に失敗します。正しいリカバリは、旧リストを保持してエラーを飲み込むのではなく、スナップショットを再構築することです。

図を準備しています
スナップショット後の変化、リスニングはどこから再接続するか?

スナップショットの revision と後続の Watch は接続されている必要があります。リスニング確立時の「現在」から開始すると、中間の変化を見逃します。必要な履歴が圧縮されている場合は、スナップショットを再取得する必要があります。

クライアント選択とプロキシ選択

モードアドレスの更新元、ターゲットの選択元主なコスト
クライアントディスカバリとロードバランシング呼び出し元 SDK/resolver各言語ごとに互換性のある実装と設定が必要
プロキシまたはゲートウェイロードバランシングデータプレーンプロキシがコントロールプレーンの更新を受信リソースと障害境界が1つ増える
プラットフォーム仮想アドレスプラットフォームが安定したアドレスをバックエンドにマッピング接続レベルの割り当てと長接続の動作を理解する必要がある

プロキシはマルチレプリカでデプロイできるため、天然のシングルポイントではありません。クライアントのダイレクト接続も、コントロールプレーンへの依存がないことを意味しません。gRPC は名前解決とロードバランシングポリシーを分離しており、resolver はアドレスの変化とサービス設定を提供できます。Custom Name Resolution を参照してください。

長接続は特に重要です:HTTP/2 や gRPC の多くのリクエストは同じ接続を共有するため、接続確立時のみバックエンドを選択する方式では、負荷が偏る可能性があります。新しいインスタンスを発見しても、既存の接続上のリクエストが自動的に移動することはありません。

エンドポイントを削除しても、既存の接続は收回されない

インスタンス A が t=0 で登録を解除し、クライアントが t=2 で変更を受信したが、A への接続プールにまだ利用可能な長接続が1つ残っているとします。クライアントが新しい接続の作成時のみ候補セットを参照する場合、t=10 のリクエストでも旧接続を経由する可能性があります。DNS TTL や Watch の更新だけでは、これを単独で阻止することはできません。

シャットダウンの段階変更が必要な状態このステップのみを行った場合の欠陥
準備完了解除または登録解除コントロールプレーンの候補インスタンスセットクライアントのビューがまだ更新されていない
セレクタの更新新しいリクエストは当該インスタンスを選択しない接続プールが旧接続を再利用し続ける可能性がある
接続とタスクの排空新しい作業の受入を停止し、在来タスクの完了を待つ長時間のタスクが終了タイムアウトを超える可能性がある
終了デッドライン到達事前に合意された終了またはキャンセルポリシーを実行呼び出し元に失敗が返される可能性があるため、セマンティクスに従った回復が必要

したがって、グレースフルシャットダウンでは、ディスカバリ伝播、接続のドレイン、タスクの期限を同時に合意する必要があります。すでにビジネス効果を生んだが応答が中断したリクエストの場合、リトライは冪等性が必要です。「グレースフル」であっても、すべてのリクエストが損失なくで完了することを保証するものではありません。ディスカバリ層は位置の変化を提供し、接続層とリクエスト層はそれを実際のトラフィックの変化として実現する責任があります。

ヘルスシグナルの組み合わせ方

アクティブチェックは定期的にプローブを実行し、ビジネストラフィックがない場合でも問題を発見できます。パッシブチェックは実際のリクエストを観察し、ユーザーパスにより近接します。TTLレポートはチェック対象側が積極的に更新することに依存します。これら3つはすべて誤判定する可能性があるため、通常は連続する失敗/成功の閾値とジッタ抑制が必要です。

チェックの範囲にも自制が必要です。共有ダウンストリームの一時的な障害によりすべてのインスタンスがデッドと判定されると、ロードバランサはすべての候補を削除する可能性があります。また、ヘルスインターフェースが常に 200 を返す場合、実際にサービスを提供できないインスタンスを発見できません。readiness は、すべての依存関係を機械的にチェックするのではなく、サービスのディグレード能力に基づいて定義すべきです。

レジストリとの接続が切れた場合どうするか

既存のエンドポイントキャッシュにより、データプレーンは一時的に動作を継続できますが、キャッシュが古くなればなるほど陳腐化します。新しいインスタンスは参加できず、旧インスタンスはすでに消失している可能性があります。セキュリティやポリシーの取り消しもまだ有効になっていないかもしれません。ビジネスに応じて許容される陳腐化の程度を選択します:最後の既知のセットを引き続き使用するか、連続して失敗したエンドポイントのみを削除するか、または権限が敏感なシナリオではサービス停止を選択します。

これはコントロールプレーンとデータプレーンの分離を示しています:レジストリの障害が即座に既存の接続を中断するわけではありませんが、時間が経つほど、インスタンスの変化によるリスクが大きくなります。ディスカバリシステムを検証する際は、伝播遅延、接続の更新、実際のリクエスト成功率を同時に観察する必要があります。

参考

キーワード:service discovery、DNS-SD、SRV、Lease、Watch、readiness、ロードバランシング、接続プール、コントロールプレーン、データプレーン