這個(gè)特殊問(wèn)題涉及自定義內(nèi)部FUSE 文件系統(tǒng):ndrive。它已經(jīng)潰爛了一段時(shí)間,但需要有人坐下來(lái)憤怒地看著它。/proc這篇博文描述了在將問(wèn)題發(fā)布到內(nèi)核郵件列表并了解內(nèi)核等待代碼的實(shí)際工作原理之前,我是如何深入了解發(fā)生了什么的!
癥狀:卡住 Docker Kill 和僵尸進(jìn)程
我們有一個(gè)停滯的 docker API 調(diào)用:
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.(*客戶(hù)端)。做(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/[email protected]/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/[email protected]/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/[email protected]/client/request.go:122 +0x156
github.com/docker/docker/client.(*Client).get(...)
/go/pkg/mod /github.com/moby/[email protected]/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/[email protected]/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- 創(chuàng)建執(zhí)行者/執(zhí)行者/runner.StartTaskWithRuntime
/var/lib/buildkite-agent/builds/ip-192-168-1-90-1/netflix/titus-executor/executor/runner/runner.go:81 +0x411
在這里,我們的管理引擎對(duì) Docker API 的 unix 套接字進(jìn)行了 HTTP 調(diào)用,要求它終止一個(gè)容器。我們的容器配置為通過(guò)SIGKILL. 但這很奇怪。kill(SIGKILL)應(yīng)該是比較致命的,那么容器是干什么的呢?
$ 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)失敗了。為什么會(huì)這樣?如果我們通過(guò)查看進(jìn)程樹(shù)ps awwfux,我們會(huì)看到:
_ containerd-shim -namespace moby -workdir /var/lib/containerd/io.containerd.runtime.v1.linux/moby/6643cd073492ba9166100ed30dbe389ff1caef0dc3d35
| _ [碼頭工人初始化]
| _ [ndrive] <已失效>
好的,所以容器的 init 進(jìn)程仍然存在,但是它有一個(gè)僵尸子進(jìn)程。容器的初始化進(jìn)程可能在做什么?
# 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
它正在退出,但似乎卡住了。不過(guò),唯一的子進(jìn)程是處于 Z(即“僵尸”)狀態(tài)的 ndrive 進(jìn)程。Zombies 是已成功退出的進(jìn)程,正在等待wait()其父進(jìn)程的相應(yīng)系統(tǒng)調(diào)用對(duì)其進(jìn)行收割。那么內(nèi)核怎么會(huì)卡在等待僵尸呢?
# ls /proc/1544450/任務(wù)
1544450 1544574
啊哈,線(xiàn)程組里有兩個(gè)線(xiàn)程。其中一個(gè)是僵尸,也許另一個(gè)不是:
# 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
事實(shí)上它不是僵尸。它試圖盡可能地成為一個(gè),但由于某種原因它在 FUSE 內(nèi)部阻塞。為了找出原因,讓我們看一些內(nèi)核代碼。如果我們查看zap_pid_ns_processes(),它會(huì):
/*
* 在我們忽略 SIGCHLD 之前獲取我們擁有的 EXIT_ZOMBIE 孩子。
* kernel_wait4() 也將阻塞,直到我們從
* parent 命名空間追蹤到的孩子被分離并變成 EXIT_DEAD。
*/
做{
clear_thread_flag(TIF_SIGPENDING);
rc = kernel_wait4( -1 , NULL , __WALL, NULL );
} while (rc != -ECHILD);
這是我們卡住的地方,但在此之前,它已經(jīng)完成了:
/* 不允許更多進(jìn)程進(jìn)入 pid 命名空間 */
disable_pid_allocation(pid_ns);
這就是為什么 docker 不能setns()——命名空間是一個(gè)僵尸。好的,所以我們不能setns(2),但為什么我們被困在里面kernel_wait4()?要了解原因,讓我們看看另一個(gè)線(xiàn)程在 FUSE 中做了什么request_wait_answer():
/*
* 要么請(qǐng)求已經(jīng)在用戶(hù)空間中,要么是強(qiáng)制的。
* 等等。
*/
wait_event(req->waitq, test_bit(FR_FINISHED, &req->flags));
好的,所以我們正在等待一個(gè)事件(在這種情況下,用戶(hù)空間已經(jīng)回復(fù)了 FUSE 刷新請(qǐng)求)。但是zap_pid_ns_processes()發(fā)了一個(gè)SIGKILL!SIGKILL對(duì)一個(gè)進(jìn)程應(yīng)該是非常致命的。如果我們看一下這個(gè)過(guò)程,我們確實(shí)可以看到有一個(gè) pending SIGKILL:
# grep Pnd /proc/1544574/status
SigPnd: 0000000000000000
ShdPnd: 0000000000000100
這樣查看進(jìn)程狀態(tài),可以看到0x100(即第9位被置位)ShdPnd,是對(duì)應(yīng)的信號(hào)號(hào)SIGKILL。掛起信號(hào)是由內(nèi)核生成但尚未傳送到用戶(hù)空間的信號(hào)。信號(hào)僅在特定時(shí)間傳遞,例如進(jìn)入或離開(kāi)系統(tǒng)調(diào)用時(shí),或等待事件時(shí)。如果內(nèi)核當(dāng)前正在代表任務(wù)做某事,則信號(hào)可能處于掛起狀態(tài)。信號(hào)也可以被任務(wù)阻塞,因此它們永遠(yuǎn)不會(huì)被傳遞。被阻止的信號(hào)也將出現(xiàn)在它們各自的待處理集中。然而,man 7 signal他說(shuō):“信號(hào)SIGKILL不能SIGSTOP被捕獲、阻止或忽略。” 但是內(nèi)核在這里告訴我們,我們有一個(gè)未決的SIGKILL,也就是即使在任務(wù)等待時(shí)它也被忽略了!
紅鯡魚(yú):信號(hào)是如何工作的?
嗯,這很奇怪。等待代碼(即include/linux/wait.h)在內(nèi)核中無(wú)處不在:信號(hào)量、等待隊(duì)列、完成等。它當(dāng)然知道尋找SIGKILLs。那么wait_event()實(shí)際上是做什么的呢?通過(guò)宏擴(kuò)展和包裝器挖掘,它的核心是:
# 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;
轉(zhuǎn)到 __out;
}
命令;
}
finish_wait(&wq_head, &__wq_entry);
__out: __ret;
})
所以它永遠(yuǎn)循環(huán),做prepare_to_wait_event(),檢查條件,然后檢查我們是否需要中斷。然后它確實(shí)如此cmd,在這種情況下是schedule(),即“暫時(shí)做其他事情”。prepare_to_wait_event()好像:
long prepare_to_wait_event ( struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry, int state)
{
無(wú)符號(hào) 長(zhǎng)標(biāo)志;
長(zhǎng)ret = 0 ;
spin_lock_irqsave(&wq_head->lock, flags);
if (signal_pending_state(state, current)) {
/*
* 如果它被喚醒選擇,獨(dú)占的等待者不能失敗,
* 它應(yīng)該“消耗”我們正在等待的條件。
*
* 調(diào)用者將重新檢查條件并返回成功如果
* 我們已經(jīng)被喚醒,我們不能錯(cuò)過(guò)事件,因?yàn)? * 喚醒鎖定/解鎖相同的 wq_head->lock。
*
* 但我們需要確保 set-condition + wakeup after that
* 看不到我們,如果
我們失敗,它應(yīng)該喚醒另一個(gè)獨(dú)占的服務(wù)員。
*/
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);
看起來(lái)我們可以使用非零退出代碼打破這種情況的唯一方法是 ifsignal_pending_state()為真。因?yàn)槲覀兊恼{(diào)用站點(diǎn)是 just wait_event(),所以我們知道這里的狀態(tài)是TASK_UNINTERRUPTIBLE;的定義signal_pending_state()看起來(lái)像:
static inline int signal_pending_state ( unsigned int state, struct task_struct *p)
{
if (!(state & (TASK_INTERRUPTIBLE | TASK_WAKEKILL)))
返回 0 ;
如果(!signal_pending(p))
返回 0;
返回(狀態(tài)和 TASK_INTERRUPTIBLE)|| __fatal_signal_pending(p);
}
我們的任務(wù)是不可中斷的,所以第一個(gè) if 失敗了。不過(guò),我們的任務(wù)應(yīng)該有一個(gè)待處理的信號(hào),對(duì)嗎?
static inline int signal_pending ( struct task_struct *p)
{
/*
* TIF_NOTIFY_SIGNAL 并不是真正的信號(hào),但它需要相同的
* 行為來(lái)確保我們跳出等待循環(huán)
* 以便可以處理通知信號(hào)回調(diào)。
*/
if (unlikely(test_tsk_thread_flag(p, TIF_NOTIFY_SIGNAL)))
return 1 ;
返回task_sigpending(p);
}
正如評(píng)論指出的那樣,TIF_NOTIFY_SIGNAL盡管它的名字在這里并不相關(guān),但讓我們看看task_sigpending():
static inline int task_sigpending ( struct task_struct *p)
{
return unlikely(test_tsk_thread_flag(p,TIF_SIGPENDING));
}
唔。看起來(lái)我們應(yīng)該設(shè)置那個(gè)標(biāo)志,對(duì)吧?為了弄清楚這一點(diǎn),讓我們看看信號(hào)傳遞是如何工作的。當(dāng)我們關(guān)閉 中的 pid 命名空間時(shí)zap_pid_ns_processes(),它會(huì):
group_send_sig_info(SIGKILL,SEND_SIG_PRIV,任務(wù),PIDTYPE_MAX);
最終到達(dá)__send_signal_locked(),其中有:
掛起=(類(lèi)型!= PIDTYPE_PID)?&t->signal->shared_pending : &t->pending;
...
sigaddset(&pending->signal, sig);
...
complete_signal(sig, t, type);
使用PIDTYPE_MAX這里作為類(lèi)型有點(diǎn)奇怪,但它大致表示“這是發(fā)送此信號(hào)的非常特權(quán)的內(nèi)核內(nèi)容,你絕對(duì)應(yīng)該傳遞它”。不過(guò),這里有一些意想不到的后果,因?yàn)開(kāi)_send_signal_locked()最終將 發(fā)送SIGKILL到共享集,而不是單個(gè)任務(wù)集。如果我們查看代碼__fatal_signal_pending(),我們會(huì)看到:
static inline int __fatal_signal_pending( struct task_struct *p)
{
return unlikely(sigismember(&p->pending.signal, SIGKILL));
}
但事實(shí)證明這有點(diǎn)轉(zhuǎn)移注意力(盡管我 花 了 一段 時(shí)間才明白這一點(diǎn))。
信號(hào)實(shí)際上是如何傳遞給進(jìn)程的
要了解這里到底發(fā)生了什么,我們需要查看complete_signal(),因?yàn)樗鼰o(wú)條件地將 a 添加SIGKILL到任務(wù)的待處理集:
sigaddset(&t->pending.signal, SIGKILL);
但為什么它不起作用?在函數(shù)的頂部,我們有:
/*
* 現(xiàn)在找到一個(gè)我們可以喚醒的線(xiàn)程,從隊(duì)列中取出信號(hào)。
*
* 如果主線(xiàn)程需要信號(hào),它會(huì)首先破解。
* 對(duì)普通熊而言,這可能是最不令人驚訝的。
*/
if (wants_signal(sig, p))
t = p;
else if ((type == PIDTYPE_PID) || thread_group_empty(p))
/*
* 只有一個(gè)線(xiàn)程,不需要被喚醒。
* 它會(huì)在再次運(yùn)行之前使未阻塞的信號(hào)出隊(duì)。
*/
返回;
但正如Eric Biederman 所描述的SIGKILL,基本上每個(gè)線(xiàn)程都可以隨時(shí)處理一個(gè)。這是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);
}
所以……如果一個(gè)線(xiàn)程已經(jīng)退出(即它有PF_EXITING),它不需要信號(hào)??紤]以下事件序列:
1. 任務(wù)打開(kāi)一個(gè) FUSE 文件,但沒(méi)有關(guān)閉它,然后退出。在退出期間,內(nèi)核盡職地調(diào)用do_exit(),它執(zhí)行以下操作:
退出信號(hào)(tsk);/* 設(shè)置 PF_EXITING */
2.do_exit()繼續(xù)執(zhí)行exit_files(tsk);,這會(huì)刷新所有仍打開(kāi)的文件,從而產(chǎn)生上面的堆棧跟蹤。
3. pid 命名空間退出,進(jìn)入zap_pid_ns_processes(),向所有人發(fā)送一個(gè)SIGKILL(它預(yù)計(jì)是致命的),然后等待所有人退出。
4. 這會(huì)殺死 pid ns 中的 FUSE 守護(hù)進(jìn)程,因此它永遠(yuǎn)無(wú)法響應(yīng)。
5.complete_signal()對(duì)于已經(jīng)退出的 FUSE 任務(wù)忽略信號(hào),因?yàn)樗蠵F_EXITING.
6.死鎖。如果不手動(dòng)中止 FUSE 連接,事情將永遠(yuǎn)掛起。
解決方案:不要等待!
在這種情況下等待刷新真的沒(méi)有意義:任務(wù)快結(jié)束了,所以沒(méi)有人可以告訴flush()to 的返回碼。事實(shí)證明,這個(gè)錯(cuò)誤可能發(fā)生在幾個(gè)文件系統(tǒng)上(任何調(diào)用內(nèi)核等待代碼的東西flush(),即基本上任何與本地內(nèi)核之外的東西對(duì)話(huà)的東西)。
同時(shí)需要為單個(gè)文件系統(tǒng)打補(bǔ)丁,例如 FUSE 的修復(fù)程序在這里,它于 4 月 23 日在 Linux 6.3 中發(fā)布。
雖然這篇博文解決了 FUSE 死鎖問(wèn)題,但 nfs 代碼和其他地方肯定存在問(wèn)題,我們尚未在生產(chǎn)中遇到這些問(wèn)題,但幾乎肯定會(huì)遇到。您還可以將其視為其他文件系統(tǒng)錯(cuò)誤的癥狀。如果您有一個(gè)不會(huì)退出的 pid 名稱(chēng)空間,則需要注意一些事項(xiàng)。
出處
:https://netflixtechblog.com/debugging-a-fuse-deadlock-in-the-linux-kernel-c75cd7989b6d






