Почему docker stop ждёт 10 секунд и теряет данные Python-воркера

· ·

Почему docker stop ждёт 10 секунд и теряет данные Python-воркера

PrimeCoder · 2026 · формат: tech

Разбор новинки простыми словами: что это, кому нужно и брать ли.

Проверка из трёх пунктов — exec в entrypoint, обработчик SIGTERM и stop_grace_period — за пару минут спасает незакоммиченные данные при каждом деплое.

Связанные услуги: каталог, AI Boost Team, стать клиентом.

Деплой, который всегда занимает десять секунд

Обновление парсера у заказчика выглядело одинаково каждый раз. docker compose up -d --build, строка Stopping worker висит секунд десять, контейнер пересоздаётся, работа идёт дальше. Раздражает, но не ломает. Пока не сломало.

После очередного обновления в утреннем отчёте не хватило куска данных. Парсер собирал цены пачками и писал пачку в базу одной транзакцией в конце. Момент остановки пришёлся на середину пачки. Транзакция не закоммитилась. Следующий запуск начал уже с новой точки, и дырка в отчёте осталась навсегда.

Вот тогда десять секунд перестали быть мелочью. За ними стоял SIGKILL, который прилетал процессу ровно в тот момент, когда тот дописывал данные.

Разбор этой истории на Хабре (автор whysafe, хабы Python / DevOps / Linux, около 2.1K охвата, пять минут чтения) полезен не красотой решения, а трезвостью диагностики. Там три причины накладываются друг на друга, и каждая по отдельности выглядит безобидно.

Что docker stop делает на самом деле

Механика короткая. Docker отправляет SIGTERM главному процессу контейнера, то есть PID 1. Дальше ждёт. Для Linux-контейнеров по умолчанию десять секунд. Не вышел за это время, приходит SIGKILL. Этот сигнал перехватить нельзя, ядро просто убивает процесс.

Отсюда простой диагностический признак: если остановка стабильно занимает почти ровно десять секунд, ваш процесс умер от SIGKILL, а не завершился сам. Проверяется одной командой:

docker ps -a --filter name=worker --format "{{.Names}} {{.Status}}"

Увидели Exited (137) в статусе, значит 137 = 128 + 9, смерть от сигнала номер 9. Нормальное завершение даёт Exited (0). Всё остальное — украшение.

Если остановка всегда занимает почти ровно 10 секунд, процесс почти наверняка умер от SIGKILL.

Дальше вопрос один: почему SIGTERM не сработал. В описанном кейсе причин было две, и они наложились.

Причина первая: сигнал уходит не тому процессу

Dockerfile парсера был самым обычным: базовый образ python:3.12-slim, копирование кода, запуск через CMD ["./entrypoint.sh"]. Внутри скрипта перед стартом воркера накатывались миграции: alembic upgrade head, потом python worker.py.

Выглядит безобидно ровно до момента, когда вы вспоминаете про PID 1. Главным процессом контейнера становится /bin/sh. Python запускается его дочерним процессом. SIGTERM от Docker уходит в shell. Shell его детям не пересылает. Вообще.

Проверить, кто у вас PID 1, можно без ps (в slim-образах его нет):

docker compose exec worker sh -c 'tr "\0" " " < /proc/1/cmdline; echo'

Ответ /bin/sh ./entrypoint.sh означает, что вы в ловушке.

Лечится одним словом. exec заменяет процесс shell на Python, и PID 1 становится уже Python:

exec python worker.py

Та же болезнь бывает с shell-формой CMD, когда вместо JSON-массива пишут строкой: CMD python worker.py. Docker запускает это через /bin/sh -c. Часть оболочек для одной простой команды сама делает exec, часть нет. Как только команд становится две через &&, гарантий не остаётся никаких.

Практическое правило: CMD и ENTRYPOINT пишутся только массивом, в скриптах перед последней командой всегда стоит exec.

Причина вторая: PID 1 живёт по отдельным правилам

После правки с exec автор ждал, что всё заработает. Пересобрал, запустил time docker compose stop worker. Снова десять секунд и снова код 137.

Здесь всплывает вторая особенность Linux, о которой обычно не думают. Для процесса с PID 1 внутри своего пространства имён ядро не применяет действия сигналов по умолчанию. Обычный процесс без обработчика SIGTERM просто умирает. PID 1 без обработчика тот же сигнал игнорирует.

