TT Lab
Blog

한국어 English 日本語

Get started
Blog

Blog

gRPC in Kubernetes: Why It Floods a Single Pod and Health Checks

Status: Draft (2026-10-17), partially measured. Based on grpc/grpc-go v1.83.2, kubernetes v1.34.10, cilium v1.20.1, and grpc/grpc v1.84.0 documentation. Measurements were obtained during the 03:00 hour (KST) on 2026-10-17 from Prometheus andkubectl getinside the Cilium agent cilium-dbg status by reading only. Load skew was not directly reproduced(unmeasured). To reproduce it, a test gRPC server image and deployment would need to be deployed to the cluster, but this task decided to read-only. Requirements are noted in Section 8.

Table of Contents

1. Intuition: Taxi Stand

A Service (ClusterIP) is a dispatcher at a taxi stand. When a customer (connection) arrives, it assigns an empty taxi (pod). HTTP/1.1 clients make short trips frequently and get off, giving the dispatcher plenty of opportunities to evenly distribute them. A gRPC client charters a single taxi all day long charters a single taxi all day long . Even if thousands of trips (calls) are made inside that taxi, the dispatcher was only involved the very first time. Even if the number of taxis is increased to ten, the customer who already chartered one continues to ride the first taxi.

2. Diagram

연결 단위 분산과 호출 단위 분산 ① ClusterIP(L4): 연결을 처음 열 때 한 번 고른다 클라이언트 채널 1개 서비스 IP eBPF/iptables 파드 1 · 호출 전부 파드 2 · 0 파드 3 · 0 TCP 하나가 오래 살면 새 파드가 늘어도 그 연결은 그대로다. ② 헤드리스 + 클라이언트 LB(round_robin) 클라이언트 채널 DNS: 파드 IP 3개 서브채널 3개 호출마다 고름 파드 1 · 1/3 파드 2 · 1/3 파드 3 · 1/3 새 파드는 DNS 를 다시 풀어야 보인다(grpc-go 는 연결이 끊길 때 등). ③ L7 프록시(메시·게이트웨이·xDS) 클라이언트 Envoy 등 HTTP/2 스트림 단위 파드 1 파드 2 파드 3 xDS 를 쓰면 프록시 없이 gRPC 라이브러리가 직접 설정을 받는다. 점선 상자: 놀고 있는 파드. 굵은 실선: 실제 호출이 흐르는 길.
Diagram: Comparison of connection-level distribution, headless with client LB, and L7 proxy

3. Why Sizing Skews

This issue was covered by the Kubernetes blog in 2018 in "gRPC Load Balancing on Kubernetes without Tears" (by Buoyant, kubernetes.io/blog).

4. Three Solutions

4.1 Headless Service + Client-side round_robin

clusterIP: None A headless service returns pod IPs directly via DNS. When a client creates a channel with dns:///scoreboard.tt.svc.cluster.local:50051 and selects round_robin in the service config, the channel creates a subchannel for each pod and rotates them per call .

{"loadBalancingConfig": [{"round_robin": {}}]}

In grpc-go, it can be passed via grpc.WithDefaultServiceConfig(...) or received via a DNS TXT record (Kubernetes DNS does not provide TXT, so the former method is usual).

There is a catch: when is DNS re-queried. The grpc-go DNS resolver does not periodically re-query once successful, but waits until someone calls ResolveNow (though at least 30 seconds must have passed since the last query).

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:
	}
}

Source: grpc/grpc-go v1.83.2 internal/resolver/dns/dns_resolver.go excerpt from lines 218-229 (watcher), Apache-2.0

ResolveNow is mainly called when a connection drops or fails (clientconn.go:1366·1515). Thus, when pods decrease (because connections drop), it notices quickly, but when they increase, no one knows. This is because if existing pods are healthy, connections have no reason to drop.

4.2 Periodic Rotation via Server MaxConnectionAge

The solution is for the server to give connections a lifespan. If you configure keepalive.ServerParameters.MaxConnectionAge on the grpc-go server, it sends GOAWAY to connections that exceed that time (±10% jitter) and closes them after MaxConnectionAgeGrace (comment inkeepalive.go ). The defaults for both are infinity. Clients receiving GOAWAY finish ongoing calls and send new calls over a new connection, re-resolving DNS in the process of dropping the old connection. Even behind ClusterIP, dispatching happens again when opening new connections, gradually resolving the skew. The reason for jitter is to prevent all clients from reconnecting at the exact same moment.

