Главная/Python внутри Container/Урок

6.5. Сигналы и PID 1 в Python

Цели

После этого материала вы сможете:

  • реализовать обработчик SIGTERM для синхронного, асинхронного и многопоточного приложения;
  • объяснить ограничения обработки сигналов в Python: только главный поток, задержка до следующей инструкции байт-кода;
  • реализовать graceful shutdown в asyncio через lifespan или обработчик цикла событий;
  • объяснить, как ведут себя сигналы под Gunicorn и Uvicorn;
  • уведомлять дочерние процессы при завершении;
  • диагностировать ситуацию «обработчик есть, но не срабатывает».

Предварительные знания

Ключевые термины

ТерминОбъяснение
обработчик сигналаФункция, вызываемая при получении сигнала
главный потокПоток, в котором запущен интерпретатор; только он получает сигналы
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 обрабатывает сигналы

Механизм не такой прямой, как кажется:

text
   ядро доставляет сигнал
          │
          ▼
   C-обработчик Python устанавливает флаг
   и записывает байт в wakeup-fd
          │
          ▼
   интерпретатор продолжает выполнение
          │
          ▼
   между инструкциями байт-кода проверяет флаги
          │
          ▼
   вызывает Python-обработчик в ГЛАВНОМ потоке

Из этого следуют три практических ограничения.

Ограничение 1: только главный поток. Обработчик всегда вызывается в главном потоке, независимо от того, какой поток был активен. Установить обработчик из дочернего потока нельзя — signal.signal выбросит ValueError.

Ограничение 2: задержка. Обработчик вызывается между инструкциями байт-кода. Если главный поток заблокирован в долгом системном вызове или в C-расширении, не освобождающем GIL, обработчик не вызовется до его завершения.

Классический случай — time.sleep(3600). Он прерывается сигналом, но обработчик отработает только после возврата из вызова.

Ограничение 3: ограниченный набор безопасных операций. Обработчик выполняется в обычном контексте Python, поэтому большинство операций безопасны. Но длительная работа в обработчике блокирует главный поток — правильнее выставить флаг и завершиться.

Правильная структура обработчика

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:

python
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) или хуки сервера:

python
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 под UvicornUvicorn; ваш код — через lifespan
WSGI под GunicornGunicorn; ваш код — через хуки worker_exit и подобные

Дочерние процессы

Сигнал получает только PID 1 (урок 4.4). Если приложение порождает подпроцессы, их нужно уведомить самостоятельно:

python
def handle_shutdown(signum, frame):
    global _shutdown
    _shutdown = True
    for proc in children:
        proc.terminate()      # посылает SIGTERM

И дождаться их в основном потоке:

python
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 из главного потока, и при вызове из дочернего выбрасывает исключение:

text
ValueError: signal only works in main thread of the main interpreter

Причина архитектурная: сигнал доставляется процессу, а не потоку, и Python сводит обработку к одному потоку, чтобы избежать гонок.

Практическое следствие: в приложении, где главный поток запускает рабочие потоки и ждёт их, обработчик нужно установить до запуска потоков, в главном потоке.

Почему time.sleep задерживает обработку

Сигнал прерывает sleep, но обработчик вызывается только когда интерпретатор проверит флаги — то есть после возврата из вызова.

Для time.sleep в Python 3.5+ поведение улучшено: вызов прерывается, обработчик вызывается, и если он не выбросил исключение, sleep продолжается на оставшееся время. Отсюда рекомендация: вместо одного длинного sleep использовать цикл коротких, проверяя флаг между итерациями.

python
# плохо: реакция на сигнал через час
time.sleep(3600)

# хорошо: реакция в пределах 0.5 секунды
for _ in range(7200):
    if _shutdown:
        break
    time.sleep(0.5)

Для asyncio проблемы нет: add_signal_handler работает через цикл событий.


Команды и примеры

Подготовка

bash
mkdir -p /tmp/pysig && cd /tmp/pysig

