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

Каждый раз, когда вы перезапускаете сервис в Kubernetes — деплой новой версии, масштабирование вниз, перезапуск пода — часть запросов оказывается «в пути» прямо в момент остановки. Без специальной настройки uvicorn обрывает их, клиент получает 502 или connection reset. Это не баг фреймворка, это поведение по умолчанию, которое нужно исправить.

Обязательно

Что происходит при остановке без настройки

Kubernetes решает остановить под и отправляет процессу SIGTERM. Uvicorn закрывает порт и перестаёт принимать соединения, а начатые запросы ждёт — по умолчанию без ограничения по времени. Пока он ждёт один зависший запрос, остальные клиенты уже получают отказ в соединении, а через terminationGracePeriodSeconds Kubernetes убивает процесс вместе со всем, что не успело.

Параллельно Kubernetes обновляет маршрутизацию — убирает под из списка живых endpoints. Но это происходит не мгновенно: kube-proxy на других узлах обновляет правила iptables за 5–15 секунд. В этот промежуток новые запросы ещё могут прийти на уже умирающий под.

Итог: до 1–2% запросов теряется на каждом деплое, если не принять меры.

Как uvicorn дожидается in-flight запросовспросят на собеседовании

Uvicorn умеет завершаться правильно — нужно лишь ограничить ожидание параметром timeout_graceful_shutdown. При получении SIGTERM он перестаёт принимать новые соединения, активные обработчики продолжают работу до завершения, а по истечении таймаута оставшиеся задачи отменяются и выполняется блок shutdown в lifespan.

# 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

Отсутствие параметра — ожидание без предела, до SIGKILL; значение 0 — обрыв начатых запросов сразу. Ни то, ни другое не graceful shutdown.

Readiness probe — первый сигнал «я умираю»спросят на собеседовании

Из endpoints сервиса под уходит в момент удаления, а не по пробе, и это хорошо: при SIGTERM uvicorn закрывает порт, и пробы до него уже не доходят. Readiness-флаг нужен раньше — на старте, пока не поднялись Kafka и база, под не должен получать трафик. В 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):
    # старт: инициализация
    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"}

Пока /health/ready отдаёт 503, Kubernetes не шлёт на под новые запросы. На завершении этот механизм уже не нужен: блок shutdown в lifespan выполняется после того, как 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: sleep 10 тогда же под убирают из Service, kube-proxy правит iptables T=10s preStop закончился, правила разъехались по узлам T=10s kubelet отправляет SIGTERM T=10s+ uvicorn дожидается начатые запросы, новых не берёт

Эти десять секунд нужны не приложению, а кластеру: за них правила маршрутизации на всех узлах успевают забыть про под. Приложение в это время работает как обычно — сигнала остановки ещё не было, — поэтому платим мы только временем выката, а не отказами клиентам.

За эти 10 секунд приложение продолжает нормально обрабатывать запросы — SIGTERM ещё не пришёл. Это важно: sleep не останавливает приложение, он только откладывает сигнал завершения.

terminationGracePeriodSeconds: 60 — это общий бюджет Kubernetes на весь процесс. Он должен быть больше, чем preStop sleep + timeout_graceful_shutdown + время блока shutdown в lifespan (10 + 30 + до 15 = 55 секунд), иначе 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. При завершении uvicorn ждёт и такие фоновые задачи (BackgroundTasks выполняются внутри того же ASGI-вызова) в пределах того же таймаута, новых запросов не приходит.

Заголовок 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 не задан — uvicorn ждёт начатые запросы без ограничения, зависший запрос приводит к SIGKILL без выполнения lifespan. Задан 0 — начатые запросы обрываются сразу. Ставьте 20–30.

Нет preStop sleep — даже при правильном uvicorn graceful в окне 5–15 секунд приходит новый трафик на умирающий под. Гарантированные 502. Минимум sleep 10.

sleep меньше 5 секунд на большом кластере — kube-proxy не успевает обновиться на всех узлах. На кластерах с 1000+ узлов нужно 20 секунд.

Синхронный endpoint без преобразования в async — долгий endpoint блокирует дренаж и всё равно обрывается по таймауту. Паттерн 202 Accepted решает это.

