ESPNGame 4 feels like a must-win for Valkyries -- but can they generate enough offense?ESPN DeportesLa cima de la Premier espera al Arsenal si gana al EvertonThe Jerusalem PostIsraeli woman visiting Thailand has visa revoked following conduct complaints by localsVanguardAbia govt has abandoned us — Ubani Market traders cry out, seek Otti’s interventionWirtualna PolskaHołownia ogłasza usunięcie Pełczyńskiej-Nałęcz. "To nieuniknione"OnetTragiczny wypadek w Wielkopolsce. Mężczyzna przygnieciony przez palety z cegłamiInquirerOver 41,000 affected by Super Typhoon Rosal in N. Luzon, Bicol – DSWDPunchNDC to present campaign council, financial statement MondayEngadgetThe UK is investing $134 million into an AI Biosecurity LabZDF heuteEntdecken Sie das ZDF-NachrichtenstudioBBC عربيفيلم "أرض الخرسانة" الوثائقي الأردني يفوز بجائزة مهرجان لندن السينمائيHindustan Times SportManchester City's perfect Premier League start ends with defeat to Aston Villa
The Daily Newsstand · Free, Always
Saturday, October 24, 2026

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

Translate

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

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.