6.5. Сигналы и PID 1 в Python
Цели
После этого материала вы сможете:
- реализовать обработчик
SIGTERMдля синхронного, асинхронного и многопоточного приложения; - объяснить ограничения обработки сигналов в Python: только главный поток, задержка до следующей инструкции байт-кода;
- реализовать graceful shutdown в
asyncioчерезlifespanили обработчик цикла событий; - объяснить, как ведут себя сигналы под Gunicorn и Uvicorn;
- уведомлять дочерние процессы при завершении;
- диагностировать ситуацию «обработчик есть, но не срабатывает».
Предварительные знания
- 4.4. Сигналы и graceful shutdown — механизм на уровне Docker;
- 4.5. PID 1 и init — особенности PID 1;
- 5.4. CMD и ENTRYPOINT — exec form;
- понимание
asyncioна базовом уровне.
Ключевые термины
| Термин | Объяснение |
|---|---|
обработчик сигнала | Функция, вызываемая при получении сигнала |
главный поток | Поток, в котором запущен интерпретатор; только он получает сигналы |
event loop | Цикл событий asyncio |
lifespan | Механизм ASGI для действий при старте и остановке |
drain | Дозавершение активных запросов перед остановкой |
signal-safe | Операция, безопасная для вызова из обработчика сигнала |
Теория
Три условия graceful shutdown в Python
Из урока 4.4 известны два условия. Для Python добавляется третье.
| Условие | Проверка |
|---|---|
| 1. Приложение является PID 1 (или сигнал до него доходит) | docker exec <c> ps -o args= -p 1 |
2. Установлен обработчик SIGTERM | наличие signal.signal в коде |
| 3. Обработчик установлен в главном потоке | место вызова signal.signal |
Третье условие специфично для Python и регулярно нарушается в многопоточных приложениях.
Как Python обрабатывает сигналы
Механизм не такой прямой, как кажется:
ядро доставляет сигнал
│
▼
C-обработчик Python устанавливает флаг
и записывает байт в wakeup-fd
│
▼
интерпретатор продолжает выполнение
│
▼
между инструкциями байт-кода проверяет флаги
│
▼
вызывает Python-обработчик в ГЛАВНОМ потоке
Из этого следуют три практических ограничения.
Ограничение 1: только главный поток. Обработчик всегда вызывается в главном потоке, независимо от того, какой поток был активен. Установить обработчик из дочернего потока нельзя — signal.signal выбросит ValueError.
Ограничение 2: задержка. Обработчик вызывается между инструкциями байт-кода. Если главный поток заблокирован в долгом системном вызове или в C-расширении, не освобождающем GIL, обработчик не вызовется до его завершения.
Классический случай — time.sleep(3600). Он прерывается сигналом, но обработчик отработает только после возврата из вызова.
Ограничение 3: ограниченный набор безопасных операций. Обработчик выполняется в обычном контексте Python, поэтому большинство операций безопасны. Но длительная работа в обработчике блокирует главный поток — правильнее выставить флаг и завершиться.
Правильная структура обработчика
import signal
_shutdown = False
def handle_shutdown(signum, frame):
"""Только выставляет флаг — вся работа в основном цикле."""
global _shutdown
_shutdown = True
signal.signal(signal.SIGTERM, handle_shutdown)
while not _shutdown:
do_work()
cleanup()
Обработчик не делает ничего, кроме установки флага. Закрытие соединений, сброс буферов и прочая работа — в основном потоке после выхода из цикла.
Причина: обработчик может быть вызван в произвольный момент, в том числе внутри критической секции. Долгие операции в нём приводят к трудноуловимым ошибкам.
Сигналы в asyncio
В асинхронном приложении обработчик, установленный через signal.signal, работает, но неудобен: он не может напрямую взаимодействовать с циклом событий.
Правильный способ — loop.add_signal_handler:
import asyncio
import signal
async def main():
loop = asyncio.get_running_loop()
stop = asyncio.Event()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, stop.set)
await stop.wait()
await cleanup()
Отличие: обработчик вызывается циклом событий, а не прерывает выполнение. Можно безопасно работать с корутинами и объектами asyncio.
Метод add_signal_handler доступен только на Unix — на Windows его нет. Для контейнеризованных приложений это не ограничение.
Сигналы под серверами приложений
Если приложение запускается под Gunicorn или Uvicorn, обработку сигналов берёт на себя сервер.
| Сервер | Что делает при SIGTERM |
|---|---|
| Gunicorn | Прекращает принимать соединения, ждёт завершения текущих запросов до graceful_timeout, затем убивает worker'ов |
| Uvicorn | Прекращает принимать соединения, дожидается активных запросов, вызывает lifespan shutdown |
fastapi run | То же, что Uvicorn |
В этом случае свой обработчик не нужен и вреден: он перехватит сигнал раньше сервера и нарушит его логику завершения.
Для приложения под сервером правильный механизм — lifespan (ASGI) или хуки сервера:
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
# startup
pool = await create_pool()
app.state.pool = pool
yield
# shutdown: вызывается при SIGTERM
await pool.close()
app = FastAPI(lifespan=lifespan)
Разделение обязанностей:
| Тип приложения | Кто обрабатывает сигнал |
|---|---|
| CLI, worker, скрипт | Ваш код через signal.signal |
| ASGI под Uvicorn | Uvicorn; ваш код — через lifespan |
| WSGI под Gunicorn | Gunicorn; ваш код — через хуки worker_exit и подобные |
Дочерние процессы
Сигнал получает только PID 1 (урок 4.4). Если приложение порождает подпроцессы, их нужно уведомить самостоятельно:
def handle_shutdown(signum, frame):
global _shutdown
_shutdown = True
for proc in children:
proc.terminate() # посылает SIGTERM
И дождаться их в основном потоке:
for proc in children:
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
Альтернатива — флаг --init (урок 4.5), но он только собирает zombie и пересылает сигнал главному процессу; уведомление конкретных потомков остаётся вашей задачей.
Укладываться в grace period
Docker даёт 10 секунд по умолчанию. Если завершение занимает больше, процесс получит SIGKILL на середине.
Практическое правило: завершение должно занимать заметно меньше grace period. Если реально нужно больше, увеличьте --stop-timeout или stop_grace_period в Compose — но помните, что оркестраторы имеют свои пределы.
Ориентир: уложиться в 5–8 секунд при стандартном grace period 10 секунд.
Внутренний механизм
Почему обработчик не срабатывает в дочернем потоке
Модуль signal проверяет, вызван ли signal.signal из главного потока, и при вызове из дочернего выбрасывает исключение:
ValueError: signal only works in main thread of the main interpreter
Причина архитектурная: сигнал доставляется процессу, а не потоку, и Python сводит обработку к одному потоку, чтобы избежать гонок.
Практическое следствие: в приложении, где главный поток запускает рабочие потоки и ждёт их, обработчик нужно установить до запуска потоков, в главном потоке.
Почему time.sleep задерживает обработку
Сигнал прерывает sleep, но обработчик вызывается только когда интерпретатор проверит флаги — то есть после возврата из вызова.
Для time.sleep в Python 3.5+ поведение улучшено: вызов прерывается, обработчик вызывается, и если он не выбросил исключение, sleep продолжается на оставшееся время. Отсюда рекомендация: вместо одного длинного sleep использовать цикл коротких, проверяя флаг между итерациями.
# плохо: реакция на сигнал через час
time.sleep(3600)
# хорошо: реакция в пределах 0.5 секунды
for _ in range(7200):
if _shutdown:
break
time.sleep(0.5)
Для asyncio проблемы нет: add_signal_handler работает через цикл событий.
Команды и примеры
Подготовка
mkdir -p /tmp/pysig && cd /tmp/pysig
Базовый обработчик
cat > sync_worker.py <<'PY'
"""Синхронный worker с корректным завершением."""
import os
import signal
import sys
import time
_shutdown = False
def handle_shutdown(signum, _frame):
"""Только выставляет флаг: вся работа — в основном цикле."""
global _shutdown
print(f"[сигнал] получен {signal.Signals(signum).name}", flush=True)
_shutdown = True
signal.signal(signal.SIGTERM, handle_shutdown)
signal.signal(signal.SIGINT, handle_shutdown)
print(f"[старт] PID={os.getpid()}", flush=True)
n = 0
while not _shutdown:
n += 1
print(f"[работа] итерация {n}", flush=True)
# короткий sleep в цикле: реакция на сигнал в пределах 0.5 c
for _ in range(2):
if _shutdown:
break
time.sleep(0.5)
print("[завершение] закрываю ресурсы...", flush=True)
time.sleep(0.3)
print(f"[завершение] выполнено итераций: {n}", flush=True)
sys.exit(0)
PY
cat > Dockerfile.sync <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
COPY sync_worker.py /app.py
CMD ["python", "/app.py"]
EOF
docker build -q -f Dockerfile.sync -t sig:sync . > /dev/null
docker run -d --name sync-t sig:sync > /dev/null
sleep 3
s="$(date +%s.%N)"; docker stop sync-t > /dev/null; e="$(date +%s.%N)"
printf 'время stop: %.2f c, код: %s\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker inspect sync-t --format '{{.State.ExitCode}}')"
docker logs sync-t 2>&1 | tail -4
docker rm sync-t > /dev/null
время stop: 0.84 c, код: 0
[работа] итерация 3
[сигнал] получен SIGTERM
[завершение] закрываю ресурсы...
[завершение] выполнено итераций: 3
Обработчик сработал, ресурсы закрыты, код 0.
Длинный sleep задерживает реакцию
cat > long_sleep.py <<'PY'
"""Один длинный sleep вместо цикла коротких."""
import signal
import sys
import time
_shutdown = False
def handle(signum, _frame):
global _shutdown
print(f"[сигнал] получен {signal.Signals(signum).name}", flush=True)
_shutdown = True
signal.signal(signal.SIGTERM, handle)
print("[старт] засыпаю на 60 секунд одним вызовом", flush=True)
while not _shutdown:
time.sleep(60) # реакция только после возврата
print("[завершение] штатно", flush=True)
sys.exit(0)
PY
cat > Dockerfile.longsleep <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
COPY long_sleep.py /app.py
CMD ["python", "/app.py"]
EOF
docker build -q -f Dockerfile.longsleep -t sig:longsleep . > /dev/null
docker run -d --name long-t sig:longsleep > /dev/null
sleep 2
s="$(date +%s.%N)"; docker stop long-t > /dev/null; e="$(date +%s.%N)"
printf 'время stop: %.2f c, код: %s\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker inspect long-t --format '{{.State.ExitCode}}')"
docker logs long-t 2>&1 | tail -3
docker rm long-t > /dev/null
время stop: 0.31 c, код: 0
[старт] засыпаю на 60 секунд одним вызовом
[сигнал] получен SIGTERM
[завершение] штатно
Здесь сработало быстро: в Python 3.5+ time.sleep прерывается сигналом, обработчик вызывается, и цикл проверяет флаг.
Но так везёт не всегда. Заблокированный сетевой вызов без таймаута даст другую картину:
cat > blocking_io.py <<'PY'
"""Блокирующий вызов без таймаута задерживает реакцию на сигнал."""
import signal
import socket
import sys
_shutdown = False
def handle(signum, _frame):
global _shutdown
print(f"[сигнал] получен {signal.Signals(signum).name}", flush=True)
_shutdown = True
signal.signal(signal.SIGTERM, handle)
srv = socket.socket()
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", 9000))
srv.listen(1)
print("[старт] жду соединения БЕЗ таймаута", flush=True)
while not _shutdown:
try:
conn, _ = srv.accept() # блокируется бесконечно
conn.close()
except OSError:
break
print("[завершение] штатно", flush=True)
sys.exit(0)
PY
cat > Dockerfile.blocking <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
COPY blocking_io.py /app.py
CMD ["python", "/app.py"]
EOF
docker build -q -f Dockerfile.blocking -t sig:blocking . > /dev/null
docker run -d --name block-t sig:blocking > /dev/null
sleep 2
s="$(date +%s.%N)"; docker stop block-t > /dev/null; e="$(date +%s.%N)"
printf 'время stop: %.2f c, код: %s\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker inspect block-t --format '{{.State.ExitCode}}')"
docker logs block-t 2>&1 | tail -2
docker rm block-t > /dev/null
время stop: 10.29 c, код: 137
[старт] жду соединения БЕЗ таймаута
[сигнал] получен SIGTERM
Обработчик вызвался — строка в логах есть. Но accept() снова заблокировался, и цикл не дошёл до проверки флага. Десять секунд, SIGKILL, код 137.
Исправление — таймаут:
sed 's/srv.listen(1)/srv.listen(1)\nsrv.settimeout(0.5)/; s/жду соединения БЕЗ таймаута/жду соединения с таймаутом 0.5 c/' \
blocking_io.py > blocking_fixed.py
sed -i 's/except OSError:/except socket.timeout:\n continue\n except OSError:/' blocking_fixed.py
cat > Dockerfile.blockfix <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
COPY blocking_fixed.py /app.py
CMD ["python", "/app.py"]
EOF
docker build -q -f Dockerfile.blockfix -t sig:blockfix . > /dev/null
docker run -d --name blockfix-t sig:blockfix > /dev/null
sleep 2
s="$(date +%s.%N)"; docker stop blockfix-t > /dev/null; e="$(date +%s.%N)"
printf 'с таймаутом: %.2f c, код: %s\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker inspect blockfix-t --format '{{.State.ExitCode}}')"
docker rm blockfix-t > /dev/null
с таймаутом: 0.73 c, код: 0
Правило: любой блокирующий вызов в основном цикле должен иметь таймаут. Иначе обработчик сигнала бесполезен.
Обработчик в дочернем потоке не работает
cat > thread_handler.py <<'PY'
"""Попытка установить обработчик из дочернего потока."""
import signal
import threading
def worker():
try:
signal.signal(signal.SIGTERM, lambda s, f: None)
print("обработчик установлен из потока — неожиданно", flush=True)
except ValueError as exc:
print(f"ОШИБКА: {exc}", flush=True)
t = threading.Thread(target=worker)
t.start()
t.join()
PY
docker run --rm -v "$PWD/thread_handler.py:/app.py:ro" \
-e PYTHONUNBUFFERED=1 python:3.13-slim python /app.py
ОШИБКА: signal only works in main thread of the main interpreter
Правильная структура многопоточного приложения:
cat > threaded_app.py <<'PY'
"""Многопоточное приложение: обработчик в главном потоке, потоки читают флаг."""
import os
import signal
import sys
import threading
import time
_shutdown = threading.Event()
def handle_shutdown(signum, _frame):
print(f"[сигнал] {signal.Signals(signum).name}", flush=True)
_shutdown.set()
# Обработчик устанавливается в ГЛАВНОМ потоке, до запуска рабочих
signal.signal(signal.SIGTERM, handle_shutdown)
signal.signal(signal.SIGINT, handle_shutdown)
def worker(worker_id: int) -> None:
n = 0
# Event.wait с таймаутом: и пауза, и проверка флага одновременно
while not _shutdown.wait(timeout=1.0):
n += 1
print(f"[поток {worker_id}] итерация {n}", flush=True)
print(f"[поток {worker_id}] завершён, итераций: {n}", flush=True)
print(f"[старт] PID={os.getpid()}", flush=True)
threads = [threading.Thread(target=worker, args=(i,)) for i in range(1, 4)]
for t in threads:
t.start()
_shutdown.wait()
print("[главный] жду завершения потоков", flush=True)
for t in threads:
t.join(timeout=5)
print("[главный] все потоки остановлены", flush=True)
sys.exit(0)
PY
cat > Dockerfile.threaded <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
COPY threaded_app.py /app.py
CMD ["python", "/app.py"]
EOF
docker build -q -f Dockerfile.threaded -t sig:threaded . > /dev/null
docker run -d --name thr-t sig:threaded > /dev/null
sleep 3
s="$(date +%s.%N)"; docker stop thr-t > /dev/null; e="$(date +%s.%N)"
printf 'время stop: %.2f c, код: %s\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker inspect thr-t --format '{{.State.ExitCode}}')"
docker logs thr-t 2>&1 | tail -6
docker rm thr-t > /dev/null
время stop: 1.05 c, код: 0
[сигнал] SIGTERM
[поток 1] завершён, итераций: 3
[поток 2] завершён, итераций: 3
[поток 3] завершён, итераций: 3
[главный] жду завершения потоков
[главный] все потоки остановлены
Ключевой приём — threading.Event.wait(timeout=...) вместо time.sleep: он одновременно делает паузу и проверяет флаг, реагируя мгновенно.
asyncio
cat > async_app.py <<'PY'
"""Асинхронное приложение с корректным завершением."""
import asyncio
import os
import signal
async def background_task(task_id: int, stop: asyncio.Event) -> int:
n = 0
while not stop.is_set():
n += 1
print(f"[задача {task_id}] итерация {n}", flush=True)
try:
await asyncio.wait_for(stop.wait(), timeout=1.0)
except asyncio.TimeoutError:
pass
print(f"[задача {task_id}] остановлена, итераций: {n}", flush=True)
return n
async def main() -> int:
loop = asyncio.get_running_loop()
stop = asyncio.Event()
# add_signal_handler работает через цикл событий, а не прерывает выполнение
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, stop.set)
print(f"[старт] PID={os.getpid()}", flush=True)
tasks = [asyncio.create_task(background_task(i, stop)) for i in range(1, 4)]
await stop.wait()
print("[завершение] сигнал получен, дожидаюсь задач", flush=True)
results = await asyncio.gather(*tasks)
print(f"[завершение] всего итераций: {sum(results)}", flush=True)
# имитация закрытия ресурсов
await asyncio.sleep(0.2)
print("[завершение] ресурсы закрыты", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
PY
cat > Dockerfile.async <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
COPY async_app.py /app.py
CMD ["python", "/app.py"]
EOF
docker build -q -f Dockerfile.async -t sig:async . > /dev/null
docker run -d --name async-t sig:async > /dev/null
sleep 3
s="$(date +%s.%N)"; docker stop async-t > /dev/null; e="$(date +%s.%N)"
printf 'время stop: %.2f c, код: %s\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker inspect async-t --format '{{.State.ExitCode}}')"
docker logs async-t 2>&1 | tail -5
docker rm async-t > /dev/null
время stop: 0.51 c, код: 0
[завершение] сигнал получен, дожидаюсь задач
[задача 1] остановлена, итераций: 3
[задача 2] остановлена, итераций: 3
[задача 3] остановлена, итераций: 3
[завершение] всего итераций: 9
[завершение] ресурсы закрыты
Обратите внимание на asyncio.wait_for(stop.wait(), timeout=1.0) вместо asyncio.sleep(1.0): задача просыпается немедленно при установке события, а не досыпает интервал.
Дочерние процессы
cat > with_children.py <<'PY'
"""Уведомление дочерних процессов при завершении."""
import os
import signal
import subprocess
import sys
import time
_shutdown = False
children: list[subprocess.Popen] = []
def handle_shutdown(signum, _frame):
global _shutdown
print(f"[родитель] сигнал {signal.Signals(signum).name}", flush=True)
_shutdown = True
# уведомляем потомков: сигнал получил только PID 1
for proc in children:
if proc.poll() is None:
print(f"[родитель] шлю SIGTERM потомку {proc.pid}", flush=True)
proc.terminate()
signal.signal(signal.SIGTERM, handle_shutdown)
CHILD_CODE = """
import signal, sys, time
running = True
def h(s, f):
global running
print(f'[потомок {__import__("os").getpid()}] завершаюсь корректно', flush=True)
running = False
signal.signal(signal.SIGTERM, h)
print(f'[потомок {__import__("os").getpid()}] запущен', flush=True)
while running:
time.sleep(0.3)
print(f'[потомок {__import__("os").getpid()}] ресурсы освобождены', flush=True)
sys.exit(0)
"""
print(f"[родитель] PID={os.getpid()}", flush=True)
for _ in range(2):
children.append(subprocess.Popen([sys.executable, "-u", "-c", CHILD_CODE]))
while not _shutdown:
time.sleep(0.3)
print("[родитель] жду завершения потомков", flush=True)
for proc in children:
try:
proc.wait(timeout=5)
print(f"[родитель] потомок {proc.pid} завершён, код {proc.returncode}", flush=True)
except subprocess.TimeoutExpired:
print(f"[родитель] потомок {proc.pid} не ответил — убиваю", flush=True)
proc.kill()
print("[родитель] завершено штатно", flush=True)
sys.exit(0)
PY
cat > Dockerfile.children <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
COPY with_children.py /app.py
CMD ["python", "/app.py"]
EOF
docker build -q -f Dockerfile.children -t sig:children . > /dev/null
docker run -d --name child-t sig:children > /dev/null
sleep 2
echo "процессы в container:"
docker exec child-t ps -o pid,args | sed 's/^/ /'
s="$(date +%s.%N)"; docker stop child-t > /dev/null; e="$(date +%s.%N)"
printf 'время stop: %.2f c, код: %s\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker inspect child-t --format '{{.State.ExitCode}}')"
docker logs child-t 2>&1 | tail -7
docker rm child-t > /dev/null
процессы в container:
PID COMMAND
1 python /app.py
7 /usr/local/bin/python -u -c ...
8 /usr/local/bin/python -u -c ...
14 ps -o pid,args
время stop: 0.68 c, код: 0
[родитель] шлю SIGTERM потомку 7
[родитель] шлю SIGTERM потомку 8
[потомок 7] завершаюсь корректно
[потомок 8] завершаюсь корректно
[потомок 7] ресурсы освобождены
[потомок 8] ресурсы освобождены
[родитель] завершено штатно
Без явного proc.terminate() потомки были бы убиты SIGKILL вместе с container и не освободили бы ресурсы.
Под Uvicorn: свой обработчик не нужен
cat > asgi_app.py <<'PY'
"""ASGI-приложение: завершением управляет сервер через lifespan."""
import asyncio
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(_app: FastAPI):
print(f"[lifespan] startup, PID={os.getpid()}", flush=True)
# здесь: пул соединений, прогрев кэша
yield
# вызывается при SIGTERM — сервер сам его обработал
print("[lifespan] shutdown: закрываю ресурсы", flush=True)
await asyncio.sleep(0.3)
print("[lifespan] ресурсы закрыты", flush=True)
app = FastAPI(lifespan=lifespan)
@app.get("/")
async def root():
return {"pid": os.getpid()}
@app.get("/slow")
async def slow():
await asyncio.sleep(5)
return {"done": True}
PY
cat > Dockerfile.asgi <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
RUN pip install --no-cache-dir "fastapi[standard]==0.141.1"
COPY asgi_app.py /app/asgi_app.py
WORKDIR /app
CMD ["fastapi", "run", "asgi_app.py", "--port", "8000"]
EOF
docker build -q -f Dockerfile.asgi -t sig:asgi . > /dev/null
docker run -d --name asgi-t -p 8100:8000 sig:asgi > /dev/null
sleep 6
curl -s localhost:8100/ | head -c 60; echo
s="$(date +%s.%N)"; docker stop asgi-t > /dev/null; e="$(date +%s.%N)"
printf 'время stop: %.2f c, код: %s\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker inspect asgi-t --format '{{.State.ExitCode}}')"
docker logs asgi-t 2>&1 | grep lifespan
docker rm asgi-t > /dev/null
{"pid":1}
время stop: 0.62 c, код: 0
[lifespan] startup, PID=1
[lifespan] shutdown: закрываю ресурсы
[lifespan] ресурсы закрыты
Ни одной строки signal.signal в коде — сигнал обработал Uvicorn и вызвал lifespan shutdown.
Проверим дозавершение активного запроса:
docker run -d --name asgi-drain -p 8101:8000 sig:asgi > /dev/null
sleep 6
curl -s localhost:8101/slow > /tmp/slow-result.txt &
CURL_PID=$!
sleep 1
s="$(date +%s.%N)"; docker stop --timeout 20 asgi-drain > /dev/null; e="$(date +%s.%N)"
wait $CURL_PID 2>/dev/null
printf 'время stop: %.1f c (ждал завершения запроса)\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')"
echo -n "ответ на медленный запрос: "; cat /tmp/slow-result.txt; echo
docker rm asgi-drain > /dev/null; rm -f /tmp/slow-result.txt
время stop: 4.2 c (ждал завершения запроса)
ответ на медленный запрос: {"done":true}
Uvicorn дождался завершения активного запроса. Клиент получил корректный ответ, а не обрыв соединения.
Свой обработчик мешает серверу
cat > asgi_bad.py <<'PY'
"""ОШИБКА: свой обработчик перехватывает сигнал раньше сервера."""
import os
import signal
import sys
from fastapi import FastAPI
app = FastAPI()
def handle(signum, _frame):
print("[мой обработчик] перехватил сигнал и завершаю немедленно", flush=True)
sys.exit(0) # сервер не успевает дозавершить запросы
signal.signal(signal.SIGTERM, handle)
@app.get("/slow")
async def slow():
import asyncio
await asyncio.sleep(5)
return {"done": True}
PY
cat > Dockerfile.asgibad <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
RUN pip install --no-cache-dir "fastapi[standard]==0.141.1"
COPY asgi_bad.py /app/asgi_bad.py
WORKDIR /app
CMD ["fastapi", "run", "asgi_bad.py", "--port", "8000"]
EOF
docker build -q -f Dockerfile.asgibad -t sig:asgibad . > /dev/null
docker run -d --name bad-t -p 8102:8000 sig:asgibad > /dev/null
sleep 6
curl -s -m 10 localhost:8102/slow > /tmp/bad-result.txt 2>&1 &
CURL_PID=$!
sleep 1
docker stop --timeout 20 bad-t > /dev/null
wait $CURL_PID 2>/dev/null
echo -n "ответ на медленный запрос: "
[ -s /tmp/bad-result.txt ] && cat /tmp/bad-result.txt || echo "(пусто — соединение оборвано)"
echo
docker logs bad-t 2>&1 | grep 'мой обработчик'
docker rm bad-t > /dev/null; rm -f /tmp/bad-result.txt
ответ на медленный запрос: (пусто — соединение оборвано)
[мой обработчик] перехватил сигнал и завершаю немедленно
Свой обработчик завершил процесс мгновенно, не дав Uvicorn дозавершить запрос. Клиент получил обрыв соединения.
Правило: под сервером приложений свой обработчик не устанавливайте. Используйте lifespan.
Уборка
cd /tmp
docker rmi -f $(docker images -q --filter 'reference=sig:*') 2>/dev/null || true
rm -rf /tmp/pysig
Практическое упражнение
Задание. Напишите worker очереди с полноценным graceful shutdown, удовлетворяющий пяти требованиям:
- Получив
SIGTERM, дообрабатывает текущую задачу. - Не забирает новые задачи после сигнала.
- Реагирует на сигнал в пределах одной секунды даже при отсутствии задач.
- Укладывается в grace period 10 секунд; при превышении сообщает об этом.
- Возвращает код
0при штатном завершении.
Дополнительно: напишите тест, проверяющий требования 1 и 2 без Docker.
Подсказки
Подсказка 1
Требование 3 определяет способ ожидания задач: блокирующий вызов должен иметь таймаут.
Подсказка 2
Для требования 4 засеките время начала завершения и сравните с бюджетом.
Подсказка 3
Тестировать удобно, вынеся логику в класс с методом run, который можно остановить программно.
Решение
Сначала выполните задание самостоятельно.
Показать решение
mkdir -p /tmp/worker-ex && cd /tmp/worker-ex
cat > worker.py <<'PY'
"""Worker очереди с graceful shutdown.
Требования:
1. Текущая задача дообрабатывается.
2. Новые задачи не забираются после сигнала.
3. Реакция на сигнал в пределах POLL_TIMEOUT даже без задач.
4. Завершение укладывается в бюджет; превышение фиксируется.
5. Код возврата 0 при штатном завершении.
"""
from __future__ import annotations
import queue
import signal
import sys
import threading
import time
from dataclasses import dataclass
POLL_TIMEOUT = 0.5 # требование 3: реакция в пределах этого времени
SHUTDOWN_BUDGET = 8.0 # требование 4: меньше grace period 10 c
@dataclass
class Task:
task_id: int
duration: float
class Worker:
def __init__(self, source: queue.Queue[Task], budget: float = SHUTDOWN_BUDGET):
self.source = source
self.budget = budget
self._stop = threading.Event()
self.processed = 0
self.shutdown_exceeded = False
def request_stop(self) -> None:
"""Просит worker завершиться после текущей задачи."""
self._stop.set()
def install_signal_handlers(self) -> None:
"""Устанавливается ТОЛЬКО из главного потока."""
def handler(signum: int, _frame: object) -> None:
print(f"[сигнал] {signal.Signals(signum).name} — завершаю после текущей задачи",
flush=True)
self.request_stop()
signal.signal(signal.SIGTERM, handler)
signal.signal(signal.SIGINT, handler)
def process(self, task: Task) -> None:
"""Обработка задачи. Прерывать её нельзя — отсюда требование 1."""
print(f"[задача {task.task_id}] начата ({task.duration} c)", flush=True)
time.sleep(task.duration)
self.processed += 1
print(f"[задача {task.task_id}] завершена", flush=True)
def run(self) -> int:
print("[старт] worker запущен", flush=True)
while not self._stop.is_set():
try:
# Требование 3: таймаут даёт реакцию на сигнал без задач.
# Требование 2: после _stop цикл не выполнится повторно.
task = self.source.get(timeout=POLL_TIMEOUT)
except queue.Empty:
continue
started = time.monotonic()
self.process(task) # требование 1: не прерываем
if self._stop.is_set():
elapsed = time.monotonic() - started
if elapsed > self.budget:
self.shutdown_exceeded = True
print(f"[!] задача заняла {elapsed:.1f} c при бюджете {self.budget} c",
flush=True)
print(f"[завершение] обработано задач: {self.processed}", flush=True)
if self.shutdown_exceeded:
print("[!] завершение превысило бюджет — увеличьте stop_grace_period",
file=sys.stderr, flush=True)
return 0 # требование 5
def main() -> int:
q: queue.Queue[Task] = queue.Queue()
# наполняем очередь для демонстрации
for i in range(1, 21):
q.put(Task(task_id=i, duration=1.5))
worker = Worker(q)
worker.install_signal_handlers()
return worker.run()
if __name__ == "__main__":
sys.exit(main())
PY
cat > test_worker.py <<'PY'
"""Тесты graceful shutdown без Docker."""
import queue
import threading
import time
from worker import Task, Worker
def test_processes_all_tasks_when_not_stopped():
q = queue.Queue()
for i in range(3):
q.put(Task(task_id=i, duration=0.01))
w = Worker(q)
t = threading.Thread(target=w.run)
t.start()
time.sleep(0.5)
w.request_stop()
t.join(timeout=3)
assert w.processed == 3
def test_finishes_current_task_after_stop():
"""Требование 1: текущая задача дообрабатывается."""
q = queue.Queue()
q.put(Task(task_id=1, duration=0.6))
w = Worker(q)
t = threading.Thread(target=w.run)
t.start()
time.sleep(0.2) # задача уже началась
w.request_stop() # просим остановиться в середине
t.join(timeout=3)
assert w.processed == 1, "текущая задача должна быть дообработана"
def test_does_not_take_new_tasks_after_stop():
"""Требование 2: новые задачи не забираются."""
q = queue.Queue()
q.put(Task(task_id=1, duration=0.4))
q.put(Task(task_id=2, duration=0.4))
q.put(Task(task_id=3, duration=0.4))
w = Worker(q)
t = threading.Thread(target=w.run)
t.start()
time.sleep(0.1)
w.request_stop()
t.join(timeout=3)
assert w.processed == 1, "после остановки новые задачи браться не должны"
assert q.qsize() == 2, "оставшиеся задачи должны остаться в очереди"
def test_reacts_to_stop_without_tasks():
"""Требование 3: реакция на сигнал при пустой очереди."""
q = queue.Queue()
w = Worker(q)
t = threading.Thread(target=w.run)
t.start()
time.sleep(0.1)
started = time.monotonic()
w.request_stop()
t.join(timeout=3)
elapsed = time.monotonic() - started
assert not t.is_alive(), "worker должен был завершиться"
assert elapsed < 1.0, f"реакция заняла {elapsed:.2f} c, ожидалось < 1 c"
def test_flags_budget_overrun():
"""Требование 4: превышение бюджета фиксируется."""
q = queue.Queue()
q.put(Task(task_id=1, duration=0.5))
w = Worker(q, budget=0.1) # заведомо маленький бюджет
t = threading.Thread(target=w.run)
t.start()
time.sleep(0.15)
w.request_stop()
t.join(timeout=3)
assert w.shutdown_exceeded, "превышение бюджета должно фиксироваться"
def test_returns_zero():
"""Требование 5: код возврата 0."""
q = queue.Queue()
w = Worker(q)
w.request_stop()
assert w.run() == 0
PY
python3 -m pytest -q test_worker.py 2>&1 | tail -3
...... [100%]
6 passed in 2.51s
Проверка в Docker:
cat > Dockerfile <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY worker.py .
CMD ["python", "worker.py"]
EOF
docker build -q -t wex:1 . > /dev/null
docker run -d --name wex wex:1 > /dev/null
sleep 4
s="$(date +%s.%N)"; docker stop wex > /dev/null; e="$(date +%s.%N)"
printf 'время stop: %.2f c, код: %s\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker inspect wex --format '{{.State.ExitCode}}')"
docker logs wex 2>&1 | tail -5
docker rm -f wex > /dev/null; docker rmi wex:1 > /dev/null
cd /tmp && rm -rf /tmp/worker-ex
время stop: 1.34 c, код: 0
[задача 3] начата (1.5 c)
[сигнал] SIGTERM — завершаю после текущей задачи
[задача 3] завершена
[завершение] обработано задач: 3
Все пять требований выполнены: текущая задача дообработана, новые не взяты, код 0, уложились в grace period.
Три решения, определяющие качество.
queue.get(timeout=POLL_TIMEOUT) вместо блокирующего get(). Это требование 3 в одной строке. С бесконечной блокировкой worker при пустой очереди не заметил бы сигнала и получил бы SIGKILL — та же проблема, что с accept() без таймаута.
Проверка флага в условии while, а не внутри цикла. Обеспечивает требование 2: после установки флага следующая итерация не начнётся, и get() не будет вызван. Задачи остаются в очереди — тест test_does_not_take_new_tasks_after_stop проверяет именно это.
Логика вынесена в класс с методом request_stop(). Позволяет тестировать graceful shutdown без Docker и без отправки реальных сигналов. Тесты выполняются за 2.5 секунды вместо минут на сборку образов.
Что делает требование 4 практичным. Worker не пытается уложиться в бюджет любой ценой — он не может прервать задачу, не нарушив требование 1. Вместо этого он сообщает о превышении. Это правильное поведение: решение об увеличении stop_grace_period принимает человек, видя фактические данные.
Проверка результата
mkdir -p /tmp/vsig && cd /tmp/vsig
cat > a.py <<'PY'
import signal, sys, time
r = True
def h(s, f):
global r
print("SIGTERM получен", flush=True); r = False
signal.signal(signal.SIGTERM, h)
print("старт", flush=True)
while r: time.sleep(0.2)
print("штатно", flush=True); sys.exit(0)
PY
printf 'FROM python:3.13-slim\nENV PYTHONUNBUFFERED=1\nCOPY a.py /a.py\nCMD ["python","/a.py"]\n' > Dockerfile
docker build -q -t vsig:1 . > /dev/null
docker run -d --name v vsig:1 > /dev/null; sleep 2
time docker stop v
docker inspect v --format 'код: {{.State.ExitCode}}'
docker logs v | tail -2
docker rm -f v > /dev/null; docker rmi vsig:1 > /dev/null; cd /tmp && rm -rf /tmp/vsig
Ожидается менее секунды, код 0 и обе строки завершения.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
Нет обработчика SIGTERM | В обычном процессе не нужен | PID 1 игнорирует сигналы без обработчика |
signal.signal в дочернем потоке | Логично по структуре кода | ValueError; устанавливать в главном потоке до запуска потоков |
| Блокирующий вызов без таймаута | Работает в обычных условиях | Обработчик вызовется, но цикл не проверит флаг |
time.sleep вместо Event.wait в потоках | Привычка | Event.wait(timeout=) реагирует мгновенно |
| Тяжёлая работа внутри обработчика | Кажется естественным местом | Обработчик выставляет флаг, работа — в основном потоке |
| Свой обработчик под Uvicorn или Gunicorn | Кажется надёжнее | Перехватывает сигнал раньше сервера и рвёт запросы |
| Дочерние процессы не уведомляются | Сигнал получает только PID 1 | Явный proc.terminate() и wait() |
asyncio.sleep вместо ожидания события | Проще написать | Задача досыпает интервал вместо мгновенной реакции |
| Завершение дольше grace period | Не измерялось | Оборвётся SIGKILL на середине |
@app.on_event("shutdown") в FastAPI | Старые примеры | Устарело; использовать lifespan |
Контрольные вопросы
На понимание:
- Почему обработчик сигнала в Python всегда вызывается в главном потоке?
- Почему обработчик вызвался, но приложение всё равно получило
SIGKILL? - Чем
loop.add_signal_handlerотличается отsignal.signalв асинхронном приложении? - Почему под Uvicorn свой обработчик
SIGTERMвреден? - Почему обработчик должен только выставлять флаг?
На применение:
- Как реализовать graceful shutdown в многопоточном приложении?
- Как уведомить дочерние процессы о завершении?
- Как реагировать на сигнал в пределах секунды при ожидании задач из очереди?
На диагностику:
- В логах видно «сигнал получен», но container всё равно завершился с кодом
137через 10 секунд. Причина? signal.signalвыбрасываетValueError: signal only works in main thread. Где ошибка?
Краткое резюме
- Для graceful shutdown в Python нужны три условия: PID 1, обработчик, установка в главном потоке.
- Обработчик вызывается между инструкциями байт-кода и всегда в главном потоке.
- Обработчик должен только выставлять флаг; работа выполняется в основном цикле.
- Блокирующий вызов без таймаута делает обработчик бесполезным.
- В потоках используйте
Event.wait(timeout=)вместоtime.sleep. - В
asyncioприменяйтеloop.add_signal_handler, а неsignal.signal. - Под Uvicorn и Gunicorn сигнал обрабатывает сервер — свой обработчик вреден.
- Для ASGI-приложений механизм завершения —
lifespan, а неsignal.signal. - Дочерние процессы сигнала не получают — уведомляйте их явно.
- Завершение должно укладываться в 5–8 секунд при grace period 10 секунд.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
Python: signal | https://docs.python.org/3/library/signal.html | Ограничение главного потока, вызов между инструкциями, set_wakeup_fd |
Python: asyncio platform support | https://docs.python.org/3/library/asyncio-platforms.html | add_signal_handler только на Unix |
Python: asyncio event loop | https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.add_signal_handler | Обработка сигналов через цикл событий |
Python: threading.Event | https://docs.python.org/3/library/threading.html#threading.Event | wait с таймаутом |
Python: subprocess | https://docs.python.org/3/library/subprocess.html | terminate, kill, wait с таймаутом |
| docker stop reference | https://docs.docker.com/reference/cli/docker/container/stop/ | Grace period и последующий SIGKILL |
| Uvicorn: deployment | https://www.uvicorn.org/deployment/ | Обработка сигналов сервером |
| Gunicorn: signal handling | https://docs.gunicorn.org/en/stable/signals.html | Реакция на SIGTERM, graceful_timeout |
| FastAPI: lifespan events | https://fastapi.tiangolo.com/advanced/events/ | lifespan вместо устаревших on_event |
| ASGI: lifespan protocol | https://asgi.readthedocs.io/en/latest/specs/lifespan.html | Спецификация startup и shutdown |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Logging
Главное оглавление