Когда Kubernetes решает перезапустить или обновить под, он отправляет процессу сигнал SIGTERM и ждёт, пока тот завершится сам. FastAPI с uvicorn умеет это делать корректно — дожидаться in-flight запросов и закрыть соединения. Но без правильной конфигурации самого Kubernetes часть запросов всё равно падает: kube-proxy продолжает слать трафик на умирающий под ещё несколько секунд после начала завершения.
В этой статье — какие настройки нужны и почему.
Как Kubernetes останавливает под
Когда Kubernetes завершает под (при обновлении деплоя или масштабировании вниз), он делает это в несколько шагов:
- Запускает
preStop-хук — если он настроен. - Параллельно убирает под из списка активных адресов сервиса (endpoints).
- После завершения
preStopотправляет процессу SIGTERM. - Ждёт, пока процесс завершится сам.
- Если процесс не завершился за
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 может сработать раньше времени и перезапустить ещё не поднявшийся под.
Как это работает при завершении
- lifespan-shutdown выставляет
app_state.ready = False. /health/readyначинает возвращать 503.- Kubernetes видит два подряд неуспешных ответа (
failureThreshold: 2×periodSeconds: 5= ~10 секунд). - Под убирается из endpoints, новые запросы больше не приходят.
- 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 — убирает под из трафика до закрытия ресурсов.