Грациозная остановка в Python: разбор инцидента с потерей задач
Воркер на SIGTERM обрывал задачи посреди выполнения, а данные оставались в неконсистентном состоянии. Разбираем, почему стандартный signal.signal не спас и как устроена остановка процесса под управлением orchestrator.

Инцидент выглядел так: при выкатке новой версии Kubernetes посылал воркерам SIGTERM, через 30 секунд — SIGKILL. Часть задач из очереди обрывалась ровно в середине: деньги списаны, письмо не отправлено, запись в БД наполовину обновлена. За сутки набралось около 140 задач в неконсистентном состоянии, часть пришлось чинить руками. Код обработчика сигнала в проекте был, но не работал так, как думала команда. Ниже — почему стандартный подход к остановке в Python ломается под нагрузкой и что с этим делать. Речь про CPython 3.11, приложение на asyncio плюс пул синхронных воркеров.
Что на самом деле делает SIGTERM в Python
Первое заблуждение: многие считают, что обработчик сигнала прерывает текущую работу и передаёт управление в функцию-хендлер немедленно. В CPython это не так. Обработчики Python-сигналов не вызываются из контекста самого сигнала — интерпретатор лишь ставит флаг, а сам хендлер выполняется между байткод-инструкциями главного потока.
Из этого следуют два неприятных факта. Первый: если главный поток в этот момент сидит внутри блокирующего C-вызова — time.sleep, сетевой read, requests.post — обработчик не запустится, пока вызов не вернётся. Второй: сигнал обрабатывается только в главном потоке. Если задача крутится в ThreadPoolExecutor, поставленный флаг до неё не дойдёт напрямую.
Как это выглядело в инциденте
Хендлер выставлял глобальный shutting_down = True, а рабочие потоки должны были проверять его между задачами. Проблема: одна задача обработки платежа шла 8–15 секунд внутри одного синхронного HTTP-вызова к внешнему API. Флаг выставлялся, но поток был заблокирован в середине запроса и проверить флаг мог только после возврата — а SIGKILL прилетал раньше.
Грациозная остановка — это не «поймать сигнал». Это гарантия, что между приёмом сигнала и реальным завершением процесса каждая начатая единица работы либо доведена до конца, либо откачена целиком. Обработчик сигнала — только триггер.
Механизм: три уровня, на которых что-то может пойти не так
Разложим путь сигнала до фактического завершения работы.
- Приём сигнала. ОС доставляет
SIGTERM. CPython ставит внутренний флаг. Хендлер Python выполнится при следующей проверке между инструкциями главного потока. - Оповещение исполнителей. Флаг или событие должны дойти до кода, который реально держит незавершённую работу: корутины, потоки, процессы пула.
- Дренаж. Перестать брать новые задачи, дождаться завершения текущих в отведённый бюджет времени, а то, что не влезло, — вернуть в очередь или откатить.
Ошибка в проекте была на всех трёх: хендлер не будился из блокирующего вызова, потоки не могли прервать HTTP-запрос, а дренажа с таймаутом не было вообще — процесс просто ждал, пока Kubernetes его убьёт.
asyncio: правильная точка перехвата
Для asyncio ловить сигналы через signal.signal — плохая идея: хендлер выполнится в главном потоке в непредсказуемый момент относительно цикла событий. Событийный цикл даёт свой механизм — loop.add_signal_handler, который вызывает колбэк внутри цикла, а не между байткодами.
# Python 3.11, asyncio
import asyncio
import signal
async def worker(stop_event: asyncio.Event, queue: asyncio.Queue):
while not stop_event.is_set():
try:
# не блокируемся навечно: даём шанс проверить stop_event
task = await asyncio.wait_for(queue.get(), timeout=1.0)
except asyncio.TimeoutError:
continue
await handle_task(task) # эту задачу доводим до конца
queue.task_done()
async def main():
stop_event = asyncio.Event()
queue: asyncio.Queue = asyncio.Queue()
loop = asyncio.get_running_loop()
# колбэк вызывается внутри цикла событий, безопасно трогать stop_event
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, stop_event.set)
workers = [asyncio.create_task(worker(stop_event, queue)) for _ in range(4)]
await stop_event.wait() # ждём сигнала
# даём воркерам добить взятые задачи, но не дольше бюджета
_, pending = await asyncio.wait(workers, timeout=25)
for t in pending:
t.cancel() # то, что не успело, — отменяем явно
await asyncio.gather(*workers, return_exceptions=True)
asyncio.run(main())
Ключевое здесь — не бесконечный await queue.get(), а ожидание с таймаутом, чтобы цикл воркера регулярно проверял stop_event. И бюджет в 25 секунд, оставляющий запас до SIGKILL на 30-й секунде.
Блокирующий код в пуле потоков
С синхронным HTTP-вызовом внутри ThreadPoolExecutor нет магии: поток нельзя прервать снаружи. Единственное, чем можно управлять, — таймаут самого вызова. Если у клиента нет таймаута, поток может висеть сколько угодно, и никакая грациозная остановка не поможет.
Что реально работает:
- жёсткий таймаут на каждый внешний вызов, меньше бюджета остановки. Для
requestsэто параметрtimeout; без него запрос ждёт бесконечно; - перестать раздавать пулу новые задачи сразу по сигналу — новые в очередь не кладём;
- идемпотентность: если задача оборвалась, повторный запуск после рестарта не должен списать деньги дважды. Это спасает даже при
SIGKILL, который отменить нельзя в принципе.
Последний пункт — главный вывод инцидента. SIGKILL невозможно перехватить и обработать. Значит, корректность не может опираться только на грациозную остановку: она должна держаться на идемпотентности и транзакциях, а остановка лишь сокращает число обрывов.
Сравнение подходов к перехвату
| Подход | Где срабатывает | Разбудит из блокирующего вызова | Когда применять |
|---|---|---|---|
signal.signal + флаг | главный поток, между инструкциями | Нет | простой синхронный скрипт без долгих C-вызовов |
loop.add_signal_handler | внутри цикла событий | Косвенно, через отмену задач | любое приложение на asyncio |
Event + таймауты в цикле | рабочий поток/корутина | Да, если вызовы с таймаутом | пулы воркеров, обработка очередей |
| Идемпотентность + транзакции | уровень данных | Не нужно — работает даже при SIGKILL | всегда, где есть побочные эффекты |
Граничные случаи, где всё ломается снова
- Второй SIGTERM во время дренажа. Оператор нервничает и жмёт остановку повторно. Если не защититься, второй колбэк может сбросить состояние. Первый сигнал должен переводить процесс в режим остановки и игнорировать повторные, либо второй — эскалировать в немедленный выход.
- Задача дольше бюджета. Если единица работы в принципе идёт 40 секунд, а до
SIGKILL30, грациозность не поможет: нужно либо увеличиватьterminationGracePeriodSeconds, либо дробить задачу на этапы с сохранением прогресса. - Демонические потоки. Потоки, созданные с
daemon=True, при завершении главного потока просто исчезают вместе с процессом — без шанса доработать. Для воркеров, держащих незавершённую работу, это ловушка. - Сигнал приходит до готовности приложения. Kubernetes может прислать
SIGTERM, пока приложение ещё стартует. Хендлер должен быть повешен как можно раньше, иначе сигнал проигнорируется, и под убьётся жёстко.
Что и как мерить
После инцидента команда добавила метрики, которые показывают здоровье остановки. Без них вы не узнаете о проблеме, пока данные снова не поедут.
- Время от сигнала до фактического выхода. Если оно стабильно упирается в
terminationGracePeriodSeconds— значит, дренаж не укладывается в бюджет и часть задач убивается. - Счётчик задач, отменённых при остановке. Растёт — либо задачи слишком длинные, либо бюджет мал.
- Счётчик повторных запусков идемпотентных операций. Показывает, насколько часто вы реально полагаетесь на страховочный механизм.
Что делать дальше
Порядок действий, если у вас похожая связка воркеров и очередей:
- Проверьте, что
terminationGracePeriodSecondsв манифесте больше самого долгого разумного времени задачи с запасом на дренаж. - Убедитесь, что у всех внешних вызовов проставлен таймаут — это первое, что рвёт грациозную остановку.
- Для asyncio перейдите с
signal.signalнаloop.add_signal_handler. - Сделайте побочные эффекты идемпотентными, чтобы пережить неизбежный
SIGKILL. - Добавьте метрику времени остановки в мониторинг и алерт на приближение к бюджету.
Механику самой очереди задач и стратегию ретраев здесь не разбираем — это отдельная тема; фокус был на пути сигнала от ОС до завершения работы.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Python docs — signal: Set handlers for asynchronous events, Python docs — asyncio: loop.add_signal_handler
Частые вопросы
А если у меня не asyncio, а обычный синхронный воркер?
Можно ли перехватить SIGKILL и доделать задачи?
Что если задача в принципе не укладывается в отведённый бюджет времени?
Не сработало: сигнал приходит, а хендлер не вызывается. В чём дело?
Отличается ли поведение сигналов между версиями Python?
Стоит ли делать daemon-потоки для воркеров?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.