Когда 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 секунд. Этого недостаточно.
Посмотрим, как расходуется время:
Из шестидесяти секунд десять забирает preStop, ещё двадцать пять — уже начатые запросы, остаток уходит на закрытие соединений. При стандартных тридцати на всё это остаётся двадцать секунд, и принудительное завершение приходит посреди работы.
При 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"]
С Kubernetes 1.30 вместо exec с shell есть встроенное действие preStop: sleep: {seconds: 10} — удобно для образов без sh.
Readiness и liveness — разные проверки с разным смысломспросят на собеседовании
Kubernetes проверяет состояние пода через пробы. Важно понимать разницу:
- readinessProbe — «готов ли под принимать трафик?» При сбое под убирается из endpoints, но не перезапускается.
- livenessProbe — «жив ли под вообще?» При сбое под перезапускается.
Это важно на старте: пока сервис ждёт базу или Kafka, под должен отвечать 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 может сработать раньше времени и перезапустить ещё не поднявшийся под.
Как это работает при завершении
- Kubernetes помечает под удаляемым и сразу убирает его из endpoints;
preStop: sleep 10даёт kube-proxy время разнести это по узлам. - Приходит SIGTERM, uvicorn закрывает порт: новые соединения и пробы до пода не доходят.
- uvicorn дожидается текущих in-flight запросов (
--timeout-graceful-shutdown). - Выполняется блок shutdown в lifespan:
app_state.ready = False, остановка задач, Kafka, пула. - Процесс завершается; readiness-проба на этом пути роли не играет — она защищает старт, когда под уже слушает порт, но ещё не готов.
Полный 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— можно временно создать один дополнительный под сверх указанного числа реплик.
Порядок обновления при такой конфигурации:
Новый под входит в трафик раньше, чем уходит старый, поэтому мощность не проседает. Цена — на время выката в кластере живут обе версии сразу, и они обязаны уживаться.
Без maxUnavailable: 0 Kubernetes может убить v1 до появления v2, и на пике трафика будет меньше реплик, чем нужно — возможны 503.
Uvicorn: явный таймаут завершения
Uvicorn должен запускаться с явным таймаутом graceful shutdown. Без него ожидание начатых запросов не ограничено: один зависший запрос держит под до SIGKILL, и блок shutdown в lifespan не выполняется:
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 секунд после preStop 25 уходит на дренаж, остальное — на блок shutdown в lifespan и запас).
Частые ошибки
Нет preStop — kube-proxy не успевает обновить маршруты, новые запросы идут на умирающий под, получают 502.
terminationGracePeriodSeconds: 30 (стандартное значение) — с preStop 10 на uvicorn остаётся 20 секунд. Долгие запросы или медленное закрытие ресурсов → SIGKILL посередине.
Один /health для обеих проб — пока сервис стартует и отвечает 503, Kubernetes считает под мёртвым и перезапускает его, не давая подняться.
Liveness проверяет соединение с базой — если база недоступна, liveness падает, Kubernetes перезапускает под. Liveness должна отвечать 200 всегда, пока процесс жив; она не проверяет внешние зависимости.
timeout_graceful_shutdown не задан в uvicorn — ожидание без предела: зависший запрос доводит под до SIGKILL, lifespan не выполняется.
Глубже: sidecar умирает раньше приложениярасширенное
Схема остановки выше нарисована для пода с одним контейнером. В проде рядом с приложением обычно стоит прокси сервисной сетки или агент секретов, и SIGTERM приходит всем контейнерам пода одновременно. Прокси завершается за секунды (у Istio время дренажа terminationDrainDuration по умолчанию 5 секунд), а приложение в это время ещё дожимает запросы, коммитит offset в Kafka и пишет в базу через тот же прокси. Исходящие вызовы начинают падать с ошибками соединения ровно в блоке shutdown, и в логах это выглядит как случайные сетевые сбои на каждом выкате.
Два способа это починить. Старый: удлинить дренаж прокси аннотацией пода proxy.istio.io/config: '{"terminationDrainDuration": "60s"}' до своего бюджета, чтобы прокси жил дольше приложения. Новый: объявить прокси и агента нативными sidecar-контейнерами, то есть initContainers с restartPolicy: Always; такие контейнеры Kubernetes запускает до основного и останавливает после него, включено по умолчанию с версии 1.29, в 1.33 стало стабильным. Istio умеет регистрировать прокси именно так, и тогда порядок завершения правильный без аннотаций.
Проверяется это одним выкатом с включённым логом ошибок исходящих вызовов и командой kubectl logs <pod> -c istio-proxy --previous: если прокси пишет о завершении раньше, чем приложение пишет shutdown complete, порядок надо чинить.
Коротко
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 — останавливает фоновые задачи до закрытия ресурсов; сам блок выполняется после дренажа HTTP.- Sidecar получает SIGTERM вместе с приложением и обычно умирает первым, обрывая исходящие вызовы в блоке shutdown; лечат удлинением дренажа прокси или нативными sidecar-контейнерами, которые завершаются после основного.
Что почитать дальше
- HTTP drain в FastAPI — что происходит с начатыми запросами, пока под уходит.
- Бюджеты и наблюдаемость — как разложить 60 секунд и проверить выкат под нагрузкой.
- Конфигурация uvicorn и lifespan — таймаут завершения и порядок закрытия ресурсов.