写点什么

频繁创建基于 Etcd 实现的分布式锁会有什么问题?

用户头像
BUG侦探
关注
发布于: 4 小时前
频繁创建基于Etcd实现的分布式锁会有什么问题?

今天的主角是 CPU 压缩组件,它是 Go 语言开发的。

CPU 压缩组件是做什么的?

业务申请实例时所要的 CPU 配额往往比实际使用要大很多,使得 K8s 集群因为 CPU 分配率饱和无法再创建新的实例,最终导致集群的资源利用率处于非常低的水平。为了让集群的 CPU 分配率和 CPU 使用峰值更接近,更进一步提升集群整体的利用率,我们引入了 CPU 压缩机制。

CPU 压缩组件会根据业务晚高峰的实际 CPU 使用动态调整实例的 request,解决业务申请值和实际使用有偏差的问题。

问题描述

突然收到 CPU 压缩组件所在机器的内存报警。

登录机器上查看后,发现上面的两个进程的 RES 内存占用比较大,主要是 CPU 压缩进程以及 Etcd server 进程。

机器的内存监控大盘:


备注:

  • 其实内存报警这几天有出现了 3 次了,评估具体影响不大,所以没有及时介入处理,前两次都是通过重启进程缓解

排查记录


优先解决问题

先进行初步的分析


进程对内存的销毁可能有哪些?

CPU 压缩进程仅缓存了 prometheus 数据,这部分对内存的使用应该很小。

CPU 压缩进程对 Etcd 的写入操作只有在发现实例所属的服务没有 prometheus 数据时才会触发,理论上这部分的 QPS 应该很低。

对 Etcd 中的数据 dump 处理,发现大量的分布式锁 key 存在,如下图所示:



key 的量总共有 2w 个左右、key 的存储量并不大,不应该是 Etcd 对内存占用 30+G 的原因,所以这里可以确定 Etcd 内存量增加并非是存储的数据量大引起的,怀疑是大量分布式锁 key 存在时,服务端需要维护很多相关对象导致内存使用增加。


确认为什么会产生这么多分布式锁 key?

产生分布式锁争抢的逻辑描述如下:当 Pod 压缩进程遇到 prometheus 里没有服务数据时,会主动把该服务添加到 Etcd 中,这样异步拉取 Prometheus 数据时就会拉取该服务的数据了,由于往 Etcd 中写入时,为了保证操作的事务性,引入了分布式锁,避免多个 Pod 压缩节点之间有写入相互覆盖的情况。

因为是周五,所以考虑的是优先解决问题,避免周末再发生,所以决定去掉异步上报的逻辑(因为 Etcd 中已经基本存储了全量的服务列表、新增的服务即使不开压缩也不会有太大影响),提了紧急上线单,上线后观察,发现问题解决,如下图所示:



也基本印证是大量分布式锁导致客户端(Pod 压缩进程)和服务端(Etcd)进程内存使用暴涨。


下面继续排查确定下面几个问题:

  • 为什么大量分布式锁会引起内存的增加?

  • 正常实例创建不应该这么频繁:因为只有实例创建时,才会有请求到 Pod 压缩进程,这个请求量理论上应该非常低。


尝试在测试环境验证和复现

超时设置和线上保持一致,demo 程序中分批往 Etcd 进行分布式锁的创建和销毁,每批开 5000 个 goroutine 并发抢锁。



只去掉分布锁的逻辑,其它一致,观察到内存也有上升(内存占用 4g),但相比有分布式锁的情况(9g),内存的使用明显少了很多,另外 etcd 服务端的内存增长不明显(仅增长了 0.7g,相比有锁的情况 2.5g,也少了很多)



备注:

  • 测试过程中保持 GODEBUG=madvdontneed=1:这个参数主要控制 Go 应用的 res 内存回收方式,如果设置为 1,表示只要 Go 内存不用了,就直归还给 OS,如果是 0,只有在 OS 的内存有压力时才会归还。测试时有发现,测试程序的内存并没有持续增加,在锁释放后,会缓慢回收,只是回收速度比较慢。


从上面的测试程序得出的结论:

  1. 产生大量分布式锁确实会造成客户端和服务端的内存使用明显增加。

  2. 没有锁时,只是正常访问 Etcd 也会造成内存使用量增加,但不明显。

同时猜测:

  1. 线上并没有发生内存泄露,只是新锁产生的内存增长比释放要快,导致在不断增长的现象。

为了查看更多现场数据,计划在线上把锁的逻辑加回去。


线上复现问题

除了把分布锁的逻辑加回去,还添加了 pprof 方便查看进程状态

进程刚启动时内存的消耗很小,但进程的 res 在逐步增加,问题复现。



查看 pprof 的数据,发现进程的 goroutine 在不断增加



经过 5 个小时左右,内存的消耗增长到 13G



对应的 goroutine 数量达到了 30 万,锁 key 的量是 20504 个。



对应的机器内存监控如下图所示:



为什么会有这么多 goroutine?

