gRPC を使用したパフォーマンスのベスト プラクティス

作成者: James Newton-King

gRPC は、高パフォーマンス サービス向けに設計されています。 このドキュメントでは、gRPC から最大限のパフォーマンスを得る方法について説明します。

gRPC チャネルを再利用する

gRPC の呼び出しを行う場合は、gRPC チャネルを再利用する必要があります。 チャネルを再利用すると、既存の HTTP/2 接続を介して、呼び出しを多重化できます。

gRPC 呼び出しごとに新しいチャネルが作成された場合、完了までにかかる時間が大幅に増加する可能性があります。 呼び出しのたびに新しい HTTP/2 接続が作成されるため、クライアントとサーバーの間に次のような複数のネットワーク ラウンドトリップが必要になります。

  1. ソケットを開く
  2. TCP 接続を確立する
  3. TLS ネゴシエーションを行う
  4. HTTP/2 接続を開始する
  5. gRPC 呼び出しを行う

チャネルは、gRPC 呼び出しの間で安全に共有し、再利用できます。

  • gRPC クライアントはチャネルを使用して作成されます。 gRPC クライアントは軽量なオブジェクトであり、キャッシュしたり再利用したりする必要はありません。
  • 異なる種類のクライアントを含め、1 つチャネルから複数の gRPC クライアントを作成できます。
  • チャネルおよびそのチャネルから作成されたクライアントは、複数のスレッドで安全に使用できます。
  • チャネルから作成されたクライアントは、複数の同時呼び出しを行えます。

gRPC クライアント ファクトリには、チャネルを構成するための一元的な方法が用意されています。 基になるチャネルが自動的に再利用されます。 詳細については、「.NET での gRPC クライアント ファクトリの統合」を参照してください。

接続の同時実行

HTTP/2 接続では、通常、1 つの接続で同時に実行できるストリームの最大数 (アクティブな HTTP 要求数) に制限があります。 既定では、ほとんどのサーバーの同時に実行できるストリームの制限数は 100 に設定されます。

gRPC チャネルは 1 つの HTTP/2 接続を使用し、その接続で同時呼び出しが多重化されます。 アクティブな呼び出しの数が接続ストリームの制限に達すると、追加の呼び出しがクライアントのキューに入れられます。 キューに入れられた呼び出しは、アクティブな呼び出しが完了するのを待ってから送信されます。 高負荷のアプリケーションや、長時間実行されるストリーミング gRPC 呼び出しでは、この制限により、呼び出しキューが原因でパフォーマンスの問題が発生する可能性があります。

.NET 5 により、SocketsHttpHandler.EnableMultipleHttp2Connections プロパティが導入されています。 true に設定すると、同時実行ストリームの制限に達したときに、チャネルによって追加の HTTP/2 接続が作成されます。 GrpcChannel が作成されると、その内部の SocketsHttpHandler が、追加の HTTP/2 接続を作成するように自動的に構成されます。 アプリによって独自のハンドラーが構成される場合は、EnableMultipleHttp2Connectionstrue に設定することを検討してください。

var channel = GrpcChannel.ForAddress("https://localhost", new GrpcChannelOptions
{
    HttpHandler = new SocketsHttpHandler
    {
        EnableMultipleHttp2Connections = true,

        // ...configure other handler settings
    }
});

gRPC 呼び出しをする .NET Framework アプリは、WinHttpHandler を使用するように構成する必要があります。 .NET Framework アプリでは、WinHttpHandler.EnableMultipleHttp2Connections プロパティを true に設定して、追加の接続を作成できます。

.NET Core 3.1 アプリには、2 つの回避策があります。

  • 高負荷のアプリの領域に対して、別々の gRPC チャネルを作成します。 たとえば、Logger gRPC サービスに高い負荷がかかることがあります。 別個のチャネルを使用して、このアプリに LoggerClient を作成します。
  • gRPC チャネルのプールを使用します。たとえば、gRPC チャネルの一覧を作成します。 Random は、gRPC チャネルが必要になるたびに一覧からチャネルを選択するために使用されます。 Random を使用すると、複数の接続で呼び出しがランダムに分散されます。