httpGet в preStop вместо паузы — хук должен просто подождать. С Kubernetes 1.30 для этого есть встроенное действие preStop: sleep: {seconds: 10}, которому не нужен shell в образе; в старых кластерах — exec: ["sh", "-c", "sleep 10"].

Дополнительно: при первом чтении можно пропустить

Глубже: долгоживущие соединения: WebSocket и SSE при остановкерасширенное

--timeout-graceful-shutdown ждёт завершения запросов в работе. WebSocket и SSE это запросы, которые не завершаются никогда: соединение открыто часами, и для uvicorn каждое из них «в работе».

С WebSocket uvicorn поступает жёстко и сразу: при остановке он закрывает их кодом 1012 Service Restart, не дожидаясь таймаута. Клиент, который умеет переподключаться, за секунды уйдёт на другой под, а тот, кто обрабатывает только 1000, останется с ошибкой. Поэтому на стороне клиента переподключение с паузой обязательно, а на стороне сервера лучше закрыть соединения самому в начале блока shutdown, аккуратно и с кодом 1001 Going Away, пока uvicorn этого не сделал за вас: тогда клиенты расходятся по живым подам до того, как закроется пул базы.

SSE это обычный StreamingResponse, и его uvicorn ждёт весь таймаут, а потом отменяет генератор CancelledError. Двадцать тысяч открытых стримов на поде означают, что дренаж длится ровно timeout_graceful_shutdown и завершается обрывом всех разом. Выход тот же: генератор проверяет флаг ready между событиями и завершается сам, когда под уходит; EventSource в браузере переподключится через секунды, а заголовок retry: в потоке задаёт эту паузу.

Общее правило: долгоживущее соединение закрывает приложение по своему сигналу, а таймаут uvicorn остаётся страховкой для тех, кто не успел.

Глубже: keep-alive от ingress: endpoints обновились, соединение живорасширенное

Сделано всё по статье, а 502 при выкате остались, редкие, по несколько на выкат. Причина в соединениях, которые уже открыты. Ingress держит к каждому поду пул keep-alive соединений и переиспользует их; под снят из endpoints, но открытое соединение к нему никто не рвал, и очередной запрос уходит по нему.

Uvicorn при остановке закрывает простаивающие keep-alive соединения и ставит Connection: close в ответы на начатые, так что со своей стороны он честен. Но между закрытием и моментом, когда ingress это заметит, есть окно, и запрос, отправленный в него, получает обрыв; nginx повторит его на другом поде только если метод идемпотентный и включён proxy_next_upstream, POST он повторять не станет.

Две меры. Пауза preStop должна быть не меньше времени жизни простаивающего соединения у ingress (у ingress-nginx это настройка upstream-keepalive-timeout, порядка минуты по умолчанию), либо этот таймаут снижают до десяти-пятнадцати секунд, и тогда sleep 10 закрывает окно. И на стороне клиентов повтор идемпотентных запросов при обрыве соединения остаётся правилом; для POST его делает Idempotency-Key.

Проверяют это нагрузкой во время выката, а не рассуждениями; как именно, разобрано в статье про бюджеты.

Коротко

  • Uvicorn дожидается in-flight запросов; --timeout-graceful-shutdown 30 ограничивает ожидание, без него под висит до SIGKILL.
  • Из endpoints под убирает удаление, а не проба; readiness-флаг держит под без трафика на старте и останавливает фоновые задачи на завершении.
  • preStop: exec: sleep 10 в Kubernetes обязателен: kube-proxy обновляет правила 5–15 секунд после удаления пода, в этот промежуток без sleep приходит новый трафик на умирающий под.
  • terminationGracePeriodSeconds должен быть больше суммы preStop sleep + timeout_graceful_shutdown + блока shutdown в lifespan.
  • Долгий синхронный endpoint > 10 секунд — паттерн 202 Accepted + polling, либо явные asyncio.Task с дожиданием в lifespan.
  • CancelledError в задачах — обрабатывать явно: дожать транзакцию, затем raise.
  • WebSocket uvicorn закрывает сразу кодом 1012, SSE ждёт весь таймаут и обрывает; долгоживущие соединения закрывает приложение по флагу ready, а окно keep-alive у ingress перекрывают паузой preStop не короче его таймаута.

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