Я долго не обращал внимания на одну мелочь. Обновляю у заказчика парсер, делаю 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 ago

137 это 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:

  1. CMD и ENTRYPOINT записаны массивом, а не строкой.

  2. Если есть entrypoint.sh, последняя команда в нём идёт через exec.

  3. В коде есть обработчик SIGTERM, либо фреймворк ставит его сам (aiogram, uvicorn, gunicorn это делают).

  4. time docker compose stop занимает заметно меньше десяти секунд, а в docker ps -a стоит Exited (0), а не Exited (137).

  5. Если работа идёт крупными кусками, stop_grace_period больше самого долгого куска.

На весь список уходит пара минут. Это дешевле, чем потом объяснять заказчику, куда делись утренние цены.