這個特殊問題涉及自定義內部FUSE 文件系統:ndrive。它已經潰爛了一段時間,但需要有人坐下來憤怒地看著它。/proc這篇博文描述了在將問題發布到內核郵件列表并了解內核等待代碼的實際工作原理之前,我是如何深入了解發生了什么的!
癥狀:卡住 Docker Kill 和僵尸進程
我們有一個停滯的 docker API 調用:
goroutine 146 [選擇,8817 分鐘]:
net/http.(*persistConn).roundTrip(0xc000658fc0, 0xc0003fc080, 0x0, 0x0, 0x0)
/usr/local/go/src/net/http/transport.go:2610 +0x765
net/http.(*Transport).roundTrip(0xc000420140, 0xc000966200, 0x30, 0x1366f20, 0x162)
/usr/local/go/src/net/http/transport.go:592 +0xacb
net/http.(*Transport).往返(0xc000420140、0xc000966200、0xc000420140、0x0、0x0)
/usr/local/go/src/net/http/roundtrip.go:17 +0x35
net/http.send(0xc000966200、0x161eba0、0xc000420 140、0x0、0x0、0x0、 0xc00000e050, 0x3, 0x1, 0x0)
/usr/local/go/src/net/http/client.go:251 +0x454
net/http.(*Client).send(0xc000438480, 0xc000966200, 0x0, 0x0, 0x0, 0xc00000e 050 , 0x0, 0x1, 0x10000168e)
/usr/local/go/src/net/http/client.go:175 +0xff
net/http.(*客戶端)。做(0xc000438480, 0xc000966200, 0x0, 0x0, 0x0)
/usr/local/go/src/net/http/client.go:717 +0x45f
net/http.(*Client).Do(...)
/usr/ local/go/src/net/http/client.go:585
golang.org/x/net/context/ctxhttp.Do(0x163bd48, 0xc000044090, 0xc000438480, 0xc000966100, 0x0, 0x0, 0x0)
/go/pkg/mod/ golang.org/x/net@v0.0.0-20211209124913-491a49abca63/context/ctxhttp/ctxhttp.go:27 +0x10f
Github.com/docker/docker/client.(*Client).doRequest(0xc0001a8200, 0x163bd48, 0xc00004409 0, 0xc000966100, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, ...)
/go/pkg/mod/github.com/moby/moby@v0.0.0-20190408150954-50ebe4562dfc/client/request.go:132 +0xbe
github.com/docker/docker/client.(*Client).sendRequest(0xc0001a8200, 0x163bd48, 0xc000044090, 0x13d8643, 0x3, 0xc00079a720, 0x51, 0x0, 0x0, 0x0, ...)
/go/pkg/mod /github。 com/moby/moby@v0.0.0-20190408150954-50ebe4562dfc/client/request.go:122 +0x156
github.com/docker/docker/client.(*Client).get(...)
/go/pkg/mod /github.com/moby/moby@v0.0.0-20190408150954-50ebe4562dfc/client/request.go:37
github.com/docker/docker/client.(*Client).ContainerInspect(0xc0001a8200, 0x163bd48, 0xc000044090, 0xc 0006a01c0, 0x40 , 0x0, 0x0, 0x0, 0x0, 0x0, ...)
/go/pkg/mod/github.com/moby/moby@v0.0.0-20190408150954-50ebe4562dfc/client/container_inspect.go:18 +0x128
github.com/Netflix/titus-executor/executor/runtime/docker.(*DockerRuntime).Kill(0xc000215180, 0x163bdb8, 0xc000938600, 0x1, 0x0, 0x0)
/var/lib/buildkite-agent/builds/ip-192- 168-1-90-1/netflix/titus-executor/executor/runtime/docker/docker.go:2835 +0x310
github.com/Netflix/titus-executor/executor/runner.(*Runner).doShutdown(0xc000432dc0, 0x163bd10, 0xc000938390, 0x1, 0xc000b821e0, 0x1d, 0xc0005e4710)
/var/lib/buildkite-agent/builds/ip-192-168-1-90-1/netflix/titus-executor/executor/runner/runner.go:3 26 +0x4f4
github.com/Netflix/titus-executor/executor/runner.(*Runner).startRunner(0xc000432dc0, 0x163bdb8, 0xc00071e0c0, 0xc0a502e28c08b488, 0x24572b8, 0x1df5980)
/var/lib/buildkite-agent/builds/ip-192-168-1-90-1/netflix/titus-executor/executor/runner/runner.go:122 +0x391
由 github.com/Netflix/titus- 創建執行者/執行者/runner.StartTaskWithRuntime
/var/lib/buildkite-agent/builds/ip-192-168-1-90-1/netflix/titus-executor/executor/runner/runner.go:81 +0x411
在這里,我們的管理引擎對 Docker API 的 unix 套接字進行了 HTTP 調用,要求它終止一個容器。我們的容器配置為通過SIGKILL. 但這很奇怪。kill(SIGKILL)應該是比較致命的,那么容器是干什么的呢?
$ docker exec -it 6643cd073492 bash
OCI runtime exec failed: exec failed: container_linux.go:380: starting container process caused: process_linux.go:130: executing setns process caused: exit status 1: 未知
唔。似乎它還活著,但setns(2)失敗了。為什么會這樣?如果我們通過查看進程樹ps awwfux,我們會看到:
_ containerd-shim -namespace moby -workdir /var/lib/containerd/io.containerd.runtime.v1.linux/moby/6643cd073492ba9166100ed30dbe389ff1caef0dc3d35
| _ [碼頭工人初始化]
| _ [ndrive] <已失效>
好的,所以容器的 init 進程仍然存在,但是它有一個僵尸子進程。容器的初始化進程可能在做什么?
# cat /proc/1528591/stack
[<0>] do_wait+0x156/0x2f0
[<0>] kernel_wait4+0x8d/0x140
[<0>] zap_pid_ns_processes+0x104/0x180
[<0>] do_exit+0xa41/0xb80
[< 0>] do_group_exit+0x3a/0xa0
[<0>] __x64_sys_exit_group+0x14/0x20
[<0>] do_syscall_64+0x37/0xb0
[<0>] entry_SYSCALL_64_after_hwframe+0x44/0xae
它正在退出,但似乎卡住了。不過,唯一的子進程是處于 Z(即“僵尸”)狀態的 ndrive 進程。Zombies 是已成功退出的進程,正在等待wait()其父進程的相應系統調用對其進行收割。那么內核怎么會卡在等待僵尸呢?
# ls /proc/1544450/任務
1544450 1544574
啊哈,線程組里有兩個線程。其中一個是僵尸,也許另一個不是:
# cat /proc/1544574/stack
[<0>] request_wait_answer+0x12f/0x210
[<0>] fuse_simple_request+0x109/0x2c0
[<0>] fuse_flush+0x16f/0x1b0
[<0>] filp_close+0x27/0x70
[< 0>] put_files_struct+0x6b/0xc0
[<0>] do_exit+0x360/0xb80
[<0>] do_group_exit+0x3a/0xa0
[<0>] get_signal+0x140/0x870
[<0>] arch_do_signal_or_restart+0xae/0x7c0
[< 0>] exit_to_user_mode_prepare+0x10f/0x1c0
[<0>] syscall_exit_to_user_mode+0x26/0x40
[<0>] do_syscall_64+0x46/0xb0
[<0>] entry_SYSCALL_64_after_hwframe+0x44/0xae
事實上它不是僵尸。它試圖盡可能地成為一個,但由于某種原因它在 FUSE 內部阻塞。為了找出原因,讓我們看一些內核代碼。如果我們查看zap_pid_ns_processes(),它會:
/*
* 在我們忽略 SIGCHLD 之前獲取我們擁有的 EXIT_ZOMBIE 孩子。
* kernel_wait4() 也將阻塞,直到我們從
* parent 命名空間追蹤到的孩子被分離并變成 EXIT_DEAD。
*/
做{
clear_thread_flag(TIF_SIGPENDING);
rc = kernel_wait4( -1 , NULL , __WALL, NULL );
} while (rc != -ECHILD);
這是我們卡住的地方,但在此之前,它已經完成了:
/* 不允許更多進程進入 pid 命名空間 */
disable_pid_allocation(pid_ns);
這就是為什么 docker 不能setns()——命名空間是一個僵尸。好的,所以我們不能setns(2),但為什么我們被困在里面kernel_wait4()?要了解原因,讓我們看看另一個線程在 FUSE 中做了什么request_wait_answer():
/*
* 要么請求已經在用戶空間中,要么是強制的。
* 等等。
*/
wait_event(req->waitq, test_bit(FR_FINISHED, &req->flags));
好的,所以我們正在等待一個事件(在這種情況下,用戶空間已經回復了 FUSE 刷新請求)。但是zap_pid_ns_processes()發了一個SIGKILL!SIGKILL對一個進程應該是非常致命的。如果我們看一下這個過程,我們確實可以看到有一個 pending SIGKILL:
# grep Pnd /proc/1544574/status
SigPnd: 0000000000000000
ShdPnd: 0000000000000100
這樣查看進程狀態,可以看到0x100(即第9位被置位)ShdPnd,是對應的信號號SIGKILL。掛起信號是由內核生成但尚未傳送到用戶空間的信號。信號僅在特定時間傳遞,例如進入或離開系統調用時,或等待事件時。如果內核當前正在代表任務做某事,則信號可能處于掛起狀態。信號也可以被任務阻塞,因此它們永遠不會被傳遞。被阻止的信號也將出現在它們各自的待處理集中。然而,man 7 signal他說:“信號SIGKILL不能SIGSTOP被捕獲、阻止或忽略。” 但是內核在這里告訴我們,我們有一個未決的SIGKILL,也就是即使在任務等待時它也被忽略了!
紅鯡魚:信號是如何工作的?
嗯,這很奇怪。等待代碼(即include/linux/wait.h)在內核中無處不在:信號量、等待隊列、完成等。它當然知道尋找SIGKILLs。那么wait_event()實際上是做什么的呢?通過宏擴展和包裝器挖掘,它的核心是:
# define ___wait_event(wq_head, condition, state, exclusive, ret, cmd)
({
__label__ __out;
struct wait_queue_entry __wq_entry;
long __ret = ret; /* 顯式陰影 */
init_wait_entry(&__wq_entry, exclusive ? WQ_FLAG_EXCLUSIVE : 0);
為 (;;) {
long __int = prepare_to_wait_event(&wq_head, &__wq_entry, state);
if (條件)
break;
if (___wait_is_interruptible(state) && __int) {
__ret = __int;
轉到 __out;
}
命令;
}
finish_wait(&wq_head, &__wq_entry);
__out: __ret;
})
所以它永遠循環,做prepare_to_wait_event(),檢查條件,然后檢查我們是否需要中斷。然后它確實如此cmd,在這種情況下是schedule(),即“暫時做其他事情”。prepare_to_wait_event()好像:
long prepare_to_wait_event ( struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry, int state)
{
無符號 長標志;
長ret = 0 ;
spin_lock_irqsave(&wq_head->lock, flags);
if (signal_pending_state(state, current)) {
/*
* 如果它被喚醒選擇,獨占的等待者不能失敗,
* 它應該“消耗”我們正在等待的條件。
*
* 調用者將重新檢查條件并返回成功如果
* 我們已經被喚醒,我們不能錯過事件,因為
* 喚醒鎖定/解鎖相同的 wq_head->lock。
*
* 但我們需要確保 set-condition + wakeup after that
* 看不到我們,如果
我們失敗,它應該喚醒另一個獨占的服務員。
*/
list_del_init(&wq_entry->entry);
ret = -ERESTARTSYS;
} else {
if (list_empty(&wq_entry->entry)) {
if (wq_entry->flags & WQ_FLAG_EXCLUSIVE)
__add_wait_queue_entry_tail(wq_head, wq_entry);
別的
__add_wait_queue(wq_head, wq_entry);
}
set_current_state(state);
}
spin_unlock_irqrestore(&wq_head->lock, flags);
返還;
}
EXPORT_SYMBOL(prepare_to_wait_event);
看起來我們可以使用非零退出代碼打破這種情況的唯一方法是 ifsignal_pending_state()為真。因為我們的調用站點是 just wait_event(),所以我們知道這里的狀態是TASK_UNINTERRUPTIBLE;的定義signal_pending_state()看起來像:
static inline int signal_pending_state ( unsigned int state, struct task_struct *p)
{
if (!(state & (TASK_INTERRUPTIBLE | TASK_WAKEKILL)))
返回 0 ;
如果(!signal_pending(p))
返回 0;
返回(狀態和 TASK_INTERRUPTIBLE)|| __fatal_signal_pending(p);
}
我們的任務是不可中斷的,所以第一個 if 失敗了。不過,我們的任務應該有一個待處理的信號,對嗎?
static inline int signal_pending ( struct task_struct *p)
{
/*
* TIF_NOTIFY_SIGNAL 并不是真正的信號,但它需要相同的
* 行為來確保我們跳出等待循環
* 以便可以處理通知信號回調。
*/
if (unlikely(test_tsk_thread_flag(p, TIF_NOTIFY_SIGNAL)))
return 1 ;
返回task_sigpending(p);
}
正如評論指出的那樣,TIF_NOTIFY_SIGNAL盡管它的名字在這里并不相關,但讓我們看看task_sigpending():
static inline int task_sigpending ( struct task_struct *p)
{
return unlikely(test_tsk_thread_flag(p,TIF_SIGPENDING));
}
唔。看起來我們應該設置那個標志,對吧?為了弄清楚這一點,讓我們看看信號傳遞是如何工作的。當我們關閉 中的 pid 命名空間時zap_pid_ns_processes(),它會:
group_send_sig_info(SIGKILL,SEND_SIG_PRIV,任務,PIDTYPE_MAX);
最終到達__send_signal_locked(),其中有:
掛起=(類型!= PIDTYPE_PID)?&t->signal->shared_pending : &t->pending;
...
sigaddset(&pending->signal, sig);
...
complete_signal(sig, t, type);
使用PIDTYPE_MAX這里作為類型有點奇怪,但它大致表示“這是發送此信號的非常特權的內核內容,你絕對應該傳遞它”。不過,這里有一些意想不到的后果,因為__send_signal_locked()最終將 發送SIGKILL到共享集,而不是單個任務集。如果我們查看代碼__fatal_signal_pending(),我們會看到:
static inline int __fatal_signal_pending( struct task_struct *p)
{
return unlikely(sigismember(&p->pending.signal, SIGKILL));
}
但事實證明這有點轉移注意力(盡管我 花 了 一段 時間才明白這一點)。
信號實際上是如何傳遞給進程的
要了解這里到底發生了什么,我們需要查看complete_signal(),因為它無條件地將 a 添加SIGKILL到任務的待處理集:
sigaddset(&t->pending.signal, SIGKILL);
但為什么它不起作用?在函數的頂部,我們有:
/*
* 現在找到一個我們可以喚醒的線程,從隊列中取出信號。
*
* 如果主線程需要信號,它會首先破解。
* 對普通熊而言,這可能是最不令人驚訝的。
*/
if (wants_signal(sig, p))
t = p;
else if ((type == PIDTYPE_PID) || thread_group_empty(p))
/*
* 只有一個線程,不需要被喚醒。
* 它會在再次運行之前使未阻塞的信號出隊。
*/
返回;
但正如Eric Biederman 所描述的SIGKILL,基本上每個線程都可以隨時處理一個。這是wants_signal():
static inline bool wants_signal ( int sig, struct task_struct *p)
{
if (sigismember(&p->blocked, sig))
返回 false ;
如果(p->flags & PF_EXITING)
返回 false;
如果(sig == SIGKILL)
返回 true;
如果(task_is_stopped_or_traced(p))
返回 false;
返回task_curr(p) || !task_sigpending(p);
}
所以……如果一個線程已經退出(即它有PF_EXITING),它不需要信號。考慮以下事件序列:
1. 任務打開一個 FUSE 文件,但沒有關閉它,然后退出。在退出期間,內核盡職地調用do_exit(),它執行以下操作:
退出信號(tsk);/* 設置 PF_EXITING */
2.do_exit()繼續執行exit_files(tsk);,這會刷新所有仍打開的文件,從而產生上面的堆棧跟蹤。
3. pid 命名空間退出,進入zap_pid_ns_processes(),向所有人發送一個SIGKILL(它預計是致命的),然后等待所有人退出。
4. 這會殺死 pid ns 中的 FUSE 守護進程,因此它永遠無法響應。
5.complete_signal()對于已經退出的 FUSE 任務忽略信號,因為它有PF_EXITING.
6.死鎖。如果不手動中止 FUSE 連接,事情將永遠掛起。
解決方案:不要等待!
在這種情況下等待刷新真的沒有意義:任務快結束了,所以沒有人可以告訴flush()to 的返回碼。事實證明,這個錯誤可能發生在幾個文件系統上(任何調用內核等待代碼的東西flush(),即基本上任何與本地內核之外的東西對話的東西)。
同時需要為單個文件系統打補丁,例如 FUSE 的修復程序在這里,它于 4 月 23 日在 Linux 6.3 中發布。
雖然這篇博文解決了 FUSE 死鎖問題,但 nfs 代碼和其他地方肯定存在問題,我們尚未在生產中遇到這些問題,但幾乎肯定會遇到。您還可以將其視為其他文件系統錯誤的癥狀。如果您有一個不會退出的 pid 名稱空間,則需要注意一些事項。
出處
:https://netflixtechblog.com/debugging-a-fuse-deadlock-in-the-linux-kernel-c75cd7989b6d