Read OSS

フィルター、サーバーアーキテクチャ、セキュリティスタック:gRPC の全体像を完成させる

上級

前提知識

  • 本シリーズの第 1〜5 回
  • TLS/SSL 証明書認証の基本的な理解
  • Web フレームワークにおける middleware/interceptor パターンへの慣れ

フィルター、サーバーアーキテクチャ、セキュリティスタック:gRPC の全体像を完成させる

前回までの 5 回で、gRPC のクライアント側アーキテクチャを体系的に解説してきました。API サーフェスから始まり、channel、call、promise ランタイム、transport、ルーティングパイプラインと順を追ってきました。最終回となる今回は、残る 3 つのサブシステムでその全体像を完成させます。取り上げるのは、フィルターモデル(圧縮や認証のクロスカッティングロジック)、サーバーアーキテクチャ(受信 RPC のディスパッチ)、セキュリティスタック(credentials・handshaker・TSI によるコネクション保護)の 3 つです。

フィルターモデル:V2 Channel Stack と V3 Promise Filters

フィルターは gRPC の middleware システムです。call をインターセプトして、圧縮・認証・ロギング・フォールトインジェクションといった機能を追加します。このシリーズを通じて触れてきたように、2 つのフィルターシステムが現在も共存しています。

V2:Channel Stack Filters

V2 では、フィルターは grpc_channel_filter 構造体として grpc_channel_stacksrc/core/lib/channel/channel_stack.h で定義)に並べられます。各フィルターは init・destroy・call 処理のコールバックを提供し、per-call の状態は CallCombiner を通じてアクセスがシリアライズされます。

V2 スタックはリンクリスト構造で、各フィルターが call を処理して次に渡していきます。LegacyChannel が使用するこのシステムは、現在も多くの本番環境で稼働しています。

V3:Promise ベースの CallFilters

V3 システムは src/core/call/call_filters.h で定義されており、V2 とは根本的に異なる設計です。V3 フィルターは Call インナークラスを持つクラスで、型付きのインターセプションポイントを定義します。

OnClientInitialMetadata   - クライアントのリクエストヘッダーをインターセプト
OnServerInitialMetadata   - サーバーのレスポンスヘッダーをインターセプト
OnClientToServerMessage   - リクエストメッセージをインターセプト
OnServerToClientMessage   - レスポンスメッセージをインターセプト
OnClientToServerHalfClose - half-close をインターセプト
OnServerTrailingMetadata  - サーバーのトレイリングメタデータをインターセプト
OnFinalize                - call 完了時のクリーンアップ

V3 の重要な革新が NoInterceptor センチネルです。フィルターが static inline const NoInterceptor OnClientToServerMessage; と宣言すると、コンパイラはそのインターセプションポイントを call パスから完全に取り除きます。使わないフックのコストはゼロになるわけです。V2 の vtable ベースのディスパッチではこれは実現できません。

各インターセプションポイントは複数のシグネチャに対応しています。

  • void method(Value&) — 変更のみ、失敗なし
  • absl::Status method(Value&) — 変更あり、失敗の可能性あり
  • ServerMetadataHandle method(Value&) — 変更あり、カスタムメタデータで失敗
  • 上記すべてに FilterType* を追加してチャネルレベルの状態にアクセス可能
  • 上記すべてで promise を返す(非同期インターセプション)
flowchart LR
    subgraph "V3 CallFilters Pipeline"
        CIM["Client Initial Metadata"] --> F1["Filter A<br/>OnClientInitialMetadata"]
        F1 --> F2["Filter B<br/>(NoInterceptor - skipped)"]
        F2 --> F3["Filter C<br/>OnClientInitialMetadata"]
        F3 --> Transport["Transport"]
    end

インターセプションチェーン

V3 の call パイプラインは InterceptionChainsrc/core/call/interception_chain.h)によって構築されます。UnstartedCallDestination オブジェクトをチェーン状につなぐ構造で、各ノードはフィルタースタックか Interceptor のどちらかです。

Interceptor クラス(line 98)は特に興味深い設計です。call をインターセプトした際に、次の 3 つのアクションを取ることができます。

  1. HijackHijackedCall オブジェクトを生成して処理済みのメタデータを検査し、後続の call を新たに作成する
  2. Consume — call をそのまま処理する
  3. Pass through — call を変更せず転送する

