Каждый раз, когда вы перезапускаете сервис в Kubernetes — деплой новой версии, масштабирование вниз, перезапуск пода — часть запросов оказывается «в пути» прямо в момент остановки. Без специальной настройки uvicorn обрывает их, клиент получает 502 или connection reset. Это не баг фреймворка, это поведение по умолчанию, которое нужно исправить.
Что происходит при остановке без настройки
Kubernetes решает остановить под. Он отправляет процессу сигнал SIGTERM. Uvicorn получает сигнал и тут же завершает все активные соединения. Клиенты, чьи запросы были в обработке, получают обрыв соединения.
Параллельно Kubernetes обновляет маршрутизацию — убирает под из списка живых endpoints. Но это происходит не мгновенно: kube-proxy на других узлах обновляет правила iptables за 5–15 секунд. В этот промежуток новые запросы ещё могут прийти на уже умирающий под.
Итог: до 1–2% запросов теряется на каждом деплое, если не принять меры.
Как uvicorn дожидается in-flight запросов
Uvicorn умеет завершаться правильно — нужно лишь включить параметр timeout_graceful_shutdown. При получении SIGTERM он перестаёт принимать новые соединения, но активные обработчики продолжают работу до завершения (или до истечения таймаута).
# main.py
import uvicorn
if __name__ == "__main__":
uvicorn.run(
"app:app",
host="0.0.0.0",
port=8080,
timeout_graceful_shutdown=30,
)
Или через командную строку при запуске контейнера:
uvicorn app:app --host 0.0.0.0 --port 8080 --timeout-graceful-shutdown 30
Значение 0 или отсутствие параметра — uvicorn обрывает соединения мгновенно. Это не graceful shutdown.
Readiness probe — первый сигнал «я умираю»
Чтобы Kubernetes быстрее перестал слать трафик на умирающий под, readiness probe должна начать возвращать 503 в самом начале shutdown — до того, как реально закроются соединения.
Для этого используют lifespan FastAPI: флаг ready устанавливается в False первым делом при завершении.
# 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):
# старт: инициализация
await kafka_consumer.start()
app_state.ready = True
yield
# завершение: сначала флаг, потом закрытие ресурсов
app_state.ready = False
await engine.dispose()
await kafka_consumer.stop()
# app/health.py
from fastapi import APIRouter, Response
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"}
Как только readiness probe видит 503, Kubernetes перестаёт слать новые запросы на этот под. Активные обработчики при этом продолжают работу — uvicorn их не обрывает.
preStop sleep — зачем ждать перед SIGTERM
Даже при правильном uvicorn graceful shutdown есть окно в 5–15 секунд, когда kube-proxy на других узлах ещё не обновил правила маршрутизации. В эти секунды новый трафик продолжает поступать на под, который уже начал завершаться и не принимает соединения — клиент получает 502.
Решение простое: задержать отправку SIGTERM на 10 секунд через preStop hook. За это время kube-proxy успевает обновиться, и к моменту реального завершения новый трафик уже не поступает.
spec:
containers:
- name: order-service
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
terminationGracePeriodSeconds: 60
Что происходит с этой настройкой:
T=0 Kubernetes решает остановить под
T=0+ kubelet запускает preStop hook (sleep 10)
Одновременно: endpoints-controller убирает под из Service
Одновременно: kube-proxy обновляет iptables на всех узлах
T=10s preStop завершился — все правила уже обновлены
T=10s kubelet отправляет SIGTERM
T=10s+ uvicorn graceful: дожидает in-flight запросы, принимает только их
За эти 10 секунд приложение продолжает нормально обрабатывать запросы — SIGTERM ещё не пришёл. Это важно: sleep не останавливает приложение, он только откладывает сигнал завершения.
terminationGracePeriodSeconds: 60 — это общий бюджет Kubernetes на весь процесс. Он должен быть больше, чем preStop sleep + timeout_graceful_shutdown (10 + 30 = 40 секунд), иначе Kubernetes принудительно убьёт под раньше.
На больших кластерах (1000+ узлов) kube-proxy может обновляться до 20 секунд — тогда sleep стоит увеличить до 20.
Долгие endpoints — особая проблема
Представьте endpoint, который работает 30 секунд: генерация большого отчёта, сложный расчёт. При SIGTERM uvicorn ждёт завершения активных обработчиков максимум timeout_graceful_shutdown секунд. Если три таких запроса пришли одновременно и все работают 30 секунд — половина не успеет завершиться в бюджет и будет прервана.
Вариант 1: 202 Accepted и polling
Самый чистый способ. Вместо того чтобы держать HTTP-соединение открытым 30 секунд, endpoint принимает задачу и сразу возвращает 202 с идентификатором. Клиент периодически спрашивает о статусе.
# app/orders/router.py
import uuid
from fastapi import APIRouter, BackgroundTasks, Depends, Header
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,
report_id=report_id,
idempotency_key=idempotency_key,
)
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 возвращает ответ меньше чем за секунду. Клиент периодически вызывает GET до получения status: READY. При завершении сервиса текущая итерация фоновой задачи дожимается, новых запросов не приходит.
Заголовок Idempotency-Key позволяет клиенту безопасно повторить запрос при неуверенности — дублей не возникнет.
Вариант 2: явные asyncio.Task с дожиданием на shutdown
Для сервисов с интенсивной фоновой работой — явный контроль задач через 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
app_state.ready = False
if running_tasks:
await asyncio.wait(running_tasks, timeout=25.0)
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
# использование в роутере
@router.post("/products/{product_id}/sync", status_code=202)
async def sync_product(product_id: str) -> dict:
create_tracked_task(product_sync_service.sync(product_id))
return {"status": "ACCEPTED"}
Когда asyncio отменяет задачу при таймауте — она получает CancelledError. Критичные секции нужно обрабатывать явно: дожать транзакцию, затем пробросить исключение дальше.
async def sync_product_inventory(product_id: 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
Частые ошибки
--timeout-graceful-shutdown 0 или не задан — uvicorn обрывает все активные соединения при SIGTERM мгновенно. Клиент видит connection reset. Всегда ставить 30 и выше.
Нет preStop sleep — даже при правильном uvicorn graceful в окне 5–15 секунд приходит новый трафик на умирающий под. Гарантированные 502. Минимум sleep 10.
sleep меньше 5 секунд на большом кластере — kube-proxy не успевает обновиться на всех узлах. На кластерах с 1000+ узлов нужно 20 секунд.
Синхронный endpoint без преобразования в async — долгий endpoint блокирует дренаж и всё равно обрывается по таймауту. Паттерн 202 Accepted решает это.
httpGet в preStop вместо exec sleep — при завершении пода HTTP-ответы ненадёжны. Только exec: ["sh", "-c", "sleep N"].
Коротко
- Uvicorn дожидается in-flight запросов при
--timeout-graceful-shutdown 30. Без этого параметра — мгновенный обрыв. - Readiness probe переключается в 503 первой делом в
lifespanshutdown — через флагapp_state.ready = False. preStop: exec: sleep 10в Kubernetes обязателен: kube-proxy обновляет правила 5–15 секунд после SIGTERM, в этот промежуток без sleep приходит новый трафик на умирающий под.terminationGracePeriodSecondsдолжен быть больше суммыpreStop sleep+timeout_graceful_shutdown.- Долгий синхронный endpoint > 10 секунд — паттерн 202 Accepted + polling, либо явные
asyncio.Taskс дожиданием в lifespan. CancelledErrorв задачах — обрабатывать явно: дожать транзакцию, затемraise.
Что почитать дальше
- Конфигурация uvicorn и lifespan — полные параметры запуска, раздельные health-пробы.
- Kubernetes и terminationGracePeriodSeconds — детали настройки пода для graceful shutdown.
- Идемпотентность in-flight запросов — как клиент безопасно повторяет запрос при перезапуске сервиса.
- База данных и persistence — порядок вызова
engine.dispose()при завершении.