Опирается на правила: R-SHUT-HTTP-1R-SHUT-HTTP-3 и R-SHUT-HTTP-X1 из Graceful Shutdown Style Guide → раздел 2. HTTP drain.

Важно знать

  • In-flight requests дожимаются до response при --timeout-graceful-shutdown > 0 (uvicorn graceful).
  • Readiness → 503 первым — lifespan-shutdown переключает флаг до того, как начнётся дренаж.
  • preStop hook со sleep 10 в k8s обязателен даже при правильном uvicorn graceful.
  • kube-proxy на других нодах обновляет iptables 5–15 секунд после SIGTERM — в это окно трафик ещё приходит.
  • Долгие синхронные endpoints (>10s) — 202 Accepted + polling или BackgroundTasks с Idempotency-Key.
  • --timeout-graceful-shutdown 0 или форсированный kill воркеров аннулирует graceful (R-SHUT-HTTP-X1).
  • Без preStop — даже идеальный uvicorn graceful даёт гарантированные 502 в окне 5–15s.

HTTP drain — наиболее заметная часть graceful shutdown. Любая 502 при rolling deploy — это инцидент в UX и метриках. Для FastAPI/uvicorn нужно два слоя защиты: uvicorn graceful + k8s preStop. Без обоих в продакшне стабильно 1–2% запросов теряются на каждом деплое.

In-flight requests дожимаются

R-SHUT-HTTP-1: что делает uvicorn graceful при --timeout-graceful-shutdown 30.

T=0    SIGTERM получен uvicorn
T=0+   lifespan-shutdown: readiness_flag = False
T=0+   /health/ready → 503
T=0+   uvicorn: новые connections не принимаются
       Активные ASGI-обработчики продолжают работу
T=5s   k8s readiness probe видит 503, убирает pod из endpoints
       Но на других нодах kube-proxy ещё может слать трафик (см. preStop)
T=N    Активные requests завершились
T=N    lifespan-shutdown продолжает: engine.dispose(), consumer.stop()

Lifespan — единственное место, где readiness-флаг переключается в False. Никаких AtomicBoolean-аналогов (R-SHUT-CFG-X1) — только через этот флаг.

Конфигурация uvicorn

# main.py
import uvicorn

if __name__ == "__main__":
    uvicorn.run(
        "app:app",
        host="0.0.0.0",
        port=8080,
        timeout_graceful_shutdown=30,   # R-SHUT-CFG-1, R-SHUT-CFG-2
    )

Или через CLI при запуске контейнера:

uvicorn app:app --host 0.0.0.0 --port 8080 --timeout-graceful-shutdown 30

Readiness-флаг в lifespan

# app/state.py
from dataclasses import dataclass

@dataclass
class AppState:
    ready: bool = False
# app/lifespan.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.state import AppState

app_state = AppState()

@asynccontextmanager
async def lifespan(app: FastAPI):
    # startup
    await engine.connect()          # SQLAlchemy, R-SHUT-DB-1
    await kafka_consumer.start()    # aiokafka, R-SHUT-KFK-1
    app_state.ready = True

    yield

    # shutdown — readiness первым (R-SHUT-CFG-3)
    app_state.ready = False
    # дальше: engine.dispose(), consumer.stop() — после дрейна HTTP
# app/health.py
from fastapi import APIRouter
from app.lifespan import app_state

router = APIRouter()

@router.get("/health/ready")
async def readiness():
    if not app_state.ready:
        return Response(status_code=503)
    return {"status": "ok"}

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

Активный HTTP-обработчик (например, 8 секунд обработки) продолжает работать — uvicorn ждёт его завершения до timeout_graceful_shutdown. Это работает только если graceful-timeout > 0. При 0 — uvicorn обрывает все активные соединения мгновенно.

preStop sleep — обязателен

R-SHUT-HTTP-2: даже при идеальном uvicorn graceful.

spec:
  containers:
    - name: order-service
      lifecycle:
        preStop:
          exec:
            command: ["sh", "-c", "sleep 10"]
  terminationGracePeriodSeconds: 60

Почему preStop нужен

K8s shutdown sequence:

T=0    kubelet получил команду delete pod
T=0+   kubelet вызывает preStop hook (sleep 10)
       Параллельно: endpoints-controller убирает pod из Service
       Параллельно: kube-proxy на других нодах обновляет iptables
T=10s  preStop завершился
T=10s  kubelet отправляет SIGTERM процессу
T=10s+ uvicorn graceful начинается

Без preStop:

T=0    SIGTERM сразу
T=0+   uvicorn graceful → readiness=503
T=0+   k8s readiness probe → ещё не успела опросить (period 5s)
T=0+   Pod ещё в endpoints на других нодах (kube-proxy не обновился)
T=0..15s  Новый трафик продолжает идти на pod
          uvicorn уже не принимает connections → 502 / connection refused

10 секунд — типичное значение:

  • kube-proxy iptables update — 1–5s.
  • Load balancer cache flush — 1–3s.
  • DNS TTL (если используется) — единицы секунд.

На больших кластерах (1000+ nodes) — до 20s.

Что происходит за sleep 10

