Когда Kubernetes останавливает под, у приложения есть ограниченное время на то, чтобы завершить работу без потери данных. Если это время кончится раньше, чем приложение доделает своё — Kubernetes просто убьёт процесс сигналом SIGKILL. Данные потеряны, транзакции оборваны, Kafka-сообщения не дообработаны.
Бюджет времени нужно спланировать заранее, а не угадывать. А потом — измерять: без метрик первое падение под нагрузкой превращается в расследование через kubectl logs и догадки.
Как выглядит 60-секундный бюджет в FastAPI
Kubernetes по умолчанию даёт поду 30 секунд на завершение. Для FastAPI-приложения с Kafka и фоновыми задачами этого обычно мало — выставляют 60 секунд (terminationGracePeriodSeconds: 60).
Эти 60 секунд распределяются между несколькими фазами:
| Этап | Длительность | Что делает |
|---|---|---|
| preStop sleep | 10s | даёт kube-proxy разойтись по нодам, чтобы новые запросы перестали приходить |
| uvicorn graceful drain | до 25s | дожимает in-flight HTTP-запросы, новые соединения не принимает |
| lifespan-shutdown | до 20s | readiness 503, остановка Kafka, отмена asyncio-задач |
| Итого | до 35s wall clock | остаток 25s — запас на нагруженный кластер |
Важный момент: uvicorn graceful drain и lifespan-shutdown идут параллельно. Пока uvicorn дожимает активные HTTP-соединения, lifespan-блок одновременно останавливает Kafka-потребитель и отменяет фоновые задачи. Wall clock = preStop + max(drain, lifespan), не сумма всех этапов.
T=0 SIGTERM от Kubernetes
T=0 uvicorn.Server.should_exit = True
T=0 Параллельно стартуют:
├── lifespan shutdown-блок:
│ ├── readiness_state.is_ready = False → /health/ready отвечает 503
│ ├── asyncio-задачи: cancel + ожидание CancelledError (до 20s)
│ ├── APScheduler shutdown(wait=True)
│ ├── aiokafka consumer.stop() / producer.stop() (до 15s)
│ └── engine.dispose() (пул соединений SQLAlchemy)
└── uvicorn graceful drain (--timeout-graceful-shutdown 25s):
├── текущие HTTP-запросы дожимаются до завершения
└── новые соединения отклоняются
T≤35s process exit(0)
Что делать, если не укладываетесь
Простой ответ — увеличить terminationGracePeriodSeconds до 90 или 120 секунд. Это не лучшее решение: длинное завершение = длинный rolling deploy, больше времени несовместимости схем, дольше зависает kubectl drain при обслуживании ноды.
Правильнее сократить объём работы в каждой фазе:
- aiokafka с
max_poll_records=500→ уменьшить до100; обработка одной пачки займёт 5s вместо 25s; - APScheduler-задача с тяжёлой итерацией → уменьшить размер пачки с 500 до 50;
- asyncio-задача с длинным каскадом → добавить
asyncio.wait_for(task, timeout=15)и выходить раньше.
Идея простая: если операция не помещается в отведённый бюджет — делай её меньше, а не давай больше времени.
Метрика app_shutdown_duration_seconds
Без метрики невозможно понять, почему deploy иногда зависает. Добавляем простой Gauge через prometheus_client:
import logging
import time
from prometheus_client import Gauge, REGISTRY
logger = logging.getLogger(__name__)
_shutdown_duration = Gauge(
"app_shutdown_duration_seconds",
"Duration of graceful shutdown in seconds",
["service"],
registry=REGISTRY,
)
class ShutdownObserver:
def __init__(self, service_name: str) -> None:
self._service = service_name
self._start: float = 0.0
def on_sigterm(self) -> None:
self._start = time.monotonic()
logger.info("получили SIGTERM, начинаем graceful shutdown")
def on_complete(self) -> None:
duration = time.monotonic() - self._start
_shutdown_duration.labels(service=self._service).set(duration)
logger.info("graceful shutdown завершён за %.1fs", duration)
Используем в lifespan-блоке:
from contextlib import asynccontextmanager
from fastapi import FastAPI
observer = ShutdownObserver(service_name="order-service")
@asynccontextmanager
async def lifespan(app: FastAPI):
# startup
yield
# shutdown — on_sigterm первым, до любого cleanup
observer.on_sigterm()
await consumer.stop()
await producer.stop()
observer.on_complete()
app = FastAPI(lifespan=lifespan)
on_sigterm() вызывается первым — фиксируем момент получения сигнала. on_complete() вызывается после всех фаз дренажа — фиксируем итоговую длительность.
В Prometheus настраивают алерт, который срабатывает заранее:
# Максимальная длительность shutdown по сервису
max by (service) (app_shutdown_duration_seconds)
# Алерт: shutdown приближается к budget (50s из 60s)
max(app_shutdown_duration_seconds) > 50
Алерт при 50s из 60s даёт время отреагировать до того, как приложения начнут получать SIGKILL.
Почему Kubernetes не скажет, почему пришёл SIGTERM
uvicorn не знает причины сигнала — это информация инфраструктурного уровня. Причин может быть несколько: rolling deploy новой версии, HPA scale-down из-за падения нагрузки, ручной kubectl delete pod, OOM killer, обслуживание ноды.
В коде фиксируем только сам факт получения:
def on_sigterm(self) -> None:
self._start = time.monotonic()
logger.info("получили SIGTERM, начинаем graceful shutdown")
Причину смотрят в kubectl describe pod <pod-name>:
Events:
Type Reason Age From Message
---- ------ --- ---- -------
Normal Killing 2m kubelet Stopping container order-service
Normal ScalingReplicaSet 10m deployment-controller Scaled down replica set order-service-7c8d
Не пытайтесь определить причину в коде — это не задача приложения.
Уровни логирования при завершении
Частая ошибка: логировать нормальное закрытие компонентов на уровне ERROR.
# Частая ошибка — каждый deploy спамит алерты
ERROR - SQLAlchemy engine disposed
ERROR - aiokafka consumer stopped
ERROR - APScheduler shut down
Команда привыкает игнорировать эти алерты, и когда произойдёт реальный инцидент — его заметят не сразу.
Правильный подход: нормальное завершение — это INFO.
async def _stop_kafka(consumer: AIOKafkaConsumer) -> None:
await consumer.stop()
logger.info("aiokafka consumer остановлен")
async def _dispose_db(engine: AsyncEngine) -> None:
await engine.dispose()
logger.info("SQLAlchemy engine закрыт")
ERROR при завершении — только если что-то действительно пошло не так: force-kill до завершения транзакций, потеря соединения в процессе дренажа, необработанное исключение в lifespan.
Коротко
- Бюджет 60s: preStop 10s + параллельно uvicorn drain (до 25s) и lifespan-shutdown (до 20s); wall clock ≤ 35s, остаток — запас.
- Lifespan-shutdown и HTTP drain идут параллельно, wall clock = max(drain, lifespan), не сумма.
- Не помещаетесь в бюджет — сокращайте размер пачек и таймауты операций, не увеличивайте
terminationGracePeriodSeconds. app_shutdown_duration_seconds— Gauge отprometheus_client;on_sigterm()первым,on_complete()после дренажа.- Алерт при
shutdown_duration > 50sиз 60s — срабатывает до SIGKILL. - Причину SIGTERM видно в
kubectl describe pod, не в коде приложения. - Нормальное закрытие (
engine.dispose(),consumer.stop()) логируем на INFO, не ERROR — иначе каждый deploy генерирует ложные алерты.
Что почитать дальше
- Конфигурация uvicorn и lifespan —
--timeout-graceful-shutdown, readiness-флаг, раздельные/health/liveи/health/ready. - HTTP drain в FastAPI — uvicorn graceful, preStop sleep, долгие эндпоинты через 202 Accepted.
- Kafka shutdown в Python —
consumer.stop()с таймаутом, ручной коммит оффсетов. - БД и persistence —
engine.dispose()в правильной точке lifespan-shutdown. - Фоновые задачи и asyncio — APScheduler, CancelledError, draining-флаг в outbox-relay.