runc hang 導致 Kubernetes 節點 NotReady

2022-07-04 12:00:41

Kubernetes 1.19.3

OS: CentOS 7.9.2009

Kernel: 5.4.94-1.el7.elrepo.x86_64

Docker: 20.10.6

先說結論,runc v1.0.0-rc93 有 bug,會導致 docker hang 住。

發現問題

線上告警提示叢集中存在 2-3 個 K8s 節點處於 NotReady 的狀態,並且 NotReady 狀態一直持續。

  • kubectl describe node,有 NotReady 相關事件。

  • 登入問題機器後,檢視節點負載情況,一切正常。

  • 檢視 kubelet 紀錄檔,發現 PLEG 時間過長,導致節點被標記為 NotReady。

  • docker ps 正常。

  • 執行 ps 檢視程序,發現存在幾個 runc init 的程序。runc 是 containerd 啟動容器時呼叫的 OCI Runtime 程式。初步懷疑是 docker hang 住了。

要解決這個問題可以通過兩種方法,首先來看一下 A 方案。

解決方案 A

針對 docker hang 住這樣的現象,通過搜尋資料後發現了以下兩篇文章裡也遇到了相似的問題:

這兩篇文章都提到了是由於 pipe 容量不夠導致 runc init 往 pipe 寫入卡住了,將 /proc/sys/fs/pipe-user-pages-soft 的限制放開,就能解決問題。

於是,檢視問題主機上 /proc/sys/fs/pipe-user-pages-soft 設定的是 16384。所以將它放大 10 倍 echo 163840 > /proc/sys/fs/pipe-user-pages-soft,然而 kubelet 還是沒有恢復正常,pleg 報錯紀錄檔還在持續,runc init 程式也沒有退出。

考慮到 runc init 是 kubelet 呼叫 CRI 介面建立的,可能需要將 runc init 退出才能使 kubelet 退出。而根據文章中的說明,只需要將對應的 pipe 中的內容讀取掉,runc init 就能退出。因為讀取 pipe 的內容可以利用「UNIX/Linux 一切皆檔案」的原則,通過 lsof -p 檢視 runc init 開啟的控制程式碼資訊,獲取寫入型別的 pipe 對應的編號(可能存在多個),依次執行 cat /proc/$pid/fd/$id 的方式,讀取 pipe 中的內容。嘗試了幾個後,runc init 果然退出了。

再次檢查,節點狀態切換成 Ready,pleg 報錯紀錄檔也消失了,觀察一天也沒有出現節點 NotReady 的情況,問題(臨時)解決。

對解決方案 A 疑問

雖然問題解決了,但是仔細讀 /proc/sys/fs/pipe-user-pages-soft 引數的說明檔案,不難發現這個引數跟本次問題的根本原因不太對得上。

pipe-user-pages-soft 含義是對沒有 CAP_SYS_RESOURCE CAP_SYS_ADMIN 許可權的使用者使用 pipe 容量大小做出限制,預設最多隻能使用 1024 個 pipe,一個 pipe 容量大小為 16k。

那這裡就有了疑問:

  • dockerd/containerd/kubelet 等元件均通過 root 使用者執行,並且 runc init 處於容器初始化階段,理論上不會將 1024 個 pipe 消耗掉。因此,pipe-user-pages-soft 不會對 docker hang 住這個問題產生影響,但是實際引數放大後問題就消失了,解釋不通。

  • pipe 容量是固定,使用者在建立 pipe 時無法宣告容量。從線上來看,pipe 的確被建出來了,容量是固定的話,不應該因為使用者使用 pipe 總量超過 pipe-user-pages-soft 限制,而導致無法寫入的問題。是不是新建立的 pipe 容量變小了,導致原先可以寫入的資料,本次無法寫入了?

  • 目前對 pipe-user-pages-soft 放大了 10 倍,放大 2 倍夠不夠,哪個值是最合適的值?

探索

定位問題最直接的方法,就是閱讀原始碼。

先檢視下 Linux 核心跟 pipe-user-pages-soft 相關的程式碼。線上核心版本為 5.4.94-1,切換到對應的版本進行檢索。

