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 也就永远等不到。两个进程互相等,死锁了。
这里同一个 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() 里无条件执行。
怎么处理
这个问题要四个条件同时满足才会触发:
- 内核是
3.10.0-862.el7及之后的版本(el7 全系,4.14 仍受影响,5.4 以后修了) - RedHat 系的发行版,RedHat、CentOS 和各家衍生版都算
- 两个或以上的进程用同一个 cgroup 做内存限额,并且有机会触发 OOM
- 文件系统是 ext3 或 ext4
这也解释了为什么同一个采集器跑了六七年、几十万台机器都没事:前两条是客户的环境,第四条是分区规划,只有第三条是我们自己的。
应急的办法就是破坏其中任意一条。按实际用量适当调大采集进程的内存限额,少触发 OOM,不 OOM 死锁就不成立;额外挂一个 xfs 文件系统,把采集器单独部署过去,不走 jbd,死锁也不成立;或者把内核升到 5.4 以上,这是根治,但代价最大,得排窗口期。
长期的改法有两条。一是采集器启动时自己判断,内核在 3.10 到 5.4 之间、RedHat 系、文件系统是 ext3 或 ext4,就强制关掉 cgroup 内存限额。二是换一套限额机制,自己监控内存,子进程内存超过限额一定阈值,就自动重启整个采集进程。cgroup 的 OOM 是内核的行为,它在什么内核上以什么方式收场,我们控制不了;自己重启自己是可控的,精度差一点,但行为可预测。
这个结论最后得到了客户和云厂商内核团队的认可,推动了内核热补丁。
采集器触发了 OOM,这一点我们从头到尾没否认过,内存限额本来就是我们自己设的。可一个进程 OOM 了,正常就该被杀掉、内存释放、机器恢复,把整机锁死不是应用程序该有能力做到的事。那天要是只看到第二层就认了,这个锅我们大概就背上了,而那个内核 BUG 还会在别人的机器上接着出现。