この仕組みは retry interceptor、ロードバランスされた call destination、xDS ルーティングで活用されています。

フィルター登録と順序付け

フィルターは BuildCoreConfiguration() の中で ChannelInit::Builder を通じて登録されます。登録 API はフルーエントビルダーパターンを採用しており、順序制約を宣言できます。

builder->channel_init()
    ->RegisterFilter<ClientAuthFilter>(GRPC_CLIENT_SUBCHANNEL)
    .IfHasChannelArg(GRPC_ARG_SECURITY_CONNECTOR);

利用できる制約は以下のとおりです。

制約 目的
.Before<T>() このフィルターをフィルター T の前に配置する
.After<T>() このフィルターをフィルター T の後に配置する
.IfHasChannelArg(arg) channel arg が存在する場合のみ有効にする
.IfChannelArg(arg, default) boolean arg が true の場合のみ有効にする
.Terminal() スタックの末端フィルターとして機能する
.BeforeAll() スタックの先頭に配置する
.SkipV3() V2 専用フィルター
.SkipV2() V3 専用フィルター
.FloatToTop() できるだけ上位に配置する
.SinkToBottom() できるだけ下位に配置する

このシステムは、宣言された Before/After 制約と優先度レベル(Top・Default・Bottom)を考慮しながら、登録済みフィルターをトポロジカルソートします。処理は CoreConfiguration のビルド時に一度だけ行われ、channel ごとには実行されません。

flowchart TD
    subgraph "Client Subchannel Stack (example)"
        Auth["ClientAuthFilter<br/>.IfHasChannelArg(SECURITY_CONNECTOR)"]
        HTTP["HttpClientFilter"]
        Msg["MessageSizeFilter"]
        Comp["CompressionFilter"]
        Connected["ConnectedChannel (terminal)"]
    end
    Auth --> HTTP --> Msg --> Comp --> Connected

src/core/lib/surface/init.cc の具体的な実装例として、セキュリティフィルターの条件付き登録を見てみましょう。

void RegisterSecurityFilters(CoreConfiguration::Builder* builder) {
    builder->channel_init()
        ->RegisterFilter<ClientAuthFilter>(GRPC_CLIENT_SUBCHANNEL)
        .IfHasChannelArg(GRPC_ARG_SECURITY_CONNECTOR);
    // ... also for GRPC_CLIENT_DIRECT_CHANNEL and GRPC_SERVER_CHANNEL
    builder->channel_init()
        ->RegisterFilter<GrpcServerAuthzFilter>(GRPC_SERVER_CHANNEL)
        .IfHasChannelArg(GRPC_ARG_AUTHORIZATION_POLICY_PROVIDER)
        .After<ServerAuthFilter>();
}

.IfHasChannelArg() の条件判定により、セキュリティコネクターが設定されている場合にのみ auth フィルターが追加されます。平文の insecure channel では完全にスキップされます。また GrpcServerAuthzFilter には明示的に .After<ServerAuthFilter>() が指定されており、認証(authentication)の後に認可(authorization)が行われることが保証されます。

ヒント: channel のフィルタースタックがどのような順序で構成されているかを確認したい場合は、channel_stack_builder トレースカテゴリを有効にしましょう。channel 作成時にトポロジカルソートされたフィルターリストがログ出力されます。

サーバーアーキテクチャ

src/core/server/server.hServer クラスは、複数のベースクラスを継承しています。

class Server : public ServerInterface,
               public InternallyRefCounted<Server>,
               public channelz::DataSource,
               public CppImplOf<Server, grpc_server> {

Channel と同様に、C の不透明型 grpc_server* との橋渡しに CppImplOf を使用しています。

Server が管理するのは主に以下の要素です。

  • ListenersListenerInterface):受信コネクションを受け付けます。各 listener はポートで動作し、新しいコネクションに対して transport インスタンスを作成します。
  • 登録済みメソッド:(host, method)ペアをハンドラーにマッピングするテーブルです。grpc_server_register_method() で登録されたメソッドはファストパスマッチングが適用され、未登録のメソッドはジェネリックリクエストキューにフォールバックします。
  • Completion queues:サーバーは登録済みの CQ 群に受信 call を分散させて負荷を分散します。
  • Server::kServerTopFilter:サーバーの channel スタック先頭に配置される V2 フィルター(line 133)で、.BeforeAll() で登録されています。
