MAATRIX / Блог / PID 1 в контейнере: почему сигналы теряются и контейнер останавливается 10 секунд

PID 1 в контейнере: почему сигналы теряются и контейнер останавливается 10 секунд

MAATRIX

Вы жмёте docker stop, и контейнер вместо мгновенной остановки думает несколько секунд, а потом всё равно обрывается жёстко — будто вы и не просили его завершиться по-хорошему. Причина почти всегда одна и та же: процесс, который ядро считает PID 1 внутри контейнера, живёт по особым правилам обработки сигналов, и если приложение их не учитывает, сигнал завершения просто никуда не доходит.

Почему PID 1 — не такой процесс, как все остальные

В классическом Unix PID 1 — это init: первый процесс, который запускает ядро при старте системы, и родитель всех остальных процессов. У init есть особая обязанность — «подбирать» (reap) завершившиеся процессы, у которых умер непосредственный родитель, чтобы они не зависали в системе как зомби. Из-за этой особой роли ядро обращается с сигналами для PID 1 не так, как с сигналами для любого другого процесса.

Для обычного процесса у каждого сигнала есть действие по умолчанию (default disposition): SIGTERM без обработчика завершает процесс, SIGINT — тоже завершает, SIGSTOP — приостанавливает, и так далее. Ядро просто применяет это действие, если процесс сам не назначил обработчик через sigaction().

Для PID 1 это правило не работает. Ядро Linux не применяет действие по умолчанию к сигналам, адресованным PID 1, если только процесс явно не зарегистрировал для них обработчик. Единственные исключения — SIGKILL и SIGSTOP, которые нельзя ни поймать, ни проигнорировать ни для одного процесса, включая PID 1. Всё остальное — SIGTERM, SIGINT, SIGHUP, SIGUSR1 и десятки других — будет молча проигнорировано ядром, если процесс не сказал явно: «я умею такое обрабатывать».

Это не баг и не недосмотр разработчиков контейнерных рантаймов — так себя ведёт ядро для любого процесса с PID 1 в *его* PID-namespace. Контейнер — это, помимо прочего, собственное пространство имён процессов: тот процесс, который вы запускаете первым внутри контейнера, получает PID 1 не в системе целиком, а внутри своего namespace, и на него распространяется то же самое исключение. Подробнее о том, как контейнер вообще получает ощущение, что он «один в системе», — в статье про устройство namespace-изоляции контейнера.

Куда именно пропадает сигнал

Разберём на конкретном примере. У вас в Dockerfile:

FROM node:20-alpine
COPY . /app
WORKDIR /app
CMD ["node", "server.js"]

Если это единственная команда и она указана в exec-форме (массивом), node действительно становится PID 1 внутри контейнера. Если ваше Node-приложение не подписалось на SIGTERM явно:

// такого обработчика в коде нет
process.on('SIGTERM', () => {
  server.close(() => process.exit(0));
});

— то при получении SIGTERM с процессом node не происходит ровным счётом ничего. Не потому что Node.js как-то по-особому обрабатывает сигналы (обычно всё наоборот — рантаймы вроде Node или Python в дефолтном состоянии просто наследуют системное поведение для сигнала), а потому что ядро в принципе не выполняет для PID 1 стандартное «завершить процесс», если обработчик не установлен. Сигнал долетает до процесса, ядро смотрит — обработчика нет, действие по умолчанию к PID 1 не применяется — и на этом всё, событие исчерпано. Никакой ошибки, никакой записи в лог: снаружи это выглядит так, будто SIGTERM был потерян.

Важно понимать: сама механика доставки сигнала не ломается. Проблема не в том, что «Docker не смог отправить SIGTERM» — она в том, что ядро для процесса PID 1 не переводит необработанный сигнал в конкретное действие. Это принципиальное отличие от бага — с точки зрения ядра всё работает штатно, просто правила для PID 1 в контейнерном мире оказываются неожиданными для тех, кто их не знает.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Что при этом делает docker stop

Команда docker stop не убивает контейнер сразу — она сначала пытается остановить его аккуратно:

  1. Отправляет SIGTERM процессу с PID 1 внутри контейнера.
  2. Ждёт какое-то время, пока процесс завершится сам.
  3. Если по истечении этого времени контейнер всё ещё жив — отправляет SIGKILL, который принудительно и мгновенно завершает процесс.