У процесса с PID 1 внутри своего пространства имён ядро не применяет действия сигналов по умолчанию.

Логика защиты понятная: случайный сигнал не должен ронять init всей системы. Вот только в контейнере init это ваш скрипт с парсером.

Python сам вешает обработчик только на SIGINT, из него получается KeyboardInterrupt. На SIGTERM обработчика нет. Отсюда забавная картина, на которой легко сбиться: в docker run -it по Ctrl+C всё останавливается мгновенно, а docker stop висит десять секунд. Локально «всё работает», в проде теряются данные.

Отдельно про ботов: у aiogram 3 параметр handle_signals по умолчанию True, диспетчер сам регистрирует обработчики SIGTERM и SIGINT через loop.add_signal_handler. Поэтому боты останавливаются нормально, а самописный воркер нет. Разница именно в обработчике.

Причина третья: таймаут меньше, чем ваша работа

Даже идеальный обработчик не поможет, если пачка данных дольше, чем отведённое окно. В описанном кейсе одна пачка иногда шла до 20 секунд, когда сайт-источник тормозил. Десяти секунд не хватает, и Docker всё равно убивает процесс посреди записи.

Тут работает настройка, о которой вспоминают реже, чем про сигналы:

  • stop_grace_period: 30s в docker-compose.yml
  • docker run --stop-timeout 30
  • docker stop -t 30

Значение подбирается под самую долгую операцию, которую воркер обязан довести до конца. Не под среднее, а под худший реальный случай. Если пачка доходит до 20 секунд, окно в 30 секунд даёт запас на запись и закрытие соединения.

Почему init: true не спасает данные

Самый частый совет в таких тредах: добавьте init: true в compose или --init в docker run. Тогда PID 1 становится маленький init (tini), он пересылает сигналы дочернему процессу и подбирает зомби.

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

То есть данные теряются точно так же. Просто быстрее и незаметнее.

Так что init: true полезен, особенно если воркер запускает дочерние процессы (браузер для парсинга, например). Но корректное завершение он за вас не напишет.

Есть ещё хак STOPSIGNAL SIGINT в Dockerfile. Docker шлёт SIGINT, Python превращает его в KeyboardInterrupt. Работает, но исключение вылетает в произвольной точке кода, в том числе посреди записи в базу. Такой способ годится, когда нужно срочно заткнуть десятисекундную паузу, и плохо годится как постоянная практика.

Как выглядит воркер, который выходит сам

Правильная схема проста: сигнал не завершает работу, а поднимает флаг. Воркер дописывает текущую пачку, видит флаг и выходит сам. Никаких исключений посреди транзакции.

В шаблоне на asyncio это делается через loop.add_signal_handler для SIGTERM и SIGINT, который выставляет asyncio.Event. Колбэк вызывается внутри event loop, а не в произвольный момент, поэтому трогать asyncio.Event из него безопасно. С signal.signal такой фокус уже не пройдёт.

Второй важный элемент, пауза между циклами. Она сделана через asyncio.wait_for(stop.wait(), timeout=5), а не через asyncio.sleep(5). Разница принципиальная: во сне воркер, получив сигнал, досыпал бы свои пять секунд. С wait_for он просыпается сразу.

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

Проверка из трёх пунктов за пару минут

Это и есть практическая часть, ради которой стоит держать материал под рукой при каждом деплое.

  • Пункт 1. exec в entrypoint. Смотрите, что отвечает /proc/1/cmdline. Если там shell, добавьте exec перед последней командой и пересоберите образ.
  • Пункт 2. Обработчик SIGTERM. В воркере должен быть add_signal_handler на SIGTERM и SIGINT, поднимающий Event. Флаг проверяется в цикле между пачками, а не внутри пачки.
  • Пункт 3. stop_grace_period. Значение больше самой долгой пачки. Если худший случай 20 секунд, ставьте 30s, а не оставляйте дефолт.

Признак здоровья проверяется за один прогон: docker compose stop заметно быстрее десяти секунд, а в docker ps -a стоит Exited (0) вместо Exited (137).

Кому это нужно, а кому не срочно

В первую очередь тем, у кого воркер пишет данные пачками одной транзакцией: парсеры, загрузчики прайсов, ETL-скрипты, агрегаторы. Любая потеря середины пачки там не восстанавливается автоматически.

Тем, кто деплоит через docker compose up -d --build и принимает десятисекундную паузу за нормальную работу Docker. Она не нормальная, она диагностический симптом.