flowchart TD
    Network["Network"] --> Listener["Listener<br/>(port binding)"]
    Listener --> Handshake["Handshaker Pipeline"]
    Handshake --> Transport["Server Transport"]
    Transport --> ServerFilter["Server Top Filter"]
    ServerFilter --> Match{"Method Matching"}
    Match -->|registered| Fast["Fast-path handler"]
    Match -->|unregistered| Generic["Generic request queue"]
    Fast --> CQ["Completion Queue"]
    Generic --> CQ

LogicalConnection インターフェース(line 189)を使うと、listener はコネクションのライフサイクル全体——accept から handshake、tear-down まで——を追跡できます。これはグレースフルシャットダウンに活用されます。xDS によるサーバー設定変更時には、既存のコネクションに SendGoAway() を送ってドレインしつつ、新規コネクションには更新後の設定を適用できます。

セキュリティスタック:Credentials から TSI へ

gRPC のセキュリティは後付けではなく、最初からレイヤー構造として設計されています。セキュリティパイプラインを上から下へ順に見ていきましょう。

sequenceDiagram
    participant App as Application
    participant Creds as Channel/Server Credentials
    participant Connector as Security Connector
    participant Handshaker as Security Handshaker
    participant TSI as TSI (Transport Security Interface)
    participant TLS as OpenSSL/BoringSSL or ALTS

    App->>Creds: Create credentials
    Creds->>Connector: Create security connector
    Connector->>Handshaker: Register in handshaker pipeline
    Note over Handshaker: During connection setup:
    Handshaker->>TSI: tsi_handshaker_create()
    TSI->>TLS: Perform handshake
    TLS->>TSI: Return tsi_frame_protector
    TSI->>Handshaker: Secured endpoint

Channel Credentialssrc/core/credentials/transport/)は、クライアントがサーバーに対してどのように認証するかを定義します。SSL/TLS、Google デフォルト、ALTS、ローカル、insecure、そしてチャネルと call の credentials を組み合わせた composite タイプがあります。

Call Credentialssrc/core/credentials/call/)は OAuth2 トークンや JWT アサーションといった per-call の認証情報を付加します。ClientAuthFilter によって適用されます。

Security Connectors は credentials と handshaker パイプラインの橋渡し役を担います。credential タイプに応じた TSI handshaker を生成します。

TSIsrc/core/tsi/transport_security_interface.h)は低レベルのインターフェースで、以下を定義します。

  • tsi_handshaker:セキュリティハンドシェイクを実行する(複数ラウンドのバイト交換)
  • tsi_frame_protector:ハンドシェイク後にデータフレームを保護・復元する
  • tsi_peer:リモートエンドポイントの認証済み ID

実装には以下が含まれます。

  • SSL TSI(OpenSSL/BoringSSL):証明書検証を伴う標準的な TLS
  • ALTS TSI:Google の Application Layer Transport Security。ALTS handshaker サービスを使用
  • Local TSI:Unix ドメインソケット向け。ピア credential による検証を使用
  • Fake TSI:テスト用。実際の暗号化なし

init.cc で登録された auth フィルターは、セキュリティコネクターの有無を条件としています。

builder->channel_init()
    ->RegisterFilter<ClientAuthFilter>(GRPC_CLIENT_SUBCHANNEL)
    .IfHasChannelArg(GRPC_ARG_SECURITY_CONNECTOR);

つまり insecure channel ではセキュリティのオーバーヘッドは完全にゼロです。フィルター自体がインスタンス化されません。

オブザーバビリティ、DualRefCounted、そしてまとめ

Channelz

gRPC にはデバッグ用のランタイムイントロスペクションシステム channelz が組み込まれています。channelz::DataSource ミックスイン(ServerCallSpine の両方が実装)は、アクティブな channel・subchannel・サーバー・ソケットに関する構造化データをエクスポートします。include/grpc/grpc.h の C API は、このデータを照会するための JSON エンドポイントを提供します。

