引言

在 Kubernetes (K8s) 环境中,gRPC 作为一种高性能的 RPC 框架被广泛应用。然而,许多开发者在实践中发现,标准的 K8s Service 似乎无法对 gRPC 服务进行有效的负载均衡,导致所有请求都涌向了单个 Pod。本文将深入探讨这个问题的根源,并详细介绍四种主流的解决方案。

参考资料:


问题的根源:L4 vs L7 负载均衡

传统的 K8s Service(如 ClusterIP)工作在 L4(传输层),它通过 kube-proxy 使用 IPtables 或 IPVS 来分发 TCP/UDP 流量。这种模式对于大多数基于 HTTP/1.1 的服务工作得很好,因为每个请求通常对应一个新的 TCP 连接。

然而,gRPC 构建于 HTTP/2 之上,其核心特性之一是多路复用(Multiplexing)。客户端与服务端之间会建立一个长期存在的 TCP 连接,所有的 gRPC 请求都在这个单一的连接上并发传输。

HTTP/1.1 vs HTTP/2

这就导致了 L4 负载均衡的失效:

  • kube-proxy 在建立连接时,将 TCP 连接路由到了后端的某一个 Pod。
  • 由于连接是长期的,后续所有通过此连接的 gRPC 请求都会被发送到同一个 Pod。
  • 即使后端有多个 Pod 实例,其他 Pod 也无法接收到流量,从而失去了负载均衡的效果。

为了解决这个问题,我们需要一个能够理解 HTTP/2 和 gRPC 协议的 L7(应用层)负载均衡器,它能够在请求级别上进行分发,而不是在连接级别。


方案一:客户端负载均衡 (Client-Side Load Balancing)

这是最直接的解决方案,将负载均衡的逻辑从基础设施层移至客户端。gRPC 客户端本身支持可插拔的名称解析 (Name Resolution) 和负载均衡策略 (Load Balancing Policy)。

核心思路:

  1. 服务发现: 客户端需要一种机制来发现后端服务的所有 Pod IP 地址。
  2. 负载均衡: 客户端在获取到所有 Pod IP 后,根据指定的策略(如 round_robin)将请求分发到不同的 Pod。

实现方式:Headless Service + DNS Resolver

这是最常见的客户端负载均衡实现。

  1. 创建 Headless Service:
    为 gRPC 服务创建一个 Headless Service(即设置 clusterIP: None)。K8s 不会为这个 Service 分配 ClusterIP,而是会为它创建一个 DNS A 记录,该记录直接解析到后端所有 Pod 的 IP 地址列表。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    apiVersion: v1
    kind: Service
    metadata:
    name: my-grpc-service
    spec:
    clusterIP: None # 关键配置
    selector:
    app: my-grpc-app
    ports:
    - protocol: TCP
    port: 8080
    targetPort: 8080
  2. 配置 gRPC 客户端:
    在客户端,我们需要:

    • 使用 dns 作为名称解析器。
    • 将负载均衡策略设置为 round_robin

    grpc-js 为例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    const client = new MyServiceClient(
    'dns:///my-grpc-service.default.svc.cluster.local:8080', // 使用 dns resolver
    grpc.credentials.createInsecure(),
    {
    'grpc.service_config': JSON.stringify({
    loadBalancingConfig: [{ round_robin: {} }] // 设置轮询策略
    })
    }
    );

存在的问题与优化

这种方案的一个主要挑战是如何处理 Pod 的动态变化

  • Pod 缩容: 当一个 Pod 被删除时,客户端的连接会断开,gRPC 的连接探活机制可以处理这种情况,客户端会自动从可用地址列表中移除该 Pod。
  • Pod 扩容: 当新的 Pod 加入时,客户端不会自动感知。因为 DNS 解析结果会被缓存,客户端不会主动重新解析 DNS 来获取新的 Pod IP。

解决方案:

  1. 设置连接最大存活时间 (max_connection_age):
    通过在服务端和客户端设置 max_connection_age_ms,可以强制 gRPC 连接在一段时间后关闭并重新建立。在重建连接的过程中,客户端会重新进行 DNS 解析,从而发现新加入的 Pod。

    • 服务端 (grpc-js):
      1
      2
      3
      4
      const server = new grpc.Server({
      'grpc.max_connection_age_ms': 300000, // e.g., 5 minutes
      'grpc.max_connection_age_grace_ms': 5000 // 宽限期
      });
    • 客户端 (grpc-js):
      1
      2
      3
      const client = new MyServiceClient(address, credentials, {
      'grpc.dns_min_time_between_resolutions_ms': 5000 // 最小DNS解析间隔
      });

    缺点: 这种方式比较粗暴,可能会中断正在进行的请求,并引入不必要的连接开销。

  2. 使用 kuberesolver (Go 特定):
    对于 Go 语言,社区提供了 kuberesolver 库。它实现了一个自定义的 gRPC 名称解析器,通过直接 Watch K8s API Server 的 Endpoints 资源来动态获取 Pod 列表。

    1
    2
    3
    4
    import "github.com/sercand/kuberesolver/v3"

    kuberesolver.RegisterInCluster()
    cc, err := grpc.Dial("kubernetes:///my-grpc-service.default:8080", opts...)

    这种方式响应更及时,也更优雅,但需要为客户端 Pod 配置访问 K8s API 的 RBAC 权限。

解决方案2:Istio【service mesh】

参考:https://istio.io/latest/zh/docs/ops/configuration/traffic-management/protocol-selection/

Istio 可以自动检测出 HTTP 和 HTTP/2 流量。如果未自动检测出协议,流量将会视为普通 TCP 流量。

