← назад к разделу

Когда Kubernetes останавливает под, у приложения есть ограниченное время на то, чтобы завершить работу без потери данных. Если это время кончится раньше, чем приложение доделает своё — Kubernetes просто убьёт процесс сигналом SIGKILL. Данные потеряны, транзакции оборваны, Kafka-сообщения не дообработаны.

Бюджет времени нужно спланировать заранее, а не угадывать. А потом — измерять: без метрик первое падение под нагрузкой превращается в расследование через kubectl logs и догадки.

Как выглядит 60-секундный бюджет в FastAPI

Kubernetes по умолчанию даёт поду 30 секунд на завершение. Для FastAPI-приложения с Kafka и фоновыми задачами этого обычно мало — выставляют 60 секунд (terminationGracePeriodSeconds: 60).

Эти 60 секунд распределяются между несколькими фазами:

ЭтапДлительностьЧто делает
preStop sleep10sдаёт kube-proxy разойтись по нодам, чтобы новые запросы перестали приходить
uvicorn graceful drainдо 25sдожимает in-flight HTTP-запросы, новые соединения не принимает
lifespan-shutdownдо 20sreadiness 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.