<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          由 OOM 引發(fā)的 ext4 文件系統(tǒng)卡死

          共 23210字,需瀏覽 47分鐘

           ·

          2021-01-27 12:48


          注:本問題影響 3.10.0-862.el7.centos 及之后的 CentOS 7 版本內(nèi)核,目前問題還未被修復。

          背景

          近日,我司的測試同學發(fā)現(xiàn)內(nèi)部集群中一個存儲節(jié)點無法通過 ssh 訪問了。iDRAC console 界面出現(xiàn)如下圖的錯誤:有若干個服務(wù)進程由于 cgroup 超過內(nèi)存限制被 Kill,隨后許多進程陸續(xù)卡住,出現(xiàn) “task xxx blocked for more than 120 seconds”。此時,系統(tǒng)無法進行賬號登錄,似乎整個根文件系統(tǒng)(ext4)都卡住了。測試同學通過 ipmitool 工具發(fā)送 NMI 信號讓系統(tǒng)產(chǎn)生 vmcore(Linux 內(nèi)核崩潰時產(chǎn)生的內(nèi)核轉(zhuǎn)儲文件)以便后續(xù)分析,然后重啟機器讓業(yè)務(wù)恢復。

          將多個服務(wù)放置在 cgroup 中,限制它們的內(nèi)存使用量;當內(nèi)存使用量超過預(yù)設(shè)上限值,OOM(Out-of-Memory) Killer 按照一定策略 Kill 掉部分進程,實現(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)中某個服務(wù)存在內(nèi)存占用隨著實例數(shù)增加而線性增長的 bug。但是,為什么 OOM 會導致 ext4 文件系統(tǒng)卡死呢?

          相關(guān)問題檢索

          從日志和采集到的 vmcore 中,發(fā)現(xiàn)有許多進程都卡在 ext4/jbd2 的 I/O 操作上。其中 jbd2 (Journaling block device)是 kernel 中通用的 journal 模塊,被 ext4 使用。從日志中可以看出,OOM 后不久就觸發(fā)許多進程卡住,可以推斷 OOM 與 ext4 卡住存在很強的關(guān)聯(lián)性。

          為此,找到如下一些 RedHat 官方關(guān)于 ext4/jbd2 的 KB,但經(jīng)過版本和調(diào)用棧比對,我們排除了它們,看上去這是一個新的問題。

          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).

          嘗試復現(xiàn)

          嘗試根據(jù)現(xiàn)有線索構(gòu)建復現(xiàn)腳本,方便后續(xù) debug 分析和假想驗證。一旦復現(xiàn)問題,就可以生成 vmcore 做分析。然而,vmcore 只能記錄產(chǎn)生 vmcore 時刻的快照信息,犯罪進程可能已經(jīng)逃離現(xiàn)場,此時更詳細的日志可以幫助恢復現(xiàn)場。因此,我們還開啟了 jbd2 模塊日志功能,重新編譯內(nèi)核。

          初次嘗試

          根據(jù)網(wǎng)上一些相似問題的討論,我們最初的猜想是 cgroup 內(nèi)的進程在做 ext4 I/O 操作時,可能由于內(nèi)存限制無法申請到內(nèi)存后忘記釋放文件系統(tǒng)的某些關(guān)鍵資源,導致其他進程(可能是 cgroup 內(nèi),也可能不處于 cgroup 中)ext4 I/O 操作無法進行。于是,有了以下復現(xiàn)方法:

          • 在 cgroup 中有兩類進程,一類不斷分配內(nèi)存,在 15s 左右達到 cgroup 的內(nèi)存限制;另一類跑 fio,對 ext4 分區(qū)的文件做大塊隨機寫操作。每類進程各 3 個;如果進程被 OOM Kill,會被 systemd 重新拉起;
          • 在 cgroup 之外,有另外 3 個 fio 進程,對 ext4 分區(qū)的其他文件做大塊隨機寫操作;

          經(jīng)歷了 7 個小時左右,我們復現(xiàn)了一次,卡住現(xiàn)象與最初現(xiàn)場一致。但隨后一天中,4 個節(jié)點都沒法再次復現(xiàn),需要找到加速復現(xiàn)的方式。

          再次嘗試

          由于前兩次復現(xiàn)的場景最后被 OOM Kill 的進程都是 IO 進程,而上述復現(xiàn)步驟中經(jīng)常被 Kill 的是分配內(nèi)存的進程,因此考慮做以下調(diào)整:

          • 準備一個測試程序 ext4-repro-with-write,打開 ext4 文件系統(tǒng)上的文件,并不斷地對該文件做寫操作,過程中會涉及到 ext4 inode 內(nèi)部的一些內(nèi)存分配操作;測試的 cgroup 中只有這一類進程,OOM Kill 的只可能是這類進程;
          • 創(chuàng)建 10 個 cgroup,每個的內(nèi)存限制為 100 MB;
          • 對于每個 cgroup,創(chuàng)建 30 個 ext4-repro-with-write 進程;

          使用這一腳本,我們能夠在 10 分鐘以內(nèi)復現(xiàn)這個問題。相關(guān)復現(xiàn)腳本已經(jīng)上傳到 https://gist.github.com/jiewei-ke/4962ac0061703fdcd0837bab2dbec261。

          確定影響版本

          分別測試 CentOS 7 的幾個官方內(nèi)核版本以及 CentOS 8 的 4.18 內(nèi)核,我們確定:

          • CentOS 7 3.10.0-693.el7 版本沒有問題;
          • CentOS 7 3.10.0-862.el7 及后續(xù)版本(包含最新的 3.10.0-1160.el7)都有問題;
          • CentOS 8 4.18.0 版本沒有問題;

          即該問題是 CentOS 7 3.10.0-862.el7 引入的。

          vmcore 分析

          獲取到帶有 jbd2 模塊日志的 vmcore 后,下面使用 crash 工具對其進行分析。

          注:分析過程比較冗長,對于 vmcore 分析不感興趣的同學可以直接跳到 Recap 部分看結(jié)論。

          ext4/jbd2 簡介

          參考 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 時發(fā)生元數(shù)據(jù)損壞。每一個 ext4 文件系統(tǒng)實例對應(yīng)一個 jbd2 Journal 實例。每個用戶線程的一次數(shù)據(jù)更新稱為一個 Handle,在時間上連續(xù)的多個 Handle(可能來自不同線程)組成一個 Transaction,以便實現(xiàn)批量提交。Transaction 提交滿足原子性。

          Journal 包括兩個階段,Commit 和 Checkpoint。Commit 負責提交修改,由用戶線程和內(nèi)核 Journal 線程配合完成。

          • 當用戶程序?qū)?ext4 文件系統(tǒng)做文件操作(例如 create())時,用戶線程先獲取 Handle,然后獲取 Journal 當前的 running Transaction,如果存在則使用它,不存在則新創(chuàng)建一個。同時原子增加 running Transaction 的 t_updates 計數(shù),表示該 Transaction 被多少個線程使用。隨后,用戶線程將修改的元數(shù)據(jù)內(nèi)容加入到 Transaction 的 buffer 鏈表中,遞減 t_updates。
          • 內(nèi)核的 Journal 線程會定期喚醒,將 Journal 的 running Transaction 變?yōu)?committing 狀態(tài),然后等待 t_updates 歸零,即等待用戶線程完成插入 buffer 鏈表。隨后將 buffer 鏈表合并寫入到對應(yīng)的 Journal 區(qū)域。隨后該 Transaction 加入等待 checkpoint 的 Transaction 列表中。
          • 以上過程中,每個 Journal 實例同時只有一個 running Transaction 和一個 committing Transaction。

          Checkpoint 負責持久化修改,由用戶線程發(fā)起。用戶線程從待 checkpoint 的 Transaction 列表中獲取 buffer,然后合并寫入到物理磁盤,Transaction 至此結(jié)束。

          以上描述只關(guān)注本文涉及到的步驟。更詳細的過程請參考 High-Performance Transaction Processing in Journaling File Systems(https://www.usenix.org/system/files/conference/fast18/fast18-son.pdf) 的 Background 部分。

          進程堆棧分析

          針對 vmcore,通過 crash 命令 ”foreach bt > bt.log“ 獲得所有進程的堆棧信息。根據(jù) jbd2 關(guān)鍵字過濾進程,主要有以下幾類:

          類別 1:內(nèi)核的 Journal 線程卡在等待 t_updates 歸零

          上文提到,內(nèi)核 Journal 線程進行 Commit 之前需要先等待 t_updates 歸零。下圖的 Journal 線程正是卡在等待 t_updates 歸零的循環(huán)中,說明此時有一個用戶進程正在發(fā)起 Commit 操作,但是還沒有完成 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 完成的進程

          有 3 個進程,它們都在等待 Transaction Commit 的完成。jbd2_log_wait_commit() 正在等待 j_wait_done_commit,該鎖正被類別 1 的 Journal 線程持有,只有當 Journal 線程完成 jbd2_journal_commit_transaction() 時,才會釋放 j_wait_done_commit。因此,這 3 個進程也是被動卡住的線程,正在等待 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 的用戶進程

          這類有 32 個進程,數(shù)量比較多,都卡在 jbd2__journal_start() -> wait_transaction_locked() 上,在 Commit Transaction 的早期階段。這些進程都是被動卡住的,不是事發(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 線程卡住是導致其他進程卡住的原因,此時整個 ext4 文件系統(tǒng)的文件操作都無法繼續(xù)。下面著重分析該進程。

          內(nèi)核的 Journal 線程分析

          首先檢查當前 Journal 的狀況。crash 切換到內(nèi)核 Journal 線程,顯示每個 stack frame 的參數(shù)寄存器內(nèi)容,主要關(guān)注 jbd2_journal_commit_transaction()。嘗試解析各個地址,發(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 的用戶進程分析

          t_updates 的更新是由提交 Transaction 的用戶進程完成的。下面看一下其更新流程。

          t_updates 遞增的過程

          在 Transaction 被提交前,用戶進程會先通過 jbd2__journal_start() 獲取當前進程的 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(); // 獲取進程的 Handle
          ...
          err = start_this_handle(journal, handle, gfp_mask);

          在 start_this_handle() 中,我們看到 Handle 中保存了 running Transaction 的指針,同時 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 遞減的過程

          相對應(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 為何沒有歸零?

          我們找一下是否有 Handle 持有著這個 Transaction。事先添加的 jbd2 模塊日志會打印出 Handle 相關(guān)日志,檢索日志中曾經(jīng)出現(xiàn)過的 Handle 指針如下,

          $ grep jbd2_journal_stop vmcore-dmesg.txt | grep Handle | awk '{print $7}' | sort | uniq
          ...
          ffff9f53f9676cf0
          ffff9f53fa89bb70
          ffff9f53fe3423c0

          逐個嘗試解析 Handle,發(fā)現(xiàn) Handle ffff9f53fa89bb70 正在處理上述的 Transaction ?0xffff9f53d4ec1900。其中,h_line_no 為 2264,即該 Handle 是在行號為 2264 的代碼啟動的(但沒有包含文件名信息)。

          crash> handle_t ffff9f53fa89bb70
          struct handle_t {
          {
          h_transaction = 0xffff9f53d4ec1900,
          h_journal = 0xffff9f53d4ec1900 // 這里好像填錯了,跟 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() 的行號正好與之匹配。根據(jù)代碼,ext4_new_inode_start_handle() 會調(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); // 還沒有調(diào)用
          if (err == -ENOSPC && ext4_should_retry_alloc(dir->i_sb, &retries))
          goto retry;
          return err;
          }

          t_updates 沒有歸零,說明 ext4_journal_stop() 沒有調(diào)用。如果 Handle 還沒有執(zhí)行 jbd2_journal_stop(),那么它會被某個進程所持有,保存在進程控制塊 task_struct 的 journal_info 中。通過搜索所有進程的 task_struct,可以看到 task 27225(我們的測試程序 ext4-repro-with-write)進程正在持有這個 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 進程處于 RUNNING 狀態(tài),正在執(zhí)行 ext4_create -> ext4_new_inode_start_handle -> __ext4_new_inode,此時 t_updates 已經(jīng)加一,但是由于 ext4_new_inode_start_handle() 還沒有結(jié)束,后續(xù)的 ext4_journal_stop() 也沒有執(zhí)行,故 t_updates 沒有歸零。

          問題出在 ext4_new_inode_start_handle(),如果 __get_blk() 獲取不到內(nèi)存時,會不斷地嘗試調(diào)用 free_more_memory() 釋放掉部分內(nèi)存(事實證明釋放并沒有成功),然后再嘗試獲取。如果一直獲取不到,那么將會卡在這里。

          get_blk 為何分配不到內(nèi)存?

          與前文的復現(xiàn)腳本略微有些差異,這個測試中 ext4-repro-with-write 內(nèi)部包含兩個線程,一個負責不斷分配內(nèi)存,另一個負責持續(xù)寫文件。在內(nèi)存限制為 900MB 左右的 cgroup 中啟動了 3 個進程。

          $cat /etc/systemd/system/system-fio.slice.d/50-MemoryLimit.conf
          [Slice]
          MemoryLimit=973741824
          >>> 973741824/1024.0/1024
          928.632568359375 // 900MB 左右

          按照 task group 方式檢查當前 3 個 ext4-repro-with-write 進程的內(nèi)存使用量,可以看到內(nèi)存使用已經(jīng)接近 cgroup 內(nèi)存限制,提交 Transaction 的 task 27225 所在的 group 只占用了 37 MB,而另外兩個進程各自占用 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 的進程

          >>> 37.8906+403.246+487.488
          928.6246

          從 vmcore-dmesg 日志看,最近一次觸發(fā) OOM Kill 的進程是 27153 ,但是內(nè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 進程,處于 UN 狀態(tài)而無法接受 Kill 信號,所以內(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

          如下圖,該進程屬于上述類型 2 的進程,它正在等到內(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ù)以上冗長的分析,OOM 導致 ext4 文件系統(tǒng)死鎖的時間線整理如下:

          • 系統(tǒng)內(nèi)有兩個用戶進程,位于同一個 cgroup 中,cgroup 上限制最大可用的內(nèi)存;
          • 進程 1,分配大量內(nèi)存,使得 cgroup 內(nèi)存使用量超過限制,OOM Killer 選擇該進程殺死;該進程正在執(zhí)行 ext4_sync_file(),在 jbd2_log_wait_commit() 等待進程 2 的 Transaction 完成;此時該進程處于不可中斷狀態(tài),無法被 Kill,內(nèi)存無法被釋放;
          • 進程 2,處于 ext4_create()(或者其他 ext4_xxx 函數(shù))中,在 __getblk() 上等待可用的內(nèi)存;只有拿到足夠內(nèi)存,才能完成 ext4_journal_stop(),將 t_updates 遞減,其對應(yīng)的內(nèi)核 Journal 線程才能完成 jbd2_journal_commit_transaction();
          • (死鎖條件 1)進程 1 在等待進程 2 的 Transaction Commit 完成才能被 Kill 而釋放內(nèi)存;(死鎖條件 2)進程 2 在等待進程 1 的釋放內(nèi)存才能拿到內(nèi)存完成 Transaction Commit。

          解決方案

          在上述兩個死鎖條件中,只要我們破壞一個條件就可以避免死鎖發(fā)生。條件 1 是 jbd2 的實現(xiàn)要求,我們難以改變;條件 2 則有可能打破,例如,針對這種關(guān)鍵路徑上的內(nèi)存分配,允許 cgroup 的內(nèi)存暫時超過限制。

          Linux VMM(Virtual Memory Manager)提供了 Get Free Page(GFP)Flags 來控制分配器的分配、釋放行為。其中有一個 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ù)代碼確認,如果分配內(nèi)存時 gfp_mask 攜帶了 __GFP_NOFAIL,那么 VMM 會盡量滿足分配需求,不再理會 cgroup 的內(nèi)存限制。更詳細的 GFP 說明請參考 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,按理來說,后續(xù)的內(nèi)存分配就不會受到 cgroup 限制了(__mem_cgroup_try_charge() )。但為什么 __getblk() 還是沒有成功呢?

          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 是引入該問題的版本。我們對照 3.10.0-693.el7.centos 和 3.10.0-862.el7.centos 兩個版本在上述路徑上的代碼差異,發(fā)現(xiàn)后者在函數(shù) __add_to_page_cache_locked() 中,增加了以下代碼。mapping_gfp_constraint() 會將傳遞的 gfp_mask 與 inode->i_mapping 支持的 flags 進行按位與操作。根據(jù) vmcore 分析,確認 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 的主線代碼歷史中并沒有在該位置添加過這行代碼。我們傾向于認為是 RedHat 的維護者在移植以下 patch 出錯了,mapping_gfp_constraint() 應(yīng)該針對部分操作作為 add_to_page_cache_lru() 的參數(shù)傳遞,而不是在隨后的 __add_to_page_cache_locked() 無條件執(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)

          一個比較保險的修復方法是,在 __add_to_page_cache_locked() 中判斷上層是否傳入 __GFP_NOFAIL,在 mapping_gfp_constraint() 之后把 __GFP_NOFAIL 加回來。相比直接刪掉 mapping_gfp_constraint(),這種改法不需要修改所有 add_to_page_cache_lru() 的 caller,盡量保持原有的邏輯。

          保證 grow_dev_page() 始終使用 __GFP_NOFAIL

          在主線代碼中,我們發(fā)現(xiàn)下面三個 patch 加強了 grow_dev_page(),保證在所有場景中 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.

          最終修復方案

          帶上上述兩方面修復,我們的復現(xiàn)腳本再也沒有復現(xiàn)該問題。目前我們已經(jīng)把該問題和可能的修復方式匯報給 Redhat(https://bugzilla.redhat.com/show_bug.cgi?id=1911392),希望后續(xù)能夠得到官方確認的最終修復方式。

          總結(jié)

          OOM Killer 是 Linux 提供的一項基本功能,然而這個場景相對比較罕見,難以保證各個模塊在內(nèi)存分配時能夠很好地處理。對于服務(wù)提供方,一方面,我們需要盡可能保證我們的服務(wù)不會出現(xiàn)內(nèi)存泄漏或者 OOM,這非常困難卻是值得投入的。另一方面,Linux Kernel 的 OOM 處理也飽受大家詬病,有許多開源項目幫助大家在用戶態(tài)做好 OOM 監(jiān)控,例如 facebookincubator/oomd(https://github.com/facebookincubator/oomd) 和 rfjakob/earlyoom(https://github.com/rfjakob/earlyoom),這樣就可以規(guī)避掉 Kernel OOM Killer 潛在的問題。

          對文件系統(tǒng)和內(nèi)核感興趣的同學也歡迎加入我們。


          作者介紹

          Jiewei,SmartX 研發(fā)工程師。SmartX 擁有國內(nèi)最頂尖的分布式存儲和超融合架構(gòu)研發(fā)團隊,是國內(nèi)超融合領(lǐng)域的技術(shù)領(lǐng)導者。


          瀏覽 98
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  北条麻妃高清无码视频 | 波多野结衣网站视频 | 一区视频 | 午夜福利美女视频 | 使劲操蜜桃av |