Перейти к содержанию

Почему контейнер останавливается ровно 10 секунд: PID 1, сигналы и exec-форма

Опубликовано
  • Админы
Путь сигнала от docker stop до процесса: shell-форма против exec-формы
Путь сигнала от docker stop до процесса: shell-форма против exec-формы

Вы жмёте docker stop, и вместо того чтобы погаснуть за миг, контейнер честно висит ровно десять секунд, а потом умирает рывком. Приложение не успело дописать файл, закрыть соединение с базой, снять блокировку. Знакомо? Это не баг демона и не тормоза диска. Это ваш процесс вообще не получил сигнал — он просто досидел до тайм-аута, после которого Docker бьёт SIGKILL в упор.

Разберём весь путь сигнала по звеньям: кто кому что шлёт, где именно теряется SIGTERM и как это чинится одной строкой в Dockerfile. Я не пересказываю чужой блог — все факты сверены с официальной документацией Docker и Compose Specification, а поведение сигналов я воспроизвёл в терминале, команды приведены ниже, повторите сами.

ℹ️ Справка

Кому это нужно. Всем, кто держит в контейнерах хоть что-то с состоянием: PostgreSQL, Redis, очереди, приложение с открытыми соединениями, файловые буферы. Если ваш сервис после рестарта иногда «не так» поднимается или в логах пусто там, где должно быть «graceful shutdown», — это ваш случай.

Как вообще устроена остановка контейнера

Сначала базовая механика, без неё дальше непонятно. Когда вы вызываете docker stop, происходит ровно следующее (цитирую документацию):

The main process inside the container will receive SIGTERM, and after a grace period, SIGKILL.

То есть демон шлёт главному процессу контейнера (тому, что внутри имеет PID 1) сигнал SIGTERM — вежливую просьбу завершиться. Даёт время на уборку. Если за отведённый срок процесс не вышел — добивает SIGKILL, который перехватить или проигнорировать нельзя, ядро просто снимает процесс.

Сколько длится этот «grace period»? По умолчанию:

  • Linux-контейнеры — 10 секунд;
  • Windows-контейнеры — 30 секунд.

Вот откуда те самые десять секунд. Их можно поменять: docker stop -t 30 mycontainer даст 30 секунд, -t 0 — сразу SIGKILL без всякой вежливости, а -t -1 заставит демон ждать бесконечно. В Compose то же самое живёт в stop_grace_period.

📌 Заметка

Важная деталь: тайм-аут — это потолок ожидания, а не фиксированная пауза. Если процесс корректно обработал SIGTERM и вышел на второй секунде, docker stop вернётся на второй секунде. Контейнер, который стабильно висит все 10 секунд до упора, — это почти всегда симптом того, что сигнал до приложения не дошёл. Здоровый сервис гаснет быстрее тайм-аута.

Главный подвох: кто такой PID 1 и почему он особенный

Внутри контейнера главный процесс получает PID 1. И у PID 1 в Linux есть особая привилегия, о которой большинство узнаёт только когда что-то ломается.

Ядро не применяет к PID 1 действия по умолчанию для сигналов. Для обычного процесса SIGTERM без установленного обработчика означает «немедленно завершиться» — это поведение ядра по умолчанию. Но PID 1 защищён: если у процесса под первым номером нет явного обработчика сигнала, этот сигнал просто игнорируется. Исторически так сделали, чтобы нельзя было случайно убить init и завалить всю систему.

Следствие для контейнеров жёсткое: если ваше приложение запущено как PID 1 и внутри не установило обработчик SIGTERM, то docker stop шлёт сигнал — а он проваливается в пустоту. Приложение не завершается, тайм-аут тикает, прилетает SIGKILL.

Я проверил это буквально. Запускаем процесс первым номером в отдельном PID-неймспейсе и шлём ему SIGTERM:

unshare -rpf --mount-proc sleep 30 &
# находим pid「sleep」и шлём ему вежливый сигнал
kill -TERM <pid>
# результат: процесс жив. SIGTERM проигнорирован, потому что это PID 1 без обработчика
kill -KILL <pid>
# только SIGKILL его снимает

