ステータス:ドラフト(2026-10-17)、一部実測。 基準バージョン:grpc/grpc-go v1.83.2、kubernetes v1.34.10、cilium v1.20.1、grpc/grpc v1.84.0 ドキュメント。 実測は2026-10-17 03時台(KST)にPrometheus・
kubectl get・Ciliumエージェント内の「cilium-dbg status」を読むことだけで取得しました。 負荷の偏りを直接再現したわけではありません(未実測)。再現するにはテスト用のgRPCサーバーのイメージとデプロイメントをクラスターに上げる必要がありますが、今回の作業は 読むだけにとどめました。必要な事項は第8節に記載しています。
目次
- 1. 直感:タクシー乗り場
- 2. 図
- 3. なぜ偏るのか
- 4. 3つの解決策
- 5. ヘルスチェック:grpc.health.v1
- 6. kubeletのgRPCプローブ
- 7. 制限事項
- 8. 小規模クラスターとの関連
- 参考
1. 直感:タクシー乗り場
Service(ClusterIP)はタクシー乗り場の配車係です。客(コネクション)が来ると空きタクシー(Pod)を1台割り当てます。HTTP/1.1クライアントは短距離を頻繁に 乗り降りするため、配車係が均等に割り振る機会が多くあります。gRPCクライアントは「 タクシー1台を終日貸し切り 」にします。そのタクシーの中で何千回 移動(呼び出し)しても、配車係が関与したのは最初の1回だけです。タクシーを10台に増やしても、すでに貸し切りにした客は最初のタクシーに乗り続けます。
2. 図
3. なぜ偏るのか
- kube-proxyのiptablesやIPVSであれ、Ciliumのkube-proxy代替であれ、ClusterIPは「 コネクション単位」でバックエンドを選択します。Ciliumのソケットレベルの負荷分散は、
アプリが「
connect()」を呼び出すときにサービスIPをバックエンドアドレスに変更し、ソケットをそのバックエンドに直接結びつけます(kube-proxy free ドキュメント)。 小規模クラスターのエージェントも「KubeProxyReplacement: True」でした(cilium-dbg status、参照)。構造は Ciliumの参照にあります。 - gRPCチャネルは1つの対象に対して1つのコネクションを長く維持し、HTTP/2ストリームで呼び出しを同時に流し込みます(第1回)。gRPCの負荷分散ドキュメントには、最初から 分散を「 コネクションではなく呼び出し単位」で行うように設計したと記載されています(load-balancing.md)。 しかし、その分散はクライアントライブラリ内のLBポリシーが行う必要があり、ClusterIPは1つのアドレスしか返さないため、ライブラリが分散する対象がありません。
- デフォルトのLBポリシー「
pick_first」は、アドレスリストの「 最初のアドレスに接続し 」、すべての呼び出しをそこに送信します(同ドキュメント)。アドレスが複数あってもデフォルトでは分散しません。
この問題について、Kubernetesブログは2018年に「gRPC Load Balancing on Kubernetes without Tears」という記事でまとめています(Buoyantの記事、 kubernetes.io/blog).
4. 3つの解決策
4.1 ヘッドレスサービス + クライアント側 round_robin
clusterIP: None であるヘッドレスサービスは、DNSでPodのIPをそのまま返します。クライアントが「 dns:///scoreboard.tt.svc.cluster.local:50051 」でチャネルを作成し、サービス設定で「 round_robin 」を選択すると、チャネルがPodごとにサブチャネルを作成し、「 呼び出しごと 」に切り替えながら送信します。
{"loadBalancingConfig": [{"round_robin": {}}]}
grpc-goでは「 grpc.WithDefaultServiceConfig(...) 」として指定するか、DNS TXTレコードから取得できます(Kubernetes DNSはTXTレコードを提供しないため、通常は前者の方法をとります)。
落とし穴が1つあります。「 いつDNSを再問い合わせするか」という点です。grpc-goのDNSリゾルバは一度成功すると定期的に再問い合わせせず、誰かが「
ResolveNow 」を呼び出すまで待機します(ただし直前のクエリから30秒以上経過している必要があります)。
var nextResolutionTime time.Time
if err == nil {
// Success resolving, wait for the next ResolveNow. However, also wait 30
// seconds at the very least to prevent constantly re-resolving.
backoffIndex = 1
nextResolutionTime = internal.TimeNowFunc().Add(MinResolutionInterval)
select {
case <-d.ctx.Done():
return
case <-d.rn:
}
}
出典:grpc/grpc-go v1.83.2 internal/resolver/dns/dns_resolver.go 218〜229行抜粋(watcher)、Apache-2.0
ResolveNow は主に「 コネクションが切断されたり接続に失敗したりしたとき 」に呼び出されます(clientconn.go:1366·1515)。
そのため、Podが減ると(コネクションが切断されるため)すぐに気づきますが、 増えても誰も気づきません 。既存のPodに問題がなければコネクションが切断される理由がないためです。
4.2 サーバーの MaxConnectionAge による定期的な切り替え
解決策は、サーバーがコネクションに寿命を持たせることです。grpc-goサーバーの「 keepalive.ServerParameters.MaxConnectionAge 」を設定すると、その時間(±10%のジッター)が経過したコネクションに GOAWAY を送信し、「 MaxConnectionAgeGrace 」の経過後に切断します(keepalive.go のコメント)。デフォルト値はいずれも無限です。GOAWAYを受信したクライアントは進行中の呼び出しを完了させ、新しい呼び出しは新しいコネクションに送信します。また、コネクションが切断されたついでにDNSを再解決します。ClusterIPの後ろであっても、新しいコネクションを開くときに再度配車が行われるため、偏りが徐々に解消されます。ジッターが存在する理由は、すべてのクライアントが同じタイミングで集中して再接続するのを防ぐためです。
4.3 L7プロキシ:Envoy、メッシュ、Cilium
HTTP/2を理解するプロキシは、「 ストリーム(呼び出し)単位で バックエンドを選択できます。サイドカーメッシュ(Istio・Linkerd)やゲートウェイがこの処理を行いますが、クライアントコードを変更する必要はありません。その代償として、1ホップの追加とプロキシのCPU・メモリ消費が発生します。
Ciliumは、ノード上のEnvoyによりL7でgRPCを処理できます。gRPCメソッドが POST /패키지.서비스/메서드 という点を利用して、 パスによる許可リストを作成するネットワークポリシーが可能になり、拒否された呼び出しはパケットを破棄する代わりにPERMISSION_DENIEDステータスを返します(Cilium gRPCポリシーガイド).
rules:
http:
- method: "POST"
path: "/tt.v1.Scoreboard/Hit"
小さなクラスターのエージェントは Proxy Status: OK … 0 redirects active でした(cilium-dbg status)。現在L7ポリシーが適用されている箇所がないため、gRPCをL7で検査するパスが使用されていないことを意味します。
4.4 xDS: プロキシレスメッシュ
gRPCライブラリが 直接xDS(Envoyの設定API)と通信する 方式です(gRFC A27)。クライアントは xds:///서비스:포트 でチャネルを作成し、コントロールプレーン(例:istiod)からリスナー・ルート・クラスター・エンドポイントを受け取って呼び出し単位で分散します。Istioはこれを「proxyless gRPC」として提供し、エージェントがブートストラップファイルを書き込んでxDSを代行中継します(Istioブログ 2021).
注意すべき点は、gRPCがxDSの 一部のみ を実装しているという点です。サポートされていない設定はログも出さずに無視され、サポートされているフィールドにサポートされていない値が指定された場合は設定全体が拒否(NACK)されます(ズgrpc_xds_features.md)。Envoyサイドカーで機能していた設定が、プロキシレスgRPCでは何のエラーもなく効かなくなることがあります。
小さなクラスターのistiodは、3つのEnvoyプロキシとxDSで接続されており(pilot_xds 3)、起動以降のプッシュ回数はEDS 44,798回、RDS 21,150回、CDS・LDSが各21,102回でした(sum by (type) (pilot_xds_pushes))。エンドポイント(EDS)が最も頻繁に変更されるのは、Podが頻繁に出入りするためです。プロキシレスgRPCクライアントは存在しませんでした。
4.5 選択基準
| 方式 | クライアントの変更 | 新Podへの反映 | 追加コスト | 適したケース |
|---|---|---|---|---|
| ClusterIPのまま | なし | 新規接続から | なし | クライアントが多く、それぞれの呼び出しが少ないとき |
| + サーバーのMaxConnectionAge | サーバー設定 | 接続の寿命ごと | 再接続のコスト | 上記状況で偏りが確認されるとき |
| ヘッドレス + round_robin | サービス設定 | 接続が切れたとき(+寿命) | なし | クライアントとサーバーの両方を自社で開発する場合 |
| L7プロキシ・メッシュ | なし | 即時(xDS) | ホップ、プロキシのリソース | 言語が混在し、ポリシーやオブザーバビリティが必要なとき |
| プロキシレスxDS | ターゲット文字列、ブートストラップ | 即時 | コントロールプレーン | ホップを減らし、機能の制限を受け入れるとき |
5. ヘルスチェック: grpc.health.v1
gRPCには標準のヘルスサービスがあります(health-checking.md).
Check(service)はSERVING・NOT_SERVINGを返します。サービス名が空文字列の場合はサーバー全体のステータスとなり、未知の名前の場合はNOT_FOUNDになります。grpc-goのhealth.Server.Checkはまさにそのように動作し、新規作成時は空の名前がSERVINGに設定されています。Watch(service)はサーバーのストリーミングにより、ステータスが変更されるたびに通知します。List()は登録されているすべてのサービスのステータスを一度に返します(gRFC A90)。grpc-goではバージョン1.83.2ですでに実装されておりプロトファイルにも存在しますが、grpc/grpcのドキュメントにはまだ記載されていません(パート8のコントリビューション候補)。- クライアント側のヘルスチェック(gRFC A17)を有効にすると、チャネルはサブチャネルごとに
Watchを開き、NOT_SERVINGであるバックエンドには呼び出しを送信しません(addrConn.startHealthCheck)。round_robinと組み合わせて使用することで、「生存はしているがトラフィックを受け取るべきではない」Podを回避できます。
6. kubeletのgRPCプローブ
Kubernetesは1.27から grpc プローブが安定版となっています。ポートとオプションのサービス名を指定すると、kubeletが grpc.health.v1.Health/Check を呼び出します。ドキュメントに記載されている制限は以下の通りです(プローブ設定ドキュメント):名前付きポートは使用できず、デフォルトは平文接続であり、TLSは最新のドキュメントにおいて GRPCContainerProbeTLS 機能ゲートとして追加されています(1.34にはなく、証明書は検証されません)。
kubelet 1.34.10の実装はシンプルです。
opts := []grpc.DialOption{
grpc.WithUserAgent(fmt.Sprintf("kube-probe/%s.%s", v.Major, v.Minor)),
grpc.WithBlock(),
grpc.WithTransportCredentials(insecure.NewCredentials()), //credentials are currently not supported
...
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
conn, err := grpc.DialContext(ctx, addr, opts...)
...
resp, err := client.Check(metadata.NewOutgoingContext(ctx, make(metadata.MD)), &grpchealth.HealthCheckRequest{
Service: service,
})
出典: kubernetes v1.34.10 pkg/probe/grpc/grpc.go の56〜90行目を抜粋(Probe)、Apache-2.0
- 1回のプローブごとに1つのチャネルを作成し、完了時に閉じます。パート1のセクション6にある「チャネルは再利用する」という原則に反しますが、プローブは「今新しく接続できるかどうか」を確認するためのものであるため、この方が適切です。その代わり、プローブの実行間隔ごとにTCPとHTTP/2の設定ネゴシエーションが1回ずつ発生します。
timeoutSecondsの1つが接続と呼び出しの両方をカバーします。サーバーがヘルスサービスを実装していない場合はUNIMPLEMENTEDを受信し、失敗として扱われます。- Kubernetesのmainブランチでは、同じ関数が
grpc.NewClient("passthrough:///"+addr, …)に変更されました。NewClientのデフォルトリゾルバーがdnsであるため、従来のDialContextのようにアドレスをそのまま使うにはpassthroughを付けなければならないためです(第1部第6節)。
6.1 実測:誰がgRPCプローブを使っているか
kubectl get pods -o json のプローブの種類を集計しました(ユーザー業務のネームスペース1つは除外、Pod 271個)。
| プローブの種類 | 数 |
|---|---|
| httpGet | 368 |
| exec | 38 |
| tcpSocket | 20 |
| grpc | 3 |
gRPCプローブ3つはすべてhubble-relayのLiveness・Startup・Readinessプローブです(ポート4222、サービス名空文字、周期はLiveness・Readiness 10秒、Startup 3秒)。リレーのログにも起動時に4222で「gRPC health server」を開くことが記録されます。結果の指標は次の通りでした。
sum by (probe_type, result) (prober_probe_total{namespace="kube-system", container="hubble-relay"})
| プローブ | 成功 | 失敗 |
|---|---|---|
| Liveness | 32,161 | 0 |
| Readiness | 32,162 | 0 |
| Startup | 1 | 8 |
- スタートアッププローブが8回失敗した後に成功しました。リレーは起動直後にノード一覧を渡す
hubble-peerサービスへの接続が拒否されて30秒後に再試行し (ログのconnection refused、第6部)、その間にヘルスがSERVINGでなかったものとみられます(推測)。 - 6時間の遅延は、p50・p99ともに最初のバケット(5ms)内でした。プローブごとに新しい接続を開くコストは、同じノード内ではこの程度という意味です。
- 2つのプローブが10秒ごとに接続を開閉するため、このPod1つあたり1日に約17,280回の短いTCP接続が発生します(計算)。
7. 制限事項
- 偏りの再現(未実測)。必要なもの:テスト用gRPCサーバーイメージ(例:grpc-go の
examples/helloworldをビルドしたもの)、3レプリカのデプロイメントとClusterIP・ヘッドレス サービスがそれぞれ1つ、負荷生成ツール(ghzなど)。指標としてPod別のgrpc_server_handled_totalを比較すればよいです。 - 小規模クラスターでgRPCサーバーを複数のレプリカで実行しているところはありませんでした(Argo CD repo-server も1つ)。そのため、実際の偏りの事例は測定できませんでした。
- Linkerd・Envoy のgRPCリトライ・タイムアウトポリシー、Gateway APIの GRPCRoute は扱っていません。
8. 小規模クラスターとの関連
- 自作のgRPCサーバーを複数のレプリカで立ち上げる場合、 サーバーに MaxConnectionAge(例:数分)をまず 設定するのが最も手軽な出発点です。クライアントを修正できる場合は、ヘッドレス + round_robin を追加します。
- ヘルスサービスを登録し、kubelet の
grpcプローブを使用すると、execプローブ(grpc_health_probeバイナリ)なしで同じことができます。execプローブはCRIのExecSync呼び出しを生成し、containerd に負担をかけます(第4部第4.1節の exec_sync は1時間あたり約11,000回)。
ティーの一言。 タクシーを終日チャーターすると、配車係がどんなに賢くても意味がありません。たまには降りて(MaxConnectionAge)並び直してこそ、新しいタクシーにも乗れますよ!
参考
すべての資料は2026-10-17に閲覧しました。
- grpc/grpc v1.84.0:
doc/load-balancing.md,doc/health-checking.md,doc/grpc_xds_features.md,doc/naming.md - gRFC: A17 Client-side Health Checking, A27 xDS-Based Global Load Balancing, A90 Health Service List Method
- grpc/grpc-go v1.83.2:
internal/resolver/dns/dns_resolver.go,clientconn.go,health/server.go,keepalive/keepalive.go - kubernetes v1.34.10
pkg/probe/grpc/grpc.go、同ファイルの main - Kubernetesドキュメント Configure Liveness, Readiness and Startup Probes、 ブログ gRPC Load Balancing on Kubernetes without Tears
- Cilium: Kubernetes Without kube-proxy, Securing gRPC
- Istioブログ gRPC Proxyless Service Mesh
- 引用コード:grpc-go v1.83.2・kubernetes v1.34.10、Apache License 2.0。