重要

この問題を解決するもう 1 つの方法は、サーバーの最大同時実行ストリーム制限を増やすことです。 Kestrel では、これは MaxStreamsPerConnection で構成されます。

最大同時実行ストリーム制限を増やすことはお勧めしません。 1 つの HTTP/2 接続でストリームが多すぎると、パフォーマンスの新たな問題が発生します。

  • ストリーム間のスレッド競合により、接続への書き込みが試行されます。
  • 接続パケットが失われると、TCP 層ですべての呼び出しがブロックされます。

クライアント アプリの ServerGarbageCollection

.NET ガベージ コレクターには、ワークステーション ガベージ コレクション (GC) とサーバー ガベージ コレクションの 2 つのモードがあります。 それぞれは別のワークロードに合わせて調整されています。 ASP.NET Core アプリでは、既定でサーバー GC が使用されます。

一般に、同時実行数が多いアプリはサーバー GC の方がパフォーマンスが向上します。 gRPC クライアント アプリが多数の gRPC 呼び出しを同時に送受信している場合は、サーバー GC を使用するようにアプリを更新すると、パフォーマンスが向上する可能性があります。

サーバー GC を有効にするには、アプリのプロジェクト ファイルで <ServerGarbageCollection> を設定します。

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>

ガベージ コレクションの詳細については、「ワークステーションとサーバーのガベージ コレクション」を参照してください。

Note

ASP.NET Core アプリでは、既定でサーバー GC が使用されます。 <ServerGarbageCollection> の有効化は、gRPC クライアント コンソール アプリなど、サーバー以外の gRPC クライアント アプリでのみ役立ちます。

負荷分散

gRPC では、一部のロード バランサーが効率よく動作しません。 L4 (トランスポート) ロードバランサーは、TCP 接続をエンドポイント間で分散することで、接続レベルで動作します。 この方法は、HTTP/1.1 を使用して行われた API 呼び出しを負荷分散する場合に適しています。 HTTP/1.1 で行われる同時呼び出しは異なる接続で送信されるため、エンドポイント間で負荷を分散することができます。

L4 ロード バランサーは接続レベルで動作するため、gRPC ではうまく機能しません。 gRPC は HTTP/2 を使用します。これにより、1 つの TCP 接続での複数の呼び出しが多重化されます。 その接続でのすべての gRPC 呼び出しが、1 つのエンドポイントに送られます。

gRPC を効果的に負荷分散するには、次の 2 つのオプションがあります。

  • クライアント側の負荷分散
  • L7 (アプリケーション) のプロキシ負荷分散

Note

gRPC 呼び出しのみが、エンドポイント間で負荷分散できます。 ストリーミング gRPC 呼び出しが確立されると、ストリームを介して送信されるすべてのメッセージが 1 つのエンドポイントに送られます。

クライアント側の負荷分散

クライアント側の負荷分散では、クライアントがエンドポイントを認識します。 各 gRPC 呼び出しの場合は、呼び出しの送信先として、別のエンドポイントが選択されます。 クライアント側の負荷分散は、待機時間が重要な場合に適しています。 クライアントとサービスの間にプロキシが存在しないため、呼び出しはサービスに直接送信されます。 クライアント側の負荷分散には、使用可能なエンドポイントを各クライアントが追跡し続ける必要があるという欠点があります。

ルックアサイド クライアントの負荷分散は、負荷分散の状態を中央の場所に格納する手法です。 クライアントは、負荷分散の決定を行うときに使用する情報を中央の場所に定期的にクエリします。

詳細については、「gRPC クライアント側の負荷分散」を参照してください。

プロキシの負荷分散

L7 (アプリケーション) プロキシは、L4 (トランスポート) プロキシよりも高いレベルで動作します。 L7 プロキシは HTTP/2 を認識します。 プロキシは、1 つの HTTP/2 接続で多重化された gRPC の呼び出しを受信し、複数のバックエンド エンドポイントに分散させます。 プロキシを使うと、クライアント側で負荷分散するより簡単ですが、gRPC の呼び出しでの待ち時間が長くなります。