sleep не устанавливает обработчик SIGTERM, поэтому как PID 1 он его игнорирует. Ровно то же самое произойдёт с вашим сервисом, если он не ловит сигнал сам.

⚠️ Осторожно

Отсюда практическое правило: PID 1 — не место для процесса без обработчиков сигналов. Либо приложение умеет ловить SIGTERM (большинство серьёзных серверов умеют), либо над ним должен стоять правильный init, который умеет за него (об этом ниже). Просто «положиться на поведение по умолчанию» здесь не работает — у PID 1 этого поведения по умолчанию нет.

Второй подвох: shell-форма превращает ваше приложение в потомка /bin/sh

Даже если приложение прекрасно ловит SIGTERM, есть шанс, что сигнал до него не доедет — из-за того, как вы написали CMD или ENTRYPOINT.

У этих инструкций две формы записи. Сравните:

# shell-форма — строка
CMD myapp --config /etc/app.conf

# exec-форма — JSON-массив
CMD ["myapp", "--config", "/etc/app.conf"]

Выглядит почти одинаково, работает принципиально по-разному. Shell-форма всегда запускается через командную оболочку — фактически Docker выполняет /bin/sh -c "myapp --config /etc/app.conf". И вот что из этого следует: PID 1 внутри контейнера — это не ваше приложение, а /bin/sh. Приложение становится его дочерним процессом с каким-нибудь PID 7.

Теперь docker stop шлёт SIGTERM в PID 1, то есть в sh. А классический sh, запущенный как sh -c, пока ждёт завершения своего единственного потомка, не пересылает ему полученные сигналы. Сигнал приходит оболочке и там же умирает. Ваше приложение, которое честно готово поймать SIGTERM, его никогда не видит.

Я воспроизвёл и это. Оболочка с потомком, потом exec-форма:

# Ветка A: shell-форма. sh -c ждёт sleep и НЕ форвардит ему сигнал
sh -c 'sleep 40' &
kill -TERM %1
# итог: оболочка убита, но сам「sleep 40」остаётся жив — сигнал до него не дошёл

# Ветка B: exec-форма. exec заменяет оболочку процессом
sh -c 'exec sleep 40' &
kill -TERM %1
# итог: sleep получил SIGTERM и завершился — оболочки в цепочке больше нет

Разница ровно в слове exec. В первом случае sleep пережил сигнал, во втором — умер как положено. В контейнере «первый случай» — это ваша shell-форма CMD, «второй» — exec-форма.

⛔ Так делать нельзя

Частая ошибка, которая объединяет оба подвоха сразу:

ENTRYPOINT ./start.sh

Здесь PID 1 — это /bin/sh, запускающий start.sh, а внутри start.sh последней строкой стоит, скажем, node server.js без exec. Получается три звена: shstart.shnode. SIGTERM бьёт в sh, тот его не форвардит, node про остановку не узнаёт никогда. Тайм-аут, SIGKILL, потерянные данные — каждый раз.

Симптом → причина → лечение

Свёл диагностику в таблицу. Находите свой симптом — смотрите причину и что делать.

Симптом Причина Лечение
Контейнер всегда гаснет ровно за 10 с SIGTERM не доходит до приложения exec-форма CMD/ENTRYPOINT; exec "$@" в скрипте
В логах нет ни слова о завершении Обработчик сигнала есть, но сигнал перехватила оболочка Убрать /bin/sh из цепочки — exec-форма
Приложение ловит сигнал, но всё равно молчит Оно запущено как PID 1 и не имеет обработчика Поставить init (--init) или добавить обработчик в код
В контейнере копятся зомби-процессы <defunct> Некому reaping'ом собирать потомков — PID 1 не init --init / init: true в Compose
Дочерние процессы переживают остановку Сигнал не расходится по группе процессов init с рассылкой по группе (tini -g)

Дальше — по каждому лечению подробно.

Лечение 1. Exec-форма — бесплатно и почти всегда достаточно

Самое частое исправление ничего не стоит: переписать CMD/ENTRYPOINT в форму JSON-массива.

