Наша команда вычислительных ресурсов Netflix (Compute team) отвечает за администрирование AWS, в том числе за автомасштабирование и развертывание контейнеризованных приложений, включая решение возникающих проблем. В мои обязанности входит разбор разного рода странных ошибок, о которых сообщают пользователи.
Проблема, описываемая далее, была связана с ndrive — внутренней файловой системой, реализованной с помощью FUSE. Я расскажу, как я копался в /proc в поисках причины, и узнал, как на самом деле работает wait в ядре.
Зависание Docker-контейнера при вызове kill и зомби-процесс
Мы столкнулись с зависанием при вызове API докера:
goroutine 146 [select, 8817 minutes]:
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).RoundTrip(0xc000420140, 0xc000966200, 0xc000420140, 0x0, 0x0)
/usr/local/go/src/net/http/roundtrip.go:17 +0x35
net/http.send(0xc000966200, 0x161eba0, 0xc000420140, 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, 0xc00000e050, 0x0, 0x1, 0x10000168e)
/usr/local/go/src/net/http/client.go:175 +0xff
net/http.(*Client).do(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, 0xc000044090, 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, 0xc0006a01c0, 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:326 +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
created by github.com/Netflix/titus-executor/executor/runner.StartTaskWithRuntime
/var/lib/buildkite-agent/builds/ip-192-168-1-90-1/netflix/titus-executor/executor/runner/runner.go:81 +0x411
Здесь наш сервис (management engine) выполняет HTTP-вызов к unix-сокету Docker API с просьбой уничтожить (kill
) контейнер. Наши контейнеры настроены на уничтожение через 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: unknown
Странно. Вроде бы контейнер еще жив, но setns(2)
завершается ошибкой. Что же происходит? Если мы посмотрим на дерево процессов через ps awwfux
, то увидим следующее:
\_ containerd-shim -namespace moby -workdir /var/lib/containerd/io.containerd.runtime.v1.linux/moby/6643cd073492ba9166100ed30dbe389ff1caef0dc3d35
| \_ [docker-init]
| \_ [ndrive] <defunct>
Итак, процесс init контейнера все еще жив, но у него есть один дочерний зомби. Чем может быть занят 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
Он находится в процессе завершения, но, похоже, завис, и единственный дочерний процесс ndrive — в состоянии Z ("зомби"). Зомби — это процессы, которые успешно завершились и ожидают, когда родительский процесс считает их код завершения с помощью системного вызова wait()
. Но как же ядро могло застрять в ожидании зомби?
# ls /proc/1544450/task
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(), в которой и застряли:
/*
* Reap the EXIT_ZOMBIE children we had before we ignored SIGCHLD.
* kernel_wait4() will also block until our children traced from the
* parent namespace are detached and become EXIT_DEAD.
*/
do {
clear_thread_flag(TIF_SIGPENDING);
rc = kernel_wait4(-1, NULL, __WALL, NULL);
} while (rc != -ECHILD);
Но перед этим выполняется:
/* Don't allow any more processes into the pid namespace */
disable_pid_allocation(pid_ns);
Поэтому докер и не может выполнить setns()
, так как пространство имен (namespace) — зомби. Хорошо, мы не можем выполнить setns(2)
, но почему мы застряли в kernel_wait4()
? Чтобы найти причину, давайте посмотрим, что делал другой поток в методе request_wait_answer() FUSE:
/*
* Either request is already in userspace, or it was forced.
* Wait it out.
*/
wait_event(req->waitq, test_bit(FR_FINISHED, &req->flags));
Здесь мы ждем события (в этом случае ответа из пользовательского пространства об обработке запроса сброса на диск (flush) от FUSE). Но zap_pid_ns_processes()
отправил SIGKILL
! Сигнал SIGKILL
должен быть фатальным для процесса. Если мы посмотрим на процесс, то действительно увидим, что есть ожидающий (pending) сигнал SIGKILL
:
# grep Pnd /proc/1544574/status
SigPnd: 0000000000000000
ShdPnd: 0000000000000100
В ShdPnd можно увидеть 0x100 (т. е. установлен 9-й бит), что соответствует SIGKILL.
Ожидающие (pending) сигналы — это сигналы, сгенерированные ядром, но еще не доставленные в пользовательское пространство. Сигналы доставляются только в определенное время, например, при входе или выходе из системного вызова или при ожидании событий. Если ядро в данный момент чем-то занято, сигнал может находиться в ожидании (pending). Также сигналы могут быть заблокированы задачей, и они никогда не будут доставлены. Заблокированные сигналы будут отображаться в соответствующих наборах. Однако в man 7 signal сказано: "Сигналы SIGKILL
и SIGSTOP
не могут быть пойманы, блокированы или проигнорированы". Но здесь ядро говорит, что есть ожидающий сигнал SIGKILL
, который игнорируется, даже во время ожидания задачи!
Ложный след: как работают сигналы?
Все это очень странно, учитывая, что wait (т. е. include/linux/wait.h
) используется в ядре повсюду: семафоры, очереди ожидания, завершения и т. д. Разумеется, он знает о SIGKILL
. Давайте разбираться, что же на самом деле делает wait_event()
? Покопавшись в коде, я нашел следующее:
#define ___wait_event(wq_head, condition, state, exclusive, ret, cmd) \
({ \
__label__ __out; \
struct wait_queue_entry __wq_entry; \
long __ret = ret; /* explicit shadow */ \
\
init_wait_entry(&__wq_entry, exclusive ? WQ_FLAG_EXCLUSIVE : 0); \
for (;;) { \
long __int = prepare_to_wait_event(&wq_head, &__wq_entry, state);\
\
if (condition) \
break; \
\
if (___wait_is_interruptible(state) && __int) { \
__ret = __int; \
goto __out; \
} \
\
cmd; \
} \
finish_wait(&wq_head, &__wq_entry); \
__out: __ret; \
})
В бесконечном цикле сначала выполняется prepare_to_wait_event()
, потом проверяется condition
, а после — проверка должны ли мы прерваться. После всех условий выполняется 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)
{
unsigned long flags;
long ret = 0;
spin_lock_irqsave(&wq_head->lock, flags);
if (signal_pending_state(state, current)) {
/*
* Exclusive waiter must not fail if it was selected by wakeup,
* it should "consume" the condition we were waiting for.
*
* The caller will recheck the condition and return success if
* we were already woken up, we can not miss the event because
* wakeup locks/unlocks the same wq_head->lock.
*
* But we need to ensure that set-condition + wakeup after that
* can't see us, it should wake up another exclusive waiter if
* we fail.
*/
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);
else
__add_wait_queue(wq_head, wq_entry);
}
set_current_state(state);
}
spin_unlock_irqrestore(&wq_head->lock, flags);
return ret;
}
EXPORT_SYMBOL(prepare_to_wait_event);
Похоже, что единственный выход из этой ситуации с ненулевым кодом возврата — если signal_pending_state()
будет true
. Поскольку данную функцию вызвали из 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)))
return 0;
if (!signal_pending(p))
return 0;
return (state & TASK_INTERRUPTIBLE) || __fatal_signal_pending(p);
}
Наша задача непрерываемая, поэтому первый if
не сработает. Однако у нашей задачи должен быть ожидающий сигнал, верно?
static inline int signal_pending(struct task_struct *p)
{
/*
* TIF_NOTIFY_SIGNAL isn't really a signal, but it requires the same
* behavior in terms of ensuring that we break out of wait loops
* so that notify signal callbacks can be processed.
*/
if (unlikely(test_tsk_thread_flag(p, TIF_NOTIFY_SIGNAL)))
return 1;
return 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, task, PIDTYPE_MAX);
Что в итоге приводит к функции __send_signal_locked()
, в которой есть:
pending = (type != PIDTYPE_PID) ? &t->signal->shared_pending : &t->pending;
...
sigaddset(&pending->signal, sig);
...
complete_signal(sig, t, type);
Использовать в качестве типа PIDTYPE_MAX
немного странно, но, по сути, здесь говорится: "это очень привилегированная часть ядра, отправляющая данный сигнал, и вы должны обязательно его доставить". Здесь также есть небольшое неожиданное последствие, заключающееся в том, что __send_signal_locked()
в итоге отправляет SIGKILL
в общий набор (shared set), а не в набор конкретной задачи. И если мы посмотрим на код __fatal_signal_pending()
, то увидим:
static inline int __fatal_signal_pending(struct task_struct *p)
{
return unlikely(sigismember(&p->pending.signal, SIGKILL));
}
Это ложный след (но мне потребовалось время, чтобы понять это).
Как сигналы на самом деле доставляются процессу
Чтобы понять, что на самом деле здесь происходит, нужно взглянуть на функцию complete_signal()
, поскольку она безусловно добавляет SIGKILL в набор ожидающих сигналов задачи (task’s pending set):
sigaddset(&t->pending.signal, SIGKILL);
Но почему это не работает? В начале функции есть следующее:
/*
* Now find a thread we can wake up to take the signal off the queue.
*
* If the main thread wants the signal, it gets first crack.
* Probably the least surprising to the average bear.
*/
if (wants_signal(sig, p))
t = p;
else if ((type == PIDTYPE_PID) || thread_group_empty(p))
/*
* There is just one thread and it does not need to be woken.
* It will dequeue unblocked signals before it runs again.
*/
return;
Как пояснил Эрик Бидерман (Eric Biederman), в принципе, каждый поток может обработать SIGKILL в любое время. Вот код wants_signal()
:
static inline bool wants_signal(int sig, struct task_struct *p)
{
if (sigismember(&p->blocked, sig))
return false;
if (p->flags & PF_EXITING)
return false;
if (sig == SIGKILL)
return true;
if (task_is_stopped_or_traced(p))
return false;
return task_curr(p) || !task_sigpending(p);
}
Итак… если поток уже завершается (т. е. у него есть PF_EXITING
), ему не нужно обрабатывать еще один сигнал завершения.
Давайте рассмотрим следующую последовательность событий:
1. Задача открывает файл, используя FUSE
, не закрывает его, и завершает работу. Во время этого завершения ядро послушно вызывает функцию do_exit()
, в которой выполняется:
exit_signals(tsk); /* sets PF_EXITING */
2. do_exit()
выполняется до exit_files(tsk)
, где сбрасываются (flush) все открытые файлы, в результате чего появляется приведенная выше трассировка стека.
3. Пространство имен pid
завершает работу, вызывается zap_pid_ns_processes()
, всем отправляется SIGKILL
(который, как ожидается, будет фатальным), а дальше — ожидание завершения работы всех процессов.
4. Это убивает демона FUSE
в pid ns
, поэтому он уже не сможет ответить.
5. complete_signal()
для задачи FUSE
, которая уже завершилась, игнорирует сигнал, т.к. у него есть PF_EXITING
.
6. Дедлок. Без ручного прерывания подключения к FUSE
, все будет висеть бесконечно.
Решение: не ждите!
В этом случае нет смысла ждать сброса (flush): задача умирает, поэтому некому сообщить код возврата flush()
. Эта ошибка может возникнуть и с другими файловыми системами, которые вызывают код wait
ядра во flash()
, т. е. практически со всеми, кто обращается к чему-то за пределами локального ядра.
Тем временем для некоторых файловых систем уже вышли патчи, например, здесь находится исправление для FUSE, которое включено в ядро Linux 6.3, выпущенное 23 апреля 2023 года.
В этой статье была речь о дедлоке FUSE, но определенно проблемы есть и в коде nfs и в других местах, с которыми мы еще не столкнулись в продакшене, но почти наверняка столкнемся. Также описанное поведение может быть симптомом других ошибок файловой системы. На это стоит обратить внимание, если у вас есть пространство имен pid, которое не завершается.
Это всего лишь один из примеров странных проблем, с которыми мы сталкиваемся в Netflix, активно используя контейнеры в больших масштабах.
В завершение приглашаем всех желающих на открытый урок OTUS «Инфраструктура как код, ansible, terraform», который пройдет уже сегодня вечером. Записаться можно здесь.
DungeonLords
Прочитал как детектив - на одном дыхании! Определенно лайк.