Я долго не обращал внимания на одну мелочь. Обновляю у заказчика парсер, делаю 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:
-
CMD и ENTRYPOINT записаны массивом, а не строкой.
-
Если есть
entrypoint.sh, последняя команда в нём идёт черезexec. -
В коде есть обработчик SIGTERM, либо фреймворк ставит его сам (aiogram, uvicorn, gunicorn это делают).
-
time docker compose stopзанимает заметно меньше десяти секунд, а вdocker ps -aстоитExited (0), а неExited (137). -
Если работа идёт крупными кусками,
stop_grace_periodбольше самого долгого куска.
На весь список уходит пара минут. Это дешевле, чем потом объяснять заказчику, куда делись утренние цены.
ссылка на оригинал статьи https://habr.com/ru/articles/1098920/