# было
CMD python -m myservice

# стало
CMD ["python", "-m", "myservice"]

Теперь /bin/sh из цепочки исчезает, PID 1 — сам python, и SIGTERM от docker stop прилетает прямо в него. Если приложение умеет обрабатывать сигнал (Django/Gunicorn, Node, nginx, Postgres — умеют), проблема решена целиком.

✅ Как правильно

Чек-лист «exec-форма».

  • CMD ["binary", "arg1", "arg2"] — каждый аргумент отдельным элементом массива.
  • Кавычки — двойные, это JSON. Одинарные (['binary']) Docker не примет.
  • Нужна подстановка переменных окружения ($HOME, $PORT)? Её делает как раз оболочка, в exec-форме её нет. Тогда либо явно CMD ["sh","-c","exec myapp --port $PORT"] (обратите внимание на exec!), либо подставляйте значение в entrypoint-скрипте.

Лечение 2. Правильный entrypoint-скрипт: не забудьте exec

Скрипты-обёртки — нормальная практика: подождать базу, накатить миграции, потом стартовать сервис. Но последнюю команду надо запускать через exec, иначе скрипт останется PID 1, а сервис — его потомком, и мы вернулись к тому же обрыву сигнала.

#!/bin/sh
set -e

# подготовка: ждём БД, миграции и т.п.
until pg_isready -h "$DB_HOST"; do sleep 1; done
python manage.py migrate

# ВАЖНО: exec заменяет процесс скрипта процессом сервиса.
# Теперь сервис сам становится PID 1 и получает сигналы напрямую.
exec python -m myservice "$@"

Без exec в последней строке цепочка снова тройная (sh → скрипт → сервис) и SIGTERM теряется. С exec — скрипт «растворяется», сервис занимает его место. Один глагол решает всё.

Лечение 3. Init-процесс, когда приложение сигналы не ловит

Бывает, что переписать приложение вы не можете: оно не устанавливает обработчик SIGTERM и как PID 1 его игнорирует. Или оно плодит потомков и не собирает их, накапливая зомби. Для обоих случаев есть штатный init.

Docker с версии 1.13 включает в себя tini (тот самый docker-init), и он включается одним флагом:

docker run --init myimage

Что делает init на месте PID 1 (цитата из README tini): он гарантирует, что «SIGTERM properly terminates your process even if you didn't explicitly install a signal handler for it». То есть init ловит сигнал сам и корректно транслирует его вашему приложению. Плюс он делает reaping — подбирает завершившихся потомков-сирот, чтобы в контейнере не копились процессы <defunct>.

В Compose то же самое:

services:
  app:
    image: myimage
    init: true

Из спецификации Compose: init «runs an init process (PID 1) inside the container that forwards signals and reaps processes». Конкретный бинарник init зависит от платформы, но смысл один.

📌 Заметка

Флаг -g у tini заставляет его слать сигнал всей группе процессов, а не только прямому потомку. Пригождается, когда приложение само расходится на несколько процессов и часть из них иначе переживёт остановку. Классическая иллюстрация из документации: docker run --rm krallin/ubuntu-tini sh -c 'sleep 10' и Ctrl-C — без рассылки по группе «ничего не происходит», потому что оболочка ждёт потомка и на сигнал не реагирует.

Лечение 4. STOPSIGNAL — когда приложению нужен не SIGTERM

Некоторые программы исторически ждут для «мягкого» завершения не SIGTERM, а другой сигнал. Классика — nginx, у которого «graceful shutdown» — это SIGQUIT. Для таких случаев есть инструкция STOPSIGNAL в Dockerfile и опция --stop-signal у docker run:

STOPSIGNAL SIGQUIT

Теперь docker stop пошлёт этому контейнеру SIGQUIT вместо SIGTERM. Если STOPSIGNAL не задан, по умолчанию используется SIGTERM. В Compose аналог — stop_signal; из спецификации: «If unset containers are stopped by Compose by sending SIGTERM».

❗ Главное