Процесс приложения продолжает обрабатывать запросы — SIGTERM ещё не отправлен. За эти 10 секунд:

  • k8s убирает pod из endpoints.
  • kube-proxy на других нодах обновляет iptables.
  • Балансер прекращает маршрутизировать новый трафик в этот pod.

После 10s — SIGTERM, uvicorn graceful занимается уже только in-flight requests, новых не приходит.

Долгие endpoints

R-SHUT-HTTP-3: синхронный > 10s.

Сценарий поломки:

  • POST /orders/reports/generate — синхронный, занимает 30 секунд.
  • В момент SIGTERM 3 таких запроса в обработке.
  • timeout_graceful_shutdown: 30s — ждём максимум 30s.
  • Часть запросов не успевает, uvicorn обрывает их.
  • Клиент видит timeout или 500.

Вариант 1: 202 Accepted + polling

# app/orders/router.py
import uuid
from fastapi import APIRouter, Header, BackgroundTasks, Response
from app.orders.commands import RequestReportCommand
from app.orders.service import OrderReportService

router = APIRouter(prefix="/orders")

@router.post("/reports", status_code=202)
async def request_report(
    background_tasks: BackgroundTasks,
    idempotency_key: str = Header(..., alias="Idempotency-Key"),
    service: OrderReportService = Depends(get_service),
) -> dict:
    report_id = str(uuid.uuid4())
    background_tasks.add_task(
        service.generate_report,
        RequestReportCommand(idempotency_key=idempotency_key, report_id=report_id),
    )
    return {"report_id": report_id, "status": "QUEUED"}

@router.get("/reports/{report_id}")
async def get_report(
    report_id: str,
    service: OrderReportService = Depends(get_service),
) -> dict:
    return await service.get_report_status(report_id)

POST возвращает 202 + report_id мгновенно (< 100ms). Клиент polling-ает GET до status: READY. На shutdown короткий POST не блокирует дренаж, фоновая задача через BackgroundTasks завершает текущую итерацию.

Вариант 2: asyncio.Task с явным ожиданием на shutdown

Для сервисов с интенсивным фоном (Customer-уведомления, сверка Product-инвентаря) — явные asyncio.Task с дожиданием в lifespan:

# app/lifespan.py
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.state import app_state

running_tasks: set[asyncio.Task] = set()

@asynccontextmanager
async def lifespan(app: FastAPI):
    app_state.ready = True
    yield

    # shutdown
    app_state.ready = False   # readiness → 503 первым

    if running_tasks:
        await asyncio.wait(running_tasks, timeout=25.0)  # R-SHUT-CFG-2

    await engine.dispose()
    await kafka_consumer.stop()


def create_tracked_task(coro) -> asyncio.Task:
    task = asyncio.create_task(coro)
    running_tasks.add(task)
    task.add_done_callback(running_tasks.discard)
    return task
# app/products/router.py
@router.post("/products/{product_id}/sync", status_code=202)
async def sync_product(
    product_id: str,
    idempotency_key: str = Header(..., alias="Idempotency-Key"),
) -> dict:
    create_tracked_task(product_sync_service.sync(product_id, idempotency_key))
    return {"status": "ACCEPTED"}

CancelledError в долгих задачах нужно обрабатывать явно (R-SHUT-SCHED-2): дожать критичную секцию (commit транзакции), затем raise.

async def sync_product_inventory(product_id: str, idempotency_key: str) -> None:
    async with db_session() as session:
        try:
            await session.execute(update_product_query(product_id))
            await session.commit()           # критичная секция
        except asyncio.CancelledError:
            await session.rollback()
            raise                            # re-raise обязателен

Вариант 3: декомпозиция запроса

Если endpoint может быть быстрым — оптимизировать. Часто долгий синхронный endpoint — это проблема дизайна, не особенность продукта.

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

АнтипаттернПравилоЧто взамен
--timeout-graceful-shutdown 0 или форсированный kill воркеровR-SHUT-HTTP-X1--timeout-graceful-shutdown 30
Отсутствие preStop sleepR-SHUT-HTTP-2exec: ["sh", "-c", "sleep 10"]
preStop sleep < 5s на multi-node кластереR-SHUT-HTTP-2минимум 10s
Синхронный endpoint > 10s без async-паттернаR-SHUT-HTTP-3202 + polling или BackgroundTasks
preStop через httpGet (нестабильно при shutdown)R-SHUT-HTTP-2exec sleep детерминированный
Долгие endpoints без Idempotency-KeyR-SHUT-HTTP-3заголовок обязателен
Свой shutting_down: bool вместо readiness-флага через healthR-SHUT-CFG-X1readiness через /health/ready

Куда дальше

  • Graceful Shutdown → раздел 2. HTTP drain — нормативные формулировки правил.
  • python/Конфигурация uvicorn и lifespantimeout_graceful_shutdown, раздельные пробы.
  • python/Kubernetes — preStop детали, terminationGracePeriodSeconds.
  • python/Идемпотентность in-flight — клиент retry безопасен.
  • python/Бюджеты и observability — раскладка 60s budget.
  • python/База данных и persistence — engine.dispose() после дрейна.
  • python/Scheduled / async / outbox — фоновые задачи и CancelledError.