GRPCAPI char* grpc_channelz_get_top_channels(intptr_t start_channel_id);
GRPCAPI char* grpc_channelz_get_servers(intptr_t start_server_id);
GRPCAPI char* grpc_channelz_get_server(intptr_t server_id);

DualRefCounted

本シリーズを通じて繰り返し登場したパターンが DualRefCounted です。強参照カウントと弱参照カウントを分離した参照カウント方式で、UnstartedCallDestinationCallDestinationServer::ConnectionManager などで使われています。

強参照はオブジェクトを生存させます。弱参照は破棄を妨げずに観測するためのもので、transport のライフサイクル管理において欠かせません。subchannel が transport を観測できる一方で、subchannel が transport の生存を延命してしまわないようにするためです。

全体像

6 回にわたるシリーズがどのようにつながっているか、俯瞰してみましょう。

flowchart TD
    subgraph "Article 1: Architecture"
        API["C/C++ API"] --> CoreConfig["CoreConfiguration"]
        CoreConfig --> Init["grpc_init()"]
    end

    subgraph "Article 2: Channels & Calls"
        API --> Channel["Channel Hierarchy"]
        Channel --> Call["CallSpine / Batch Ops"]
    end

    subgraph "Article 3: Promise Runtime"
        Call --> Party["Party Executor"]
        Party --> Poll["Poll<T> + Combinators"]
    end

    subgraph "Article 4: Transport"
        Call --> Transport["chttp2 / chaotic_good"]
        Transport --> EE["EventEngine I/O"]
        Transport --> Handshake["Handshaker Pipeline"]
    end

    subgraph "Article 5: Routing"
        Channel --> Resolver["Name Resolution"]
        Resolver --> LB["Load Balancing"]
        LB --> Subchannel["Subchannels"]
        Subchannel --> Transport
    end

    subgraph "Article 6: Filters, Server, Security"
        Filters["Filter Pipeline"] --> Call
        Server["Server"] --> Transport
        Security["TSI / Credentials"] --> Handshake
        Security --> Filters
    end

gRPC における RPC の旅路をたどると次のようになります。

  1. アプリケーションが URI を指定して channel を作成する(第 2 回)
  2. channel が resolver を使ってバックエンドアドレスを解決する(第 5 回)
  3. LB ポリシー が subchannel を選択する(第 5 回)
  4. CallSpine が作成され、フィルター が適用される(第 6 回)
  5. promise ランタイム がフィルターパイプラインを通じて call を駆動する(第 3 回)
  6. フィルター処理済みの call が transport に到達し、HTTP/2 フレームにシリアライズされる(第 4 回)
  7. transport が EventEngine エンドポイント を通じて セキュアなコネクション 上でデータを書き出す(第 4 回・第 6 回)
  8. サーバー側では listener がコネクションを受け付けて transport を作成し、サーバーフィルターを経てアプリケーションハンドラーにディスパッチされる(第 6 回)

本シリーズで解説してきたすべてのコンポーネントは、それぞれがこの旅路において明確な役割を持っています。CoreConfigurationChannelCallSpinePartyPoll<T>chttp2EventEngineResolverLoadBalancingPolicyCallFiltersServerTSIのいずれも欠かせない要素です。

gRPC のコードベースは巨大ですが、深く一貫した設計の上に成り立っています。レイヤー化されたアーキテクチャ、CppImplOf パターンの一貫した活用、CoreConfiguration による配線、そして進行中の V2→V3 マイグレーション——すべてが明確な設計原則に従っています。この 6 回のシリーズを道案内として、本番障害のデバッグ、カスタム LB ポリシーの実装、promise ベースの transport マイグレーションへの貢献など、コードベースのどの領域にも自信を持って踏み込んでいただけるはずです。

ヒント: この知識を定着させる最良の方法は、トレースを有効にしながら実際の RPC を系全体で追いかけることです。GRPC_TRACE=api,channel,call_state,party_state,http,transport_security を設定して、grpc_channel_create() から grpc_call_start_batch() の完了まで、ログ出力を一つひとつ追ってみましょう。