По умолчанию в Docker это время ожидания — 10 секунд (флаг -t / --time у docker stop, либо stop_grace_period в docker-compose). Именно поэтому симптом настолько узнаваем: вы просите контейнер остановиться, он «зависает» на несколько секунд — ровно на то время, что Docker вежливо ждёт реакции на SIGTERM, — а затем обрывается по SIGKILL, потому что реакции так и не последовало. Конкретное число секунд зависит от вашей конфигурации: кто-то увеличивает таймаут для сервисов с долгим graceful shutdown, кто-то уменьшает его в CI. Смысл проблемы от точного значения не меняется.

Разница хорошо видна, если сравнить с docker kill, которая сразу шлёт SIGKILL и не ждёт вообще ничего — контейнер останавливается мгновенно, но без единого шанса корректно закрыть соединения, дописать буферы на диск или откатить незавершённую транзакцию.

Shell-обёртка усугubляет проблему

Отдельная ловушка — shell-форма команды в Dockerfile:

# shell-форма: PID 1 — это /bin/sh, а не ваше приложение
CMD node server.js

Здесь Docker неявно оборачивает команду в /bin/sh -c "node server.js". PID 1 внутри контейнера — это sh, а сам node запускается как его дочерний процесс с PID 2 (или больше). Даже если вы честно повесили обработчик SIGTERM в коде Node.js-приложения, это ничего не меняет: сигнал от docker stop приходит именно PID 1, то есть shell'у, а не вашему процессу.

Большинство минимальных shell'ов (в частности sh из busybox, который используется в Alpine-образах) в неинтерактивном режиме не занимаются пересылкой сигналов своим дочерним процессам — они не устанавливают trap автоматически. Получается двойная потеря: сигнал приходит не туда, кому он на самом деле нужен, а тот, кому он приходит (shell), тоже не обрабатывает его должным образом и по правилу PID 1 просто его роняет.

Это одна из причин, почему в реальных инцидентах разработчики подолгу не могут понять, куда девается SIGTERM — обработчик в коде есть, а эффекта ноль. Разбор похожего случая на практике, с логами и последовательностью событий, — в статье «PID один не пересылал сигналы, и деплой ронял запросы».

Проверить, кто на самом деле PID 1 в вашем контейнере, просто:

docker top <container_id>
# или изнутри
docker exec <container_id> ps aux

Если в верхней строке PID 1 — это sh или /bin/sh -c ..., а не ваш процесс, вы нашли источник проблемы.

tini и другие init-процессы как решение

Специализированный init-процесс вроде tini решает именно эту задачу — быть корректным PID 1 внутри контейнера. У него две обязанности:

  • Пересылка сигналов. tini явно регистрирует обработчики для сигналов вроде SIGTERM и SIGINT и, получив сигнал, пересылает его настоящему процессу приложения (и, если нужно, всей группе процессов). Приложению больше не нужно быть PID 1, чтобы получать сигналы штатно — этим занимается tini.
  • Сбор зомби-процессов. Классическая обязанность init — «подбирать» завершившихся детей, у которых умер родитель, вызывая wait(). Если этого не делает никто, в контейнере накапливаются процессы-зомби. Для одного короткоживущего процесса это некритично, но для приложений, которые сами порождают дочерние процессы (воркеры, cron внутри контейнера, обёртки над CLI-утилитами), отсутствие reaping — реальная проблема: в PID-namespace контейнера ограниченное число доступных PID, и зомби, которые никто не подбирает, займут часть этого пространства.

У Docker есть встроенная поддержка через флаг:

docker run --init myimage

В docker-compose аналогично:

services:
  app:
    image: myimage
    init: true

Флаг --init подключает встроенный минимальный init-процесс (основанный на tini) как PID 1, а ваша команда из CMD/ENTRYPOINT запускается уже как его дочерний процесс. Ничего в образе менять не нужно.

Альтернатива — явно прописать tini в Dockerfile, что даёт больше контроля (например, за какими сигналами следить или как группировать процессы):

FROM node:20-alpine
RUN apk add --no-cache tini
COPY . /app
WORKDIR /app
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]

Здесь tini становится PID 1, корректно пересылает сигналы процессу node и подбирает зомби, если ваше приложение сам порождает дочерние процессы. Похожим образом устроен dumb-init от Yelp — более старый и минималистичный аналог с тем же назначением. Для контейнеров, где реально работает несколько независимых процессов (а не один основной плюс случайные дети), обычно берут более тяжёлые supervisor-решения вроде s6-overlay или supervisord — но и в этом случае вопрос «кто здесь PID 1 и правильно ли он себя ведёт» никуда не девается, только становится сложнее, поскольку кто-то в этой связке всё равно обязан взять на себя роль init.

Практика: как найти и починить проблему в своём проекте

Порядок диагностики и исправления обычно такой.

1. Проверьте, кто в контейнере PID 1.

docker exec <container> ps -ef