Базовый обработчик

bash
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
text
время stop: 0.84 c, код: 0
[работа] итерация 3
[сигнал] получен SIGTERM
[завершение] закрываю ресурсы...
[завершение] выполнено итераций: 3

Обработчик сработал, ресурсы закрыты, код 0.

Длинный sleep задерживает реакцию

bash
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
text
время stop: 0.31 c, код: 0
[старт] засыпаю на 60 секунд одним вызовом
[сигнал] получен SIGTERM
[завершение] штатно

Здесь сработало быстро: в Python 3.5+ time.sleep прерывается сигналом, обработчик вызывается, и цикл проверяет флаг.

Но так везёт не всегда. Заблокированный сетевой вызов без таймаута даст другую картину:

bash
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
text
время stop: 10.29 c, код: 137
[старт] жду соединения БЕЗ таймаута
[сигнал] получен SIGTERM

Обработчик вызвался — строка в логах есть. Но accept() снова заблокировался, и цикл не дошёл до проверки флага. Десять секунд, SIGKILL, код 137.

Исправление — таймаут:

bash
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
text
с таймаутом: 0.73 c, код: 0

Правило: любой блокирующий вызов в основном цикле должен иметь таймаут. Иначе обработчик сигнала бесполезен.

Обработчик в дочернем потоке не работает

bash
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
text
ОШИБКА: signal only works in main thread of the main interpreter

Правильная структура многопоточного приложения:

bash
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
text
время stop: 1.05 c, код: 0
[сигнал] SIGTERM
[поток 1] завершён, итераций: 3
[поток 2] завершён, итераций: 3
[поток 3] завершён, итераций: 3
[главный] жду завершения потоков
[главный] все потоки остановлены

Ключевой приём — threading.Event.wait(timeout=...) вместо time.sleep: он одновременно делает паузу и проверяет флаг, реагируя мгновенно.

asyncio

bash
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
text
время stop: 0.51 c, код: 0
[завершение] сигнал получен, дожидаюсь задач
[задача 1] остановлена, итераций: 3
[задача 2] остановлена, итераций: 3
[задача 3] остановлена, итераций: 3
[завершение] всего итераций: 9
[завершение] ресурсы закрыты

Обратите внимание на asyncio.wait_for(stop.wait(), timeout=1.0) вместо asyncio.sleep(1.0): задача просыпается немедленно при установке события, а не досыпает интервал.

Дочерние процессы

bash
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
text
процессы в 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: свой обработчик не нужен

bash
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
text
{"pid":1}
время stop: 0.62 c, код: 0
[lifespan] startup, PID=1
[lifespan] shutdown: закрываю ресурсы
[lifespan] ресурсы закрыты

Ни одной строки signal.signal в коде — сигнал обработал Uvicorn и вызвал lifespan shutdown.

Проверим дозавершение активного запроса:

bash
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
text
время stop: 4.2 c (ждал завершения запроса)
ответ на медленный запрос: {"done":true}

Uvicorn дождался завершения активного запроса. Клиент получил корректный ответ, а не обрыв соединения.

Свой обработчик мешает серверу

bash
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
text
ответ на медленный запрос: (пусто — соединение оборвано)
[мой обработчик] перехватил сигнал и завершаю немедленно

Свой обработчик завершил процесс мгновенно, не дав Uvicorn дозавершить запрос. Клиент получил обрыв соединения.

Правило: под сервером приложений свой обработчик не устанавливайте. Используйте lifespan.

Уборка

bash
cd /tmp
docker rmi -f $(docker images -q --filter 'reference=sig:*') 2>/dev/null || true
rm -rf /tmp/pysig

Практическое упражнение

Задание. Напишите worker очереди с полноценным graceful shutdown, удовлетворяющий пяти требованиям:

  1. Получив SIGTERM, дообрабатывает текущую задачу.
  2. Не забирает новые задачи после сигнала.
  3. Реагирует на сигнал в пределах одной секунды даже при отсутствии задач.
  4. Укладывается в grace period 10 секунд; при превышении сообщает об этом.
  5. Возвращает код 0 при штатном завершении.

