一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

C#のgRPCクライアントではChannelを再利用しよう

f:id:ryo-utsunomiya:20190801150551p:plain:w160

こんにちは。宿泊事業本部の宇都宮です。6月に、Go + gRPCという構成のサービスを運用開始したという記事を書きました。

Go + gRPCによるマイクロサービス構築 - 一休.com Developers Blog

本番運用開始から2ヶ月ほどたち、いくつかのトラブルがありつつ、現在も元気に稼働中です。

運用していく中で定常的に発生していたgRPCのタイムアウトエラーについて、その対処法がわかったので、紹介します。

なお、本記事の知見はC#でのgRPCクライアント実装においては有用でしたが、他の言語では適用できない可能性が高いです。各言語のドキュメントもあわせてご参照ください。

gRPCのタイムアウト

以前の記事で紹介したマイクロサービスは、30~60req/sec程度のリクエストを常時受け付けます。

社内のサービスの中で、このマイクロサービスに最も頻繁にリクエストを送るのは認証基盤で、全リクエストの9割以上を占めます。なお、認証基盤はC#(ASP.NET MVC)で実装されています。

この認証基盤のログをみると、1時間に5件程度、gRPCの「Deadline Exceeded」エラーが発生していることがわかりました。これは通信タイムアウト時に発生するエラーです。

認証基盤では、当初、gRPCのタイムアウトを5秒に設定していました。これは大幅に余裕を持たせた数字で、実際、99%のリクエストは0.2秒以内にレスポンスを受け取っていました。また、gRPCサーバでも処理時間のログを取っていますが、こちらには1秒以上レスポンスにかかっているようなログは出ていませんでした。

つまり、gRPCのタイムアウトはレスポンスの遅延に起因するものではなく、クライアント-サーバ間のコネクションの確立失敗に起因するのでは? と仮説を立てました。

対応(1) タイムアウトの短縮とリトライ

当初、タイムアウトを短くして、代わりにリトライを行うようにすればいいのでは? と考えました。

C#でPollyを使うと、こんな感じのコードになります:

using Polly;

...

var client = new MemberServiceClient() { DeadLineSeconds = 1 };
var response = Policy.Handle<Exception>()
    .Retry(3)
    .Execute(() => client.GetMember(memberSeq));

この実装では、タイムアウトは 改善しませんでした。リトライは確かに行われるのですが、一度失敗した状態からリトライしても、失敗し続けてしまい、何回リトライしても同じ、という状態でした。

対応(2) Channelの再利用

C#向けのgRPCライブラリのドキュメントはここにあります。中でも特に重要なのは、サーバとの接続を管理する Grpc.Core.Channel クラスのドキュメントでしょう:

https://grpc.github.io/grpc/csharp/api/Grpc.Core.Channel.html

このドキュメントの冒頭には、以下のように書いてあります。

Channels are an abstraction of long-lived connections to remote servers. More client objects can reuse the same channel. Creating a channel is an expensive operation compared to invoking a remote call so in general you should reuse a single channel for as many calls as possible.

要するに、Channelはコネクションプールなので、インスタンスを使い回した方がいいよ、ということです。

もとの実装では、gRPCクライアントをnewするたびに新しいChannelを作っていました。この実装を改め、Channelは一度作成した後は、static変数に保持してサーバのライフタイムに渡って使い回すようにしました。

using Grpc.Core;

...

// もとの実装(ClientのDisposeでChannelをShutdown)
using(var client = new MemberServiceClient(new Channel(GetServerHost(), GetServerPort(), ChannelCredentials.Insecure)))
{
  //
}

// Channelを使い回すようにした実装(Application_EndでChannelをShutdown)
var client = new MemberServiceClient(GetChannel());

...

private static Channel _channel { get; set; }
private static object channelLock = new object();

public static Channel GetChannel()
{
    if (_channel == null)
    {
        lock (channelLock)
        {
            _channel = new Channel(GetServerHost(), GetServerPort(), ChannelCredentials.Insecure);
        }
    }

    return _channel;
}

この変更をしたところ、認証基盤のgRPCと通信する部分のスループットが2倍になり、さらに、gRPCのタイムアウトもほとんど発生しなくなりました。タイムアウトが発生した場合でも、Channelを頻繁に再作成していた場合と異なり、リトライすれば成功するようになりました。

むすび

gRPCは、ハイパフォーマンスなサービスを作るための強力なスタックですが、ややクセがあります。特に、コネクション周りは実装(言語)依存の部分が大きいため、クライアントを書く言語向けのドキュメントには一通り目を通した方がいいな、と思いました。