Почему docker stop ждёт ровно 10 секунд и что при этом теряет Python-воркер
Я долго не обращал внимания на одну мелочь. Обновляю у заказчика парсер, делаю docker compose up -d --build, и всё зависает на строчке Stopping worker. Секунд на десять. Потом контейнер пересоздаётся, и дальше всё работает.
Десять секунд при деплое никого не волнуют. Заволновало другое. Парсер собирал цены пачками и писал их в базу одной транзакцией в конце пачки. После одного обновления заказчик заметил, что в отчёте за утро не хватает куска данных. Пачка была на середине, когда контейнер прибили, транзакция не закоммитилась, и следующий запуск начал уже с новой.
Полез разбираться. Оказалось, Docker все эти десять секунд честно ждал, пока мой процесс завершится. А процесс сигнал завершения вообще не получил.
Что делает docker stop
Схема простая. docker stop (и docker compose stop, и пересоздание контейнера при up) отправляет главному процессу контейнера SIGTERM. Потом ждёт. Для Linux-контейнеров по умолчанию 10 секунд. Если за это время процесс не вышел, прилетает SIGKILL, который перехватить нельзя.
Отсюда и магическая цифра. Если остановка всегда занимает почти ровно 10 секунд, процесс почти наверняка умер от SIGKILL. Проверить можно по коду выхода:
docker ps -a --filter name=worker --format "{{.Names}} {{.Status}}"
# worker Exited (137) 5 seconds ago137 это 128 + 9, то есть смерть от сигнала номер 9, SIGKILL. При нормальном завершении там будет Exited (0).
Осталось понять, почему SIGTERM не сработал. У меня было сразу две причины, и они наложились друг на друга.
Причина первая: сигнал получает не тот процесс
Dockerfile у парсера выглядел так:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chmod +x entrypoint.sh
CMD ["./entrypoint.sh"]А в entrypoint.sh перед стартом накатывались миграции:
#!/bin/sh
set -e
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 становится уже он:
#!/bin/sh
set -e
alembic upgrade head
exec python worker.pyТа же история бывает с shell-формой CMD, когда вместо JSON-массива пишут строку: CMD python worker.py. Docker тогда запускает это через /bin/sh -c. Некоторые оболочки для одной простой команды сами делают exec, некоторые нет, а как только команд становится две через &&, гарантий уже никаких. Я для себя решил писать CMD и ENTRYPOINT только массивом, а в скриптах всегда ставить exec перед последней командой.
Причина вторая: PID 1 в Linux живёт по своим правилам
После правки с exec я ожидал, что всё заработает. Пересобрал, запустил time docker compose stop worker. Снова десять секунд и код 137.
Тут всплыла вторая особенность. У процесса с PID 1 внутри своего пространства имён ядро не применяет действия сигналов по умолчанию. Обычный процесс без обработчика SIGTERM просто умирает. А PID 1 без обработчика такой сигнал игнорирует. Это сделано, чтобы случайный сигнал не уронил init всей системы, но в контейнере init это ваш скрипт.
Python сам вешает обработчик только на SIGINT, из него получается KeyboardInterrupt. На SIGTERM обработчика нет. Получается забавная картина: в docker run -it по Ctrl+C всё останавливается мгновенно, а docker stop ждёт десять секунд. Я на этом и сбился в первый раз, потому что локально всё «работало».
Кстати, aiogram этой проблемы не имеет. В aiogram 3 у start_polling есть параметр handle_signals, по умолчанию он True, и диспетчер сам регистрирует обработчики SIGTERM и SIGINT через loop.add_signal_handler. Поэтому боты у меня останавливались нормально, а самописный воркер нет.
А если просто включить init: true
Частый совет: добавить init: true в compose или --init в docker run. Тогда PID 1 становится маленький init (tini), он пересылает сигналы дочернему процессу и подбирает зомби.
services:
worker:
build: .
init: trueДесять секунд действительно пропадут. Но посмотрите, что происходит дальше. Python теперь не PID 1, обработчика SIGTERM у него всё так же нет, значит срабатывает действие по умолчанию: процесс умирает на месте. Блоки finally не выполняются, соединение с базой не закрывается, незакоммиченная транзакция откатывается. Ровно то, что у меня и потеряло данные, только быстрее.
Так что init: true полезен, особенно если воркер запускает дочерние процессы (браузер для парсинга, например). Но корректное завершение он за вас не напишет.
Есть ещё хак STOPSIGNAL SIGINT в Dockerfile: Docker будет слать SIGINT, Python превратит его в KeyboardInterrupt. Работает, но исключение вылетает в случайном месте кода, хоть посреди записи в базу. Мне такое не нравится.
Шаблон воркера, который выходит сам
В итоге я переписал цикл парсера так, чтобы сигнал только поднимал флаг. Воркер дописывает текущую пачку, видит флаг и выходит.
import asyncio
import logging
import signal
log = logging.getLogger("worker")
async def process_batch(n: int) -> None:
# тут в реальном проекте парсинг и запись в базу
await asyncio.sleep(3)
log.info("батч %s сохранён", n)
async def main() -> None:
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, stop.set)
n = 0
while not stop.is_set():
n += 1
await process_batch(n)
# пауза между циклами, но по сигналу просыпаемся сразу
try:
await asyncio.wait_for(stop.wait(), timeout=5)
except asyncio.TimeoutError:
pass
log.info("сигнал получен, выхожу после батча %s", n)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
asyncio.run(main())Что тут важно:
loop.add_signal_handlerвызывает колбэк внутри event loop, а не в произвольный момент, какsignal.signal. Поэтому из него безопасно трогатьasyncio.Event.Пауза между циклами сделана через
wait_for(stop.wait(), ...), а не черезasyncio.sleep(5). Иначе воркер, получив сигнал во время сна, досыпал бы свои пять секунд.Пачку сигнал не прерывает. Если SIGTERM пришёл на второй секунде трёхсекундного батча, батч доработает и сохранится.
Я прогнал оба сценария. Сигнал посреди батча: воркер дописал пачку и вышел через три секунды после старта. Сигнал во время паузы: вышел сразу, без ожидания. Учтите, что add_signal_handler есть только на Unix. На Windows он бросает NotImplementedError, так что для локальной отладки там нужен свой вариант, а в контейнере всё работает как есть.
Если пачка дольше десяти секунд
У меня одна пачка иногда шла до 20 секунд, когда сайт-источник тормозил. Тогда стандартного таймаута не хватает, и Docker всё равно убьёт процесс посреди работы. Время ожидания можно поднять:
services:
worker:
build: .
init: true
stop_grace_period: 30s
restart: unless-stoppedДля docker run то же самое делает --stop-timeout 30, для разовой остановки docker stop -t 30 worker.
Сильно задирать не стоит. Если воркер завис по-настоящему, каждый деплой будет ждать весь этот срок. Я ставлю время с запасом примерно в полтора-два раза от самой долгой пачки, которую видел в логах.
Что я теперь проверяю в каждом проекте
После этой истории у меня появился короткий список, который я прохожу перед сдачей любого контейнера с Python:
CMD и ENTRYPOINT записаны массивом, а не строкой.
Если есть
entrypoint.sh, последняя команда в нём идёт черезexec.В коде есть обработчик SIGTERM, либо фреймворк ставит его сам (aiogram, uvicorn, gunicorn это делают).
time docker compose stopзанимает заметно меньше десяти секунд, а вdocker ps -aстоитExited (0), а неExited (137).Если работа идёт крупными кусками,
stop_grace_periodбольше самого долгого куска.
На весь список уходит пара минут. Это дешевле, чем потом объяснять заказчику, куда делись утренние цены.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.