static bool too_many_pipe_buffers_soft(unsigned long user_bufs)
{
        unsigned long soft_limit = READ_ONCE(pipe_user_pages_soft);

        return soft_limit && user_bufs > soft_limit;
}

struct pipe_inode_info *alloc_pipe_info(void)
{
  ...
  unsigned long pipe_bufs = PIPE_DEF_BUFFERS;  // #define PIPE_DEF_BUFFERS        16
  ...

        if (too_many_pipe_buffers_soft(user_bufs) && is_unprivileged_user()) {
                user_bufs = account_pipe_buffers(user, pipe_bufs, 2);
                pipe_bufs = 2;
        }

        if (too_many_pipe_buffers_hard(user_bufs) && is_unprivileged_user())
                goto out_revert_acct;

        pipe->bufs = kcalloc(pipe_bufs, sizeof(struct pipe_buffer),
                             GFP_KERNEL_ACCOUNT);
  ...
}

在建立 pipe 時,核心會通過 too_many_pipe_buffers_soft 檢查是否超過當前使用者可使用 pipe 容量大小。如果發現已經超過,則將容量大小從 16 個 PAGE_SIZE 調整成 2 個 PAGE_SIZE。通過機器上執行 getconf PAGESIZE 可以獲取到 PAGESIZE 是 4096 位元組,也就是說正常情況下 pipe 大小為 164096 位元組,但是由於超過限制,pipe 大小被調整成 24096 位元組,這就有可能出現資料無法一次性寫入 pipe 的問題,基本可以驗證問題 2 的猜想。

至此,pipe-user-pages-soft 相關的邏輯也理順了,相對還是比較好理解的。

那麼,問題就回到了「為什麼容器 root 使用者 pipe 容量會超過限制」。

百分百復現

找到問題根本原因的第一步,往往是線上下環境復現問題。

由於線上環境已經都通過方案 A 做了緊急修復,因此,已經無法線上上分析問題了,需要找到一種必現的手段。

功夫不負有心人,在 issue 中找到了相同的問題,並且可以通過以下方法復現。

https://github.com/containerd/containerd/issues/5261

echo 1 > /proc/sys/fs/pipe-user-pages-soft
while true; do docker run -itd --security-opt=no-new-privileges nginx; done

執行以上命令之後,立刻就出現 runc init 卡住的情況,跟線上的現象是一致的。通過 lsof -p 檢視 runc init 開啟的檔案控制程式碼情況:

可以看到 fd4、fd5、fd6 都是 pipe 型別,其中,fd4 跟 fd6 編號都是 415841,是同一個 pipe。那麼,如何來獲取 pipe 大小來實際驗證下「疑問 2」中的猜想呢?Linux 下沒有現成的工具可以獲取 pipe 大小,但是核心開放了系統呼叫 fcntl(fd, F_GETPIPE_SZ)可以獲取到,程式碼如下:

#include <unistd.h>
#include <errno.h>
#include <stdio.h>
// Must use Linux specific fcntl header.
#include </usr/include/linux/fcntl.h>

int main(int argc, char *argv[]) {
    int fd = open(argv[1], O_RDONLY);
    if (fd < 0) {
        perror("open failed");
        return 1;
    }

    long pipe_size = (long)fcntl(fd, F_GETPIPE_SZ);
    if (pipe_size == -1) {
        perror("get pipe size failed.");
    }
    printf("pipe size: %ld\\n", pipe_size);

    close(fd);
}

編譯好之後,檢視 pipe 大小情況如下:

重點看下 fd4 跟 fd6,兩個控制程式碼對應的是同一個 pipe,獲取到的容量大小是 8192 = 2 * PAGESIZE。所以的確是因為 pipe 超過軟限制導致 pipe 容量被調整成了 2 * PAGESIZE。

使用 A 方案解決問題後,我們來看一下 B 方案。

解決方案 B

https://github.com/opencontainers/runc/pull/2871

