RSS

gRPCとデッドライン

TL;DR: 必ずデッドラインを設定する。この記事では、デッドラインを意図的に設定することを推奨する理由と、それを示すための便利なコードスニペットを説明します。

gRPC を使用すると、gRPC ライブラリが通信、マーシャリング、アンマーシャリング、デッドライン enforcement を処理します。デッドラインにより、gRPC クライアントは、RPC が DEADLINE_EXCEEDED エラーで終了する前に、RPC が完了するまでどれだけ待つかを指定できます。デフォルトでは、このデッドラインは言語の実装に依存する非常に大きな数値です。デッドラインの指定方法も言語によって異なります。一部の言語 API は、RPC が完了すべき固定の時点であるデッドラインの観点から機能します。他の API は、RPC がタイムアウトするまでの期間であるタイムアウトを使用します。

一般的に、デッドラインを設定しない場合、リソースはすべてのインフライトリクエストに対して保持され、すべてリクエストが最大タイムアウトに達する可能性があります。これにより、サービスはメモリなどのリソースを使い果たすリスクにさらされ、サービスのレイテンシが増加したり、最悪の場合、プロセス全体がクラッシュしたりする可能性があります。

これを回避するために、サービスは技術的にサポートする最長のデフォルトデッドラインを指定し、クライアントは応答がもはや有用でなくなるまで待つべきです。サービスにとっては、.proto ファイルにコメントを追加するだけで済みます。クライアントにとっては、有用なデッドラインを設定することが重要です。

「良いデッドライン/タイムアウト値とは?」という問いに単一の答えはありません。あなたのサービスは、クイックスタートガイドの Greeter のように単純かもしれません。この場合、100 ms で十分でしょう。あなたのサービスは、グローバルに分散され、強く整合性の取れたデータベースのように複雑かもしれません。クライアントクエリのデッドラインは、テーブルをドロップするのを待つ時間とは異なります。

では、デッドラインの情報を得た選択をするために、どのようなことを考慮する必要がありますか?考慮すべき要因には、システム全体のエンドツーエンドレイテンシ、RPC が直列であるか並列であるかなどが含まれます。たとえ概算であっても、それらに数値を設定できる必要があります。エンジニアはサービスを理解し、クライアントとサーバー間の RPC に対して意図的なデッドラインを設定する必要があります。

gRPC では、クライアントとサーバーの両方が、リモートプロシージャコール(RPC)が成功したかどうかについて、それぞれ独立してローカルに判断します。これは、それらの結論が一致しない可能性があることを意味します!サーバー側で正常に完了した RPC が、クライアント側で失敗する可能性があります。たとえば、サーバーは応答を送信できますが、クライアントのデッドラインが切れた後に返信がクライアントに到着することがあります。クライアントはすでに DEADLINE_EXCEEDED ステータスエラーで終了しています。これは、アプリケーションレベルでチェックおよび管理する必要があります。

デッドラインの設定

クライアントとしては、サーバーからの返信を待つ時間に対して、常にデッドラインを設定する必要があります。クイックスタートページの Greeting サービスを使用した例を以下に示します。

C++

ClientContext context;
time_point deadline = std::chrono::system_clock::now() +
    std::chrono::milliseconds(100);
context.set_deadline(deadline);

Go

clientDeadline := time.Now().Add(time.Duration(*deadlineMs) * time.Millisecond)
ctx, cancel := context.WithDeadline(ctx, clientDeadline)

Java

response = blockingStub.withDeadlineAfter(deadlineMs, TimeUnit.MILLISECONDS).sayHello(request);

これにより、クライアント RPC が設定されてから、クライアントが応答を受信するまでのデッドラインが 100ms に設定されます。

デッドラインの確認

サーバー側では、サーバーは特定の RPC がもはや不要であるかどうかをクエリできます。サーバーが応答の処理を開始する前に、まだクライアントがそれを待っているかどうかを確認することが非常に重要です。これは、高コストな処理を開始する前に特に重要です。

C++

if (context->IsCancelled()) {
  return Status(StatusCode::CANCELLED, "Deadline exceeded or Client cancelled, abandoning.");
}

Go

if ctx.Err() == context.Canceled {
	return status.New(codes.Canceled, "Client cancelled, abandoning.")
}

Java

if (Context.current().isCancelled()) {
  responseObserver.onError(Status.CANCELLED.withDescription("Cancelled by client").asRuntimeException());
  return;
}

クライアントがデッドラインに達したことがわかっている場合に、サーバーがリクエストを続行することは有用ですか?それは状況によります。応答がサーバーにキャッシュできる場合、特にリソースを大量に消費し、リクエストごとにコストがかかる場合は、処理してキャッシュする価値があります。これにより、結果がすでに利用可能であるため、将来のリクエストが高速になります。

デッドラインの調整

デッドラインを設定したのに、新しいリリースやサーバーバージョンによって深刻なリグレッションが発生した場合はどうなりますか?デッドラインが小さすぎると、すべてのリクエストが DEADLINE_EXCEEDED でタイムアウトしたり、大きすぎるとユーザーのテールレイテンシが大幅に増加したりする可能性があります。フラグを使用してデッドラインを設定および調整できます。

C++

#include <gflags/gflags.h>
DEFINE_int32(deadline_ms, 20*1000, "Deadline in milliseconds.");

ClientContext context;
time_point deadline = std::chrono::system_clock::now() +
    std::chrono::milliseconds(FLAGS_deadline_ms);
context.set_deadline(deadline);

Go

var deadlineMs = flag.Int("deadline_ms", 20*1000, "Default deadline in milliseconds.")

ctx, cancel := context.WithTimeout(ctx, time.Duration(*deadlineMs) * time.Millisecond)

Java

@Option(name="--deadline_ms", usage="Deadline in milliseconds.")
private int deadlineMs = 20*1000;

response = blockingStub.withDeadlineAfter(deadlineMs, TimeUnit.MILLISECONDS).sayHello(request);

これにより、ハードコーディングされた異なるデッドラインを持つリリースをチェリーピックすることなく、失敗を回避するためにデッドラインを長く設定できます。これにより、リグレッションがデバッグおよび解決されるまで、ユーザーへの影響を軽減できます。