Наша команда вычислительных ресурсов 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», который пройдет уже сегодня вечером. Записаться можно здесь.

Комментарии (1)


  1. DungeonLords
    13.12.2023 08:47

    Прочитал как детектив - на одном дыхании! Определенно лайк.