手动调整请求类型

  • 可在Service中手动调整appProtocolgrpc来指定其请求类型。
  • 也可调整Service - port name的前缀,如name: grpc-backend

HTTP routes will be applied to platform service ports named "http-" / "http2-" / "grpc-*"

实战

部署单元

  • server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# 这里依旧使用普通的Service类型
# client端只要正常发起访问即可,不需额外配置【包括loadBalancingConfig/max_connection_age_ms等】,流量管理均由istio来解决
apiVersion: v1
kind: Service
metadata:
name: server
namespace: grpc
labels:
app: server
spec:
selector:
app: server
type: ClusterIP
ports:
- port: 37002
appProtocol: grpc
name: grpc
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: server-feature-test
namespace: grpc
labels:
app: server
version: feature-test
spec:
replicas: 1
selector:
matchLabels:
app: server
version: feature-test
template:
metadata:
annotations:
sidecar.istio.io/inject: 'true'
labels:
app: server
version: feature-test
spec:
containers:
- image: $REGISTRY_ADDRESS/${NODE_ENV}/${CI_PROJECT_NAME}:v${CI_PIPELINE_ID}
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: PROJECT_VERSION
value: feature-test
imagePullPolicy: IfNotPresent
livenessProbe:
tcpSocket:
port: 37002
readinessProbe:
tcpSocket:
port: 37002
name: server
ports:
- containerPort: 37002
protocol: TCP
imagePullSecrets:
- name: nexus3secret
dnsPolicy: ClusterFirst
restartPolicy: Always
  • client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
apiVersion: v1
kind: Service
metadata:
name: client
namespace: grpc
labels:
app: client
spec:
selector:
app: client
type: ClusterIP
ports:
- port: 37001
appProtocol: http
name: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: client-feature-test
namespace: grpc
labels:
app: client
version: feature-test
spec:
replicas: 1
selector:
matchLabels:
app: client
version: feature-test
template:
metadata:
annotations:
sidecar.istio.io/inject: 'true'
labels:
app: client
version: feature-test
spec:
containers:
- image: $REGISTRY_ADDRESS/${NODE_ENV}/${CI_PROJECT_NAME}:v${CI_PIPELINE_ID}
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: PROJECT_VERSION
value: feature-test
imagePullPolicy: IfNotPresent
livenessProbe:
tcpSocket:
port: 37001
readinessProbe:
tcpSocket:
port: 37001
name: client
ports:
- containerPort: 37001
protocol: TCP
imagePullSecrets:
- name: nexus3secret
dnsPolicy: ClusterFirst
restartPolicy: Always

DestinationRule

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
labels:
app: client
name: client
namespace: grpc
spec:
host: client.grpc.svc.cluster.local
trafficPolicy:
tls:
mode: ISTIO_MUTUAL
# 这里简单配置其最大重试即最大连接
connectionPool:
http:
http1MaxPendingRequests: 1
http2MaxRequests: 1
maxRequestsPerConnection: 1
tcp:
maxConnections: 1
subsets:
- labels:
version: feature-test
name: feature-test

VirtualService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
labels:
app: client
name: client
namespace: grpc
spec:
hosts:
- client.grpc.svc.cluster.local
http:
- route:
- destination:
host: client.grpc.svc.cluster.local
subset: feature-test

测试

测试请求成功与否:

一个client副本,一个server副本

  • 由client发起请求到server,响应200,请求成功

测试负载效果及扩容副本能否自动感知:

一个client副本,扩容一个server副本【此时为2 server副本】

  • 不间断由client发起请求到server,请求在这两个server副本间轮询
  • 待新副本启动成功后,新请求就会落到新副本上

测试死亡副本是否会接收请求:

一个client副本,缩容一个server副本【此时为1 server副本】

  • 不间断由client发起请求到server,client端无任何错误请求日志

测试重启server端后,client能否感知新副本

  • 不间断由client发起请求到server,client端无任何错误请求日志
  • 待新副本启动成功后,请求就会落到新副本上

测试限流/熔断功能:

xDS【此方案为Envoy的替代方案,很多功能仍属于实验性,不推荐】

参考链接:

注:截至目前该功能还是属于实验性的

gRPC的xDS是一种服务发现和负载均衡的协议,它允许gRPC客户端和服务器动态地发现网络中的服务资源。xDS协议基于Envoy xDS API,使得gRPC可以与支持xDS API的开源控制平面(如Istio Pilot)进行交互。xDS提供了更加灵活和先进的负载均衡策略配置功能,以及基于LRS(负载报告服务)的负载报告功能。

总的来说,xDS为gRPC提供了一个动态和可扩展的服务发现和负载均衡解决方案。

解决方案3:注册中心【nacos/consul】

nacos/consul等注册中心组件基本都支持常见的协议,包括grpc,但都需要和项目代码高度耦合

解决方案4:代理端走 ingress【ingress-nginx】

参考:https://kubernetes.github.io/ingress-nginx/examples/grpc/

  • 部署nginx ingress controller:略

nginx-ingress-controller 从 0.30.0 版本开始支持 grpc 的流量代理

在 nginx-ingress 代理模式下,grpc 的流量是负责均衡的。这种改动方式也比较简单,服务方只需要新增一个 ingress 代理 grpc 流量即可,客户端链接是无感的,不需要做任何改动

  • 缺点:需要ssl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: grpc-ingress
annotations:
# 注意这里:必须要配置以指明后端服务为gRPC服务
nginx.ingress.kubernetes.io/backend-protocol: "GRPC"
spec:
# 指定证书
tls:
- hosts:
- grpc.example.com
secretName: grpc-secret
rules:
# gRPC服务域名
- host: grpc.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
# gRPC服务
name: grpc-service
port:
number: 50051