状态:草案(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 服务器镜像和 Deployment,但本次工作决定 仅进行读取。所需内容已写在第 8 节中。
目录
- 1. 直观理解:出租车候客区
- 2. 图解
- 3. 为什么会倾斜
- 4. 三种解决方法
- 5. 健康检查:grpc.health.v1
- 6. kubelet 的 gRPC 探针
- 7. 局限性
- 8. 与小型集群的连接
- 参考资料
1. 直观理解:出租车候客区
Service(ClusterIP)就像是出租车候客区的调度员。当有乘客(连接)到来时,它会指定一辆空出租车(Pod)。HTTP/1.1 客户端由于短途出行频繁, 上下车次数多,调度员有充足的机会进行均等分配。而 gRPC 客户端则是 包下一辆出租车整整一天 。即便在车内进行数千次 出行(调用),调度员也只在最初参与了一次。即使将出租车增加到十辆,已经包车的乘客仍会继续乘坐第一辆出租车。
2. 图解
3. 为什么会倾斜
- 无论是 kube-proxy 的 iptables / IPVS,还是 Cilium 对 kube-proxy 的替代,ClusterIP 都是按 连接级来选择后端的。Cilium 的 Socket 级负载均衡在
应用程序调用
connect()时,会将服务 IP 替换为后端地址,并将 Socket 直接绑定到该后端(kube-proxy free 文档)。 小型集群中的代理也是KubeProxyReplacement: True(cilium-dbg status,读取)。其结构在 Cilium 读取中。 - gRPC 通道会长时间维持对单个目标的单个连接,并通过 HTTP/2 流并发传输调用(第 1 篇)。gRPC 的负载均衡文档从一开始就指出,其设计 是将分发作为 调用级而不是连接级来进行的(load-balancing.md)。 然而,这种分发必须由客户端库中的 LB 策略来完成,而 ClusterIP 只提供一个地址,因此客户端库没有可供分发的目标。
- 默认的 LB 策略
pick_first会连接到地址列表的 第一个 并将所有调用发送到那里(同上文档)。即使有多个地址,默认情况下也不会进行分发。
Kubernetes 博客曾在 2018 年发表过一篇题为《gRPC Load Balancing on Kubernetes without Tears》的文章来总结这个问题(Buoyant 的文章, kubernetes.io/blog).
4. 三种解决方法
4.1 无头服务 + 客户端侧 round_robin
clusterIP: None 作为 dns:///scoreboard.tt.svc.cluster.local:50051 的无头服务通过 DNS 原样返回 Pod 的 IP。如果客户端使用 round_robin 创建通道并在服务配置中选择 ,通道将为每个 Pod 创建子通道,并 按每次调用
{"loadBalancingConfig": [{"round_robin": {}}]}
轮流发送。 grpc.WithDefaultServiceConfig(...) 在 grpc-go 中,可以通过
传入或通过 DNS TXT 记录接收(由于 Kubernetes DNS 不提供 TXT,通常使用前一种方法)。 这里有一个陷阱:什么时候重新查询 DNS
ResolveNow 。grpc-go 的 DNS 解析器一旦成功就不会定期重新查询,而是会一直等到有人调用
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:
}
}
(尽管距离上次查询必须过去至少 30 秒)。
来源: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 增加则无人知晓。 因为只要现有 Pod 运转正常,连接就不会断开。
4.2 通过服务器的 MaxConnectionAge 周期性轮换
解决方案是让服务器为连接设定寿命。如果设定了 grpc-go 服务器的 keepalive.ServerParameters.MaxConnectionAge ,则会向超过该时间(±10% 的随机抖动)的连接发送 GOAWAY,并在 MaxConnectionAgeGrace 后关闭(keepalive.go 的注释)。两者的默认值都是无限。收到 GOAWAY 的客户端会完成正在进行的调用,并将新调用发送到新连接,同时趁着连接断开的机会重新解析 DNS。即使在 ClusterIP 背后,打开新连接时也会重新进行调度,从而逐渐缓解流量倾斜。之所以存在抖动,是为了防止所有客户端在同一瞬间涌入并重新连接。
4.3 L7 代理:Envoy、Mesh、Cilium
能够理解 HTTP/2 的代理可以 按流(调用)级别 选择后端。Sidecar Mesh(Istio·Linkerd)或网关可以完成此工作,且无需修改客户端代码。代价增加了一次网络跳数(Hop)以及代理的 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 策略的地方,因此不会使用在 L7 查看 gRPC 的路径。
4.4 xDS:无代理的 Mesh
这是 gRPC 库 直接使用 xDS(Envoy 的配置 API) 的方式(gRFC A27)。客户端使用 xds:///서비스:포트 创建通道,从控制平面(例如 istiod)接收监听器、路由、集群和端点,并在调用级别进行分发。Istio 将其作为“proxyless gRPC”提供,由代理编写引导文件并代为转发 xDS(Istio 博客 2021).
)。 需要注意的是,gRPC 只实现了 xDS 的 一部分 。不支持的配置会被静默忽略,如果支持的字段中出现了不支持的值,则会拒绝整个配置(NACK)(grpc_xds_features.md)。在 Envoy Sidecar 中生效的配置在无代理 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 代理·Mesh | 无 | 即时(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)通过服务器流式传输(Server Streaming)在状态改变时进行通知。List()一次性提供所有已注册服务的状态(gRFC A90)。grpc-go 在 v1.83.2 中就已经支持,proto 中也有,但在 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 。
文档中提及的限制如下(探针配置文档):不能使用具名端口(Named Port),默认是明文连接,最新文档中通过 GRPCContainerProbeTLS 功能门控(Feature Gate)添加了 TLS 支持(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
- 每次探针都会创建一个通道(Channel)并在结束时关闭。这与第 1 篇第 6 节中“通道应当复用”的原则相反,但由于探针的目的是检查“现在是否能够重新连接”,这样做是正确的。不过代价是,每个探针周期都会产生一次 TCP 和 HTTP/2 的配置交换。
timeoutSeconds涵盖了连接和调用。如果服务器未实现健康检查服务,则会收到 UNIMPLEMENTED 并判定为失败。- Kubernetes main 分支已将相同的函数更改为
grpc.NewClient("passthrough:///"+addr, …)。由于NewClient的默认解析器是dns,因此旧的DialContext像这样直接使用地址时必须加上passthrough(第 1 篇第 6 节)。
6.1 实测:谁在使用 gRPC 探针
kubectl get pods -o json 统计了探针的类型(未计算用户业务的一个命名空间,共 271 个 Pod)。
| 探针类型 | 数量 |
|---|---|
| httpGet | 368 |
| exec | 38 |
| tcpSocket | 20 |
| grpc | 3 |
3 个 gRPC 探针全部属于 hubble-relay 的启动、存活和就绪探针(端口 4222,服务名称为空字符串,周期为:存活与就绪 10 秒,启动 3 秒)。Relay 日志中也会在启动时留下打开 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 次后成功。Relay 在启动后连接提供节点列表的
hubble-peer服务遭到拒绝,于 30 秒后重试(日志中的connection refused,第 6 篇),在此期间健康状态似乎并非 SERVING(推测)。 - 6 小时延迟的 p50 和 p99 均在第一个桶(5ms)内。这意味着在同一节点内,每次探针打开新连接的开销大约就是这个水平。
- 由于两个探针每 10 秒打开和关闭一次连接,因此该 Pod 一天内会产生约 17,280 次短 TCP 连接(计算过程)。
7. 局限性
- 流量倾斜复现(未实测)。所需材料:测试用的 gRPC 服务器镜像(例如构建了 grpc-go 的
examples/helloworld)、3 个副本的 Deployment 以及一个 ClusterIP 和一个 Headless 服务、负载生成器(如 ghz 等)。通过指标对比每个 Pod 的grpc_server_handled_total即可。 - 没有地方在小型集群中运行具有多个副本的 gRPC 服务器(Argo CD repo-server 也只有 1 个)。因此未能测量实际的流量倾斜案例。
- 未涵盖 Linkerd 和 Envoy 的 gRPC 重试与超时策略,以及 Gateway API 的 GRPCRoute。
8. 与小型集群的连接
- 如果将自行编写的 gRPC 服务器以多个副本运行,最划算的起点是先在 服务器中加入 MaxConnectionAge(例如几分钟) 。如果可以修改客户端,则再加上 Headless 和 round_robin。
- 注册健康服务并使用 kubelet 的
grpc探针,无需 exec 探针(grpc_health_probe二进制文件)即可实现相同功能。exec 探针会创建 CRI 的ExecSync调用,从而给 containerd 带来负担(第 4 篇第 4.1 节中 exec_sync 每小时约 11,000 次)。
Titi 的一句话。 包下一辆出租车开一整天的话,调度员再聪明也没用。偶尔下车(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。