← назад к разделу

PYTHON · SHUTDOWN Фаза 19. Kubernetes и graceful shutdown

Обязательно

uvicorn и lifespan: настройка корректного завершения

SIGTERM · Readiness · Timeouts

Явный бюджет остановки, снятие с трафика и закрытие ресурсов в обратном порядке создания.

1. Таймаут завершения задаётся явно

1 / 5

--timeout-graceful-shutdown ограничивает ожидание in-flight запросов. Он меньше terminationGracePeriodSeconds, чтобы cleanup в lifespan тоже успел выполниться.

Drain: 25sLifespan: 20sReserve: 15sSIGTERM → drain → cleanup → SIGKILL only after 60s

2. lifespan владеет порядком закрытия

2 / 5

Код после yield сначала останавливает потребителей и фоновые задачи, затем закрывает клиентов и пулы, которыми они пользовались.

1. Stop taskscancel + await2. Close clientsKafka + HTTP3. Dispose poolsDB + Redis last

3. Readiness убирает под с трафика

3 / 5

Перед остановкой readiness возвращает 503, новые запросы не приходят. Liveness остаётся простым и не проверяет базу.

ready=False/health/ready → 503no new trafficLivenessprocess aliveбез DB и Kafka

4. SIGTERM идёт по известной цепочке

4 / 5

Readiness падает, uvicorn закрывает порт и ждёт запросы, затем lifespan отменяет задачи и закрывает ресурсы. Только после этого процесс завершается.

SIGTERMDrain HTTPLifespanProcess exit

5. Несколько воркеров завершаются раздельно

5 / 5

Каждый uvicorn worker имеет свой lifespan и ресурсы. Таймауты действуют на каждый процесс, поэтому общий бюджет рассчитывается на самый медленный.

Worker 1Worker 2own lifespanown poolsslowestdefines budget

Когда Kubernetes останавливает контейнер, он посылает сигнал SIGTERM. Дальше есть несколько секунд, чтобы корректно завершить обработку запросов. Разберём, что нужно настроить, чтобы завершение укладывалось в бюджет.

Зачем нужен --timeout-graceful-shutdown

По умолчанию uvicorn не знает, сколько времени ему отведено на завершение. Без явного параметра (timeout_graceful_shutdown=None) он после SIGTERM закрывает порт и ждёт начатые запросы без ограничения: один зависший на внешнем сервисе запрос держит под до SIGKILL от Kubernetes, а до блока shutdown в lifespan дело не доходит вовсе — offset Kafka не зафиксирован, пул не закрыт.

Чтобы это исправить, задаём таймаут:

# main.py
import uvicorn

if __name__ == "__main__":
    uvicorn.run(
        "app.main:app",
        host="0.0.0.0",
        port=8080,
        timeout_graceful_shutdown=30,
    )

В Kubernetes обычно передают через CLI в Dockerfile:

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080",
     "--timeout-graceful-shutdown", "30"]

После получения SIGTERM uvicorn перестаёт принимать новые подключения и даёт уже запущенным запросам до 30 секунд на завершение; что не успело, отменяется через CancelledError. Только потом выполняется блок shutdown в lifespan, и процесс останавливается.

Какое значение выбрать. Диапазон 20–45 секунд — разумный баланс:

  • меньше 20 секунд: долгие запросы прерываются;
  • больше 45 секунд: велик риск получить принудительное завершение от Kubernetes (SIGKILL) раньше, чем таймаут истечёт, если terminationGracePeriodSeconds у пода равен 60 секундам;
  • 30 секунд подходит большинству REST-сервисов (обычные запросы занимают меньше секунды, p99 — около 5 секунд).

Если у вас есть эндпоинты, которые выполняются дольше 10 секунд, проблему не решить увеличением таймаута — нужно переделать их на схему 202 Accepted с последующим polling'ом.

Lifespan: правильная точка входа для shutdown

FastAPI предоставляет механизм lifespan — функцию-генератор, которая запускается при старте и остановке приложения. Это единственное место, где корректно поднимать и опускать readiness-флаг, останавливать Kafka-клиентов и закрывать соединения с базой. Важно помнить порядок: uvicorn вызывает блок shutdown уже после того, как закрыл порт и дождался начатых запросов.

Минимальная структура:

# app/state.py
from dataclasses import dataclass

@dataclass
class AppState:
    is_ready: bool = False

app_state = AppState()
# app/main.py
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.state import app_state

logger = logging.getLogger(__name__)


@asynccontextmanager
async def lifespan(application: FastAPI):
    logger.info("startup: приложение готово")
    app_state.is_ready = True

    yield

    # shutdown
    logger.info("получили SIGTERM, начинаем завершение")
    app_state.is_ready = False

    await asyncio.sleep(0)  # дать event loop обработать ожидающие callbacks


app = FastAPI(lifespan=lifespan)

Блок до yield — это старт. Блок после yield — завершение. app_state.is_ready = False стоит первым в блоке завершения, чтобы ни одна фоновая задача не начала новую итерацию, пока закрываются ресурсы. Из балансировщика под к этому моменту уже убран: Kubernetes делает это при удалении пода, а не по пробе.

Раздельные health-эндпоинты

FastAPI-приложению нужны два эндпоинта — они выполняют разные роли:

# app/routes/health.py
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from app.state import app_state

router = APIRouter()


@router.get("/health/live")
async def liveness():
    return {"status": "alive"}


