Опирается на правила:
R-SHUT-HTTP-1…R-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 переключает флаг до того, как начнётся дренаж.
preStophook со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 sleep | R-SHUT-HTTP-2 | exec: ["sh", "-c", "sleep 10"] |
preStop sleep < 5s на multi-node кластере | R-SHUT-HTTP-2 | минимум 10s |
| Синхронный endpoint > 10s без async-паттерна | R-SHUT-HTTP-3 | 202 + polling или BackgroundTasks |
preStop через httpGet (нестабильно при shutdown) | R-SHUT-HTTP-2 | exec sleep детерминированный |
Долгие endpoints без Idempotency-Key | R-SHUT-HTTP-3 | заголовок обязателен |
Свой shutting_down: bool вместо readiness-флага через health | R-SHUT-CFG-X1 | readiness через /health/ready |
Куда дальше
- Graceful Shutdown → раздел 2. HTTP drain — нормативные формулировки правил.
- python/Конфигурация uvicorn и lifespan —
timeout_graceful_shutdown, раздельные пробы. - python/Kubernetes — preStop детали, terminationGracePeriodSeconds.
- python/Идемпотентность in-flight — клиент retry безопасен.
- python/Бюджеты и observability — раскладка 60s budget.
- python/База данных и persistence —
engine.dispose()после дрейна. - python/Scheduled / async / outbox — фоновые задачи и
CancelledError.