Когда Kubernetes останавливает контейнер, он посылает сигнал SIGTERM. Дальше есть несколько секунд, чтобы корректно завершить обработку запросов. Uvicorn по умолчанию делает обратное тому, что от него ждут: перестаёт принимать новые соединения и ждёт начатые запросы сколько угодно, пока Kubernetes не убьёт процесс. Разберём, что нужно настроить, чтобы завершение укладывалось в бюджет.
Зачем нужен --timeout-graceful-shutdown
По умолчанию uvicorn не знает, сколько времени ему отведено на завершение. Без явного параметра (timeout_graceful_shutdown=None) он после SIGTERM закрывает порт и ждёт начатые запросы без ограничения: один зависший на внешнем сервисе запрос держит под до SIGKILL от Kubernetes, а до блока shutdown в lifespan дело не доходит вовсе — offset Kafka не зафиксирован, пул не закрыт.
Чтобы это исправить, задаём таймаут:
# main.py
import uvicorn
if __name__ == "__main__":
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=8080,
timeout_graceful_shutdown=30,
)
В Kubernetes обычно передают через CLI в Dockerfile:
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080",
"--timeout-graceful-shutdown", "30"]
После получения SIGTERM uvicorn перестаёт принимать новые подключения и даёт уже запущенным запросам до 30 секунд на завершение; что не успело, отменяется через CancelledError. Только потом выполняется блок shutdown в lifespan, и процесс останавливается.
Какое значение выбрать. Диапазон 20–45 секунд — разумный баланс:
- меньше 20 секунд: долгие запросы прерываются;
- больше 45 секунд: велик риск получить принудительное завершение от Kubernetes (
SIGKILL) раньше, чем таймаут истечёт, еслиterminationGracePeriodSecondsу пода равен 60 секундам; - 30 секунд подходит большинству REST-сервисов (обычные запросы занимают меньше секунды, p99 — около 5 секунд).
Если у вас есть эндпоинты, которые выполняются дольше 10 секунд, проблему не решить увеличением таймаута — нужно переделать их на схему 202 Accepted с последующим polling'ом.
Lifespan: правильная точка входа для shutdown
FastAPI предоставляет механизм lifespan — функцию-генератор, которая запускается при старте и остановке приложения. Это единственное место, где корректно поднимать и опускать readiness-флаг, останавливать Kafka-клиентов и закрывать соединения с базой. Важно помнить порядок: uvicorn вызывает блок shutdown уже после того, как закрыл порт и дождался начатых запросов.
Минимальная структура:
# app/state.py
from dataclasses import dataclass
@dataclass
class AppState:
is_ready: bool = False
app_state = AppState()
# app/main.py
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.state import app_state
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(application: FastAPI):
logger.info("startup: приложение готово")
app_state.is_ready = True
yield
# shutdown
logger.info("получили SIGTERM, начинаем завершение")
app_state.is_ready = False
await asyncio.sleep(0) # дать event loop обработать ожидающие callbacks
app = FastAPI(lifespan=lifespan)
Блок до yield — это старт. Блок после yield — завершение. app_state.is_ready = False стоит первым в блоке завершения, чтобы ни одна фоновая задача не начала новую итерацию, пока закрываются ресурсы. Из балансировщика под к этому моменту уже убран: Kubernetes делает это при удалении пода, а не по пробе.
Раздельные health-эндпоинты
FastAPI-приложению нужны два эндпоинта — они выполняют разные роли:
# app/routes/health.py
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from app.state import app_state
router = APIRouter()
@router.get("/health/live")
async def liveness():
return {"status": "alive"}
@router.get("/health/ready")
async def readiness():
if not app_state.is_ready:
return JSONResponse(status_code=503, content={"status": "not_ready"})
return {"status": "ready"}
/health/live— проверяет, что процесс жив (event loop не завис). Если возвращает ошибку, Kubernetes перезапускает pod./health/ready— проверяет, что приложение готово принимать трафик. Если возвращает 503, Kubernetes убирает pod из списка endpoints балансировщика.
Объединять их в один эндпоинт нельзя: пока сервис стартует и ждёт базу или Kafka, /health/ready должен отдавать 503, а /health/live — 200, иначе Kubernetes перезапустит под, который просто ещё не готов.
Как выглядит весь процесс на SIGTERM
- Kubernetes удаляет под: он сразу уходит из endpoints сервиса, а
preStop: sleep 10даёт kube-proxy на узлах время убрать его из маршрутов. - После preStop приходит SIGTERM. Uvicorn закрывает порт: новых соединений нет, пробы готовности и живости с этого момента до пода не доходят.
- Начатые запросы дожимаются до
timeout_graceful_shutdown; что не успело — отменяется. - Только теперь выполняется блок shutdown в lifespan: флаг, планировщик, Kafka, пул.
- Процесс завершается; если всё вместе не уложилось в
terminationGracePeriodSeconds, Kubernetes шлёт SIGKILL.
Фазы идут друг за другом, и бюджет terminationGracePeriodSeconds это их сумма; блок lifespan выполняется только после дренажа HTTP.
Полный lifespan с реальными ресурсами
Сервис с Kafka и SQLAlchemy:
# app/main.py
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.database import engine
from app.kafka import consumer, producer
from app.scheduler import scheduler
from app.state import app_state
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(application: FastAPI):
await consumer.start()
await producer.start()
scheduler.start()
app_state.is_ready = True
logger.info("startup complete")
yield
logger.info("получили SIGTERM, начинаем завершение")
app_state.is_ready = False
await stop_scheduler(scheduler)
await consumer.stop()
await producer.stop()
await engine.dispose()
logger.info("graceful shutdown complete")
Порядок здесь важен:
is_ready = False— сразу, первым.stop_scheduler— ставит планировщик на паузу и дожидается текущей итерации (как это сделать с APScheduler, разобрано в статье про фоновые задачи).consumer.stop()/producer.stop()— выход из группы и flush буфера; с ручным commit offset фиксирует сама задача потребителя до остановки.engine.dispose()— закрытие пула соединений последним, после всех задач.
Если закрыть пул до остановки планировщика, планировщик упадёт на первом же обращении к базе.
Частые ошибки
Нет timeout_graceful_shutdown. Uvicorn ждёт начатые запросы без ограничения: зависший запрос доводит под до SIGKILL, и блок shutdown в lifespan не выполняется. Всегда задавайте явное значение.
timeout_graceful_shutdown=0. Ноль секунд ожидания: начатые запросы отменяются сразу, клиент получает обрыв.
Readiness переключается не первым. Если сначала закрыть Kafka, а потом переключить флаг, фоновая задача успеет начать итерацию на уже закрытом клиенте.
Свой signal.signal(SIGTERM, ...), выставляющий module-level переменную shutting_down. Он перебивает обработчик uvicorn, и штатное завершение ломается; флаг переключают в lifespan.
/health/live и /health/ready объединены в один эндпоинт. Kubernetes не сможет различить «процесс завис» и «приложение ещё не готово».
engine.dispose() вызван до остановки фоновых задач. Задачи, которые обращаются к базе после закрытия пула, получат ошибку подключения.
Глубже: несколько воркеров и запуск под gunicornрасширенное
С --workers N uvicorn поднимает N процессов, и lifespan выполняется в каждом: у каждого
воркера свои Kafka-клиенты, свой пул и свой readiness-флаг. SIGTERM получает главный процесс,
он пересылает сигнал воркерам и ждёт их; --timeout-graceful-shutdown действует внутри
каждого воркера отдельно. В Kubernetes проще держать один воркер на под и масштабировать
подами: так бюджет завершения считается для одного процесса, а не для самого медленного из N.
Если приложение запущено через gunicorn с классом воркеров uvicorn, ожидание запросов
ограничивает уже gunicorn: --graceful-timeout (по умолчанию 30 секунд), после которого
воркер получает SIGKILL от самого gunicorn. Параметр uvicorn в этой схеме не участвует,
а lifespan-блок, не уложившийся в эти 30 секунд, обрывается без завершения.
Коротко
--timeout-graceful-shutdown 30обязателен — без него uvicorn ждёт начатые запросы без ограничения, до SIGKILL.- Диапазон 20–45 секунд; 30 секунд подходит большинству API.
- Блок shutdown в lifespan выполняется после дренажа HTTP — это место для остановки ресурсов, а не для «ухода из трафика».
- Readiness-флаг переключается первым в блоке shutdown, до всего остального.
/health/liveи/health/ready— обязательно раздельные эндпоинты с разной семантикой.- Порядок закрытия в lifespan: флаг → планировщик → Kafka → пул соединений с базой.
Что почитать дальше
- Бюджеты и observability — как считать 60-секундный бюджет завершения.
- HTTP drain — что происходит с in-flight запросами.
- Kafka shutdown —
consumer.stop()иproducer.stop()в деталях. - БД и persistence —
engine.dispose()и порядок закрытия пула. - Kubernetes —
preStop,terminationGracePeriodSeconds, probes.