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

Когда Kubernetes решает перезапустить или обновить под, он отправляет процессу сигнал SIGTERM и ждёт, пока тот завершится сам. FastAPI с uvicorn умеет это делать корректно — дожидаться in-flight запросов и закрыть соединения. Но без правильной конфигурации самого Kubernetes часть запросов всё равно падает: kube-proxy продолжает слать трафик на умирающий под ещё несколько секунд после начала завершения.

В этой статье — какие настройки нужны и почему.

Как Kubernetes останавливает под

Когда Kubernetes завершает под (при обновлении деплоя или масштабировании вниз), он делает это в несколько шагов:

  1. Запускает preStop-хук — если он настроен.
  2. Параллельно убирает под из списка активных адресов сервиса (endpoints).
  3. После завершения preStop отправляет процессу SIGTERM.
  4. Ждёт, пока процесс завершится сам.
  5. Если процесс не завершился за terminationGracePeriodSeconds — принудительно убивает его через SIGKILL.

Проблема без preStop: шаги 2 и 3 происходят почти одновременно, но kube-proxy обновляет таблицы маршрутизации асинхронно — на это уходит 5–15 секунд. Всё это время новые запросы идут на под, который уже начал завершаться и может их не обработать.

preStop: sleep 10 решает эту проблему: под «спит» 10 секунд перед тем, как получить SIGTERM. За это время kube-proxy успевает обновить маршруты, и новые запросы уже не попадают на умирающий под.

terminationGracePeriodSeconds: почему 60, а не 30

Стандартное значение Kubernetes — 30 секунд. Этого недостаточно.

Посмотрим, как расходуется время:

T=0s    Kubernetes начинает завершение пода
T=0s    Запускается preStop sleep 10
T=10s   preStop завершён, под получает SIGTERM
T=10s+  FastAPI/uvicorn начинает graceful shutdown:
          - переводит readiness в 503 (убирает под из трафика)
          - ждёт завершения in-flight запросов (25 секунд)
T=35s+  Закрываются соединения с БД, Kafka и т.д.
T=60s   Если процесс жив — SIGKILL

При terminationGracePeriodSeconds: 30 на uvicorn после preStop остаётся только 20 секунд. Если запросы занимают дольше или закрытие ресурсов медленное — под получит SIGKILL в середине процесса.

Минимальный бюджет: 10 секунд preStop + 25 секунд uvicorn graceful + время на закрытие ресурсов ≈ 40–50 секунд. Ставим 60 секунд с запасом.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  template:
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: app
          image: order-service:1.4.2
          lifecycle:
            preStop:
              exec:
                command: ["sh", "-c", "sleep 10"]

Readiness и liveness — разные проверки с разным смыслом

Kubernetes проверяет состояние пода через пробы. Важно понимать разницу:

  • readinessProbe — «готов ли под принимать трафик?» При сбое под убирается из endpoints, но не перезапускается.
  • livenessProbe — «жив ли под вообще?» При сбое под перезапускается.

Это критически важно для graceful shutdown. На завершении под должен вернуть 503 на readiness-запросы — чтобы Kubernetes убрал его из трафика. Но liveness при этом должна продолжать отвечать 200, иначе Kubernetes решит, что под завис, и перезапустит его — и никакого корректного завершения не получится.

Поэтому нужны два разных endpoint'а:

from contextlib import asynccontextmanager
from fastapi import FastAPI
from starlette.responses import JSONResponse


class AppState:
    def __init__(self) -> None:
        self.ready: bool = False


app_state = AppState()


@asynccontextmanager
async def lifespan(app: FastAPI):
    app_state.ready = True
    yield
    app_state.ready = False  # сигнал для readiness — перестаём принимать трафик


app = FastAPI(lifespan=lifespan)


@app.get("/health/live")
async def liveness():
    return {"status": "alive"}  # всегда 200, даже при завершении


@app.get("/health/ready")
async def readiness():
    if not app_state.ready:
        return JSONResponse(status_code=503, content={"status": "not_ready"})
    return {"status": "ready"}

И соответствующая конфигурация проб в Deployment:

spec:
  containers:
    - name: app
      readinessProbe:
        httpGet:
          path: /health/ready
          port: 8080
        periodSeconds: 5
        timeoutSeconds: 2
        failureThreshold: 2
      livenessProbe:
        httpGet:
          path: /health/live
          port: 8080
        periodSeconds: 10
        timeoutSeconds: 2
        failureThreshold: 3
        initialDelaySeconds: 30

initialDelaySeconds: 30 на liveness — запас на время старта. Python стартует быстро, но если инициализация пула SQLAlchemy или подключение к Kafka занимает время, без задержки liveness может сработать раньше времени и перезапустить ещё не поднявшийся под.