該 bug 是在 runc v1.0.0-rc93 中引入的,並且在 v1.0.0-rc94 中通過上面的 PR 修復。那麼,線上應該如何做修復呢?是不是需要把 docker 所有元件都升級呢?

如果把 dockerd/containerd/runc 等元件都升級的話,就需要將業務切走然後才能升級,整個過程相對比較複雜,並且風險較高。而且在本次問題中,出問題的只有 runc,並且只有新建立的容器受到影響。因此順理成章考慮是否可以單獨升級 runc?

因為在 Kubernetes v1.19 版本中還沒有棄用 dockershim,因此執行容器整個呼叫鏈為:kubelet → dockerd → containerd → containerd-shim → runc → container。不同於 dockerd/containerd 是後臺執行的伺服器端,containerd-shim 呼叫 runc,實際是呼叫了 runc 二進位制來啟動容器。因此,我們只需要升級 runc,對於新建立的容器,就會使用新版本的 runc 來執行容器。

在測試環境驗證了下,的確不會出現 runc init 卡住的情況了。最終,逐步將線上 runc 升級成 v1.1.1,並將 /proc/sys/fs/pipe-user-pages-soft 調整回原預設值。runc hang 住的問題圓滿解決。

分析&總結

PR 做了什麼修復?

Bug 的緣由。當容器開啟 no-new-privileges 後,runc 會需要去解除安裝一段已經載入的 bpf 程式碼,然後重新載入 patch 後的 bpf 程式碼。在 bpf 的設計中,需要先獲取已經載入的 bpf 程式碼,然後才能利用這段程式碼呼叫解除安裝介面。在獲取 bpf 程式碼,核心開放了 seccomp_export_bpf 函數,runc 採用了 pipe 作為 fd 控制程式碼傳參來獲取程式碼,由於 seccomp_export_bpf 函數是同步阻塞的,核心會將程式碼寫入到 fd 控制程式碼中,因此,如果 pipe 大小太小的話,就會出現 pipe 資料寫滿後無法寫入 bpf 程式碼導致卡住的情況。

PR 中的解決方案。啟動一個 goroutine 來及時讀取 pipe 中的內容,而不是等資料寫入完成後再讀取。

為什麼超過限制?

容器的 root 使用者 UID 為 0,而宿主機的 root 使用者 UID 也是 0。在核心統計 pipe 使用量時,認為是同一使用者,沒有做區分。所以,當 runc init 申請 pipe 時,核心判斷當前使用者沒有特權,就查詢 UID 為 0 的使用者 pipe 使用量,由於核心統計的是所有 UID 為 0 使用者(包括容器內) pipe 使用量的總和,所以已經超過了 /proc/sys/fs/pipe-user-pages-soft 中的限制。而實際容器 root 使用者 pipe 使用量並沒有超過限制。這就解釋了前面提到的疑問 2。

所以我們最後做個總結,本次故障的原因是,作業系統對 pipe-user-pages-soft 有軟限制,但是由於容器 root 使用者的 UID 與宿主機一致都是 0,核心統計 pipe 使用量時沒有做區分,導致當 UID 為 0 的使用者 pipe 使用量超過軟限制後,新分配的 pipe 容量會變小。而 runc 1.0.0-rc93 正好會因為 pipe 容量太小,導致資料無法完整寫入,寫入阻塞,一直同步等待,進而 runc init 卡住,kubelet pleg 狀態異常,節點 NotReady。

修復方案,runc 通過 goroutine 及時讀取 pipe 內容,防止寫入阻塞。

參考資料

https://iximiuz.com/en/posts/container-learning-path/

https://medium.com/@mccode/understanding-how-uid-and-gid-work-in-docker-containers-c37a01d01cf

https://man7.org/linux/man-pages/man7/pipe.7.html

https://gist.github.com/cyfdecyf/1ee981611050202d670c

https://github.com/containerd/containerd/issues/5261

https://github.com/opencontainers/runc/pull/2871

推薦閱讀

面試官問,Redis 是單執行緒還是多執行緒我懵了

【實操乾貨】做好這 16 項優化,你的 Linux 作業系統煥然一新