TT Lab
はじめる
学ぶ 学習パス コース

フィールド番号を変えたら、古いクライアントが黙って間違った値を読んだ

4種類の呼び出しと16通りの失敗

TT Labで続きを見る

一言でいうと

gRPCの呼び出しは、単項・サーバーストリーミング・クライアントストリーミング・双方向の4種類で、すべての呼び出しは17個のステータスコードのどれかで終わります。デッドラインにはデフォルトがないため無限に待ち続けることがあり、リトライはポリシーを書いて初めて動作します。

なぜ必要なのか

前の2つのモジュールでは、メッセージ1つのバイトを扱いました。しかし、gRPCの事故はメッセージの中よりも呼び出しの境界でよく起きます。レスポンスがいつまでも来ないのに誰も切らない呼び出し、サーバーは成功したと言っているのにクライアントは失敗と見なす呼び出し、同じ決済を2回送ってしまうリトライなどです。コアコンセプトのドキュメントは、この境界について「クライアントとサーバーはそれぞれ独立に成功かどうかを判断し、その結論が異なることがある」と明記しています。サーバーは「レスポンスをすべて送った」と結論づけ、クライアントは「デッドラインの後に届いた」と結論づけることがありえます。この一文を受け入れると、残りのルールはすべて自然に導かれます。

どう動くのか

呼び出しの4種類: サービス定義は.protoにrpcで書きます。

service OrderService {
  rpc GetOrder (GetOrderRequest) returns (Order);                       // 단항
  rpc ListOrders (ListRequest) returns (stream Order);                  // 서버 스트리밍
  rpc UploadEvents (stream Event) returns (UploadSummary);              // 클라이언트 스트리밍
  rpc Chat (stream ChatMessage) returns (stream ChatMessage);           // 양방향
}

単項(unary)は関数呼び出しと同じです。リクエスト1つに、レスポンス1つです。サーバーストリーミングは、リクエスト1つに対して複数のレスポンスをストリームで受け取り、クライアントストリーミングはその逆です。双方向は2つのストリームが独立しているので、読み書きの順序を両側が自由に決められます。サーバーがすべて受け取ってから答えることも、1つ受け取って1つ答えるピンポンもできます。ドキュメントが保証するのは、1回の呼び出しの中でのメッセージの順序です。ストリームごとの順序は守られますが、2つのストリームの間の順序はありません。

ステータスコード: ステータスコードのドキュメントは、0から16までの17個を定義しています。ライブラリが生成するものと、アプリケーションだけが生成するものに分かれている点が重要です。INVALID_ARGUMENT・NOT_FOUND・ALREADY_EXISTS・FAILED_PRECONDITION・ABORTED・OUT_OF_RANGE・DATA_LOSSは、ライブラリが絶対に生成しません。このコードを見たなら、必ずサーバーコードが返したものです。

コード 意味 誰が生成するか
OK 0 成功
CANCELLED 1 呼び出し元がキャンセル ライブラリ・アプリ
INVALID_ARGUMENT 3 システムの状態と無関係に引数が不正 アプリのみ
DEADLINE_EXCEEDED 4 デッドライン内に終わらなかった(完了していた可能性がある) ライブラリ・アプリ
NOT_FOUND 5 要求したものがない アプリのみ
PERMISSION_DENIED 7 権限がない(リソース枯渇には使わない)
RESOURCE_EXHAUSTED 8 クォータ・容量の枯渇
FAILED_PRECONDITION 9 状態を直すまでリトライ禁止 アプリのみ
ABORTED 10 上位レベルでリトライ(トランザクションの再開) アプリのみ
UNIMPLEMENTED 12 メソッドがない
INTERNAL 13 不変条件が破られた。深刻なエラー専用
UNAVAILABLE 14 一時的。バックオフ後にリトライ可能(冪等でない呼び出しは安全でない場合がある)
UNAUTHENTICATED 16 認証の資格情報がない

3つのコードの使い分けの指針が、ドキュメントにそのまま載っています。この呼び出しだけをやり直せばよいならUNAVAILABLE、読み取り→変更→書き込みの順序を最初からやり直す必要があるならABORTED、システムの状態を人が直すまでリトライしてはならないならFAILED_PRECONDITIONです。rmdirが空でないディレクトリで失敗するのが、最後のケースです。そして、DEADLINE_EXCEEDEDの説明にある一文を覚えておく必要があります。状態を変える処理であれば、処理が正常に終わっていてもこのコードが返ってくることがあります。レスポンスが遅れて届いただけの可能性があるのです。

エラー処理のドキュメントは、ライブラリが生成するコードの状況も表で示しています。サーバーのハンドラーが例外を投げるとUNKNOWN、メソッドがなければUNIMPLEMENTED、サーバーが停止中ならUNAVAILABLE、リクエストのprotobufをパースできなければINTERNALです。標準のエラーモデルはコードと文字列メッセージだけで、構造化された詳細が必要なら、google.rpc.Statusをトレーラーに載せる拡張モデルを使います。ただし、プロキシやロガーはその中身を見られず、HTTP/2のヘッダー圧縮の効率が落ちるというコストが書かれています。

