由 OOM 引發(fā)的 ext4 文件系統(tǒng)卡死
注:本問(wèn)題影響 3.10.0-862.el7.centos 及之后的 CentOS 7 版本內(nèi)核,目前問(wèn)題還未被修復(fù)。
背景
近日,我司的測(cè)試同學(xué)發(fā)現(xiàn)內(nèi)部集群中一個(gè)存儲(chǔ)節(jié)點(diǎn)無(wú)法通過(guò) ssh 訪問(wèn)了。iDRAC console 界面出現(xiàn)如下圖的錯(cuò)誤:有若干個(gè)服務(wù)進(jìn)程由于 cgroup 超過(guò)內(nèi)存限制被 Kill,隨后許多進(jìn)程陸續(xù)卡住,出現(xiàn) “task xxx blocked for more than 120 seconds”。此時(shí),系統(tǒng)無(wú)法進(jìn)行賬號(hào)登錄,似乎整個(gè)根文件系統(tǒng)(ext4)都卡住了。測(cè)試同學(xué)通過(guò) ipmitool 工具發(fā)送 NMI 信號(hào)讓系統(tǒng)產(chǎn)生 vmcore(Linux 內(nèi)核崩潰時(shí)產(chǎn)生的內(nèi)核轉(zhuǎn)儲(chǔ)文件)以便后續(xù)分析,然后重啟機(jī)器讓業(yè)務(wù)恢復(fù)。