@router.get("/health/ready")
async def readiness():
    if not app_state.is_ready:
        return JSONResponse(status_code=503, content={"status": "not_ready"})
    return {"status": "ready"}
  • /health/live — проверяет, что процесс жив (event loop не завис). Если возвращает ошибку, Kubernetes перезапускает pod.
  • /health/ready — проверяет, что приложение готово принимать трафик. Если возвращает 503, Kubernetes убирает pod из списка endpoints балансировщика.

Объединять их в один эндпоинт нельзя: пока сервис стартует и ждёт базу или Kafka, /health/ready должен отдавать 503, а /health/live — 200, иначе Kubernetes перезапустит под, который просто ещё не готов.

Как выглядит весь процесс на SIGTERM

  1. Kubernetes удаляет под: он сразу уходит из endpoints сервиса, а preStop: sleep 10 даёт kube-proxy на узлах время убрать его из маршрутов.
  2. После preStop приходит SIGTERM. Uvicorn закрывает порт: новых соединений нет, пробы готовности и живости с этого момента до пода не доходят.
  3. Начатые запросы дожимаются до timeout_graceful_shutdown; что не успело — отменяется.
  4. Только теперь выполняется блок shutdown в lifespan: флаг, планировщик, Kafka, пул.
  5. Процесс завершается; если всё вместе не уложилось в terminationGracePeriodSeconds, Kubernetes шлёт SIGKILL.
T=0 под удалён из endpoints, preStop sleep 10 T=10 с SIGTERM: uvicorn закрывает порт до T=40 с начатые запросы дожимаются, timeout-graceful-shutdown 30 после блок shutdown lifespan: флаг, планировщик, Kafka, пул T=60 с SIGKILL, если не уложились

Фазы идут друг за другом, и бюджет terminationGracePeriodSeconds это их сумма; блок lifespan выполняется только после дренажа HTTP.

Полный lifespan с реальными ресурсами

Сервис с Kafka и SQLAlchemy:

# app/main.py
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.database import engine
from app.kafka import consumer, producer
from app.scheduler import scheduler
from app.state import app_state

logger = logging.getLogger(__name__)


@asynccontextmanager
async def lifespan(application: FastAPI):
    await consumer.start()
    await producer.start()
    scheduler.start()
    app_state.is_ready = True
    logger.info("startup complete")

    yield

    logger.info("получили SIGTERM, начинаем завершение")
    app_state.is_ready = False

    await stop_scheduler(scheduler)

    await consumer.stop()
    await producer.stop()

    await engine.dispose()
    logger.info("graceful shutdown complete")

Порядок здесь важен:

  1. is_ready = False — сразу, первым.
  2. stop_scheduler — ставит планировщик на паузу и дожидается текущей итерации (как это сделать с APScheduler, разобрано в статье про фоновые задачи).
  3. consumer.stop() / producer.stop() — выход из группы и flush буфера; с ручным commit offset фиксирует сама задача потребителя до остановки.
  4. engine.dispose() — закрытие пула соединений последним, после всех задач.

Если закрыть пул до остановки планировщика, планировщик упадёт на первом же обращении к базе.

Частые ошибки

Нет timeout_graceful_shutdown. Uvicorn ждёт начатые запросы без ограничения: зависший запрос доводит под до SIGKILL, и блок shutdown в lifespan не выполняется. Всегда задавайте явное значение.

timeout_graceful_shutdown=0. Ноль секунд ожидания: начатые запросы отменяются сразу, клиент получает обрыв.

Readiness переключается не первым. Если сначала закрыть Kafka, а потом переключить флаг, фоновая задача успеет начать итерацию на уже закрытом клиенте.

Свой signal.signal(SIGTERM, ...), выставляющий module-level переменную shutting_down. Он перебивает обработчик uvicorn, и штатное завершение ломается; флаг переключают в lifespan.

/health/live и /health/ready объединены в один эндпоинт. Kubernetes не сможет различить «процесс завис» и «приложение ещё не готово».

engine.dispose() вызван до остановки фоновых задач. Задачи, которые обращаются к базе после закрытия пула, получат ошибку подключения.

Дополнительно: при первом чтении можно пропустить

Глубже: несколько воркеров и запуск под gunicornрасширенное

С --workers N uvicorn поднимает N процессов, и lifespan выполняется в каждом: у каждого воркера свои Kafka-клиенты, свой пул и свой readiness-флаг. SIGTERM получает главный процесс, он пересылает сигнал воркерам и ждёт их; --timeout-graceful-shutdown действует внутри каждого воркера отдельно. В Kubernetes проще держать один воркер на под и масштабировать подами: так бюджет завершения считается для одного процесса, а не для самого медленного из N.

Если приложение запущено через gunicorn с классом воркеров uvicorn, ожидание запросов ограничивает уже gunicorn: --graceful-timeout (по умолчанию 30 секунд), после которого воркер получает SIGKILL от самого gunicorn. Параметр uvicorn в этой схеме не участвует, а lifespan-блок, не уложившийся в эти 30 секунд, обрывается без завершения.

Коротко

  • --timeout-graceful-shutdown 30 обязателен — без него uvicorn ждёт начатые запросы без ограничения, до SIGKILL.
  • Диапазон 20–45 секунд; 30 секунд подходит большинству API.
  • Блок shutdown в lifespan выполняется после дренажа HTTP — это место для остановки ресурсов, а не для «ухода из трафика».
  • Readiness-флаг переключается первым в блоке shutdown, до всего остального.
  • /health/live и /health/ready — обязательно раздельные эндпоинты с разной семантикой.
  • Порядок закрытия в lifespan: флаг → планировщик → Kafka → пул соединений с базой.

Что почитать дальше