使用できる L7 プロキシは多数あります。 次のようなオプションがあります。

  • Envoy - 広く普及しているオープン ソース プロキシ。
  • Linkerd - Kubernetes のサービス メッシュ。
  • YARP: Yet Another Reverse Proxy - .NET で記述されたオープン ソース プロキシ。

プロセス間通信

クライアントとサービス間の gRPC 呼び出しは、通常、TCP ソケットを介して送信されます。 TCP はネットワーク経由の通信に適していますが、クライアントとサービスが同じコンピューター上にある場合は、プロセス間通信 (IPC) の方が効率的です。

同じマシン上のプロセス間の gRPC 呼び出しには、UNIX ドメイン ソケットや名前付きパイプなどのトランスポートを使用することを検討してください。 詳細については、「gRPC を使用したプロセス間通信」を参照してください。

キープ アライブ ping

キープ アライブ ping を使用して、非アクティブな状態の間 HTTP/2 接続を維持することができます。 アプリでアクティビティが再開されるときに既存の HTTP/2 接続が準備できていると、接続の再確立による遅延が発生することなく、初期の gRPC 呼び出しをすばやく行うことができます。

キープ アライブ ping は SocketsHttpHandler に構成されます。

var handler = new SocketsHttpHandler
{
    PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan,
    KeepAlivePingDelay = TimeSpan.FromSeconds(60),
    KeepAlivePingTimeout = TimeSpan.FromSeconds(30),
    EnableMultipleHttp2Connections = true
};

var channel = GrpcChannel.ForAddress("https://localhost:5001", new GrpcChannelOptions
{
    HttpHandler = handler
});

上記のコードにより、非アクティブな状態の間 60 秒ごとにキープ アライブ ping をサーバーに送信するチャネルが構成されます。 ping により、サーバーと使用中のすべてのプロキシが、非アクティブが理由で接続が閉じられることがないように保証されます。

Note

キープ アライブの ping は単に接続を維持するためのものです。 接続に対して実行時間の長い gRPC 呼び出しがある場合、非アクティブという理由でサーバーまたは中間プロキシによって終了される可能性があります。

フロー制御

HTTP/2 フロー制御は、大量のデータによってアプリが過負荷になることを防ぐ機能です。 フロー制御を使用する場合:

  • HTTP/2 の接続と要求それぞれに使用可能なバッファー ウィンドウがあります。 バッファー ウィンドウとは、アプリが一度に受け取ることができるデータの量です。
  • フロー制御がアクティブになるのは、バッファー ウィンドウがいっぱいになったときです。 アクティブ化されると、送信アプリは、それ以上のデータの送信を一時停止します。
  • 受信アプリがデータを処理すると、バッファー ウィンドウ内のスペースが使用可能になります。 送信アプリがデータの送信を再開します。

フロー制御がパフォーマンスに悪影響を与える可能性があるのは、大きなメッセージを受信するときです。 バッファー ウィンドウが受信メッセージ ペイロードよりも小さい場合、またはクライアントとサーバーの間に待機時間がある場合は、開始/停止バーストでデータを送信できます。

フロー制御のパフォーマンスの問題は、バッファー ウィンドウのサイズを大きくすることで解決できます。 Kestrel では、これはアプリ起動時に InitialConnectionWindowSizeInitialStreamWindowSize を使用して構成します。

builder.WebHost.ConfigureKestrel(options =>
{
    var http2 = options.Limits.Http2;
    http2.InitialConnectionWindowSize = 1024 * 1024 * 2; // 2 MB
    http2.InitialStreamWindowSize = 1024 * 1024; // 1 MB
});

推奨事項:

  • gRPC サービスが 96 KB (Kestrel の既定ストリーム ウィンドウ サイズ) を超えるメッセージをよく受け取る場合は、接続ウィンドウ サイズとストリーム ウィンドウ サイズを増やすことを検討してください。
  • 接続ウィンドウのサイズは、必ずストリーム ウィンドウ サイズ以上にする必要があります。 ストリームは接続の一部であり、送信側は両方によって制限されます。

