gRPC协议在K8S环境下的负载问题

参考:

概述

系统中多个服务间的调用用的是 gRPC 进行通信,最初没考虑到负载均衡的问题,因为用的是 Kubernetes,想的是直接用 K8s 的 Service 不就可以实现负载均衡吗。

但是真正测试的时候才发现,所有流量都进入到了某一个 Pod,这时才意识到负载均衡可能出现了问题。

因为 gRPC 是基于 HTTP/2 之上的,而 HTTP/2 被设计为一个长期存在的 TCP 连接,所有请求都通过该连接进行多路复用。

这样虽然减少了管理连接的开销,但是在负载均衡上又引出了新的问题。

由于我们无法在连接层面进行均衡,为了做 gRPC 负载均衡,就需要从连接级均衡转向请求级均衡

换句话说,我们需要打开一个到每个目的地的 HTTP/2 连接,并平衡这些连接之间的请求。

这就意味着我们需要一个 7 层负载均衡,而 K8s 的 Service 核心使用的是 kube proxy,这是一个 4 层负载均衡,所以不能满足我们的要求。

补充

截至目前官方并没有提供任何在k8s环境下最为合理的dns解析方案:https://github.com/grpc/grpc/issues/12295

解决方案1:客户端负载均衡

参考链接:

这也是比较容易实现的方案,具体为:NameResolver + load balancing policy + Headless-Service

NameResolver

gRPC 中的默认 name-system 是DNS,同时在客户端以插件形式提供了自定义 name-system 的机制。

gRPC NameResolver 会根据 name-system 选择对应的解析器,用以解析用户提供的服务器名,最后返回具体地址列表(IP+端口号)。

例如:默认使用 DNS name-system,我们只需要提供服务器的域名即端口号,NameResolver 就会使用 DNS 解析出域名对应的IP列表并返回。

load balancing policy

官方gRPC文档中 中主要介绍了pick_firstround_robin以及xds(这是一种结合envoy的协议,如istio/linkerd,严格来说不能算是负载均衡策略)三种算法。

  • 【默认】pick_first:尝试连接到第一个地址,如果连接成功,则将其用于所有RPC,如果连接失败,则尝试下一个地址(并继续这样做,直到一个连接成功)。
  • round_robin:连接到它看到的所有地址,并依次向每个后端发送一个RPC。例如,第一个RPC将发送到backend-1,第二个RPC将发送到backend-2,第三个RPC将再次发送到backend-1。

结合grpc-js使用,仅需配置client option:

1
'grpc.service_config': JSON.stringify({loadBalancingConfig: [{round_robin: {}}]})

Headless-Service

K8S Service支持headless模式,创建Service时,设置clusterIp=none时,k8s将不再为Service分配clusterIp,即开启headless模式。Headless-Service会将对应的每个 Pod IP 以 A 记录的形式存储。通过dsn lookup访问Headless-Service时候,可以获取到所有Pod的IP信息。

如上图所示,基于k8s headless service方案,需要做下面两个步骤:

  1. grpc client需要通过dns查询headless service,获取所有Pod的IP
  2. 获取所有Pod IP后,grpc client需要实现客户端负载均衡,将请求均衡到所有Pod

对于步骤1,grpc原生支持dns解析,只需在服务名称前面加上dns:///【例如dns:///service-name:service-port】,grpc库内置dsn resover会解析该headless service的A记录,得到所有pod地址

存在的问题

相关issue:

当 Pod 扩缩容时 客户端可以感知到并更新连接吗?

  • Pod 缩容后,由于 gRPC 具有连接探活机制,会自动丢弃无效连接。
  • Pod 扩容后,没有感知机制,导致后续扩容的 Pod 无法被请求到。
  • gRPC服务端ReDeploy之后,client端也无法感知到【结合keepalive解决】。

通过调整grpc options来解决长链接问题

