gRPC协议在K8S环境下的负载问题
参考:
- https://www.lixueduan.com/posts/grpc/13-loadbalance-on-k8s/
- https://www.cyub.vip/2021/11/09/k8s%E7%8E%AF%E5%A2%83%E4%B8%8B%E9%83%A8%E7%BD%B2grpc%E7%9A%84%E5%87%A0%E7%A7%8D%E6%96%B9%E6%A1%88/
概述
系统中多个服务间的调用用的是 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_first和round_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方案,需要做下面两个步骤:
- grpc client需要通过dns查询headless service,获取所有Pod的IP
- 获取所有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.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: xxxReceived RST_STREAM with code 0 (Call ended without gRPC status)Canceled xxx
服务端配置参数
- go
1 | grpc.NewServer(grpc.KeepaliveParams(keepalive.ServerParameters{ |
- js
1 | // If a client is idle for ? seconds, send a GOAWAY |
客户端参数
1 | // Ping the server every ? seconds to ensure the connection is still active【Recommendation :1 minute (60000 ms)】 |
kuberesolver 【go】
kuberesolver 和 headless service的不同之处
headless service会返回每个pod的IP地址,但无法自动检查它们是否被更改。当使用kuberesolver时,它会监视服务端点【endpoints】的变化。因此,当部署一个新版本的应用程序时,gRPC会自动连接到新的pod上。以使用优雅的终止策略实现零停机应用程序部署。
为了解决以上问题,很容易想到直接在 client 端调用 Kubernetes API 监测 Service 对应的 endpoints 变化,然后动态更新连接信息。
Github 上已经有这个思路的解决方案了:kuberesolver。
1 | // Import the module |
具体就是将 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中手动调整appProtocol为
grpc来指定其请求类型。 - 也可调整Service - port name的前缀,如
name: grpc-backend
HTTP routes will be applied to platform service ports named "http-" / "http2-" / "grpc-*"
实战
部署单元
- server
1 | # 这里依旧使用普通的Service类型 |
- client
1 | apiVersion: v1 |
DestinationRule
1 | apiVersion: networking.istio.io/v1beta1 |
VirtualService
1 | apiVersion: networking.istio.io/v1beta1 |
测试
测试请求成功与否:
一个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的替代方案,很多功能仍属于实验性,不推荐】
参考链接:
- Istio xDS协议简析
- gRPC - xDS在Istio下的应用配置【Istio官方Post】
- BootStrapConfig【github】 - tells the
gRPClibrary how to connect toistiodwith xDS【补充,GRPC_XDS_BOOTSTRAP用来声明file路径,GRPC_XDS_BOOTSTRAP_CONFIG用来声明内容】 - xDS在各语言版本下的功能支持描述【github】
- Istio - gRPC官方部署样例【github】
- grpc-js-xds【github】的bootstrap对象声明位置
- grpc-js-xds npmjs
- 使用 istio 了解 Kubernetes 中的 gRPC 负载均衡【补充,这篇内容有些过时】
- 关于gRPC中的负载均衡
- 准备使用无代理 gRPC 服务设置 Traffic Director【By using Google Cloud】
- BootStrap - 对于node节点下Regions and Zones的可选code列表
- BootStrap - node节点proto message【github】
- xDS v3支持【github】
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 | apiVersion: networking.k8s.io/v1 |