フロー制御のしくみの詳細については、HTTP/2 フロー制御 (ブログ記事) を参照してください。

重要

Kestrel のウィンドウ サイズを増やすと、Kestrel がアプリのためにバッファー処理するデータが増えるため、メモリ使用量が増える可能性があります。 不必要に大きなウィンドウ サイズを構成しないでください。

ストリーム

gRPC 双方向ストリーミングを使用して、高パフォーマンスのシナリオで単項 gRPC 呼び出しを置き換えることができます。 双方向ストリームが開始されると、メッセージのやり取りが、複数の単項 gRPC 呼び出しでメッセージを送信するよりも高速になります。 ストリーム メッセージは、既存の HTTP/2 要求にデータとして送信され、各単項呼び出しに対して新しい HTTP/2 要求を作成するオーバーヘッドがなくなります。

サービスの例:

public override async Task SayHello(IAsyncStreamReader<HelloRequest> requestStream,
    IServerStreamWriter<HelloReply> responseStream, ServerCallContext context)
{
    await foreach (var request in requestStream.ReadAllAsync())
    {
        var helloReply = new HelloReply { Message = "Hello " + request.Name };

        await responseStream.WriteAsync(helloReply);
    }
}

クライアントの例:

var client = new Greet.GreeterClient(channel);
using var call = client.SayHello();

Console.WriteLine("Type a name then press enter.");
while (true)
{
    var text = Console.ReadLine();

    // Send and receive messages over the stream
    await call.RequestStream.WriteAsync(new HelloRequest { Name = text });
    await call.ResponseStream.MoveNext();

    Console.WriteLine($"Greeting: {call.ResponseStream.Current.Message}");
}

パフォーマンス上の理由で単項呼び出しを双方向ストリーミングに置き換えることは高度な手法であり、多くの状況では適切ではありません。

ストリーミング呼び出しの使用は、次の場合に適しています。

  1. 高スループットまたは短い待機時間が必要とされている。
  2. gRPC と HTTP/2 が、パフォーマンスのボトルネックとして特定された。
  3. クライアントのワーカーが、gRPC サービスを使用して通常のメッセージを送受信している。

単項ではなく、ストリーミング呼び出しを使用する場合は、さらに複雑になり、制限事項が追加されることに注意してください。

  1. ストリームは、サービスまたは接続エラーによって中断される可能性があります。 エラーが発生した場合にストリームを再開するためのロジックが必要です。
  2. RequestStream.WriteAsync は、マルチスレッドでは安全ではありません。 一度に 1 つのストリームに書き込むことができるメッセージは 1 つだけです。 1 つのストリームで複数のスレッドからメッセージを送信するには、メッセージをマーシャリングするための Channel<T> のようなプロデューサーまたはコンシューマー キューが必要です。
  3. gRPC ストリーミング方式は、1 種類のメッセージの受信と 1 種類のメッセージの送信に制限されます。 たとえば、rpc StreamingCall(stream RequestMessage) returns (stream ResponseMessage) により、RequestMessage が受信され、ResponseMessage が送信されます。 Anyoneof を使用した不明なメッセージまたは条件付きメッセージに対する Protobuf のサポートにより、この制限を回避できます。

バイナリ ペイロード

バイナリ ペイロードは、bytes スカラー値型の Protobuf でサポートされています。 C# で生成されたプロパティにより、プロパティの型として ByteString が使用されます。

syntax = "proto3";

message PayloadResponse {
    bytes data = 1;
}  

Protobuf は、オーバーヘッドを最小限に抑えて、大きなバイナリ ペイロードを効率的にシリアル化するバイナリ形式です。 JSON のようなテキスト ベースの形式では、base64 へのバイトのエンコードが必要であり、メッセージ サイズが 33% 追加されます。

