Опирается на правила: R-SHUT-K8S-1R-SHUT-K8S-3 и R-SHUT-K8S-X1R-SHUT-K8S-X2 из Graceful Shutdown Style Guide → раздел 6. Kubernetes.

Важно знать

  • terminationGracePeriodSeconds: 60 обязательно — не k8s default 30.
  • preStop sleep 10 — отдельный бюджет, не входит в terminationGracePeriodSeconds.
  • readinessProbe/health/ready, livenessProbe/health/live — разные endpoint'ы.
  • На shutdown нужен readiness=503 — убирает pod из endpoints без рестарта; liveness-падение перезапускает pod.
  • maxSurge: 1, maxUnavailable: 0 на rolling deploy — новый pod принимает трафик до завершения старого.
  • Без preStop — 5–15s трафика на умирающий pod, гарантированные 502.
  • terminationGracePeriodSeconds: 30 при uvicorn graceful 30s — preStop 10s не помещается, pod уйдёт в SIGKILL посередине дрейна.

K8s — внешний оркестратор, который запускает и останавливает pod'ы. Uvicorn graceful shutdown без правильной k8s-конфигурации работает только наполовину: uvicorn корректно дожидается in-flight запросов, но kube-proxy продолжает слать трафик на умирающий pod ещё 5–15 секунд — потому что endpoints ещё не обновились. K8s-настройки замыкают цепочку.

terminationGracePeriodSeconds: 60

R-SHUT-K8S-1: total budget.

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"]

Последовательность shutdown в k8s:

T=0     kubelet начинает shutdown (rolling deploy / kubectl delete pod)
T=0     Параллельно:
          - запускает preStop hook
          - убирает pod из Service endpoints (kube-proxy обновляется асинхронно)
T=10s   preStop sleep завершён → kubelet шлёт SIGTERM
T=10+   uvicorn получает SIGTERM:
          - lifespan-shutdown переводит readiness-флаг в False → /health/ready = 503
          - uvicorn graceful ждёт in-flight запросов (--timeout-graceful-shutdown 25)
T=70s   Если процесс жив → SIGKILL

terminationGracePeriodSeconds отсчитывается после preStop. Дефолт 30 не подходит:

  • preStop 10s + uvicorn graceful 25s = 35s > 30s.
  • Pod получает SIGKILL посередине дрейна, активные запросы прерываются.

Минимальный бюджет: preStop 10s + uvicorn graceful 25s + engine.dispose() = 35–40s → ставим 60s с запасом на Kafka/задачи (подробнее — Бюджеты и observability).

Probes — отдельные endpoint'ы

R-SHUT-K8S-2: readiness ≠ liveness.

FastAPI с lifespan предоставляет оба 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


app = FastAPI(lifespan=lifespan)


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


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

Конфигурация probe'ов в 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
ProbeEndpointДействие k8s при fail
readinessProbe/health/readyУбрать из endpoints (не рестартует)
livenessProbe/health/liveРестартует pod

На shutdown задействуется readiness:

  1. lifespan-shutdown переключает app_state.ready = False.
  2. /health/ready → 503.
  3. K8s через periodSeconds: 5 × failureThreshold: 2 = 10s видит fail.
  4. Pod убирается из endpoints, новый трафик не идёт.
  5. Uvicorn дожидается in-flight запросов и завершается чисто.

Liveness не должна падать на shutdown:

  • Падение liveness → k8s рестартует pod вместо завершения.
  • Restart loop: старый pod не успевает graceful, новый ещё не готов.
  • Это уничтожает graceful shutdown.

initialDelaySeconds: 30 на liveness — стандарт для FastAPI. Python-приложение стартует быстрее JVM, но 30s запас защищает от restart-цикла при медленной инициализации пула SQLAlchemy или первого подключения к Kafka.

Полный lifespan для Sber/Order сервиса

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"}

maxSurge / maxUnavailable

R-SHUT-K8S-3: zero-downtime rolling deploy.

spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0

Последовательность:

Состояние: 3 old pods (v1)
T=0    Create новый pod (v2). Итого 4 pods (3×v1 + 1×v2)
T=N    v2 проходит readinessProbe → joins endpoints
T=N    v1 #1 начинает shutdown:
         - preStop sleep 10
         - SIGTERM → lifespan-shutdown → readiness=503
         - k8s убирает из endpoints
         - uvicorn дожидается in-flight → exit
       Итого: 2×v1 + 1×v2 = 3 active
T=N    Create v2 #2. Итого 4 pods (2×v1 + 2×v2)
...

Без maxUnavailable: 0 k8s может убить v1 до создания v2 — capacity падает ниже 3 replicas, на пиковом трафике возможны 503.

maxSurge: 1 достаточно для небольших деплоев (до ~20 replicas). На высоконагруженных кластерах (50+ pods) — maxSurge: 25%.

Uvicorn запуск с явным graceful timeout

R-SHUT-CFG-1 / R-SHUT-CFG-2: uvicorn обязательно запускается с явным --timeout-graceful-shutdown.

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

Или через uvicorn.run() в entrypoint:

import uvicorn

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

Без явного timeout_graceful_shutdown uvicorn использует дефолт — форсированный kill воркеров без ожидания (R-SHUT-HTTP-X1).

Что запрещено

АнтипаттернПравилоЧто взамен
Нет preStop в DeploymentR-SHUT-K8S-X1lifecycle.preStop: exec sleep 10 обязателен
terminationGracePeriodSeconds: 30 (default)R-SHUT-K8S-X260
Один /health для readiness и livenessR-SHUT-K8S-2отдельные /health/ready и /health/live
Liveness проверяет соединение с БДR-SHUT-K8S-2liveness = {"status": "alive"} без внешних зависимостей
maxUnavailable: 1 без обоснованияR-SHUT-K8S-30 для zero-downtime
maxSurge: 0 (нет extra pod)R-SHUT-K8S-3maxSurge: 1 минимум
initialDelaySeconds: 0 на livenessR-SHUT-K8S-230s для прогрева
preStop через httpGet (ненадёжно)R-SHUT-K8S-X1exec sleep
timeout_graceful_shutdown не заданR-SHUT-CFG-1--timeout-graceful-shutdown 25 явно
Свой shutting_down: bool вместо readiness-флагаR-SHUT-CFG-X1app_state.ready = False в lifespan-shutdown

Куда дальше

  • Бюджеты и observability — раскладка 60s, метрика app_shutdown_duration_seconds.
  • БД и persistence — engine.dispose() после дренажа, порядок фаз.
  • HTTP drain — preStop sleep детали, in-flight запросы.
  • Идемпотентность in-flight — retry-safe операции под SIGTERM.
  • Конфигурация uvicorn/FastAPI — uvicorn graceful timeout, lifespan setup.
  • Kafka shutdown — await consumer.stop(), manual commit, await producer.stop().
  • Scheduled/async/outbox — отмена задач с дожатием CancelledError.