Почему 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 ago

137 это 128 + 9, то есть смерть от сигнала номер 9, SIGKILL. При нормальном завершении там будет Exited (0).

Осталось понять, почему SIGTERM не сработал. У меня было сразу две причины, и они наложились друг на друга.

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

Dockerfile у парсера выглядел так:

FROM python:3.12-slimWORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY . .RUN chmod +x entrypoint.shCMD ["./entrypoint.sh"]

А в entrypoint.sh перед стартом накатывались миграции:

#!/bin/shset -ealembic upgrade headpython 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/shset -ealembic upgrade headexec 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 asyncioimport loggingimport signallog = 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 больше самого долгого куска.

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

ссылка на оригинал статьи https://habr.com/ru/articles/1098920/