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来解决长链接问题

参考:

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

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

并发测试发现,如果无法合理配置max_connection_xxx参数,可能会遇到以下几类错误【因服务端链接释放不合理导致】

  • UNAVAILABLE: upstream connect error or disconnect/reset before headers. reset reason: connection termination
  • 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-*"

xDS

参考链接:

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

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

如果不使用xDS,可能会遇到以下后果:

  • 负载均衡策略的局限性:将无法利用xDS提供的灵活和先进的负载均衡策略,而是依赖于更简单或静态的负载均衡机制。
  • 服务发现的复杂性:在没有xDS的情况下,客户端需要自行处理服务发现和服务器列表的更新,这可能会增加客户端的复杂性和维护难度。
  • 可扩展性问题:xDS支持动态服务发现和负载均衡,不使用xDS可能会限制系统的可扩展性和灵活性,特别是在大规模和动态变化的环境中。

总的来说,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 流量即可,客户端链接是无感的,不需要做任何改动

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