Опирается на правила:
R-SHUT-K8S-1…R-SHUT-K8S-3иR-SHUT-K8S-X1…R-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
| Probe | Endpoint | Действие k8s при fail |
|---|---|---|
| readinessProbe | /health/ready | Убрать из endpoints (не рестартует) |
| livenessProbe | /health/live | Рестартует pod |
На shutdown задействуется readiness:
- lifespan-shutdown переключает
app_state.ready = False. /health/ready→ 503.- K8s через
periodSeconds: 5×failureThreshold: 2= 10s видит fail. - Pod убирается из endpoints, новый трафик не идёт.
- 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 в Deployment | R-SHUT-K8S-X1 | lifecycle.preStop: exec sleep 10 обязателен |
terminationGracePeriodSeconds: 30 (default) | R-SHUT-K8S-X2 | 60 |
Один /health для readiness и liveness | R-SHUT-K8S-2 | отдельные /health/ready и /health/live |
| Liveness проверяет соединение с БД | R-SHUT-K8S-2 | liveness = {"status": "alive"} без внешних зависимостей |
maxUnavailable: 1 без обоснования | R-SHUT-K8S-3 | 0 для zero-downtime |
maxSurge: 0 (нет extra pod) | R-SHUT-K8S-3 | maxSurge: 1 минимум |
initialDelaySeconds: 0 на liveness | R-SHUT-K8S-2 | 30s для прогрева |
preStop через httpGet (ненадёжно) | R-SHUT-K8S-X1 | exec sleep |
timeout_graceful_shutdown не задан | R-SHUT-CFG-1 | --timeout-graceful-shutdown 25 явно |
Свой shutting_down: bool вместо readiness-флага | R-SHUT-CFG-X1 | app_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.