应用层新建一个 goroutine 往 Etcd 执行读写操作前需要先抢占分布式锁,Etcd 客户端库和服务端底层会创建很多相关的 goroutine,比如 lease 续租、watch 主 key 的变化等等。以下是不同类型 goroutine 的堆栈数据:

比如有部分是 lease 引入的



有部分是 watch 引入的



有一部分是 context 引入的



也有一部分是 stream 引入的等等



简单总结:因为不断新增分布锁请求同时又抢不到锁,导致大量 goroutine 新增并阻塞在 select 调用上,进而导致 goroutine 数量不断增加,Pod 压缩进程的内存也不断增加。


为什么会有这么多实例创建的请求触发抢分布式锁?

从 Pod 压缩进程的日志看,很多请求是不正常的,没有 online 设置,说明是非 deployment 类型。



详细确认某个 Pod 的信息,发现基本都是来源于线上某台机器上的 daemonset 实例不断创建引入的分布式锁。【容器系统协助确认 发现该机器是 5.11 号异常后触发宕机隔离 宕机生成的 core 文件 占用/var 空间,/var 空间满了 触发了本机 pod 的驱逐】

联系容器系统处理,停掉这几个实例的创建,操作后,goroutine 的数量开始下降。



内存使用逐渐下降



简单总结:线上某台机器异常后,上面的几个 daemonset 实例由于/var 空间满了触发了不断重建的操作,这些实例不断重建(启动->挂掉->启动->挂掉)导致 Pod 压缩进程不断地处理新请求。

5why 分析


问题已经出现了 3 次,为什么没有第一时间介入排查处理?

问题最早是 5.12 号发现的,没有第一时间介入,是因为很多大集群都切换到新版本了 剩余的两个线上集群的 CPU 分配率才 30%多 即使不可用,影响也不大,所以给予的优先级不高,14 号才介入修复。


旧版 Pod 压缩在遇到没有 prometheus 数据时就抢锁并处理,这种处理方式合适吗?

这种处理方式虽然可以解决新服务能自动开启动态压缩的功能,在早期逐步灰度的情况下合适,但长远看其实是欠妥的,正确的做法应该是,定期从发布系统拉取全量的服务列表、扫描 prometheus 数据,如果存在,就正常开启压缩,如果不存在,就不开启压缩即可,完全没有必要引入分布式锁并且用 etcd 存储这个列表,新版本鹰眼动态压缩就是这么做的。


应用层新建 1 个分布锁请求,Etcd 客户端底层会增加多少 goroutine?

跑了一个测试程序,一主一从,底层 Etcd 默认创建了 27 个 goroutine,对应到一把分布式锁,Etcd 底层默认会创建 10 几个 goroutine 协助处理。抽样看了下 goroutine 的堆栈,主要包括 waitDelete、keepAliveCtxCloser、Lease、session、stream、context 等相关。


Go 应用的 RES 内存有什么特征?

RES 内存值和环境变量 madvdontneed 的关系比较大,而且 Go 在不同版本之间的默认取值也有不同。

Go 在 1.11 版本之前,madvdontneed 默认是 1,表示 GC 释放的内存默认会直接归还给 OS,这样 Go 应用的 RES 值就能实时反映出程序当前所使用的内存;Go1.12~1.15 并且内核版本高于 4.5 的情况,madvdontneed 默认是 0,表示 GC 释放的内存并不会直接归还给 OS,而是等待 OS 内存量有压力时,才会归还,这种情况,Go 应用进程的 RES 内存值会基本保持不变,且远远高于进程当前实际使用的量;由于这个特性在 Go 应用容器化时很容易触发容器 OOM,并且社区的负面反馈比较多,Go1.16 的版本又重新回到了 1.11 之前的默认取值。另外,Go 程序也可以通过 GODEBUG 参数直接指定。

解决方法

该版本的 Pod 压缩功能在核心架构组件上已经在上提供了新版本,生产环境会全部迁移到核心架构组件的版本上,对于旧版本计划去掉 Etcd 的分布式锁逻辑,后续规划是废弃该版本。

简单总结

  • 原因描述:由于 5.11 号线上的某个机器异常,/var 空间满触发了上面所有的 daemonset 实例不断地驱逐重建,触发了旧版本 Pod 压缩进程不断创建分布式锁,由于锁的创建比释放更快并且 Etcd 客户端底层针对每把锁都会默认创建 10 几个 goroutine,造成 goroutine 数量不断增加、内存不断增长的现象。

  • rootcause:旧版本 Pod 压缩组件对 Etcd 分布式锁的使用不恰当。

  • 修复安排:旧版本 Pod 压缩组件已经去掉了分布式锁的依赖,并且让容器运维处理了异常机器。






发布于: 4 小时前阅读数: 5
用户头像

BUG侦探

关注

还未添加个人签名 2021.06.08 加入

专注于发掘程序员/工程师的有趣灵魂,对工作中的思路与总结进行闪光播报。

评论

发布
暂无评论
频繁创建基于Etcd实现的分布式锁会有什么问题?