デッドライン: デッドラインのドキュメントの最初のルールは、「gRPCはデフォルトのデッドラインを設けない」です。クライアントが決めなければ永遠に待ち続けることがあるので、常に現実的な値を決めるよう求めています。デッドラインは時刻(point in time)で、タイムアウトは期間(duration)です。言語によってAPIは異なりますが、意味は同じです。デッドラインを過ぎると、クライアントはDEADLINE_EXCEEDEDで呼び出しを失敗させ、サーバーはその呼び出しを自動的にキャンセル(CANCELLED)します。ただし、サーバーアプリケーションが実行中の処理を止めるのは、サーバーコードの責任です。ライブラリにはハンドラーを中断させる手段がないので、長い処理は定期的にキャンセルされたかどうかを確認する必要があります。

伝播が肝心です。自分のサーバーが別のサーバーを呼ぶときは、元のクライアントのデッドラインを引き継がなければなりません。JavaとGoはデフォルトで伝播し、C++は有効にする必要があると、ドキュメントに書かれています。時刻をそのまま渡すと2つのサーバーの時計がずれている可能性があるため、gRPCはすでに経過した時間を差し引いたタイムアウトに変換して渡します。ドキュメントの例は図のとおりです。クライアントが2秒を指定し、ユーザーサーバーが0.5秒を使ってから課金サーバーを呼ぶと、課金サーバーは1.5秒を受け取ります。

キャンセル: キャンセルのドキュメントによると、クライアントはいつでも関心がなくなったことを通知でき、デッドラインの期限切れとI/Oエラーもキャンセルを引き起こします。キャンセルは上流に伝播するのが理想なので、Java・Go・C++は外へ出る呼び出しを自動的にキャンセルします。コアコンセプトのドキュメントにある警告の一行は、キャンセルの前にすでに変更されたものは元に戻らない、です。

リトライ: リトライのドキュメントは、誤解の多い部分を明確にしています。リトライはデフォルトで有効ですが、デフォルトのポリシーはありません。ポリシーがなければ、gRPCは「透過的リトライ」だけを行います。呼び出しがクライアントを離れていなければ無制限に、サーバーのライブラリまで届いたもののアプリケーションロジックが見ていなければ、ちょうど1回です。サーバーが処理した可能性がある場合は、リトライしません。レスポンスヘッダーを受け取った時点で呼び出しは確定(committed)し、それ以降はリトライしません。

ポリシーは、サービス設定にメソッド単位で書きます。

"retryPolicy": {
  "maxAttempts": 4,
  "initialBackoff": "0.1s",
  "maxBackoff": "1s",
  "backoffMultiplier": 2,
  "retryableStatusCodes": ["UNAVAILABLE"]
}

バックオフには±20%のジッターが付くので、初期値が0.1秒なら、実際には80–120msの間になります。リトライがサーバーを再びダウンさせないようにretryThrottling(maxTokens・tokenRatio)があり、失敗のたびにトークンが1減り、成功のたびにtokenRatioだけ増え、半分を下回るとリトライを止めます。そして、ステータスコードのドキュメントのUNAVAILABLEの説明にある一行が、ポリシー設計のすべてです。冪等でない処理はリトライが安全でない場合があります。決済の作成のような呼び出しをretryableStatusCodesに入れるには、サーバーが冪等キーで重複を防いでいる必要があります。

ヘルスチェックとメタデータ: ヘルスチェックのドキュメントは、標準サービスhealth/v1を定義しています。単項のCheckは集中モニタリング用で、ストリーミングのWatchは、クライアントが接続して状態の変化を受け取るためのものです。サービス名ごとにSERVING・NOT_SERVINGを通知し、空文字列はサーバー全体を意味します。クライアントがhealthCheckConfigを有効にすると、Watchが正常と報告するまでリクエストを送らず、正常でなくなると送信を止めます。WatchがUNIMPLEMENTEDで失敗すると、ヘルスチェックを無効にします。メタデータのドキュメントは、HTTP/2のヘッダーで運ばれるキーと値のペアを説明しています。キーはASCIIで、大文字と小文字を区別せず、grpc-で始めることはできず、バイナリ値のキーは-binで終わります。ヘッダーは最初のメッセージの前に、トレーラーはサーバーが呼び出しを閉じるときに送られます。

現場での姿

最も高くつく事故は、デッドラインのない呼び出しです。下流のサービスが止まると、上流のスレッドがすべてレスポンスを待って溜まっていき、最終的には上流まで落ちました。gRPCがデフォルトのデッドラインを設けないという一文を知らなかったことが原因でした。デッドラインを指定して伝播させれば、下流が遅くても、上流は決められた時間内にDEADLINE_EXCEEDEDで戻ってきます。

2つ目は「成功したのに失敗」です。注文の作成はサーバーでコミットされましたが、レスポンスがデッドラインを超えたため、クライアントはDEADLINE_EXCEEDEDを受け取りました。リトライポリシーにそのコードが入っていたため、同じ注文が2回作られました。ドキュメントがDEADLINE_EXCEEDEDの説明に「完了していた可能性がある」と書いている、そのケースです。冪等キーなしにリトライ対象を広げてはいけません。

3つ目は、ヘルスチェックの誤解です。ロードバランサーがCheckを呼んでいるのに、サービス名を間違えて書いたため、常にNOT_SERVINGを受け取り、トラフィックが0になっていました。空文字列がサーバー全体を意味することを知っていれば、1行で終わる設定です。

次のクイズで確認すること

4種類の呼び出しの違い、ライブラリが絶対に生成しないステータスコード、UNAVAILABLE・ABORTED・FAILED_PRECONDITIONの使い分け、デッドラインが時計のずれを避ける方法、リトライポリシーがないときに実際に起きること、ヘルスチェックの空文字列とメタデータのキーのルールを問います。