リアルタイム通信 — WebSocket・gRPC ストリーミング・WebRTC
gRPC の期限・キャンセル・フロー制御・keepalive
一言でいうと
期限は、チェーンに沿って減らしながら渡す必要があり、キャンセルは直接伝える必要があり、フロー制御は迂回してはならず、keepalive・リトライ・終了は、両側のルールが合っていなければなりません。
なぜ必要なのか
リアルタイムサービスの呼び出しは、チェーンです。ブラウザー → ゲートウェイ → 音声認識 → 言語モデル → 音声合成のようにつながり、チェーンのどこか1か所がルールを破ると、症状は見当違いの場所に現れます。前段でユーザーが諦めたリクエストを、後段で最後まで処理し、遅いコンシューマー1つのためにプロデューサーのメモリがいっぱいになり、静かなストリームが中間機器に切られ、リトライが結果を2回作り、デプロイのたびにストリームが切れます。
どう動くのか
期限の伝播: 前段のサービスが0.6秒の期限で受け取った呼び出しを後ろへ渡すとき、後ろには残り時間だけを与える必要があります。GoとJavaは、入ってきたコンテキストをそのまま渡せば期限がついていきますが、Pythonでは、context.time_remaining()を後段の呼び出しのtimeoutとして、直接渡す必要があります。期限のない呼び出しでは、この値がとても大きな数になるので、そのまま渡さずに、期限なしで呼び出します。
キャンセルの伝播: キャンセルのガイドが説明するように、クライアントが呼び出しをキャンセルすると、サーバー側のコンテキストが非アクティブになります。ところが、そのサーバーが後段のサービスを、塞がって待っている最中なら、後段の呼び出しはキャンセルを知りません。Pythonでは、後段の呼び出しをfutureでかけて、context.add_callbackで自分の呼び出しが終わるときにそのfutureをキャンセルさせてはじめて、チェーンが一緒に止まります。
フロー制御: フロー制御のガイドによると、gRPCは、HTTP/2のフロー制御で、受け取る側が耐えられる分だけを送らせます。Pythonサーバーのストリーミングジェネレーターは、ウィンドウが許すときにだけ次の値を取り出すので、コンシューマーが止まれば、生産も止まります。生産スレッドを別に置いて、制限のないキューにあらかじめ満たすと、この仕組みを迂回して、コンシューマーの分がサーバーのメモリにたまります。ラボでは、64KiBのメッセージ3,000個を要求して、1つだけ読んで止まったとき、この違いが数MBと数百MBに分かれます。
keepalive: keepaliveのガイドのクライアント設定は、pingの間隔(grpc.keepalive_time_ms)、答えを待つ時間(grpc.keepalive_timeout_ms)、呼び出しがないときにもpingするか(grpc.keepalive_permit_without_calls)です。サーバーには、頻繁すぎるpingを拒否する権利があります。サーバーが許容する最小間隔(grpc.http2.min_recv_ping_interval_without_data_ms)より頻繁にpingすると、GOAWAYにENHANCE_YOUR_CALMのエラーコードとtoo_many_pingsを載せて、接続を切ります。そのため、間隔は、中間機器のアイドル上限より短く、サーバーが許容する間隔より長くなければなりません。この2つは別々のチームが決めるので、必ず突き合わせて確認する必要があります。
リトライ: リトライ設計ドキュメント(gRFC A6)のリトライは、サービス設定(service config)のretryPolicyで宣言します。最大試行回数、最初の待機と最大待機、倍率、リトライするステータスコードのリストです。重要な制約は確定(commit)です。サーバーの応答ヘッダーや最初のメッセージを受け取ると、呼び出しが確定し、そのあとの失敗は、同じステータスコードでもリトライしません。ストリームをアプリケーションで最初から呼び直すと、すでに受け取ったメッセージを2回受け取ります。INVALID_ARGUMENTのように、やり直しても同じ結果になるエラーは、リストに入れません。
グレースフルシャットダウン: Kubernetesは、Podを落とすときにSIGTERMを送り、terminationGracePeriodSeconds(既定30秒)のあとにSIGKILLを送ります。ハンドラーがなければ、PythonのプロセスはSIGTERMですぐに死に、進行中だったストリームは、すべてUNAVAILABLEで切れます。server.stop(grace)は、新しい呼び出しを拒否し、進行中の呼び出しに猶予を与えてから終了します。
現場での姿
gRPC接続は長く生きるので、L4ロードバランサーの後ろでは、接続が最初につながったサーバーにとどまり続けます。サーバーを増やしても、新しいサーバーには負荷が行きません。サーバーが接続に最大寿命(grpc.max_connection_age_ms)を設けて、定期的にGOAWAYを送れば、クライアントが再接続して、負荷が広がります。これも、keepaliveと同じ種類の「接続寿命のルール」です。
次のラボですること
期限とキャンセルを後段のサービスへ渡すリレーサーバー、フロー制御を守るストリーミングサーバー、keepaliveとリトライポリシーを宣言したチャネル、SIGTERMでグレースフルに落ちるサーバーを作ります。判定は、基準サーバーが受け取った期限・呼び出し回数・終えたステップ数と、自分のサーバーのメモリで行います。