.NETflix 的计算团队负责管理 Netflix 的所有 AWS 和容器化工作负载,包括自动缩放、容器部署、问题修复等。作为该团队的一员,我致力于修复用户报告的奇怪问题。
这个特殊问题涉及自定义内部FUSE 文件系统:ndrive。它已经溃烂了一段时间,但需要有人坐下来愤怒地看着它。/proc这篇博文描述了在将问题发布到内核邮件列表并了解内核等待代码的实际工作原理之前,我是如何深入了解发生了什么的!
我们有一个停滞的 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