Опирается на правила: R-SHUT-CFG-1R-SHUT-CFG-4 и R-SHUT-CFG-X1 из Graceful Shutdown Style Guide → раздел 1. Runtime/конфигурация.

Важно знать

  • --timeout-graceful-shutdown 30 обязателен — без явного значения uvicorn рвёт активные запросы немедленно на SIGTERM, клиент получает 502.
  • lifespan-shutdown — единственное место, где корректно переключать readiness-флаг, останавливать aiokafka, закрывать engine.
  • Readiness-флаг переключается первым в lifespan-shutdown, до всего остального; /health/ready → 503, k8s убирает pod из endpoints.
  • Свой shutting_down: bool как module-level переменная — не интегрируется с health-эндпоинтами, k8s об этом не узнает.
  • Раздельные /health/live и /health/ready обязательны — падение liveness перезапускает pod, падение readiness убирает из endpoints.
  • Без правильной конфигурации rolling deploy = шторм 502 на клиентах OrderService, ProductService, CustomerService.

Graceful shutdown в FastAPI — это оркестрированная последовательность: переключить readiness → дать k8s убрать из endpoints → дожать in-flight HTTP → остановить фоновые задачи → закрыть aiokafka → engine.dispose() → выход. Описанный ниже минимальный набор параметров uvicorn и структура lifespan покрывают эту последовательность по правилам R-SHUT-CFG-*.

--timeout-graceful-shutdown

R-SHUT-CFG-1: первый и главный параметр запуска.

# entrypoint: main.py
import uvicorn

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

В Dockerfile/Kubernetes обычно передаётся через CLI:

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

Что делает:

  • При SIGTERM uvicorn перестаёт принимать новые connections.
  • In-flight запросы к OrderService, ProductService продолжают обрабатываться до timeout_graceful_shutdown секунд.
  • Только потом процесс завершается.

Без timeout_graceful_shutdown uvicorn использует значение None — это означает немедленное прерывание воркеров: активные HTTP рвутся, клиент получает ConnectionResetError / 502.

Явный timeout R-SHUT-CFG-2

Диапазон 20–45s — баланс между «успеть дрейнить» и «не получить SIGKILL»:

  • < 20s — мало для дрейна под нагрузкой; долгие запросы к CustomerService (оплата, списание) прерываются.
  • > 45s — риск SIGKILL внутри terminationGracePeriodSeconds: 60; полный бюджет не помещается.
  • 30s — подходит для типичных REST API (большинство запросов < 1s, p99 ~ 5s).

Если есть долгие синхронные эндпоинты (> 10s) — не увеличивайте timeout, декомпозируйте эндпоинт на 202 Accepted + polling (см. R-SHUT-HTTP-3 в HTTP drain).

Readiness-флаг и lifespan-shutdown

R-SHUT-CFG-3: readiness переключается первым в lifespan-shutdown.

# app/state.py
from dataclasses import dataclass, field

@dataclass
class AppState:
    is_ready: bool = True


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: application ready")
    app_state.is_ready = True

    yield

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

    # далее — остановка задач, aiokafka, engine.dispose()
    await asyncio.sleep(0)  # дать event loop обработать pending callbacks


app = FastAPI(lifespan=lifespan)

Последовательность на SIGTERM:

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

Health-эндпоинты R-SHUT-CFG-4

# 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"}
# app/main.py — подключение роутера
from app.routes.health import router as health_router

app.include_router(health_router)

Разница между эндпоинтами принципиальна:

  • /health/live — процесс жив (event loop не завис). Падение → k8s перезапускает pod.
  • /health/ready — готов принимать трафик. Падение → k8s убирает из endpoints (что нам и нужно на shutdown).

На shutdown нужен ready=503, не live=503. Подробнее различие — Observability → health checks.

Полный lifespan для ProductService

Реальный сервис с aiokafka и SQLAlchemy:

# app/main.py
import asyncio
import logging
from contextlib import asynccontextmanager

from fastapi import FastAPI
from sqlalchemy.ext.asyncio import AsyncEngine

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("ProductService startup complete")

    yield

    logger.info("получили SIGTERM, начинаем graceful shutdown")
    app_state.is_ready = False

    scheduler.shutdown(wait=True)

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

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

Порядок закрытия в lifespan-shutdown важен:

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

Если engine.dispose() вызвать раньше scheduler — scheduler упадёт на первом же запросе к БД (пул уже закрыт).

Что запрещено

АнтипаттернПравилоЧто взамен
--timeout-graceful-shutdown отсутствуетR-SHUT-CFG-1явный параметр 20–45s
timeout_graceful_shutdown=0 или принудительный killR-SHUT-HTTP-X130s типично
timeout_graceful_shutdown < 20R-SHUT-CFG-2минимум 25s
timeout_graceful_shutdown > 45R-SHUT-CFG-2помещаться в 60s total budget
shutting_down: bool на уровне модуля вместо readiness-флагаR-SHUT-CFG-X1app_state.is_ready через lifespan
Readiness переключается не первым в lifespan-shutdownR-SHUT-CFG-3первая строка shutdown-блока
/health/live и /health/ready объединены в один эндпоинтR-SHUT-CFG-4раздельные маршруты обязательны
engine.dispose() до остановки фоновых задачR-SHUT-DB-X1последним после задач/Kafka

Куда дальше

  • Бюджеты и observability — раскладка 60s budget и метрика app_shutdown_duration_seconds.
  • HTTP drain — что происходит с in-flight запросами к OrderService.
  • Kafka shutdown — consumer.stop() и producer.stop() в lifespan.
  • БД и persistence — engine.dispose() и порядок закрытия пула.
  • Фоновые задачи / async / outbox — scheduler.shutdown(wait=True) и CancelledError.
  • Kubernetes — preStop, terminationGracePeriodSeconds, probes.
  • Идемпотентность in-flight операций — retry-safe операции на SIGTERM.