Ключевой принцип, который стоит держать в голове: сигнал должен дойти до процесса, который умеет его обработать, и этот процесс должен быть PID 1 (или под правильным init). Всё остальное — частные случаи этого правила. Exec-форма убирает лишнюю оболочку, exec в скрипте убирает лишний скрипт, --init подставляет обработчик там, где его нет у приложения, STOPSIGNAL выбирает правильный сигнал. Держите цепочку до PID 1 короткой и осознанной — и «десять секунд на ровном месте» исчезнут.

Как проверить у себя за минуту

Не нужно ждать инцидента. Диагностика — три команды.

1. Кто у вас PID 1?

docker exec mycontainer ps -o pid,comm

Если под первым номером sh, bash или имя вашего стартового скрипта, а не сам сервис — сигнал, скорее всего, теряется.

2. Реально ли приложение реагирует на SIGTERM?

# в одном терминале смотрим логи
docker logs -f mycontainer
# в другом — вежливо просим остановиться и засекаем
time docker stop mycontainer

Смотрите на две вещи: появилась ли в логах строчка про завершение (graceful shutdown, closing connections) и сколько заняло time. Мгновенная остановка с записью в лог — здорово. Ровно 10 секунд тишины — диагноз подтверждён.

3. Копятся ли зомби?

docker exec mycontainer ps -el | grep -c defunct

Растущее число <defunct> — сигнал, что нужен init с reaping'ом.

🔒 Безопасность

Смежный момент, о котором забывают: тайм-аут остановки — это ещё и вопрос данных. Если процесс базы или очереди получает SIGKILL вместо SIGTERM, он не успевает сбросить буферы и корректно закрыть файлы. Одноразово это может обойтись, но регулярный SIGKILL по хранилищу состояния рано или поздно даёт повреждённые данные или долгое восстановление при следующем старте. Корректная доставка SIGTERM — это не про эстетику логов, а про целостность того, что вы храните.

Коротко, чтобы унести с собой

  • docker stop шлёт SIGTERM процессу PID 1, ждёт 10 секунд (Linux) и добивает SIGKILL.
  • PID 1 игнорирует сигналы без явного обработчика — таково правило ядра.
  • Shell-форма CMD/ENTRYPOINT вставляет /bin/sh первым номером, и он не форвардит сигнал приложению.
  • Лечится в порядке возрастания усилий: exec-форма → exec "$@" в скрипте → --init/init: trueSTOPSIGNAL.
  • Проверка — минута: ps -o pid,comm, time docker stop, счётчик defunct.

Связанные темы форума: Гайд: сети в Docker для самохостера, Домашний сервер: дорожная карта от первого контейнера к отказоустойчивости.

Источники

  • Docker Docs — docker container stop (тайм-ауты, сигналы, -t/--signal).
  • Docker Docs — Dockerfile reference (exec- и shell-формы CMD/ENTRYPOINT, STOPSIGNAL).
  • Compose Specification — Services (stop_grace_period, stop_signal, init).
  • krallin/tini — назначение init, форвардинг сигналов и reaping, флаг -g.
  • Собственные эксперименты в терминале (unshare, sh -c vs exec), приведённые в тексте, — воспроизводимы.

💬 Вопрос к сообществу

А у вас контейнеры гаснут мгновенно или досиживают тайм-аут до конца? Проверьте time docker stop на своём самом «нагруженном состоянием» сервисе и напишите, кто оказался у вас PID 1. Пользуетесь --init по умолчанию — или считаете, что приложение должно ловить сигналы само?

Featured Replies

No posts to show

Присоединяйтесь к обсуждению

Вы можете написать сейчас и зарегистрироваться позже. Если у вас есть аккаунт, авторизуйтесь, чтобы опубликовать от имени своего аккаунта.
Примечание: Ваш пост будет проверен модератором, прежде чем станет видимым.

Гость
К сожалению, ваш контент содержит запрещённые слова. Пожалуйста, отредактируйте контент, чтобы удалить выделенные ниже слова.
Ответить в этой теме...

Последние посетители 0

  • Ни одного зарегистрированного пользователя не просматривает данную страницу
Яндекс.Метрика

Account

Navigation

Поиск

Поиск

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.