gRPCのクライアントサイドロードバランシングでサーバ負荷の偏りを軽減する

この記事は、CYBOZU SUMMER BLOG FES '25の記事です。

CloudPlatform部のpddgです。gRPCは高性能・高機能なRPCフレームワークであり、サイボウズではバックエンドサービス間での通信に広く利用されています。gRPCの実際の通信はChannelという仕組みによって抽象化されて管理されており、デフォルトでは最初に接続した単一のサーバのみと通信します。これにより、少数の高負荷をかけるクライアントが存在すると、そのクライアントが接続したサーバに負荷が集中してしまいます。

gRPCはクライアント側で接続するサーバを選択するロードバランシング機能を備えており、これを利用することで負荷の偏りを軽減できます。本記事では、gRPCのクライアントサイドロードバランシングの導入方法や注意点について解説します。 説明にはgRPC-Goを用いますが、他の言語のgRPC実装でも同様の機能が提供されています。

背景

gRPCはクライアントとサーバの通信をChannelという仕組みで抽象化しています*1。通常、gRPCクライアントを作成する際、接続先のアドレスを渡すと(必要ならば名前解決して)そのアドレス一つに対してChannelを作成して接続します。

このとき、一度作成されたTCPコネクションはデフォルトでは切断されるまで維持されます。つまり、gRPCクライアントは単一のエンドポイントに対して一度接続し、その接続が切断されるまで一つのChannelを使い続けることになります。

これにより(gRPCのロードバランシングに対応したL7ロードバランサを実際に用意したりしない限り)バックエンドのgRPCサーバを何台用意しても、あるgRPCクライアントが発行するRPCは単一のサーバに集中してしまいます。

サイボウズではgRPCで通信するサービス間にサービスメッシュなどは導入しておらず、現状素のgRPCクライアントとgRPCサーバが直接通信するケースがほとんどです。これにより、多数のRPCを行う少数のクライアントが存在する場合に、gRPCサーバの負荷がPodごとに大きく偏り、99パーセンタイルのレイテンシが悪化するなどの問題が発生していました。

gRPCのクライアントサイドロードバランシング

通常、Webサーバへの負荷分散はクライアント側ではなくサーバ側で行われることが多いでしょう。L4 ロードバランサ(TCPレベルの分散)、L7 ロードバランサ(HTTPレベルの分散)などが用いられます。KubernetesのServiceリソースを用いて複数のPodに負荷分散するのはL4ロードバランサの一種と考えられます。更にそこからNginxやEnvoyなどのL7ロードバランサを用いることで、ホスト名・HTTPのパスなどに基づいてより細かい負荷分散やルーティングが可能になります。

これに対して、クライアント側が複数あるバックエンドを識別してリクエストを出し分ける方式をクライアントサイドロードバランシングと呼びます。gRPCにおいては、DNSによる名前解決時に得られた複数のバックエンドのアドレスについてラウンドロビン方式で接続する方法がサポートされています。

gRPCクライアントは指定されたFQDNの名前解決を行い、得られたアドレスすべてに対してChannelを作成します*2。これは実際には指定したFQDNに対するChannelに紐付く複数のSubchannelという形になります。個別のRPCを行う際にそれらのSubchannelをラウンドロビンで選択して利用することで、複数のバックエンドに対して負荷分散が行われます。

この機能を有効化するためには、gRPCクライアントに対して以下の2つの設定が必要です。

  1. ロードバランシングポリシーの設定
  2. リゾルバーの設定

ロードバランシングポリシーの設定

デフォルトのロードバランシングポリシーは pick_first です。これは複数のアドレスが返されても、最初の一つにのみリクエストを送信するというポリシーです。これを round_robin に変更することで、複数のアドレスに対してラウンドロビン方式でリクエストを分散できます。

この設定はgRPCのService Configという仕組みで設定でき、以下のいずれかの方法で指定できます。

  • DNSのTXTレコードで指定する
  • gRPCクライアントのコードで直接指定する
    • grpc.WithDefaultServiceConfig オプションを利用する
    • この方法は設定を文字列として指定するため、コンパイル時にその正しさを検証できない点に注意が必要です。
    • client, err := grpc.NewClient(addr,
          // デフォルトはpick_firstなのでround_robinに変更する
          grpc.WithDefaultServiceConfig(`{"loadBalancingConfig": [{"round_robin":{}}]}`),
      )
      

リゾルバーの設定

リゾルバーはエンドポイントの名前解決を行うコンポーネントです。gRPCには以下の物を含むいくつかのリゾルバーが組み込まれており、サーバのアドレスとして指定した文字列のschemeに基づいてリゾルバーが選択されます。

  • dns
    • DNSを用いて宛先を解決する
    • 例:dns://example.com:80
  • unix
    • Unixドメインソケットを用いる
    • 例:unix:///path/to/socket

ただし、gRPC-Goのクライアントの実装に対してはschemeを指定せずにアドレスを渡すことができ、その場合はデフォルトのリゾルバーが利用されます。grpc.NewClient で作成されたクライアントはデフォルトで dns リゾルバーを使用します*3

注意点

dnsリゾルバーを利用する場合、gRPCクライアントは接続時(および再接続時)にDNSの名前解決を行い、得られたアドレスすべてに対してChannelを作成します。しかし、その後にDNSの名前解決結果が変化しても自動的に再解決は行われません。つまり、gRPCクライアントが起動した後にバックエンドのアドレスが増加しても、その変更は反映されません。 そのため、バックエンドが負荷に応じてスケールアウトするようなケースでは、負荷分散の効果が限定的になる可能性があります。