Если верхний процесс — интерпретатор shell (sh, bash) или обёртка вроде npm start/entrypoint.sh без явной пересылки сигналов внутри, это первый кандидат на проблему.

2. Используйте exec-форму в Dockerfile, а не shell-форму, если у вас не многошаговый entrypoint:

# плохо: PID 1 — /bin/sh
CMD node server.js

# лучше: PID 1 — node
CMD ["node", "server.js"]

3. Если entrypoint.sh всё же нужен (например, для подготовительных шагов перед запуском основного процесса — миграций, генерации конфигов), используйте exec в конце скрипта, чтобы заменить процесс shell процессом приложения, а не породить его как ребёнка:

#!/bin/sh
set -e
echo "Running migrations..."
./migrate.sh
exec "$@"

Ключевое слово exec здесь важно: без него "$@" запустится как дочерний процесс shell'а, и PID 1 так и останется за shell'ом. С exec образ процесса приложения замещает образ shell в том же PID, и приложение само становится PID 1 — со всеми вытекающими требованиями к обработке сигналов.

4. Добавьте init-процесс--init в docker run / init: true в compose или явный tini в Dockerfile, как показано выше. Это снимает с вас необходимость решать проблему пересылки сигналов и reaping'а зомби на уровне приложения.

5. В любом случае добавьте обработчик SIGTERM в самом приложении. Init-процесс решает, что сигнал дойдёт до приложения — но что приложение будет делать с этим сигналом, решать всё равно вам:

let shuttingDown = false;

process.on('SIGTERM', () => {
  if (shuttingDown) return;
  shuttingDown = true;
  server.close(() => {
    db.end();
    process.exit(0);
  });
  // страховка на случай, если graceful shutdown завис
  setTimeout(() => process.exit(1), 8000).unref();
});

Восемь секунд в примере выбраны не случайно, а с запасом относительно дефолтного окна в 10 секунд у docker stop — если ваш graceful shutdown обычно длится дольше, увеличьте и таймаут у docker stop/stop_grace_period, и внутренний предохранитель, но держите между ними разумный зазор.

6. Не пытайтесь «решить» проблему одним лишь увеличением таймаута. Если приложение вообще не реагирует на SIGTERM, растягивание stop_grace_period до минуты просто отодвигает момент принудительного SIGKILL — оно не делает остановку более аккуратной, только более долгой. Таймаут нужен как страховка на случай зависшего graceful shutdown, а не как замена самому graceful shutdown.

Для образов, где контейнер иногда падает сразу после старта по смежным причинам — неверный ENTRYPOINT, отсутствующий бинарник, ошибка в CMD, — стоит заодно свериться с разбором в статье «Docker-контейнер сразу падает: причины и решение»: там симптом другой, но диагностика начинается с того же вопроса — что именно является PID 1 и что он делает при старте.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Если мой процесс сам PID 1 и уже обрабатывает SIGTERM, нужен ли мне tini?

Для доставки сигнала — нет, обработчик сработает штатно. Но если ваше приложение порождает дочерние процессы (воркеры, вызовы внешних утилит через exec/spawn), вопрос reaping'а зомби остаётся, и init-процесс всё равно избавляет вас от необходимости решать его вручную.

Почему docker kill останавливает контейнер мгновенно, а docker stop — нет?

docker kill по умолчанию сразу отправляет SIGKILL, минуя стадию ожидания. docker stop сначала пытается завершить процесс аккуратно через SIGTERM и только потом, если реакции не было, переходит к SIGKILL — отсюда и задержка.

Можно ли просто выставить stop_grace_period: 0s и не разбираться дальше?

Технически да, но это не чинит проблему, а обходит её: контейнер будет останавливаться по SIGKILL сразу, без единой попытки корректного завершения — с риском не дописать данные на диск, оставить незакрытые соединения или прервать запрос ровно в середине.

Актуально ли это для Kubernetes, а не только для Docker?

Да, механика та же: под получает SIGTERM в момент начала терминации, и если процесс с PID 1 внутри контейнера пода его не обрабатывает, ничего не происходит вплоть до истечения terminationGracePeriodSeconds, после чего kubelet посылает SIGKILL. Отличие лишь в том, что окно ожидания и место его настройки другие, а сама проблема с PID 1 — идентична.

Что если в контейнере несколько процессов и я не могу выбрать один «главный»?

Тогда вам, скорее всего, нужен не просто tini, а полноценный supervisor (s6-overlay, supervisord), который сам возьмёт на себя роль PID 1, будет следить за состоянием всех дочерних процессов и решать, что делать при получении сигнала на весь контейнер сразу.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →