September 22, 2020

Kubernetes中gRPC Load Balancing分析和解决

在k8s集群中部署gRPC服务并使用k8s中的Service来对外暴露服务,这是比较常见的用法,但是这种方式却会导致gRPC服务负载不均衡,进而影响整个系统的负载能力甚至‘雪崩’。

背景

第一次,线上遇到大量接口RT超过10s触发了系统告警,运维反馈k8s集群无异常,负载无明显上升。将报警接口相关的服务重启一番后发现并无改善。但是开发人员使用链路追踪系统发现,比较慢的请求总是某个gRPC服务中的几个POD导致,由其他POD处理的请求并不会出现超时告警。

第二次,同样遇到接口RT超过阈值触发告警,从k8s中查到某个gRPC服务(关键服务)重启次数异常,查看重启原因时发现是OOM KilledOOM killed并不是负载不均衡直接导致的,但是也有一定的关系,这个后面再说。前两次由于监控不够完善(于我而言,运维的很多面板都没有权限,没办法排查)。期间利用pprof分析了该服务内存泄漏点,并修复上线观察。经过第二次问题并解决之后,线上超时告警恢复正常水平,但是该 deployment 下的几个POD占用资源(Mem / CPU / Network-IO),差距甚大。

第二张图是运维第一次发现该服务OOM killed 之后调整了内存上限从 512MB => 1G,然而只是让它死得慢一点而已。 从上面两张图能够石锤的是该服务一定存在内存泄漏。Go项目内存占用的分析,我总结了如下的排查步骤:

1. 代码泄漏(pprof)(可能原因 goroutine泄漏;闭包)
2. Go Runtime + Linux 内核(RSS虚高导致OOM)https://github.com/golang/go/issues/23687
3. 采集指标不正常(container_memory_working_set_bytes)

2,3 是基于第1点能基本排除代码问题的后续步骤。

解决和排查手段:

1. pprof 通过heap + goroutine 是否异常,来定位泄漏点
运行`go tool pprof`命令时加上--nodefration=0.05参数,表示如果调用的子函数使用的CPU、memory不超过 5%,就忽略它。

2. 确认go版本和内核版本,确认是否开启了MADV_FREE,导致RSS下降不及时(1.12+ 和 linux内核版本大于 4.5)
3.  RSS + Cache 内存检查

> Cache 过大的原因 https://www.cnblogs.com/zh94/p/11922714.html 
// IO密集:手动释放或者定期重启

查看服务器内存使用情况: `free -g`
查看进程内存情况:      `pidstat -rI -p 13744`
查看进程打开的文件:    `lsof -p 13744`
查看容器内的PID:      `docker inspect --format "{{ .State.Pid}}" 6e7efbb80a9d`
查看进程树,找到目标:   `pstree -p 13744`

参考:https://eddycjy.com/posts/why-container-memory-exceed/

通过上述步骤,我发现了该POD被OOM killed还有另一个元凶就是,日志文件占用。这里就不过多的详述了,搜索方向是 “一个运行中程序在内存中如何组织 + Cache内存是由哪些部分构成的”。这部分要达到的目标是:一个程序运行起来它为什么占用了这么些内存,而不是更多或者更少。

问题到此并没有结束,OOM Killed 只是集群中发现的最显眼的问题。这里还有几个疑点:

  1. 服务大量超时,而运维资源却没有异常,整体的请求量没有大幅度上涨?
  2. 为什么重启不能让系统恢复?
  3. OOM Killed是什么原因导致的?(go项目 / 内存限制 1GB / 近底层数据服务,主要和DB打交道)除内存泄漏还有什么会导致服务内存占用上升?
  4. 同一个 DeploymentPods 在一个 Service 下对外提供服务,为什么占用的资源并不是近乎相等的?

猜想和验证

上面说了那么多和gRPC 和 k8s Service 有半毛钱关系吗?有半毛钱。

其实发现gRPC负载不均衡很简单,从上面的疑或者看一看k8s资源监控面板,就能看的出来同一个service中的不同POD负载不一样,因为有负载才有压力,有负载才需要消耗资源。当然因为个别POD负载极大导致内存泄漏问题突出,日志文件上升快OOM killed频繁