大きな ByteString ペイロードを使用する場合は、以下に説明する、不要なコピーや割り当てを回避するためのいくつかのベスト プラクティスがあります。

バイナリ ペイロードの送信

ByteString インスタンスは通常 ByteString.CopyFrom(byte[] data) を使用して作成されます。 このメソッドでは、新しい ByteString と新しい byte[] が割り当てられます。 データは、新しいバイト配列にコピーされます。

UnsafeByteOperations.UnsafeWrap(ReadOnlyMemory<byte> bytes) を使用して ByteString インスタンスを作成すると、追加の割り当てとコピーを回避できます。

var data = await File.ReadAllBytesAsync(path);

var payload = new PayloadResponse();
payload.Data = UnsafeByteOperations.UnsafeWrap(data);

ByteString の使用中に変更が行われないように、UnsafeByteOperations.UnsafeWrap を使用してもバイトはコピーされません。

UnsafeByteOperations.UnsafeWrap には、Google.Protobuf バージョン 3.15.0 以降が必要です。

バイナリ ペイロードの読み取り

ByteString.Memory および ByteString.Span プロパティを使用して、ByteString インスタンスからデータを効率的に読み取ることができます。

var byteString = UnsafeByteOperations.UnsafeWrap(new byte[] { 0, 1, 2 });
var data = byteString.Span;

for (var i = 0; i < data.Length; i++)
{
    Console.WriteLine(data[i]);
}

これらのプロパティにより、コードでは割り当てやコピーを行わずに、ByteString からデータを直接読み取ることができます。

ほとんどの .NET API には ReadOnlyMemory<byte> および byte[] のオーバーロードがあるため、ByteString.Memory は基になるデータを使用するための推奨される方法です。 ただし、アプリでデータをバイト配列として取得する必要がある場合もあります。 バイト配列が必要な場合は、MemoryMarshal.TryGetArray メソッドを使用して、データの新しいコピーを割り当てることなく ByteString から配列を取得できます。

var byteString = GetByteString();

ByteArrayContent content;
if (MemoryMarshal.TryGetArray(byteString.Memory, out var segment))
{
    // Success. Use the ByteString's underlying array.
    content = new ByteArrayContent(segment.Array, segment.Offset, segment.Count);
}
else
{
    // TryGetArray didn't succeed. Fall back to creating a copy of the data with ToByteArray.
    content = new ByteArrayContent(byteString.ToByteArray());
}

var httpRequest = new HttpRequestMessage();
httpRequest.Content = content;

上記のコードでは次の操作が行われます。

  • MemoryMarshal.TryGetArray を使用して ByteString.Memory からの配列の取得を試みます。
  • 正常に取得された場合は、ArraySegment<byte> を使用します。 セグメントには配列への参照、オフセット、およびカウントが含まれます。
  • それ以外の場合、ByteString.ToByteArray() による新しい配列の割り当てにフォールバックします。

gRPC サービスと大規模なバイナリ ペイロード

gRPC と Protobuf では、大規模なバイナリ ペイロードを送受信できます。 バイナリ ペイロードをシリアル化する場合、バイナリの Protobuf の方がテキストベースの JSON よりも効率的ですが、大規模なバイナリ ペイロードを操作する場合には、重要なパフォーマンス特性にも留意する必要があります。

gRPC はメッセージベースの RPC フレームワークです。そのため、次のような特性があります。

  • gRPC がメッセージを送信する前に、メッセージ全体がメモリに読み込まれます。
  • メッセージが受信されると、メッセージ全体がメモリ内に逆シリアル化されます。

バイナリ ペイロードは、バイト配列として割り当てられます。 たとえば、10 MB のバイナリ ペイロードの場合は、10 MB のバイト配列が割り当てられます。 メッセージのバイナリ ペイロードが大きい場合、大規模なオブジェクト ヒープにバイト配列が割り当てられる可能性があります。 大規模な割り当ては、サーバーのパフォーマンスとスケーラビリティに影響します。

大規模なバイナリ ペイロードを扱うアプリケーションで高パフォーマンスを維持するためのアドバイス: