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

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

Обязательно

Зачем нужен --timeout-graceful-shutdown

По умолчанию uvicorn не знает, сколько времени ему отведено на завершение. Без явного параметра (timeout_graceful_shutdown=None) он после SIGTERM закрывает порт и ждёт начатые запросы без ограничения: один зависший на внешнем сервисе запрос держит под до SIGKILL от Kubernetes, а до блока shutdown в lifespan дело не доходит вовсе — offset Kafka не зафиксирован, пул не закрыт.

Чтобы это исправить, задаём таймаут:

# main.py
import uvicorn

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

В Kubernetes обычно передают через CLI в Dockerfile:

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

После получения SIGTERM uvicorn перестаёт принимать новые подключения и даёт уже запущенным запросам до 30 секунд на завершение; что не успело, отменяется через CancelledError. Только потом выполняется блок shutdown в lifespan, и процесс останавливается.

Какое значение выбрать. Диапазон 20–45 секунд — разумный баланс:

  • меньше 20 секунд: долгие запросы прерываются;
  • больше 45 секунд: велик риск получить принудительное завершение от Kubernetes (SIGKILL) раньше, чем таймаут истечёт, если terminationGracePeriodSeconds у пода равен 60 секундам;
  • 30 секунд подходит большинству REST-сервисов (обычные запросы занимают меньше секунды, p99 — около 5 секунд).

Если у вас есть эндпоинты, которые выполняются дольше 10 секунд, проблему не решить увеличением таймаута — нужно переделать их на схему 202 Accepted с последующим polling'ом.

Lifespan: правильная точка входа для shutdown

FastAPI предоставляет механизм lifespan — функцию-генератор, которая запускается при старте и остановке приложения. Это единственное место, где корректно поднимать и опускать readiness-флаг, останавливать Kafka-клиентов и закрывать соединения с базой. Важно помнить порядок: uvicorn вызывает блок shutdown уже после того, как закрыл порт и дождался начатых запросов.

Минимальная структура:

# app/state.py
from dataclasses import dataclass

@dataclass
class AppState:
    is_ready: bool = False

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: приложение готово")
    app_state.is_ready = True

    yield

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

    await asyncio.sleep(0)  # дать event loop обработать ожидающие callbacks


app = FastAPI(lifespan=lifespan)

Блок до yield — это старт. Блок после yield — завершение. app_state.is_ready = False стоит первым в блоке завершения, чтобы ни одна фоновая задача не начала новую итерацию, пока закрываются ресурсы. Из балансировщика под к этому моменту уже убран: Kubernetes делает это при удалении пода, а не по пробе.

Раздельные health-эндпоинты

FastAPI-приложению нужны два эндпоинта — они выполняют разные роли:

# 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"}
  • /health/live — проверяет, что процесс жив (event loop не завис). Если возвращает ошибку, Kubernetes перезапускает pod.
  • /health/ready — проверяет, что приложение готово принимать трафик. Если возвращает 503, Kubernetes убирает pod из списка endpoints балансировщика.

Объединять их в один эндпоинт нельзя: пока сервис стартует и ждёт базу или Kafka, /health/ready должен отдавать 503, а /health/live — 200, иначе Kubernetes перезапустит под, который просто ещё не готов.

Как выглядит весь процесс на SIGTERM

  1. Kubernetes удаляет под: он сразу уходит из endpoints сервиса, а preStop: sleep 10 даёт kube-proxy на узлах время убрать его из маршрутов.
  2. После preStop приходит SIGTERM. Uvicorn закрывает порт: новых соединений нет, пробы готовности и живости с этого момента до пода не доходят.
  3. Начатые запросы дожимаются до timeout_graceful_shutdown; что не успело — отменяется.
  4. Только теперь выполняется блок shutdown в lifespan: флаг, планировщик, Kafka, пул.
  5. Процесс завершается; если всё вместе не уложилось в terminationGracePeriodSeconds, Kubernetes шлёт SIGKILL.
T=0 под удалён из endpoints, preStop sleep 10 T=10 с SIGTERM: uvicorn закрывает порт до T=40 с начатые запросы дожимаются, timeout-graceful-shutdown 30 после блок shutdown lifespan: флаг, планировщик, Kafka, пул T=60 с SIGKILL, если не уложились

Фазы идут друг за другом, и бюджет terminationGracePeriodSeconds это их сумма; блок lifespan выполняется только после дренажа HTTP.

Полный lifespan с реальными ресурсами

Сервис с Kafka и SQLAlchemy:

# app/main.py
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
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("startup complete")

    yield

    logger.info("получили SIGTERM, начинаем завершение")
    app_state.is_ready = False

    await stop_scheduler(scheduler)

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

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

Порядок здесь важен:

  1. is_ready = False — сразу, первым.
  2. stop_scheduler — ставит планировщик на паузу и дожидается текущей итерации (как это сделать с APScheduler, разобрано в статье про фоновые задачи).
  3. consumer.stop() / producer.stop() — выход из группы и flush буфера; с ручным commit offset фиксирует сама задача потребителя до остановки.
  4. engine.dispose() — закрытие пула соединений последним, после всех задач.

Если закрыть пул до остановки планировщика, планировщик упадёт на первом же обращении к базе.

Частые ошибки

Нет timeout_graceful_shutdown. Uvicorn ждёт начатые запросы без ограничения: зависший запрос доводит под до SIGKILL, и блок shutdown в lifespan не выполняется. Всегда задавайте явное значение.

timeout_graceful_shutdown=0. Ноль секунд ожидания: начатые запросы отменяются сразу, клиент получает обрыв.

Readiness переключается не первым. Если сначала закрыть Kafka, а потом переключить флаг, фоновая задача успеет начать итерацию на уже закрытом клиенте.

Свой signal.signal(SIGTERM, ...), выставляющий module-level переменную shutting_down. Он перебивает обработчик uvicorn, и штатное завершение ломается; флаг переключают в lifespan.

/health/live и /health/ready объединены в один эндпоинт. Kubernetes не сможет различить «процесс завис» и «приложение ещё не готово».

engine.dispose() вызван до остановки фоновых задач. Задачи, которые обращаются к базе после закрытия пула, получат ошибку подключения.

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

Глубже: несколько воркеров и запуск под gunicornрасширенное

С --workers N uvicorn поднимает N процессов, и lifespan выполняется в каждом: у каждого воркера свои Kafka-клиенты, свой пул и свой readiness-флаг. SIGTERM получает главный процесс, он пересылает сигнал воркерам и ждёт их; --timeout-graceful-shutdown действует внутри каждого воркера отдельно. В Kubernetes проще держать один воркер на под и масштабировать подами: так бюджет завершения считается для одного процесса, а не для самого медленного из N.

Если приложение запущено через gunicorn с классом воркеров uvicorn, ожидание запросов ограничивает уже gunicorn: --graceful-timeout (по умолчанию 30 секунд), после которого воркер получает SIGKILL от самого gunicorn. Параметр uvicorn в этой схеме не участвует, а lifespan-блок, не уложившийся в эти 30 секунд, обрывается без завершения.

Коротко

  • --timeout-graceful-shutdown 30 обязателен — без него uvicorn ждёт начатые запросы без ограничения, до SIGKILL.
  • Диапазон 20–45 секунд; 30 секунд подходит большинству API.
  • Блок shutdown в lifespan выполняется после дренажа HTTP — это место для остановки ресурсов, а не для «ухода из трафика».
  • Readiness-флаг переключается первым в блоке shutdown, до всего остального.
  • /health/live и /health/ready — обязательно раздельные эндпоинты с разной семантикой.
  • Порядок закрытия в lifespan: флаг → планировщик → Kafka → пул соединений с базой.

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