知道上述的现象之后,那么就可以猜想了,为什么负载不均衡呢?现列举以下的关键点:

  • 该服务是一个基于gRPC-go编写的服务端。
  • gRPC使用的应用层协议是http2
  • http2最大的特点是长链接。
  • 该服务是基于了k8s Service来对内部应用提供服务。
  • k8s Service 自带L4负载均衡(轮询)。
  • k8s 的负载均衡是基于 iptables 实现的。
  • iptables 是通过修改 netfilter 规则来实现的(这里可以简单理解为:k8s的负载均衡是无法感知服务的负载压力的)。

说了上面这些,再来理一理整个系统的请求处理流程。

SLB => k8s 集群 (nginx) ==> API服务 (k8s Service) ... gRPC-Client ==
                                                                 ||
   gRPC Server <== gRPC-Client ... gRPC-Server (k8s Service)  <==||

到这里,结合最开始的背景中提到的“负载不均衡”,基本上可以得出结论:“一个请求确定了由某个API服务处理之后,后续调用的POD几乎是确定的”。那么这种确定性是从哪里来的,明明k8s有自己的L4负载均衡?

不会吧,不会吧,你竟然才知道L4层负载均衡对长连接没有意义。

因为L4负载均衡是让客户端和服务端直接连接,而不是通过自己转发。那连接上了之后又没有重新连接,那么自然该客户端的所有请求都会交给连接上的服务端处理,而不是其他的服务端了。因此,系统中的大多数客户端在启动时,如果被k8s的负载均衡分配到了几乎同一个服务端POD上,那么这个服务端POD自然会处理大部分请求,相比其他服务它的负载自然会高出很多。这也解释了 14 疑问。

对于疑问 2 “为什么重启也不行呢?” 这个问题等价于 “为什么k8s会把客户端分配得不均匀呢?”

这里说的重启不行是指盲目重启,没有策略和优先级。大多数人理解的重启,多半会是只重启有问题的服务。事实上也是这样操作的。既然知道是k8s让服务端POD负载不均衡,进而导致接口响应慢,那么重启的目的是让“请求尽可能的均匀一些”。那么首先重启服务端,服务端重启完成后再重启依赖该服务的客户端,依赖的最底端依次往上重启。

k8s L4轮询负载均衡存在的问题是:服务没有完全部署或重启完成(滚动更新),这时候客户端通过service发现的只有其中的部分POD而不是全部,当然会让后加入的服务分配到较少的客户端连接。

解决办法

知道了问题在哪儿,那么解决思路就很明确了。正如标题中说的一样 “gRPC LoadBalancing”,这里需要找到一种能够将gRPC连接负载均衡的手段。这里提出以下三种解决办法:

序号 方案 描述 优缺点
1 集中LB 服务端LB 客户端无感知;存在单点问题
2 客户端LB gRPC resolver + LB 客户端自定义LB策略;需要注册中心或者对接k8s的API以获取服务列表;不好升级
3 Service Mesh linkerd之类的组件 服务端客户端无感知;增加延迟

这里推荐的只有两种(视体量的技术栈而定): 客户端LB 和 Service Mesh。

Service Mesh自不用多说,因为改造简单无代码入侵,维护成本的话就见人见智了。 客户端LB最大的缺点就是“不好升级,代码入侵”,但从gRPC这一套方案出发,客户端服务发现和LB都已经被集成到了gRPC(Resolver + LB Policy)里,只需要提供自己的方案并注册到gRPC就行了,相比其他两种更加可控。

这里使用了linkerd来尝试解决问题,下图是使用了linkerd之后的监控:

因为还处于测试环境观察阶段,因此数据指标不是特别高,也没有那么明显。

从上图可以发现,两个POD基本上做到的波峰波谷同步,而不是我已原地爆炸,你还快活逍遥。

客户端LB我也在尝试,有两个方向:

  1. 客户端使用k8s API获取服务列表甚至负载,以实现 服务发现 + 基于负载的LB策略。
  2. 使用额外的服务注册和发现中心,记录服务负载指标,客户端只需要实现LB策略即可。

水平有限,如有错误,欢迎勘误指正🙏

参考文献