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

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

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

По умолчанию uvicorn не знает, сколько времени ему отведено на завершение. Без явного параметра он остановит процесс сразу после получения SIGTERM, не дожидаясь, пока текущие запросы завершатся.

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

# 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 секунд на завершение. Только потом процесс останавливается.

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

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

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

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

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

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

# 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 балансировщика.

Объединять их в один эндпоинт нельзя: на shutdown нужно вернуть 503 только из /health/ready, процесс при этом ещё жив.

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

  1. Kubernetes посылает SIGTERM, uvicorn вызывает блок shutdown в lifespan.
  2. app_state.is_ready = False — флаг переключён немедленно.
  3. /health/ready начинает возвращать 503.
  4. Kubernetes замечает, что readiness probe падает, и убирает pod из endpoints (занимает около 5–10 секунд).
  5. Новый трафик больше не приходит на этот pod.
  6. In-flight запросы дожимаются до значения timeout_graceful_shutdown.

Полный 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

    scheduler.shutdown(wait=True)

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

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

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

  1. is_ready = False — сразу, первым.
  2. scheduler.shutdown(wait=True) — ждём текущую итерацию планировщика.
  3. consumer.stop() / producer.stop() — коммит offset, flush буфера.
  4. engine.dispose() — закрытие пула соединений последним, после всех задач.

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

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

Нет timeout_graceful_shutdown. Uvicorn при SIGTERM сразу прерывает запросы — клиент получает 502. Всегда задавайте явное значение.

timeout_graceful_shutdown=0. Эффект тот же, что и без параметра — немедленное завершение.

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

Module-level переменная shutting_down: bool вместо readiness-флага. Такую переменную не прочитает health-эндпоинт — Kubernetes не узнает о начале shutdown.

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

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

Коротко

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

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

  • Бюджеты и observability — как считать 60-секундный бюджет завершения.
  • HTTP drain — что происходит с in-flight запросами.
  • Kafka shutdown — consumer.stop() и producer.stop() в деталях.
  • БД и persistence — engine.dispose() и порядок закрытия пула.
  • Kubernetes — preStop, terminationGracePeriodSeconds, probes.