背景 #
第一次,线上遇到大量接口RT超过10s触发了系统告警,运维反馈k8s集群无异常,负载无明显上升。将报警接口相关的服务重启一番后发现并无改善。但是开发人员使用链路追踪系统发现,比较慢的请求总是某个gRPC服务中的几个POD导致,由其他POD处理的请求并不会出现超时告警。
第二次,同样遇到接口RT超过阈值触发告警,从k8s中查到某个gRPC服务(关键服务)重启次数异常,查看重启原因时发现是OOM Killed
,OOM 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
只是集群中发现的最显眼的问题。这里还有几个疑点:
- 服务大量超时,而运维资源却没有异常,整体的请求量没有大幅度上涨?
- 为什么重启不能让系统恢复?
OOM Killed
是什么原因导致的?(go项目 / 内存限制 1GB / 近底层数据服务,主要和DB打交道)除内存泄漏还有什么会导致服务内存占用上升?- 同一个
Deployment
的Pods
在一个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自然会处理大部分请求,相比其他服务它的负载自然会高出很多。这也解释了 1 和 4 疑问。
对于疑问 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我也在尝试,有两个方向:
- 客户端使用
k8s API
获取服务列表甚至负载,以实现 服务发现 + 基于负载的LB策略。 - 使用额外的服务注册和发现中心,记录服务负载指标,客户端只需要实现LB策略即可。
水平有限,如有错误,欢迎勘误指正🙏