Тем, у кого entrypoint делает больше одной вещи: миграции, ожидание базы, прогрев кеша. Каждая дополнительная команда в скрипте повышает шанс, что PID 1 окажется shell.

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

Ограничения и подвох

add_signal_handler только на Unix. На Windows он бросает NotImplementedError, так что для локальной отладки нужен отдельный вариант. В контейнере всё работает как есть.

Флаг не спасает от SIGKILL. Если пачка всё равно дольше stop_grace_period, Docker убьёт процесс посреди работы. Обработчик покупает вам ровное завершение, но не бесконечное время.

init: true и exec не заменяют друг друга. exec отвечает за то, чтобы сигнал дошёл до Python. init нужен, чтобы подбирать зомби и пересылать сигналы дочерним процессам вроде браузера. Ставьте и то, и другое, если воркер плодит детей.

STOPSIGNAL SIGINT это техдолг. Быстрый способ убрать паузу, который оставляет исключение в случайном месте кода.

Брать или подождать: вердикт за 30 секунд

Брать. Проверка из трёх пунктов занимает пару минут на контейнер и снимает целый класс потерь данных при деплое. Это не рефакторинг, не новая зависимость и не архитектурное решение. Три строки в конфиге и один обработчик в воркере.

Подождать можно только в одном случае: если у вас уже настроен stop_grace_period с запасом и воркер идемпотентен. Тогда вы ничего не теряете, кроме времени на остановку.

Не делать нельзя ровно в одной ситуации: воркер держит незакоммиченную транзакцию и получает SIGKILL. Здесь каждая остановка это лотерея на целостность данных, и рано или поздно она сыграет против вас.

Вердикт

История про десять секунд это история про диагностику, а не про Docker. Магическая ровная пауза почти всегда означает SIGKILL. Код 137 подтверждает за секунду. Дальше остаётся найти, почему SIGTERM не дошёл: shell вместо Python в PID 1, отсутствие обработчика сигнала, слишком короткое окно на завершение.

Порядок лечения тоже понятен: exec, обработчик через add_signal_handler с флагом, stop_grace_period под худший реальный случай. init: true и STOPSIGNAL SIGINT это инструменты вокруг, каждый со своей ценой.

Автор заплатил за этот разбор дыркой в утреннем отчёте у заказчика. Вы можете заплатить двумя минутами перед следующим деплоем.

FAQ

Почему ровно десять секунд, не больше и не меньше?

Это дефолтный таймаут docker stop для Linux-контейнеров. Docker отправляет SIGTERM, ждёт десять секунд и добивает процесс SIGKILL. Ровная пауза это подпись дефолта, а не свойство вашего кода.

Ctrl+C работает, а docker stop висит. Как так?

Python сам вешает обработчик на SIGINT и превращает его в KeyboardInterrupt. На SIGTERM обработчика нет, а PID 1 без обработчика сигнал игнорирует. Два разных сигнала, два разных результата.

init: true разве не решает проблему?

Убирает паузу, но не даёт корректного завершения. Процесс умирает на месте, finally не выполняется, транзакция откатывается. Данные теряются быстрее, чем раньше.

Какой stop_grace_period ставить?

Больше самой долгой операции, которую нужно довести до конца. Если пачка иногда идёт 20 секунд, разумный запас это 30s. Ориентируйтесь на худший случай, не на средний.

Что проверить прямо сейчас?

Один запуск. Посмотрите на код выхода в docker ps -a: Exited (137) означает потерю данных при остановке, Exited (0) означает, что воркер вышел сам.

Что сделать на этой неделе

  1. Выполните docker compose exec worker sh -c 'tr "\0" " " < /proc/1/cmdline; echo' на стенде. Увидели shell, добавьте exec перед последней командой в entrypoint.
  2. Добавьте в воркер обработчик SIGTERM через loop.add_signal_handler и Event. Паузы между циклами переведите на wait_for(stop.wait(), ...).
  3. Выставьте stop_grace_period с запасом под самую долгую пачку и пересоберите образ.
  4. Остановите контейнер и проверьте два признака: остановка быстрее десяти секунд и статус Exited (0).

Если после этого деплой всё ещё залипает на Stopping worker, проблема не в сигналах. Смотрите, что воркер делает в момент остановки: незакрытое соединение, блокирующий вызов, синхронная библиотека внутри async-цикла. Диагностика та же, только искать придётся глубже.

Читайте также