Дополнительно: напишите тест, проверяющий требования 1 и 2 без Docker.

Подсказки

Подсказка 1

Требование 3 определяет способ ожидания задач: блокирующий вызов должен иметь таймаут.

Подсказка 2

Для требования 4 засеките время начала завершения и сравните с бюджетом.

Подсказка 3

Тестировать удобно, вынеся логику в класс с методом run, который можно остановить программно.

Решение

Сначала выполните задание самостоятельно.

Показать решение
bash
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
text
......                                                                   [100%]
6 passed in 2.51s

Проверка в Docker:

bash
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
text
время 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 принимает человек, видя фактические данные.

Проверка результата

bash
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

Контрольные вопросы

На понимание:

  1. Почему обработчик сигнала в Python всегда вызывается в главном потоке?
  2. Почему обработчик вызвался, но приложение всё равно получило SIGKILL?
  3. Чем loop.add_signal_handler отличается от signal.signal в асинхронном приложении?
  4. Почему под Uvicorn свой обработчик SIGTERM вреден?
  5. Почему обработчик должен только выставлять флаг?

На применение:

  1. Как реализовать graceful shutdown в многопоточном приложении?
  2. Как уведомить дочерние процессы о завершении?
  3. Как реагировать на сигнал в пределах секунды при ожидании задач из очереди?

На диагностику:

  1. В логах видно «сигнал получен», но container всё равно завершился с кодом 137 через 10 секунд. Причина?
  2. signal.signal выбрасывает ValueError: signal only works in main thread. Где ошибка?

Краткое резюме

  1. Для graceful shutdown в Python нужны три условия: PID 1, обработчик, установка в главном потоке.
  2. Обработчик вызывается между инструкциями байт-кода и всегда в главном потоке.
  3. Обработчик должен только выставлять флаг; работа выполняется в основном цикле.
  4. Блокирующий вызов без таймаута делает обработчик бесполезным.
  5. В потоках используйте Event.wait(timeout=) вместо time.sleep.
  6. В asyncio применяйте loop.add_signal_handler, а не signal.signal.
  7. Под Uvicorn и Gunicorn сигнал обрабатывает сервер — свой обработчик вреден.
  8. Для ASGI-приложений механизм завершения — lifespan, а не signal.signal.
  9. Дочерние процессы сигнала не получают — уведомляйте их явно.
  10. Завершение должно укладываться в 5–8 секунд при grace period 10 секунд.

Официальные источники

ИсточникСсылкаЧто подтверждает
Python: signalhttps://docs.python.org/3/library/signal.htmlОграничение главного потока, вызов между инструкциями, set_wakeup_fd
Python: asyncio platform supporthttps://docs.python.org/3/library/asyncio-platforms.htmladd_signal_handler только на Unix
Python: asyncio event loophttps://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.add_signal_handlerОбработка сигналов через цикл событий
Python: threading.Eventhttps://docs.python.org/3/library/threading.html#threading.Eventwait с таймаутом
Python: subprocesshttps://docs.python.org/3/library/subprocess.htmlterminate, kill, wait с таймаутом
docker stop referencehttps://docs.docker.com/reference/cli/docker/container/stop/Grace period и последующий SIGKILL
Uvicorn: deploymenthttps://www.uvicorn.org/deployment/Обработка сигналов сервером
Gunicorn: signal handlinghttps://docs.gunicorn.org/en/stable/signals.htmlРеакция на SIGTERM, graceful_timeout
FastAPI: lifespan eventshttps://fastapi.tiangolo.com/advanced/events/lifespan вместо устаревших on_event
ASGI: lifespan protocolhttps://asgi.readthedocs.io/en/latest/specs/lifespan.htmlСпецификация startup и shutdown

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Logging
Главное оглавление

Markdown на GitHub ↗