Как это работает при завершении

  1. lifespan-shutdown выставляет app_state.ready = False.
  2. /health/ready начинает возвращать 503.
  3. Kubernetes видит два подряд неуспешных ответа (failureThreshold: 2 × periodSeconds: 5 = ~10 секунд).
  4. Под убирается из endpoints, новые запросы больше не приходят.
  5. uvicorn дожидается текущих in-flight запросов и завершается.

Полный lifespan с закрытием ресурсов

В реальном сервисе в lifespan нужно закрыть все ресурсы — базу данных, Kafka-соединения, фоновые задачи:

import asyncio
import logging
from contextlib import asynccontextmanager
from typing import AsyncGenerator

from aiokafka import AIOKafkaConsumer, AIOKafkaProducer
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from starlette.responses import JSONResponse

logger = logging.getLogger(__name__)


class OrderServiceState:
    def __init__(self) -> None:
        self.ready: bool = False
        self.engine: AsyncEngine | None = None
        self.consumer: AIOKafkaConsumer | None = None
        self.producer: AIOKafkaProducer | None = None
        self._background_tasks: set[asyncio.Task] = set()


state = OrderServiceState()


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
    logger.info("order-service startup")
    state.engine = create_async_engine("postgresql+asyncpg://...")
    state.producer = AIOKafkaProducer(bootstrap_servers="kafka:9092")
    state.consumer = AIOKafkaConsumer("order.commands", bootstrap_servers="kafka:9092")
    await state.producer.start()
    await state.consumer.start()
    state.ready = True

    yield

    logger.info("order-service shutdown: SIGTERM received")
    state.ready = False  # сначала убираем из трафика

    for task in list(state._background_tasks):
        task.cancel()
        try:
            await task
        except asyncio.CancelledError:
            pass

    await state.consumer.stop()
    await state.producer.stop()
    await state.engine.dispose()
    logger.info("order-service shutdown complete")


app = FastAPI(lifespan=lifespan)


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


@app.get("/health/ready")
async def readiness():
    if not state.ready:
        return JSONResponse(status_code=503, content={"status": "not_ready"})
    return {"status": "ready"}

Rolling deploy без потери запросов

По умолчанию Kubernetes при обновлении деплоя может убить старый под раньше, чем новый будет готов принимать трафик. Чтобы этого не было, настраивают стратегию обновления:

spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  • maxUnavailable: 0 — нельзя убрать ни одного пода, пока не поднят новый.
  • maxSurge: 1 — можно временно создать один дополнительный под сверх указанного числа реплик.

Порядок обновления при такой конфигурации:

Начало: 3 пода версии v1
→ Создаётся 1 под v2 (итого 4 пода)
→ Pod v2 проходит readinessProbe → входит в трафик
→ Один из v1 начинает завершение (preStop → SIGTERM → graceful shutdown)
→ Создаётся следующий v2
→ ...

Без maxUnavailable: 0 Kubernetes может убить v1 до появления v2, и на пике трафика будет меньше реплик, чем нужно — возможны 503.

Uvicorn: явный таймаут завершения

Uvicorn должен запускаться с явным таймаутом graceful shutdown. Без него он использует значение по умолчанию — принудительный kill воркеров без ожидания:

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

Или через Python:

import uvicorn

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

25 секунд — значение, которое помещается в бюджет при terminationGracePeriodSeconds: 60 и preStop: sleep 10 (остаётся 50 секунд на uvicorn + ресурсы).

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

Нет preStop — kube-proxy не успевает обновить маршруты, новые запросы идут на умирающий под, получают 502.

terminationGracePeriodSeconds: 30 (стандартное значение) — с preStop 10 на uvicorn остаётся 20 секунд. Долгие запросы или медленное закрытие ресурсов → SIGKILL посередине.

Один /health для обеих проб — при завершении возвращаем 503, Kubernetes интерпретирует как «под мёртв» и перезапускает его, ломая graceful shutdown.

Liveness проверяет соединение с базой — если база недоступна, liveness падает, Kubernetes перезапускает под. Liveness должна отвечать 200 всегда, пока процесс жив; она не проверяет внешние зависимости.

timeout_graceful_shutdown не задан в uvicorn — принудительный kill без ожидания текущих запросов.

Коротко

  • preStop: sleep 10 обязателен — даёт kube-proxy время убрать под из маршрутов до SIGTERM.
  • terminationGracePeriodSeconds: 60 — стандартного значения 30 не хватает с учётом preStop и uvicorn graceful.
  • Readiness и liveness — разные endpoint'ы с разным поведением при завершении: readiness возвращает 503, liveness остаётся 200.
  • Liveness не проверяет внешние зависимости — только то, жив ли процесс.
  • maxSurge: 1, maxUnavailable: 0 — новый под входит в трафик до завершения старого.
  • --timeout-graceful-shutdown 25 задаётся явно в uvicorn.
  • app_state.ready = False ставится первым в lifespan-shutdown — убирает под из трафика до закрытия ресурсов.