在 Kubernetes 环境中部署 gRPC 服务时,发现明明有多个服务端 Pod,但所有请求都只打到了其中一个 Pod,导致负载严重不均。本文将分析这个问题的根本原因,并介绍几种主流解决方案,结合实际代码演示,帮助你在不同场景下做出最佳选择。
一、背景
在 Kubernetes 集群中部署了一个 gRPC 服务,配置如下:
- 服务端:3 个 Pod 副本(grpc-server-0, grpc-server-1, grpc-server-2)
- 客户端:1 个 Pod,持续发送请求
- Service:使用默认的 ClusterIP Service
观察结果:所有请求都路由到了 grpc-server-0,其他两个 Pod 完全没有流量,这样会导致以下问题:
- 资源浪费:其他 Pod 闲置,无法充分利用集群资源
- 性能瓶颈:单个 Pod 过载,响应延迟增加
- 可用性风险:如果该 Pod 故障,所有流量中断
二、根因分析
gRPC 基于 HTTP/2 多路复用:同一 TCP 连接可并行承载多个请求,连接一旦建立即持续复用。Kubernetes Service 的 kube-proxy 仅在连接建立时执行一次 L4 负载均衡,后续所有请求均沿该连接转发,不再重新选择后端,因而无法实现请求级分摊,导致流量集中于单个 Pod。
三、解决方案
方案一:gRPC 内置 Round-Robin 负载均衡
3.1.1 核心原理
将客户端的 LoadBalancingPolicy 从默认的 pick_first 改为 round_robin。
关键变化:
- gRPC 会为每个后端地址创建独立的 Sub-Connection
- 请求在这些子连接间轮询发送
- 等于”单 client 同时保持 N 条 HTTP/2 连接”
- 天然打散到 N 个 Pod
3.1.2 代码实现
参考:client.go
// 方案 1:Round-Robin |
关键点:
- 必须使用 Headless Service(
clusterIP: None) - Headless Service 在 DNS 查询时返回所有 Pod IP
- 使用
dns:///resolver 让 gRPC 获取所有地址
3.1.3 Kubernetes 配置
# Headless Service |
3.1.4 特点分析
| 维度 | 说明 |
|---|---|
| 核心原理 | 客户端为每个 Pod 创建独立连接,请求轮询分发 |
| 适用场景 | Kubernetes 环境,需要简单快速的解决方案 |
| 部署成本 | 极低:一行代码配置 |
| 改造成本 | 极低:只需修改客户端连接配置 |
| 性能损失 | 几乎为 0(相对直连 100% QPS) |
| 优点 | 简单、高效、无需额外组件 |
| 缺点 | 需要 Headless Service,不支持高级路由(权重、灰度) |
方案二:gRPC xDS 直连(Proxyless)
3.2.1 核心原理
仍然使用 gRPC 自己的负载均衡,但把”后端列表”来源从 DNS 换成 xDS 协议。
工作流程:
- Istio/Pilot 以 LDS/RDS/CDS/EDS 形式把 Pod IP 列表推给 gRPC 客户端
- 客户端在本地做请求级 RR/wRR(加权轮询)
- 不再依赖长连接,而是”每次 pick 一个 Sub-Connection“
3.2.2 代码实现
// 方案 2:Proxyless xDS |
关键点:
- 使用
xds:///scheme 启用 xDS resolver - 需要 Istio grpc-agent 生成 bootstrap.json
- 支持高级路由:权重、子集(灰度标签)
3.2.3 Kubernetes 配置
# Deployment 配置 |
grpc-agent 的作用:
- 自动生成符合 Istio 要求的 bootstrap.json
- 提供 SDS(Secret Discovery Service)证书
- 维护正确的 node.id 格式
为快速验证 xDS 通路,手动将 bootstrap.json 以 ConfigMap 挂载到客户端 Pod,未注入 grpc-agent;只要 node.id 格式正确且 URI 与后端服务匹配,istiod 即会返回 xDS 响应。
3.2.4 特点分析
| 维度 | 说明 |
|---|---|
| 核心原理 | xDS 协议直接下发后端列表,客户端本地负载均衡 |
| 适用场景 | 已使用 Istio 服务网格,需要高级流量管理 |
| 部署成本 | 中等:需要 Istio 控制平面 |
| 改造成本 | 中等:需要配置 xDS bootstrap |
| 性能损失 | 几乎为 0(无代理开销) |
| 优点 | 支持权重、灰度、子集路由,无 Sidecar 延迟 |
| 缺点 | 依赖 Istio,需要 grpc-agent 支持 |
方案三:Sidecar(Envoy)HTTP/2 七层负载均衡
3.3.1 核心原理
在 client Pod 内注入 Envoy Sidecar。
工作流程:
- 客户端建立 1 条 HTTP/2 连接到 Sidecar
- Envoy 把”单条 HTTP/2 连接”拆成逻辑 Stream
- 按配置的负载均衡算法(RR、Least Request、Maglev…)挑选 upstream Pod
- 对 client 而言仍是 1 条连接,但 Envoy 内部做了”单连接 → 多 Pod“的映射
Sidecar(Envoy)主要在“客户端侧”负责把单条 HTTP/2 连接的 stream 逐条分发到后端上游(即实现请求级的负载均衡)。因此,为了实现请求级分摊,只在 client Pod 注入 sidecar 即可达到目的,server Pod 是否注入 sidecar 并非必须。
3.3.2 配置示例
# 默认注入 Envoy Sidecar(不指定模板) |
3.3.3 特点分析
| 维度 | 说明 |
|---|---|
| 核心原理 | Envoy Sidecar 在七层做请求级负载均衡 |
| 适用场景 | 已使用服务网格,需要统一的流量管理策略 |
| 部署成本 | 中等:需要 Sidecar,增加资源消耗 |
| 改造成本 | 低:对客户端透明,无需代码修改 |
| 性能损失 | 5-10%(Sidecar 代理开销) |
| 优点 | 对客户端透明,支持多种 LB 算法,统一管理 |
| 缺点 | 需要 Sidecar,增加延迟和资源消耗 |
方案四:Istio Gateway(Ingress/Gateway-API)
3.4.1 核心原理
把流量先打到 Ingress Gateway。
工作流程:
- Gateway 本身就是 Envoy,但只经过一次代理
- Gateway 同样能把 HTTP/2 stream 打散到 N 个 Pod
client → Gateway(1 条 HTTP/2)→ 多 Pod
3.4.2 配置示例
# Gateway |
3.4.3 特点分析
| 维度 | 说明 |
|---|---|
| 核心原理 | Gateway 在入口做七层负载均衡 |
| 适用场景 | 需要统一入口管理,外部流量接入 |
| 部署成本 | 中等:需要 Gateway 组件 |
| 改造成本 | 低:只需配置 Gateway 和 VirtualService |
| 性能损失 | 3-5%(单跳代理) |
| 优点 | 集中式流量管理,支持 TLS 终止 |
| 缺点 | 需要 Gateway,只适用于入口流量 |
四、方案对比与选型
4.1 对比矩阵
| 方案 | 性能损失 | 改造成本 | 适用场景 | 高级功能 |
|---|---|---|---|---|
| Round-Robin | ~0% | 极低 | Kubernetes | ❌ |
| xDS Proxyless | ~0% | 中 | Istio 网格 | ✅ 权重/灰度 |
| Envoy Sidecar | 5-10% | 低 | 服务网格 | ✅ 多种算法 |
| Gateway | 3-5% | 低 | 入口流量 | ✅ TLS/路由 |
4.2 选型建议
简单 K8s 用客户端 Round-Robin;已落地 Istio 选 xDS Proxyless,享网格级路由与无 Sidecar 延迟;需统一治理则注入 Envoy Sidecar,策略对应用透明;外部入口统一用 Gateway,集中 TLS 与流量调度,单跳即达后端。
五、方案验证
5.1 演示环境
简单实现了一个完整的验证项目,包含:
- 服务端:3 个 Pod 副本,每个返回自己的 Pod 名称
- 客户端:支持 3 种模式(pick_first、round_robin、xds)
- Kubernetes 配置:完整的 Deployment、Service、Job 配置
5.2 测试结果
测试结果符合预期,相关测试结果如下:
# pick_first(default) 默认会负载不均 |
注意:gateway 测试时修改 client-deployment 容器的 /etc/hosts ,将 grpc-server.example.com 域名解析到 istio-ingressgateway svc 的 clusterIP 地址。
六、总结
gRPC HTTP/2 多路复用导致“长连接 = 单 Pod”的根因是:“连接级负载均衡” vs “请求级负载均衡”。
只要让“选 Pod”的决策点从『TCP 建连时』延后到『每个 Stream/请求时』,问题就解决了——以上方案只是实现“延后决策”的不同姿势。


