Skip to content

ext3 + memcg 的 OOM 死锁:采集器差点背了锅 ​

2965 字 · 约 7 分钟

前不久,客户 A 的 Redis 服务突然访问不了,大面积业务都受了影响。排查下来,部署 Redis 的两台服务器都 hang 死了。客户的技术专家分析后发现,hang 死的那个时间点上,我们的采集器进程 CPU 占用高达 300%,而且多次 OOM。他们认为是我们的采集器频繁 OOM,把机器搞 hang 死了。

无独有偶,内部同步信息的时候我们发现,客户 B 那边半年内已经陆陆续续出现过 5 次类似的 hang 死。客户 B 的内核专家做了很深入的分析,结论也一样:是我们的采集器引发的服务器 hang 死。

两家客户,各自独立分析,同一个结论。这篇记录的是我们怎么把这个结论推翻的。客户名称和进程名都脱敏了,内核函数名、标志位保持原样。

客户的分析 ​

不得不说,客户 B 的分析质量很高。

先看 atop 的内存曲线:

11:53:22    free: 507.4M     cache: 9.4G
11:53:32    free: 9.9G       cache: 726.0M

十秒钟里 free 从 507M 涨到 9.9G,file cache 跟着释放,而且基本没有 dirty page,说明回收的不是脏页,就是直接回收 clean page。就在 11:53:32 这次回收的时候,采集子进程 D 住了。

vmcore 显示,这个进程当时正在处理一个信号,随后调用 __getblk() 等待可用内存。拿不到内存,就不断调用 free_more_memory() 去回收,最后卡在 shrink_inactive_list。dmesg 里采集子进程的 OOM 打印很多,被 kill 的是它,内存堆栈里卡住的也是它。用 crash 一看,这些子进程同属一个 cgroup,它和父进程都处在睡眠状态等唤醒。最后还有一条对照:没出现 hang 的机器,都没有 OOM 报错。

客户 B 的结论是:采集子进程写 IO 时进了 __getblk() 等内存,只有拿到内存,才能完成 ext3_journal_stop() 把 t_updates 减下来,对应的 journal 内核线程才能被唤醒。可这个 pod 的内存已经到了上限,又没有可回收的内存,于是触发了 memory cgroup 的 OOM;而触发 OOM 的进程因为申请不到内存,从 __getblk() 里退不出来,走不到 do_signal 去处理 kill 信号,内存也就释放不了,最终形成了死环。

这段描述本身是对的,机制链几乎没毛病。

先别急着认 ​

拿到报告,我们组织了一个专项小组来分析。

从客户的结论里,其实能读出三层信息:服务器 hang 死是 OOM 触发的;OOM 是我们的采集器触发的;而 OOM 之后没有按正常流程结束,是内核的行为。客户的结论走到第二层就停了。

可一个进程 OOM,正常就该被杀掉、释放内存、机器恢复,这是操作系统每天都在处理的常规事件。OOM 之后把整机锁死,不是一个应用程序应该有能力做到的事。所以不能简单定性为采集器导致服务器 hang 死,准确地说,是 OOM 导致的服务器 hang 死,内核的 OOM 逻辑里藏着某种 BUG,我们的采集器频繁 OOM,刚好把它触发了。

这不是在玩文字游戏,它决定了往哪修。如果是采集器的问题,改采集器就完了;如果是内核的问题,所有在这个内核上用 cgroup 给多个进程做内存限额的程序都有风险。

而且两家客户的操作系统恰好是同一个 RedHat 系的衍生发行版。我们的第一反应是:会不会是发行版厂商自己改内核的时候引入了问题?从过往经验看,我们的采集器已经在上百个项目、数十万台机器上跑了六七年,从来没出过类似的事故。

网上搜到一篇,但文件系统对不上 ​

在网上查资料,发现有人遇到过类似的问题,记录在腾讯云开发者社区的这篇文章里,复现过程和结论都有,比我们手上的信息更详细、更专业。结论是:这是 RedHat 在内核 3.10.0-862.el7 引入的 BUG,之后的 el7 版本都没修,和 ext4 文件系统有关。多个进程用同一个 cgroup,触发 OOM 时,被内核杀死的进程处理不了信号,另一个进程又申请不到内存,就死锁了。

那我们是不是同一个问题?对照客户给的截图,内核版本是 3.10,对得上;文件系统是 ext3,对不上,人家说的是 ext4。

其实仔细看那篇文章的分析,这个问题跟 ext3 还是 ext4 关系不大,关键在 jbd。ext4 用的是 jbd2,ext3 用的是 jbd,不管 jbd 还是 jbd2,内部实现逻辑大抵是一致的。这个死锁就出在 jbd 的事务机制上。下面按 ext3 来分析,ext4 同理。

jbd 的事务 ​

每个 ext3 文件系统对应一个 journal 实例,每次对文件系统的更新叫一个 handle。多个线程可能同时操作同一个 journal,jbd 为了保证一致性,引入了事务的概念,时间上连续的多个 handle 合成一个 transaction。

一个 transaction 分 commit 和 checkpoint 两个阶段。commit 阶段,用户线程先获取 handle,拿到当前正在运行的 transaction(没有就新建一个),把它的计数 t_updates 加一,表示这个事务正被多少个线程用着;然后把修改的数据挂到 transaction 的 buffer 链表上,再把 t_updates 减一。内核会定期唤醒 journal 线程,把 running transaction 变成 committing 状态,然后等 t_updates 归零,再把 buffer 链表合并写进 journal 区域,加入 checkpoint 列表。checkpoint 阶段,再从列表里取出 buffer 数据写到物理磁盘上。