4.3 L7 Proxy: Envoy, Mesh, Cilium

Proxies that understand HTTP/2 can load balance on a per-stream (call) basis backends. Sidecar meshes (Istio and Linkerd) or gateways handle this, so there is no need to change client code. The costs are an extra hop and the proxy's CPU and memory.

Cilium can handle gRPC at L7 using the node's Envoy. Taking advantage of the fact that gRPC methods are POST /패키지.서비스/메서드 , it makes allowlists by path, and denied calls return a PERMISSION_DENIED status instead of dropping packets (Cilium gRPC policy guide).

rules:
  http:
  - method: "POST"
    path: "/tt.v1.Scoreboard/Hit"

The agent in the small cluster was Proxy Status: OK … 0 redirects active (cilium-dbg status). This means that because no L7 policies were applied anywhere, the path inspecting gRPC at L7 was not used.

4.4 xDS: Proxyless Mesh

This is an approach where gRPC libraries directly speak xDS (Envoy's configuration API) (gRFC A27). Clients create channels with xds:///서비스:포트 , receive listeners, routes, clusters, and endpoints from a control plane (e.g., istiod), and perform load balancing per call. Istio provides this as "proxyless gRPC," where an agent writes bootstrap files and relays xDS on its behalf (Istio Blog 2021).

Note that gRPC implements only part of xDS. Unsupported configurations are ignored without logs, and if an unsupported value is provided for a supported field, the entire configuration is rejected (NACK) (grpc_xds_features.md). Settings that worked in an Envoy sidecar may silently have no effect in proxyless gRPC.

The istiod in the small cluster was connected to three Envoy proxies via xDS (pilot_xds 3), and after startup, it sent 44,798 EDS pushes, 21,150 RDS pushes, and 21,102 each for CDS and LDS (sum by (type) (pilot_xds_pushes)). Endpoints (EDS) change most frequently because pods come and go. There were no proxyless gRPC clients.

4.5 Selection Criteria

Method Client Changes New Pod Adoption Additional Cost When to Use
Keep ClusterIP None From new connections None Many clients with few calls each
+ Server MaxConnectionAge Server configuration Per connection lifespan Reconnection cost When skew is observed in the above scenario
Headless + round_robin Service configuration When connection drops (+ lifespan) None When we build both clients and servers
L7 Proxy/Mesh None Immediate (xDS) Hop, proxy resources When languages are mixed and policies/observability are needed
Proxyless xDS Target string, bootstrap Immediate Control plane When you want to reduce hops and accept functional limitations

5. Health Checking: grpc.health.v1

gRPC has a standard health service (health-checking.md).

6. gRPC Probes in kubelet

Kubernetes has supported stable grpc probes since version 1.27. By providing a port and an optional service name, kubelet calls grpc.health.v1.Health/Check . The constraints noted in the documentation are (Probe Configuration Documentation): Named ports cannot be used, plaintext connections are the default, and TLS has been added as a GRPCContainerProbeTLS feature gate in newer documentation (not present in 1.34, and certificates are not verified).

The implementation in kubelet v1.34.10 is concise.

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,
})

Source: Kubernetes v1.34.10 pkg/probe/grpc/grpc.go excerpt from lines 56–90 (Probe), Apache-2.0

6.1 Measurement: Who Uses gRPC Probes

kubectl get pods -o json counted the probe types in the cluster (excluding one user business namespace, 271 pods).

Probe Type Count
httpGet 368
exec 38
tcpSocket 20
grpc 3

All 3 gRPC probes belong to the startup, liveness, and readiness probes of hubble-relay (port 4222, empty service name, period 10s for liveness/readiness, 3s for startup). The relay logs also show that upon startup, it opens the "gRPC health server" on port 4222. The resulting metrics were as follows.

sum by (probe_type, result) (prober_probe_total{namespace="kube-system", container="hubble-relay"})
Probe Success Failure
Liveness 32,161 0
Readiness 32,162 0
Startup 1 8

7. Limitations

8. Connection to Small Clusters

Titi's Note. Renting a taxi for the whole day does not help no matter how smart the dispatcher is. You occasionally need to get out (MaxConnectionAge) and queue up again to ride a new taxi!

References

All materials were accessed on 2026-10-17.

Sign in to like

Comments

No comments yet.

Sign in to leave a comment