サービスのスケールインやロールアウト時には接続中のアドレスに接続出来なくなることがあります。この場合、gRPCクライアントは自動的に再接続を試み、その際に再度DNSの名前解決が行われて新しいアドレスが取得されます。

サーバ側で MaxConnectionAge の設定を行い、一定時間で接続を切断するようにすることで、gRPCクライアントが定期的に再接続するようになり、そのたびに新しいアドレスを取得するように促すことも可能です。ただし、頻繁な接続の切断はパフォーマンスに悪影響を与える可能性があるため、やはり即時に反映することはできません。

srv := grpc.NewServer(
    grpc.KeepaliveParams(keepalive.ServerParameters{
        // 10分ごとに接続を切断してクライアントに再接続を促す
        MaxConnectionAge:  10*time.Minute,
        MaxConnectionIdle: 5 * time.Minute,
    }),
)

分かりやすいように loadBalancingConfigpick_first に、MaxConnectionAge を30秒に設定してPodごとのリクエスト数を観測してみました。最短30秒ごとにdnsリゾルバーがアドレスを再解決し、異なるPodに接続される様子が確認できます。

MaxConnextionAge=30sのときの各Podへのリクエスト数の推移

実践的な利用方法

dns resolverとKubernetesのheadlessサービスを使う

クライアントサイドロードバランシングを利用するためには、gRPCクライアントが複数のバックエンドのアドレスを取得できる必要があります。Kubernetes環境でClusterIPのServiceを利用している場合、ServiceのDNS名を名前解決しても単一のClusterIPアドレスしか得られません。したがって、gRPCクライアントがServiceのDNS名を名前解決しても、クライアントサイドロードバランシングは機能しません。

apiVersion: v1
kind: Service
metadata:
  name: my-grpc-service
spec:
  selector:
    app: my-grpc-app
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
$ dig my-grpc-service.sandbox.svc.cluster.local +short
10.68.176.44

この問題を解決するために、KubernetesのHeadless Serviceを利用します。Headless ServiceはClusterIPアドレスを持たず、ServiceのDNS名を名前解決した際に、そのServiceに紐づくPodのIPアドレスすべてが返されます。

# Headless Serviceの例
apiVersion: v1
kind: Service
metadata:
  name: my-headless-grpc-service
spec:
  # Noneを指定してHeadless Serviceにする
  clusterIP: None
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
$ dig my-headless-grpc-service.sandbox.svc.cluster.local +short
10.64.11.135
10.64.29.167
10.64.23.139

このサービス宛てに(適切に設定された)gRPCクライアントが接続するとロードバランシングが機能するようになります。

client, err := grpc.NewClient(
    "my-headless-grpc-service.sandbox.svc.cluster.local:80",
    grpc.WithDefaultServiceConfig(`{"loadBalancingConfig": [{"round_robin":{}}]}`),
)

以下の画像は実際にKubernetes上でHeadless Serviceを用いてgRPCクライアントサイドロードバランシングを行った際の、各Podのリクエスト数を示しています。前半はloadBalancingConfigを pick_first のままにした場合、後半は round_robin に変更した場合です。round_robin に変更したことで、各Podに対しておおよそ均等にリクエストが分散されています。

pick_firstとround_robinの比較

kubernetes APIを使うカスタムリゾルバーを使う

注意点として述べた通り、DNSリゾルバーは変更の反映が即時ではなく、バックエンドのスケールアウトに対応しにくいという課題があります。できるだけ即座に反映する必要がある場合、kubernetesのAPIを利用して特定のServiceリソースを監視し、変更があったら都度反映するようなカスタムリゾルバーを作成することも可能です。

利用はしていませんが、実際に以下のような実装が存在するようです。

xDS APIの可能性

gRPCはxDS APIを利用したサービスディスカバリとロードバランシングもサポートしています*4。xDSはEnvoyなどのサービスメッシュで利用されているAPIであり、gRPCクライアントがxDSサーバからバックエンドの情報を取得してロードバランシングを行うことができます。

IstioなどのサービスメッシュはサイドカーコンテナにEnvoyを用い、xDS APIを利用してサービスディスカバリとロードバランシングを行います。gRPCクライアントが直接xDS APIを利用することで、サービスメッシュを導入せずに同様の効果を得られる可能性があります。

xDSリゾルバーはload balancing configなどもコントロールプレーンから配信できるほか、ルーティング自体も柔軟に設定できるようです。

参考:

まとめ

gRPCクライアントおよびgRPCサーバが素朴に直接通信するケースにおいて、特定のクライアントが高負荷をかけると、そのクライアントが接続したサーバに負荷が集中してしまう問題があります。gRPCのクライアントサイドロードバランシング機能を利用することで、複数のバックエンドに対して負荷分散が可能となり、負荷の偏りを軽減できます。

Kubernetes環境では、Headless Serviceを利用することであるServiceリソースに紐づくPodのアドレスすべてをgRPCクライアントが取得できるようになり、簡単にクライアントサイドロードバランシングを導入できます。

ただし、DNSリゾルバーは名前解決結果の変更を即座に反映できないため、バックエンドのスケールアウトに対応しにくい点に注意が必要です。必要に応じてカスタムリゾルバーやxDS APIを利用する方法も検討すると良いでしょう。


*1:https://christina04.hatenablog.com/entry/grpc-channel

*2:デフォルトでは複数のアドレスが得られた場合、そのうちの最初のアドレスのみに対してChannelを作成して接続します。

*3:deprecatedになった grpc.Dial を使っている場合はpassthroughリゾルバーがデフォルトで利用されます

*4:https://grpc.github.io/grpc/core/md_doc_grpc_xds_features.html