参考:

  • 关于 max_connection_xxx 的issue1

  • 关于 max_connection_xxx 的issue2

    grpc.max_connection_age_ms 选项通常与 grpc.max_connection_age_grace_ms 选项配对。如果设置了第二个选项,它将允许最大连接时间触发后可以继续运行一段时间,之后链接才会被取消。

    如果通常有运行时间超过 grpc.max_connection_age_grace_ms 设置的链接,那么就会遇到一些Cancelled xxx错误【需要结合此情况进行重试等操作】。

    如果通常不会让链接运行那么长时间,或者根本没有设置第二个选项,则链接到达grpc.max_connection_age_ms后会报错。

gRPC 连接默认永久存活,所以新链接在老链接未断开之前将永远无法被感知到。

但gRPC提供了一些相关参数,可以通过调整其时间长短来改善此类问题

并发测试发现,配置max_connection_xxx参数,可能会遇到以下几类错误【即使结合重试策略,也存在不小的错误率】

  • UNAVAILABLE: xxx
  • Received RST_STREAM with code 0 (Call ended without gRPC status)
  • Canceled xxx
服务端配置参数
  • go
1
2
3
4
grpc.NewServer(grpc.KeepaliveParams(keepalive.ServerParameters{
MaxConnectionAge: time.Minute,
......
}))
  • js
1
2
3
4
5
6
7
8
9
10
11
// If a client is idle for ? seconds, send a GOAWAY
'grpc.max_connection_idle_ms': 15_000,
// If any connection is alive for more than ? seconds, send a GOAWAY【Default:~(1 << 31)】
// when the connection is sent by goaway, The client will receive a status with code `CANCELLED` and the server handler's call object will emit either a 'cancelled' event or an 'end' event.
'grpc.max_connection_age_ms': 5_000,
// Allow ? seconds for pending RPCs to complete before forcibly closing connections【Default:~(1 << 31).通常和`max_connection_age_ms`成对出现,此参数取决于该服务每次链接的处理时间】
'grpc.max_connection_age_grace_ms': 3_000,
// Ping the client every ? seconds to ensure the connection is still active【Recommendation :1 minute (60000 ms)】
'grpc.keepalive_time_ms': 60_000,
// Wait ? second for the ping ack before assuming the connection is dead【Default:20 seconds (20000 ms)】
'grpc.keepalive_timeout_ms': 20_000
客户端参数
1
2
3
4
5
6
// Ping the server every ? seconds to ensure the connection is still active【Recommendation :1 minute (60000 ms)】
'grpc.keepalive_time_ms': 60_000,
// Wait ? second for the ping ack before assuming the connection is dead【Default:20 seconds (20000 ms)】
'grpc.keepalive_timeout_ms': 20_000,
// controls the minimum delay between successful DNS requests.【Default is 30,000 (30 seconds)】
'grpc.dns_min_time_between_resolutions_ms': 30_000

kuberesolver 【go】

kuberesolverheadless service的不同之处

headless service会返回每个pod的IP地址,但无法自动检查它们是否被更改。当使用kuberesolver时,它会监视服务端点【endpoints】的变化。因此,当部署一个新版本的应用程序时,gRPC会自动连接到新的pod上。以使用优雅的终止策略实现零停机应用程序部署。

为了解决以上问题,很容易想到直接在 client 端调用 Kubernetes API 监测 Service 对应的 endpoints 变化,然后动态更新连接信息。

Github 上已经有这个思路的解决方案了:kuberesolver

1
2
3
4
5
6
7
// Import the module
import "github.com/sercand/kuberesolver/v3"

// Register kuberesolver to grpc before calling grpc.Dial
kuberesolver.RegisterInCluster()
// if schema is 'kubernetes' then grpc will use kuberesolver to resolve addresses
cc, err := grpc.Dial("kubernetes:///service.namespace:portname", opts...)

具体就是将 DNSresolver 替换成了自定义的 kuberesolver。

而且因为 kuberesolver 是直接调用 Kubernetes API 获取 endpoint 所以不需要创建 Headless Service 了,创建普通 Service 也可以。

注:同时如果 Kubernetes 集群中使用了 RBAC 授权的话需要给 client 所在Pod赋予 endpoint 资源的 get 和 watch 权限。

解决方案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