我们仔细看了调用栈,发现调用了 ext3_journal_start_sb(),却没有调用 ext3_journal_stop()。翻源码,t_updates 在 ext3_journal_start() 里递增,在 ext3_journal_stop() 里递减。也就是说,t_updates 加上去了,就没减下来。journal 线程即使被唤醒了,也等不到 t_updates 归零,这个 transaction 就一直卡在 committing。

死锁是怎么形成的 ​

那么问题来了,t_updates 为什么一直不归零?

ext3_journal_stop() 没被调用,说明这个 handle 还被某个进程攥在手里,这个信息记在进程 task_struct 的 journal_info 里。顺着找过去,攥着它的就是那个被 D 住的采集子进程。问题出在调用栈的 ext3_get_blocks_handle() 这一帧:__getblk 申请不到内存,就一直调 free_more_memory() 释放一点再试,一直拿不到,就一直卡在这。

那为什么 __getblk 拿不到内存呢?这就不得不说一下 cgroup 了。

我们采集器的所有子进程在同一个 cgroup 里。某些子进程内存占得比较大,触发了 OOM,OOM Killer 选一个杀掉。很不巧,被选中的这个进程(叫它进程 1)当时正在 jbd_log_wait_commit() 里,等另一个进程(进程 2)的 transaction 完成,处于不可中断状态,kill 信号递不进去,内存也就释放不了。

而进程 2 要等 __getblk 拿到内存才能完成 transaction。可进程 1 杀不掉,内存就释放不出来,__getblk 永远拿不到内存,transaction 永远完成不了,进程 1 也就永远等不到。两个进程互相等,死锁了。

死锁闭环:进程 1 等进程 2 的 transaction 完成,进程 2 等进程 1 被杀释放内存,journal 线程跟着陪葬死锁闭环:进程 1 等进程 2 的 transaction 完成,进程 2 等进程 1 被杀释放内存,journal 线程跟着陪葬

这里同一个 cgroup 是闭环成立的前提。如果两个进程不在一个 cgroup 里,进程 2 的内存申请就不受进程 1 那边限额的约束,这个环就断了。

__GFP_NOFAIL 丢在了哪 ​

从上面的分析能看出来,这是 ext3 或 ext4 上,多个进程同属一个 cgroup、频繁触发 OOM 时会撞上的概率性问题,可以定性为内核 BUG。那这个 BUG 是怎么来的?

其实要打破这个死锁很简单,只要 __getblk 能申请到内存,把 transaction 完成,handle 放出来不就完了?也就是说,只要允许 __getblk 这次分配突破 cgroup 的限额,问题就不攻自破了。

事实上,内核也确实是这么设计的。2.6 的时候就有了 __GFP_NOFAIL 这个标志,gfp_mask 带上它,内核会尽量满足这次分配,不再理会 cgroup 的内存限制。翻 __getblk 的调用路径,在 grow_dev_page 之前,这个标志一直都带着。

可从现象看,它显然没生效。这个标志是在哪丢的?

网上那篇文章说 3.10.0-862.el7 是引入问题的版本。我们对照 3.10.0-693.el7 和 3.10.0-862.el7 在这条路径上的代码,发现后者在 __add_to_page_cache_locked() 里多了一句 mapping_gfp_constraint()。它会把传进来的 gfp_mask 和 inode->i_mapping 支持的 flags 做按位与。根据 vmcore 分析,inode->i_mapping 恰好不支持 __GFP_NOFAIL,上层一路传下来的标志,到这里就被与没了。

Linux 主线的代码历史上,从来没有在这个位置加过这行代码。我们倾向于认为,是 RedHat 的维护者在移植上游 patch 时出了错:mapping_gfp_constraint() 本来应该针对部分操作,作为 add_to_page_cache_lru() 的参数传进去,而不是在随后的 __add_to_page_cache_locked() 里无条件执行。

怎么处理 ​

这个问题要四个条件同时满足才会触发:

  1. 内核是 3.10.0-862.el7 及之后的版本(el7 全系,4.14 仍受影响,5.4 以后修了)
  2. RedHat 系的发行版,RedHat、CentOS 和各家衍生版都算
  3. 两个或以上的进程用同一个 cgroup 做内存限额,并且有机会触发 OOM
  4. 文件系统是 ext3 或 ext4

这也解释了为什么同一个采集器跑了六七年、几十万台机器都没事:前两条是客户的环境,第四条是分区规划,只有第三条是我们自己的。

应急的办法就是破坏其中任意一条。按实际用量适当调大采集进程的内存限额,少触发 OOM,不 OOM 死锁就不成立;额外挂一个 xfs 文件系统,把采集器单独部署过去,不走 jbd,死锁也不成立;或者把内核升到 5.4 以上,这是根治,但代价最大,得排窗口期。

长期的改法有两条。一是采集器启动时自己判断,内核在 3.10 到 5.4 之间、RedHat 系、文件系统是 ext3 或 ext4,就强制关掉 cgroup 内存限额。二是换一套限额机制,自己监控内存,子进程内存超过限额一定阈值,就自动重启整个采集进程。cgroup 的 OOM 是内核的行为,它在什么内核上以什么方式收场,我们控制不了;自己重启自己是可控的,精度差一点,但行为可预测。

这个结论最后得到了客户和云厂商内核团队的认可,推动了内核热补丁。

采集器触发了 OOM,这一点我们从头到尾没否认过,内存限额本来就是我们自己设的。可一个进程 OOM 了,正常就该被杀掉、内存释放、机器恢复,把整机锁死不是应用程序该有能力做到的事。那天要是只看到第二层就认了,这个锅我们大概就背上了,而那个内核 BUG 还会在别人的机器上接着出现。


相关:XFS soft lockup:限了 CPU 配额,整机还是卡死 22 秒

内容来自一线生产环境,客户信息均已脱敏