將多個(gè)服務(wù)放置在 cgroup 中,限制它們的內(nèi)存使用量;當(dāng)內(nèi)存使用量超過(guò)預(yù)設(shè)上限值,OOM(Out-of-Memory) Killer 按照一定策略 Kill 掉部分進(jìn)程,實(shí)現(xiàn)內(nèi)存釋放。這是很常規(guī)的操作,如 Red Hat Enterprise Linux Resource Management Guide - memory(https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/6/html/resource_management_guide/sec-memory)。
從內(nèi)存使用量的監(jiān)控圖表中,我們確定了系統(tǒng)中某個(gè)服務(wù)存在內(nèi)存占用隨著實(shí)例數(shù)增加而線性增長(zhǎng)的 bug。但是,為什么 OOM 會(huì)導(dǎo)致 ext4 文件系統(tǒng)卡死呢?
相關(guān)問(wèn)題檢索
從日志和采集到的 vmcore 中,發(fā)現(xiàn)有許多進(jìn)程都卡在 ext4/jbd2 的 I/O 操作上。其中 jbd2 (Journaling block device)是 kernel 中通用的 journal 模塊,被 ext4 使用。從日志中可以看出,OOM 后不久就觸發(fā)許多進(jìn)程卡住,可以推斷 OOM 與 ext4 卡住存在很強(qiáng)的關(guān)聯(lián)性。
為此,找到如下一些 RedHat 官方關(guān)于 ext4/jbd2 的 KB,但經(jīng)過(guò)版本和調(diào)用棧比對(duì),我們排除了它們,看上去這是一個(gè)新的問(wèn)題。
Hung task "jbd2/dm-16-8" in jbd2_journal_commit_transaction() when issuing I/O through a software RAID or mirror device(https://access.redhat.com/solutions/3226391)
RHEL7: jbd2 deadlock occurs while checkpoint thread waits commit thread to finish(https://access.redhat.com/solutions/4054181).
嘗試復(fù)現(xiàn)
嘗試根據(jù)現(xiàn)有線索構(gòu)建復(fù)現(xiàn)腳本,方便后續(xù) debug 分析和假想驗(yàn)證。一旦復(fù)現(xiàn)問(wèn)題,就可以生成 vmcore 做分析。然而,vmcore 只能記錄產(chǎn)生 vmcore 時(shí)刻的快照信息,犯罪進(jìn)程可能已經(jīng)逃離現(xiàn)場(chǎng),此時(shí)更詳細(xì)的日志可以幫助恢復(fù)現(xiàn)場(chǎng)。因此,我們還開(kāi)啟了 jbd2 模塊日志功能,重新編譯內(nèi)核。
初次嘗試
根據(jù)網(wǎng)上一些相似問(wèn)題的討論,我們最初的猜想是 cgroup 內(nèi)的進(jìn)程在做 ext4 I/O 操作時(shí),可能由于內(nèi)存限制無(wú)法申請(qǐng)到內(nèi)存后忘記釋放文件系統(tǒng)的某些關(guān)鍵資源,導(dǎo)致其他進(jìn)程(可能是 cgroup 內(nèi),也可能不處于 cgroup 中)ext4 I/O 操作無(wú)法進(jìn)行。于是,有了以下復(fù)現(xiàn)方法:
在 cgroup 中有兩類進(jìn)程,一類不斷分配內(nèi)存,在 15s 左右達(dá)到 cgroup 的內(nèi)存限制;另一類跑 fio,對(duì) ext4 分區(qū)的文件做大塊隨機(jī)寫(xiě)操作。每類進(jìn)程各 3 個(gè);如果進(jìn)程被 OOM Kill,會(huì)被 systemd 重新拉起; 在 cgroup 之外,有另外 3 個(gè) fio 進(jìn)程,對(duì) ext4 分區(qū)的其他文件做大塊隨機(jī)寫(xiě)操作;
經(jīng)歷了 7 個(gè)小時(shí)左右,我們復(fù)現(xiàn)了一次,卡住現(xiàn)象與最初現(xiàn)場(chǎng)一致。但隨后一天中,4 個(gè)節(jié)點(diǎn)都沒(méi)法再次復(fù)現(xiàn),需要找到加速?gòu)?fù)現(xiàn)的方式。
再次嘗試
由于前兩次復(fù)現(xiàn)的場(chǎng)景最后被 OOM Kill 的進(jìn)程都是 IO 進(jìn)程,而上述復(fù)現(xiàn)步驟中經(jīng)常被 Kill 的是分配內(nèi)存的進(jìn)程,因此考慮做以下調(diào)整:
準(zhǔn)備一個(gè)測(cè)試程序 ext4-repro-with-write,打開(kāi) ext4 文件系統(tǒng)上的文件,并不斷地對(duì)該文件做寫(xiě)操作,過(guò)程中會(huì)涉及到 ext4 inode 內(nèi)部的一些內(nèi)存分配操作;測(cè)試的 cgroup 中只有這一類進(jìn)程,OOM Kill 的只可能是這類進(jìn)程; 創(chuàng)建 10 個(gè) cgroup,每個(gè)的內(nèi)存限制為 100 MB; 對(duì)于每個(gè) cgroup,創(chuàng)建 30 個(gè) ext4-repro-with-write 進(jìn)程;
使用這一腳本,我們能夠在 10 分鐘以內(nèi)復(fù)現(xiàn)這個(gè)問(wèn)題。相關(guān)復(fù)現(xiàn)腳本已經(jīng)上傳到 https://gist.github.com/jiewei-ke/4962ac0061703fdcd0837bab2dbec261。
確定影響版本
分別測(cè)試 CentOS 7 的幾個(gè)官方內(nèi)核版本以及 CentOS 8 的 4.18 內(nèi)核,我們確定:
CentOS 7 3.10.0-693.el7 版本沒(méi)有問(wèn)題; CentOS 7 3.10.0-862.el7 及后續(xù)版本(包含最新的 3.10.0-1160.el7)都有問(wèn)題; CentOS 8 4.18.0 版本沒(méi)有問(wèn)題;
即該問(wèn)題是 CentOS 7 3.10.0-862.el7 引入的。
vmcore 分析
獲取到帶有 jbd2 模塊日志的 vmcore 后,下面使用 crash 工具對(duì)其進(jìn)行分析。
注:分析過(guò)程比較冗長(zhǎng),對(duì)于 vmcore 分析不感興趣的同學(xué)可以直接跳到 Recap 部分看結(jié)論。
ext4/jbd2 簡(jiǎn)介
參考 Linux: The Journaling Block Device(https://web.archive.org/web/20070926223043/http://kerneltrap.org/node/6741),ext4 文件系統(tǒng)基于 jbd2 以 WAL(write-ahead logging)形式更新元數(shù)據(jù),防止系統(tǒng) crash 時(shí)發(fā)生元數(shù)據(jù)損壞。每一個(gè) ext4 文件系統(tǒng)實(shí)例對(duì)應(yīng)一個(gè) jbd2 Journal 實(shí)例。每個(gè)用戶線程的一次數(shù)據(jù)更新稱為一個(gè) Handle,在時(shí)間上連續(xù)的多個(gè) Handle(可能來(lái)自不同線程)組成一個(gè) Transaction,以便實(shí)現(xiàn)批量提交。Transaction 提交滿足原子性。
Journal 包括兩個(gè)階段,Commit 和 Checkpoint。Commit 負(fù)責(zé)提交修改,由用戶線程和內(nèi)核 Journal 線程配合完成。
當(dāng)用戶程序?qū)?ext4 文件系統(tǒng)做文件操作(例如 create())時(shí),用戶線程先獲取 Handle,然后獲取 Journal 當(dāng)前的 running Transaction,如果存在則使用它,不存在則新創(chuàng)建一個(gè)。同時(shí)原子增加 running Transaction 的 t_updates 計(jì)數(shù),表示該 Transaction 被多少個(gè)線程使用。隨后,用戶線程將修改的元數(shù)據(jù)內(nèi)容加入到 Transaction 的 buffer 鏈表中,遞減 t_updates。 內(nèi)核的 Journal 線程會(huì)定期喚醒,將 Journal 的 running Transaction 變?yōu)?committing 狀態(tài),然后等待 t_updates 歸零,即等待用戶線程完成插入 buffer 鏈表。隨后將 buffer 鏈表合并寫(xiě)入到對(duì)應(yīng)的 Journal 區(qū)域。隨后該 Transaction 加入等待 checkpoint 的 Transaction 列表中。 以上過(guò)程中,每個(gè) Journal 實(shí)例同時(shí)只有一個(gè) running Transaction 和一個(gè) committing Transaction。
Checkpoint 負(fù)責(zé)持久化修改,由用戶線程發(fā)起。用戶線程從待 checkpoint 的 Transaction 列表中獲取 buffer,然后合并寫(xiě)入到物理磁盤(pán),Transaction 至此結(jié)束。
以上描述只關(guān)注本文涉及到的步驟。更詳細(xì)的過(guò)程請(qǐng)參考 High-Performance Transaction Processing in Journaling File Systems(https://www.usenix.org/system/files/conference/fast18/fast18-son.pdf) 的 Background 部分。
進(jìn)程堆棧分析
針對(duì) vmcore,通過(guò) crash 命令 ”foreach bt > bt.log“ 獲得所有進(jìn)程的堆棧信息。根據(jù) jbd2 關(guān)鍵字過(guò)濾進(jìn)程,主要有以下幾類:
類別 1:內(nèi)核的 Journal 線程卡在等待 t_updates 歸零
上文提到,內(nèi)核 Journal 線程進(jìn)行 Commit 之前需要先等待 t_updates 歸零。下圖的 Journal 線程正是卡在等待 t_updates 歸零的循環(huán)中,說(shuō)明此時(shí)有一個(gè)用戶進(jìn)程正在發(fā)起 Commit 操作,但是還沒(méi)有完成 buffer 鏈表的更新。
PID: 587 TASK: ffff9f53f8c31070 CPU: 5 COMMAND: "jbd2/md127-8"
#0 [ffff9f53ff35bbf0] __schedule at ffffffffb139e420
#1 [ffff9f53ff35bc88] schedule at ffffffffb139eb09
#2 [ffff9f53ff35bc98] jbd2_journal_commit_transaction at ffffffffc01fe8b0 [jbd2]
#3 [ffff9f53ff35be40] kjournald2 at ffffffffc0205c5e [jbd2]
#4 [ffff9f53ff35bec8] kthread at ffffffffb0cc6bd1
/*
* jbd2_journal_commit_transaction
*
* The primary function for committing a transaction to the log. This
* function is called by the journal thread to begin a complete commit.
*/
void jbd2_journal_commit_transaction(journal_t *journal)
{
// ...
while (atomic_read(&commit_transaction->t_updates)) { <<< 在這循環(huán)
DEFINE_WAIT(wait);
prepare_to_wait(&journal->j_wait_updates, &wait,
TASK_UNINTERRUPTIBLE);
if (atomic_read(&commit_transaction->t_updates)) {
spin_unlock(&commit_transaction->t_handle_lock);
write_unlock(&journal->j_state_lock);
schedule();
write_lock(&journal->j_state_lock);
spin_lock(&commit_transaction->t_handle_lock);
}
finish_wait(&journal->j_wait_updates, &wait);
}
類別 2:等待 Transaction Commit 完成的進(jìn)程
有 3 個(gè)進(jìn)程,它們都在等待 Transaction Commit 的完成。jbd2_log_wait_commit() 正在等待 j_wait_done_commit,該鎖正被類別 1 的 Journal 線程持有,只有當(dāng) Journal 線程完成 jbd2_journal_commit_transaction() 時(shí),才會(huì)釋放 j_wait_done_commit。因此,這 3 個(gè)進(jìn)程也是被動(dòng)卡住的線程,正在等待 Journal 線程。
PID: 27153 TASK: ffff9f53fc35a0e0 CPU: 5 COMMAND: "ext4-repro-with-write"
#0 [ffff9f526e04fd70] __schedule at ffffffffb139e420
#1 [ffff9f526e04fe08] schedule at ffffffffb139eb09
#2 [ffff9f526e04fe18] jbd2_log_wait_commit at ffffffffc02054a5 [jbd2]
#3 [ffff9f526e04fe98] jbd2_complete_transaction at ffffffffc0206e21 [jbd2]
#4 [ffff9f526e04feb8] ext4_sync_file at ffffffffc03cff60 [ext4]
#5 [ffff9f526e04ff00] do_fsync at ffffffffb0e8ccf7
#6 [ffff9f526e04ff40] sys_fsync at ffffffffb0e8cff0
#7 [ffff9f526e04ff50] system_call_fastpath at ffffffffb13ac81e
類別 3:等待 Commit Transaction 的用戶進(jìn)程
這類有 32 個(gè)進(jìn)程,數(shù)量比較多,都卡在 jbd2__journal_start() -> wait_transaction_locked() 上,在 Commit Transaction 的早期階段。這些進(jìn)程都是被動(dòng)卡住的,不是事發(fā)原因。
PID: 906 TASK: ffff9f53fa13b150 CPU: 5 COMMAND: "loop0"
#0 [ffff9f53fb7cf830] __schedule at ffffffffb139e420
#1 [ffff9f53fb7cf8c8] schedule at ffffffffb139eb09
#2 [ffff9f53fb7cf8d8] wait_transaction_locked at ffffffffc01fb085 [jbd2]
#3 [ffff9f53fb7cf930] add_transaction_credits at ffffffffc01fb360 [jbd2]
#4 [ffff9f53fb7cf990] start_this_handle at ffffffffc01fb63f [jbd2]
#5 [ffff9f53fb7cfa48] jbd2__journal_start at ffffffffc01fbbb6 [jbd2]
#6 [ffff9f53fb7cfa90] __ext4_journal_start_sb at ffffffffc0409c89 [ext4]
#7 [ffff9f53fb7cfad0] ext4_dirty_inode at ffffffffc03dc9da [ext4]
#8 [ffff9f53fb7cfaf0] __mark_inode_dirty at ffffffffb0e87280
#9 [ffff9f53fb7cfb28] update_time at ffffffffb0e7484a
#10 [ffff9f53fb7cfb58] file_update_time at ffffffffb0e74961
#11 [ffff9f53fb7cfba0] __generic_file_aio_write at ffffffffb0dc4eac
#12 [ffff9f53fb7cfc20] generic_file_aio_write at ffffffffb0dc5109
#13 [ffff9f53fb7cfc60] ext4_file_write at ffffffffc03cfb8e [ext4]
#14 [ffff9f53fb7cfcd8] do_sync_write at ffffffffb0e54936
#15 [ffff9f53fb7cfdb0] __do_lo_send_write at ffffffffc0597045 [loop]
#16 [ffff9f53fb7cfdf8] do_lo_send_direct_write at ffffffffc0597156 [loop]
#17 [ffff9f53fb7cfe30] loop_thread at ffffffffc0598382 [loop]
#18 [ffff9f53fb7cfec8] kthread at ffffffffb0cc6bd1
在以上 3 種類別中,類別 1 內(nèi)核的 Journal 線程卡住是導(dǎo)致其他進(jìn)程卡住的原因,此時(shí)整個(gè) ext4 文件系統(tǒng)的文件操作都無(wú)法繼續(xù)。下面著重分析該進(jìn)程。
內(nèi)核的 Journal 線程分析
首先檢查當(dāng)前 Journal 的狀況。crash 切換到內(nèi)核 Journal 線程,顯示每個(gè) stack frame 的參數(shù)寄存器內(nèi)容,主要關(guān)注 jbd2_journal_commit_transaction()。嘗試解析各個(gè)地址,發(fā)現(xiàn) ffff9f4539ac9000 能夠正確解析為 journal_t 類型(也可以根據(jù)反匯編確定參數(shù)是如何傳遞的),拿到 Journal 的指針。
crash> mod -s jbd2
MODULE NAME SIZE OBJECT FILE
ffffffffc02100e0 jbd2 117134 /lib/modules/3.10.0-1.el7.x86_64/kernel/fs/jbd2/jbd2.ko
crash> set 587
PID: 587
COMMAND: "jbd2/md127-8"
TASK: ffff9f53f8c31070 [THREAD_INFO: ffff9f53ff358000]
CPU: 5
STATE: TASK_UNINTERRUPTIBLE
crash> bt
PID: 587 TASK: ffff9f53f8c31070 CPU: 5 COMMAND: "jbd2/md127-8"
#0 [ffff9f53ff35bbf0] __schedule at ffffffffb139e420
#1 [ffff9f53ff35bc88] schedule at ffffffffb139eb09
#2 [ffff9f53ff35bc98] jbd2_journal_commit_transaction at ffffffffc01fe8b0 [jbd2]
#3 [ffff9f53ff35be40] kjournald2 at ffffffffc0205c5e [jbd2]
#4 [ffff9f53ff35bec8] kthread at ffffffffb0cc6bd1
crash> bt -fl
PID: 587 TASK: ffff9f53f8c31070 CPU: 5 COMMAND: "jbd2/md127-8"
#0 [ffff9f53ff35bbf0] __schedule at ffffffffb139e420
...
#1 [ffff9f53ff35bc88] schedule at ffffffffb139eb09
/usr/src/debug/kernel-ml-3.10.0/linux-3.10.0-1.el7.x86_64/kernel/sched/core.c: 3654
ffff9f53ff35bc90: ffff9f53ff35be38 ffffffffc01fe8b0
#2 [ffff9f53ff35bc98] jbd2_journal_commit_transaction at ffffffffc01fe8b0 [jbd2]
…
ffff9f53ff35be10: ffff9f4539ac9028 ffff9f4539ac93e8
ffff9f53ff35be20: ffff9f4539ac90a8 ffff9f53ff35be80
ffff9f53ff35be30: ffff9f4539ac9000 ffff9f53ff35bec0 // 能夠正確解析為 Journal 的地址
ffff9f53ff35be40: ffffffffc0205c5e
#3 [ffff9f53ff35be40] kjournald2 at ffffffffc0205c5e [jbd2]
ffff9f53ff35be48: ffff9f5316aa0f62 ffff9f53f8c31070
ffff9f53ff35be58: ffff9f53f8c31070 ffff9f4539ac9090
...
#4 [ffff9f53ff35bec8] kthread at ffffffffb0cc6bd1
/usr/src/debug/kernel-ml-3.10.0/linux-3.10.0-1.el7.x86_64/kernel/kthread.c: 202
crash> journal_t.j_devname ffff9f4539ac9000
j_devname = "md127-8\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000"
獲取 Journal 的 running Transaction 0xffff9f53d4ec1900,可以看到它的 t_updates 為 1,因此處于循環(huán)等待中。
crash> journal_t.j_running_transaction ffff9f4539ac9000
j_running_transaction = 0xffff9f53d4ec1900
crash> transaction_t 0xffff9f53d4ec1900
struct transaction_t {
t_journal = 0xffff9f4539ac9000,
t_tid = 380243810,
t_state = T_LOCKED,
...
t_updates = {
counter = 1 // t_updates 為 1,處于循環(huán)等待
},
t_outstanding_credits = {
counter = 56
},
…
}
提交 Transaction 的用戶進(jìn)程分析
t_updates 的更新是由提交 Transaction 的用戶進(jìn)程完成的。下面看一下其更新流程。
t_updates 遞增的過(guò)程
在 Transaction 被提交前,用戶進(jìn)程會(huì)先通過(guò) jbd2__journal_start() 獲取當(dāng)前進(jìn)程的 Handle,隨后調(diào)用 start_this_handle() 為 Transaction 預(yù)留需要的 Journal 空間。
static inline handle_t *journal_current_handle(void)
{
return current->journal_info;
}
handle_t *jbd2__journal_start(journal_t *journal, int nblocks, int rsv_blocks,
gfp_t gfp_mask, unsigned int type,
unsigned int line_no)
{
handle_t *handle = journal_current_handle(); // 獲取進(jìn)程的 Handle
...
err = start_this_handle(journal, handle, gfp_mask);
在 start_this_handle() 中,我們看到 Handle 中保存了 running Transaction 的指針,同時(shí) Transaction 的 t_updates 被原子加一。
static int start_this_handle(journal_t *journal, handle_t *handle,
gfp_t gfp_mask)
{
...
transaction = journal->j_running_transaction;
...
update_t_max_wait(transaction, ts);
handle->h_transaction = transaction;
handle->h_requested_credits = blocks;
handle->h_start_jiffies = jiffies;
atomic_inc(&transaction->t_updates);
atomic_inc(&transaction->t_handle_count);
t_updates 遞減的過(guò)程
相對(duì)應(yīng)的,Transaction 的 t_updates 是由 jbd2_journal_stop 中遞減的。
int jbd2_journal_stop(handle_t *handle)
{
...
/*
* Once we drop t_updates, if it goes to zero the transaction
* could start committing on us and eventually disappear. So
* once we do this, we must not dereference transaction
* pointer again.
*/
tid = transaction->t_tid;
if (atomic_dec_and_test(&transaction->t_updates)) {
wake_up(&journal->j_wait_updates);
if (journal->j_barrier_count)
wake_up(&journal->j_wait_transaction_locked);
}
t_updates 為何沒(méi)有歸零?
我們找一下是否有 Handle 持有著這個(gè) Transaction。事先添加的 jbd2 模塊日志會(huì)打印出 Handle 相關(guān)日志,檢索日志中曾經(jīng)出現(xiàn)過(guò)的 Handle 指針如下,
$ grep jbd2_journal_stop vmcore-dmesg.txt | grep Handle | awk '{print $7}' | sort | uniq
...
ffff9f53f9676cf0
ffff9f53fa89bb70
ffff9f53fe3423c0
逐個(gè)嘗試解析 Handle,發(fā)現(xiàn) Handle ffff9f53fa89bb70 正在處理上述的 Transaction ?0xffff9f53d4ec1900。其中,h_line_no 為 2264,即該 Handle 是在行號(hào)為 2264 的代碼啟動(dòng)的(但沒(méi)有包含文件名信息)。
crash> handle_t ffff9f53fa89bb70
struct handle_t {
{
h_transaction = 0xffff9f53d4ec1900,
h_journal = 0xffff9f53d4ec1900 // 這里好像填錯(cuò)了,跟 transaction 是一樣的。。
},
h_rsv_handle = 0x0,
h_buffer_credits = 33,
h_ref = 1,
h_err = 0,
h_sync = 0,
h_jdata = 0,
h_reserved = 0,
h_aborted = 0,
h_type = 4,
h_line_no = 2264,
h_start_jiffies = 4298558935,
h_requested_credits = 35
}
在 ext4 代碼中,發(fā)現(xiàn) ext4_create() 中調(diào)用 ext4_new_inode_start_handle() 的行號(hào)正好與之匹配。根據(jù)代碼,ext4_new_inode_start_handle() 會(huì)調(diào)用 jbd2__journal_start() 將 t_updates 加一,然后將 inode 元數(shù)據(jù)信息更新加入 Transaction。之后,ext4_create() 調(diào)用 ext4_journal_stop() 將 t_updates 減一。
static int ext4_create(struct inode *dir, struct dentry *dentry, umode_t mode,
bool excl)
{
handle_t *handle;
struct inode *inode;
int err, credits, retries = 0;
dquot_initialize(dir);
credits = (EXT4_DATA_TRANS_BLOCKS(dir->i_sb) +
EXT4_INDEX_EXTRA_TRANS_BLOCKS + 3);
retry:
inode = ext4_new_inode_start_handle(dir, mode, &dentry->d_name, 0,
NULL, EXT4_HT_DIR, credits); // 在這里卡住了
handle = ext4_journal_current_handle();
err = PTR_ERR(inode);
if (!IS_ERR(inode)) {
inode->i_op = &ext4_file_inode_operations;
inode->i_fop = &ext4_file_operations.kabi_fops;
ext4_set_aops(inode);
err = ext4_add_nondir(handle, dentry, inode);
if (!err && IS_DIRSYNC(dir))
ext4_handle_sync(handle);
}
if (handle)
ext4_journal_stop(handle); // 還沒(méi)有調(diào)用
if (err == -ENOSPC && ext4_should_retry_alloc(dir->i_sb, &retries))
goto retry;
return err;
}
t_updates 沒(méi)有歸零,說(shuō)明 ext4_journal_stop() 沒(méi)有調(diào)用。如果 Handle 還沒(méi)有執(zhí)行 jbd2_journal_stop(),那么它會(huì)被某個(gè)進(jìn)程所持有,保存在進(jìn)程控制塊 task_struct 的 journal_info 中。通過(guò)搜索所有進(jìn)程的 task_struct,可以看到 task 27225(我們的測(cè)試程序 ext4-repro-with-write)進(jìn)程正在持有這個(gè) Handle。
crash> foreach task > task.log
crash> task 27225 | grep journal_info
journal_info = 0xffff9f53fa89bb70,
crash> ps | grep 27225
27225 1 4 ffff9f533a6d3150 RU 0.0 239712 38800 ext4-repro-with-write
crash> bt 27225
PID: 27225 TASK: ffff9f533a6d3150 CPU: 4 COMMAND: "ext4-repro-with-write"
#0 [ffff9f53c234f900] __schedule at ffffffffb139e420
#1 [ffff9f53c234f990] sys_sched_yield at ffffffffb0cdafdc
#2 [ffff9f53c234f9b0] yield at ffffffffb139fca2
#3 [ffff9f53c234f9c8] free_more_memory at ffffffffb0e8ee2c
#4 [ffff9f53c234fa08] __getblk at ffffffffb0e90013
#5 [ffff9f53c234fa70] __ext4_get_inode_loc at ffffffffc03d358f [ext4]
#6 [ffff9f53c234fad8] ext4_reserve_inode_write at ffffffffc03d8e62 [ext4]
#7 [ffff9f53c234fb08] ext4_mark_inode_dirty at ffffffffc03d8f23 [ext4]
#8 [ffff9f53c234fb60] ext4_ext_tree_init at ffffffffc04038ba [ext4]
#9 [ffff9f53c234fb70] __ext4_new_inode at ffffffffc03d271b [ext4]
#10 [ffff9f53c234fc18] ext4_create at ffffffffc03e3fb3 [ext4]
#11 [ffff9f53c234fc90] vfs_create at ffffffffb0e62aa8
#12 [ffff9f53c234fcc8] do_last at ffffffffb0e67e5f
#13 [ffff9f53c234fd70] path_openat at ffffffffb0e681e0
#14 [ffff9f53c234fe08] do_filp_open at ffffffffb0e69d6d
#15 [ffff9f53c234fee0] do_sys_open at ffffffffb0e5442e
#16 [ffff9f53c234ff40] sys_openat at ffffffffb0e54504
#17 [ffff9f53c234ff50] system_call_fastpath at ffffffffb13ac81e
ext4-repro-with-write 進(jìn)程處于 RUNNING 狀態(tài),正在執(zhí)行 ext4_create -> ext4_new_inode_start_handle -> __ext4_new_inode,此時(shí) t_updates 已經(jīng)加一,但是由于 ext4_new_inode_start_handle() 還沒(méi)有結(jié)束,后續(xù)的 ext4_journal_stop() 也沒(méi)有執(zhí)行,故 t_updates 沒(méi)有歸零。
問(wèn)題出在 ext4_new_inode_start_handle(),如果 __get_blk() 獲取不到內(nèi)存時(shí),會(huì)不斷地嘗試調(diào)用 free_more_memory() 釋放掉部分內(nèi)存(事實(shí)證明釋放并沒(méi)有成功),然后再嘗試獲取。如果一直獲取不到,那么將會(huì)卡在這里。
get_blk 為何分配不到內(nèi)存?
與前文的復(fù)現(xiàn)腳本略微有些差異,這個(gè)測(cè)試中 ext4-repro-with-write 內(nèi)部包含兩個(gè)線程,一個(gè)負(fù)責(zé)不斷分配內(nèi)存,另一個(gè)負(fù)責(zé)持續(xù)寫(xiě)文件。在內(nèi)存限制為 900MB 左右的 cgroup 中啟動(dòng)了 3 個(gè)進(jìn)程。
$cat /etc/systemd/system/system-fio.slice.d/50-MemoryLimit.conf
[Slice]
MemoryLimit=973741824
>>> 973741824/1024.0/1024
928.632568359375 // 900MB 左右
按照 task group 方式檢查當(dāng)前 3 個(gè) ext4-repro-with-write 進(jìn)程的內(nèi)存使用量,可以看到內(nèi)存使用已經(jīng)接近 cgroup 內(nèi)存限制,提交 Transaction 的 task 27225 所在的 group 只占用了 37 MB,而另外兩個(gè)進(jìn)程各自占用 400 MB 以上。
crash> ps -G ext4-repro-with-write | sed 's/>//g' | sort -k 8,8 -n | awk '$8 ~ /[0-9]/{ $8 = $8/1024" MB"; print }'
27222 1 5 ffff9f53b5b9b150 RU 0.0 239712 37.8906 MB ext4-repro-with-write // 27225 所在的 group
27189 1 5 ffff9f52357c1070 IN 0.3 578292 403.246 MB ext4-repro-with-write
27153 1 5 ffff9f53fc35a0e0 UN 0.4 848900 487.488 MB ext4-repro-with-write // 需要被 Kill 的進(jìn)程
>>> 37.8906+403.246+487.488
928.6246
從 vmcore-dmesg 日志看,最近一次觸發(fā) OOM Kill 的進(jìn)程是 27153 ,但是內(nèi)存并沒(méi)有降下來(lái)。
[ 3891.873807] Memory cgroup out of memory: Kill process 27158 (ext4-repro-with-write) score 511 or sacrifice child
[ 3891.875313] Killed process 27153 (ext4-repro-with-write), UID 0, total-vm:848900kB, anon-rss:499124kB, file-rss:868kB, shmem-rss:0kB
看一下 27153 進(jìn)程,處于 UN 狀態(tài)而無(wú)法接受 Kill 信號(hào),所以內(nèi)存不可能得到釋放。
crash> ps ext4-repro-with-write
PID PPID CPU TASK ST %MEM VSZ RSS COMM
27153 1 5 ffff9f53fc35a0e0 UN 0.4 848900 499188 ext4-repro-with-write
如下圖,該進(jìn)程屬于上述類型 2 的進(jìn)程,它正在等到內(nèi)核 Journal 線程完成 Commit,死鎖條件成立。
crash> bt 27153
PID: 27153 TASK: ffff9f53fc35a0e0 CPU: 5 COMMAND: "ext4-repro-with-write"
#0 [ffff9f526e04fd70] __schedule at ffffffffb139e420
#1 [ffff9f526e04fe08] schedule at ffffffffb139eb09
#2 [ffff9f526e04fe18] jbd2_log_wait_commit at ffffffffc02054a5 [jbd2]
#3 [ffff9f526e04fe98] jbd2_complete_transaction at ffffffffc0206e21 [jbd2]
#4 [ffff9f526e04feb8] ext4_sync_file at ffffffffc03cff60 [ext4]
#5 [ffff9f526e04ff00] do_fsync at ffffffffb0e8ccf7
#6 [ffff9f526e04ff40] sys_fsync at ffffffffb0e8cff0
#7 [ffff9f526e04ff50] system_call_fastpath at ffffffffb13ac81e
Recap

根據(jù)以上冗長(zhǎng)的分析,OOM 導(dǎo)致 ext4 文件系統(tǒng)死鎖的時(shí)間線整理如下:
系統(tǒng)內(nèi)有兩個(gè)用戶進(jìn)程,位于同一個(gè) cgroup 中,cgroup 上限制最大可用的內(nèi)存; 進(jìn)程 1,分配大量?jī)?nèi)存,使得 cgroup 內(nèi)存使用量超過(guò)限制,OOM Killer 選擇該進(jìn)程殺死;該進(jìn)程正在執(zhí)行 ext4_sync_file(),在 jbd2_log_wait_commit() 等待進(jìn)程 2 的 Transaction 完成;此時(shí)該進(jìn)程處于不可中斷狀態(tài),無(wú)法被 Kill,內(nèi)存無(wú)法被釋放; 進(jìn)程 2,處于 ext4_create()(或者其他 ext4_xxx 函數(shù))中,在 __getblk() 上等待可用的內(nèi)存;只有拿到足夠內(nèi)存,才能完成 ext4_journal_stop(),將 t_updates 遞減,其對(duì)應(yīng)的內(nèi)核 Journal 線程才能完成 jbd2_journal_commit_transaction(); (死鎖條件 1)進(jìn)程 1 在等待進(jìn)程 2 的 Transaction Commit 完成才能被 Kill 而釋放內(nèi)存;(死鎖條件 2)進(jìn)程 2 在等待進(jìn)程 1 的釋放內(nèi)存才能拿到內(nèi)存完成 Transaction Commit。
解決方案
在上述兩個(gè)死鎖條件中,只要我們破壞一個(gè)條件就可以避免死鎖發(fā)生。條件 1 是 jbd2 的實(shí)現(xiàn)要求,我們難以改變;條件 2 則有可能打破,例如,針對(duì)這種關(guān)鍵路徑上的內(nèi)存分配,允許 cgroup 的內(nèi)存暫時(shí)超過(guò)限制。
Linux VMM(Virtual Memory Manager)提供了 Get Free Page(GFP)Flags 來(lái)控制分配器的分配、釋放行為。其中有一個(gè) Flag 名為 __GFP_NOFAIL,在 2.6 內(nèi)核版本加入,其作用是 “This flag is used by a caller to indicate that the allocation should never fail and the allocator should keep trying to allocate indefinitely”。根據(jù)代碼確認(rèn),如果分配內(nèi)存時(shí) gfp_mask 攜帶了 __GFP_NOFAIL,那么 VMM 會(huì)盡量滿足分配需求,不再理會(huì) cgroup 的內(nèi)存限制。更詳細(xì)的 GFP 說(shuō)明請(qǐng)參考 Understanding the Linux Virtual Memory Manager: 6.4 ?Get Free Page (GFP) Flags(https://www.kernel.org/doc/gorman/html/understand/understand009.html#Sec:%20GFP%20Flags)。
我們看一下 __getblk() 的函數(shù)調(diào)用鏈,關(guān)鍵的函數(shù)如下,
__getblk
->__getblk_slow
->grow_buffers
->grow_dev_page
->find_or_create_page
->add_to_page_cache_lru
->__add_to_page_cache_locked
-> mem_cgroup_cache_charge
-> ...
->__mem_cgroup_try_charge
如下代碼所示,grow_dev_page() 在調(diào)用 find_or_create_page() 之前已經(jīng)攜帶了 __GFP_NOFAIL,按理來(lái)說(shuō),后續(xù)的內(nèi)存分配就不會(huì)受到 cgroup 限制了(__mem_cgroup_try_charge() )。但為什么 __getblk() 還是沒(méi)有成功呢?
static int
grow_dev_page(struct block_device *bdev, sector_t block,
pgoff_t index, int size, int sizebits)
{
...
gfp_mask = mapping_gfp_mask(inode->i_mapping) & ~__GFP_FS;
gfp_mask |= __GFP_MOVABLE;
/*
* XXX: __getblk_slow() can not really deal with failure and
* will endlessly loop on improvised global reclaim. Prefer
* looping in the allocator rather than here, at least that
* code knows what it's doing.
*/
gfp_mask |= __GFP_NOFAIL;
page = find_or_create_page(inode->i_mapping, index, gfp_mask);
if (!page)
return ret;
__GFP_NOFAIL 丟失
上文提到,3.10.0-862.el7.centos 是引入該問(wèn)題的版本。我們對(duì)照 3.10.0-693.el7.centos 和 3.10.0-862.el7.centos 兩個(gè)版本在上述路徑上的代碼差異,發(fā)現(xiàn)后者在函數(shù) __add_to_page_cache_locked() 中,增加了以下代碼。mapping_gfp_constraint() 會(huì)將傳遞的 gfp_mask 與 inode->i_mapping 支持的 flags 進(jìn)行按位與操作。根據(jù) vmcore 分析,確認(rèn) inode->i_mapping 是不支持 __GFP_NOFAIL 的,因此上層傳遞的 __GFP_NOFAIL 丟失了!
static int __add_to_page_cache_locked(struct page *page,
struct address_space *mapping,
pgoff_t offset, gfp_t gfp_mask,
void **shadowp)
{
...
gfp_mask = mapping_gfp_constraint(mapping, gfp_mask);
error = mem_cgroup_cache_charge(page, current->mm,
gfp_mask & GFP_RECLAIM_MASK);
...
在 Linux 的主線代碼歷史中并沒(méi)有在該位置添加過(guò)這行代碼。我們傾向于認(rèn)為是 RedHat 的維護(hù)者在移植以下 patch 出錯(cuò)了,mapping_gfp_constraint() 應(yīng)該針對(duì)部分操作作為 add_to_page_cache_lru() 的參數(shù)傳遞,而不是在隨后的 __add_to_page_cache_locked() 無(wú)條件執(zhí)行。
c20cd45eb017 mm: allow GFP_{FS,IO} for page_cache_read page cache allocation
c62d25556be6 mm, fs: introduce mapping_gfp_constraint()
6afdb859b710 mm: do not ignore mapping_gfp_mask in page cache allocation paths
diff --git a/mm/filemap.c b/mm/filemap.c
index 6ef3674c0763..1bb007624b53 100644
--- a/mm/filemap.c
+++ b/mm/filemap.c
@@ -1722,7 +1722,7 @@ static ssize_t do_generic_file_read(struct file *filp, loff_t *ppos,
goto out;
}
error = add_to_page_cache_lru(page, mapping, index,
- GFP_KERNEL & mapping_gfp_mask(mapping));
+ mapping_gfp_constraint(mapping, GFP_KERNEL));
if (error) {
page_cache_release(page);
if (error == -EEXIST) {
@@ -1824,7 +1824,7 @@ static int page_cache_read(struct file *file, pgoff_t offset)
return -ENOMEM;
ret = add_to_page_cache_lru(page, mapping, offset,
- GFP_KERNEL & mapping_gfp_mask(mapping));
+ mapping_gfp_constraint(mapping, GFP_KERNEL));
if (ret == 0)
ret = mapping->a_ops->readpage(file, page);
else if (ret == -EEXIST)
一個(gè)比較保險(xiǎn)的修復(fù)方法是,在 __add_to_page_cache_locked() 中判斷上層是否傳入 __GFP_NOFAIL,在 mapping_gfp_constraint() 之后把 __GFP_NOFAIL 加回來(lái)。相比直接刪掉 mapping_gfp_constraint(),這種改法不需要修改所有 add_to_page_cache_lru() 的 caller,盡量保持原有的邏輯。
保證 grow_dev_page() 始終使用 __GFP_NOFAIL
在主線代碼中,我們發(fā)現(xiàn)下面三個(gè) patch 加強(qiáng)了 grow_dev_page(),保證在所有場(chǎng)景中 grow_dev_page() 都使用 __GFP_NOFAIL。
bc48f001de12 buffer: eliminate the need to call free_more_memory() in getblk_slow()
94dc24c0c59a buffer: grow_dev_page() should use GFP_NOFAIL for all cases
640ab98fb362 buffer: have alloc_page_buffers() use __GFP_NOFAIL
commit 94dc24c0c59a224a093f110060d01c2c620f275a
Author: Jens Axboe
Date: Wed Sep 27 05:45:36 2017 -0600
buffer: grow_dev_page() should use __GFP_NOFAIL for all cases
We currently use it for find_or_create_page(), which means that it
cannot fail. Ensure we also pass in 'retry == true' to
alloc_page_buffers(), which also ensure that it cannot fail.
After this, there are no failure cases in grow_dev_page() that
occur because of a failed memory allocation.
最終修復(fù)方案
帶上上述兩方面修復(fù),我們的復(fù)現(xiàn)腳本再也沒(méi)有復(fù)現(xiàn)該問(wèn)題。目前我們已經(jīng)把該問(wèn)題和可能的修復(fù)方式匯報(bào)給 Redhat(https://bugzilla.redhat.com/show_bug.cgi?id=1911392),希望后續(xù)能夠得到官方確認(rèn)的最終修復(fù)方式。
總結(jié)
OOM Killer 是 Linux 提供的一項(xiàng)基本功能,然而這個(gè)場(chǎng)景相對(duì)比較罕見(jiàn),難以保證各個(gè)模塊在內(nèi)存分配時(shí)能夠很好地處理。對(duì)于服務(wù)提供方,一方面,我們需要盡可能保證我們的服務(wù)不會(huì)出現(xiàn)內(nèi)存泄漏或者 OOM,這非常困難卻是值得投入的。另一方面,Linux Kernel 的 OOM 處理也飽受大家詬病,有許多開(kāi)源項(xiàng)目幫助大家在用戶態(tài)做好 OOM 監(jiān)控,例如 facebookincubator/oomd(https://github.com/facebookincubator/oomd) 和 rfjakob/earlyoom(https://github.com/rfjakob/earlyoom),這樣就可以規(guī)避掉 Kernel OOM Killer 潛在的問(wèn)題。
對(duì)文件系統(tǒng)和內(nèi)核感興趣的同學(xué)也歡迎加入我們。
作者介紹
Jiewei,SmartX 研發(fā)工程師。SmartX 擁有國(guó)內(nèi)最頂尖的分布式存儲(chǔ)和超融合架構(gòu)研發(fā)團(tuán)隊(duì),是國(guó)內(nèi)超融合領(lǐng)域的技術(shù)領(lǐng)導(dǎo)者。
