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

Когда 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до 15sфлаг готовности, отмена asyncio-задач, остановка Kafka, закрытие пула
Итогодо 50sостаток 10s — запас на нагруженный кластер

Важный момент: фазы идут последовательно. Uvicorn сначала закрывает порт и дожимает активные HTTP-соединения, и только после этого вызывает lifespan-блок, который останавливает Kafka-потребитель и отменяет фоновые задачи. Бюджет — это сумма всех этапов, поэтому каждому нужен свой таймаут: без него одна зависшая фаза съедает всё.

T=0 SIGTERM, uvicorn получает should_exit T=0 uvicorn закрывает порт, дренаж HTTP до 25 с T≤25 с после дренажа: lifespan-блок, до 15 с отмена asyncio-задач — с таймаутом APScheduler shutdown(wait=True) aiokafka consumer.stop и producer.stop engine.dispose() — пул SQLAlchemy T≤50 с exit(0)

Дренаж HTTP и остановка фоновых частей идут друг за другом, поэтому в бюджет входит их сумма. Пул соединений закрывается последним — раньше его закрыть значит оборвать дожимаемые задачи.

Что делать, если не укладываетесь

Простой ответ — увеличить terminationGracePeriodSeconds до 90 или 120 секунд. Это не лучшее решение: длинное завершение = длинный rolling deploy, больше времени несовместимости схем, дольше зависает kubectl drain при обслуживании ноды.

Правильнее сократить объём работы в каждой фазе:

  • getmany(max_records=500) у aiokafka → уменьшить до 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() вызывается первым в lifespan-блоке — а это уже после того, как uvicorn дождался HTTP-запросов, так что метрика измеряет фазу закрытия ресурсов, не дренаж. Длительность дренажа видна по логу uvicorn «Waiting for connections to close» и по гистограмме длительности запросов. Есть и вторая оговорка: Gauge, выставленный за секунду до выхода, Prometheus может не успеть снять, поэтому ту же длительность пишем в лог, а если метрика нужна наверняка — отправляем её в Pushgateway.

В 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.

Дополнительно: при первом чтении можно пропустить

Глубже: как проверить: нагрузка во время выката и счётчик ошибокрасширенное

Всё в этой статье описано словами и настройками, а работает ли оно, узнают одним способом: под нагрузкой во время выката, и не на проде первым.

Локально, за минуту. docker stop -t 60 <контейнер> отправляет SIGTERM и ждёт до минуты. В логе должны идти строки по порядку: uvicorn пишет Waiting for connections to close, затем ваши shutdown.begin, остановка задач, Kafka, пул, shutdown complete, и процесс выходит сам, без Killed в конце. Если лог обрывается на ожидании соединений, таймаут uvicorn не задан.

На стенде, с трафиком. Запускают нагрузку на имя Service, а не на под: hey -z 3m -q 50 -c 10 https://stage/api/v1/orders/<id> или сценарий k6 с частью POST с Idempotency-Key. Пока она идёт, делают kubectl rollout restart deployment/order-service и смотрят итог генератора: число ответов не 2xx и обрывов соединения. Ожидание ноль; несколько 502 на выкат это окно keep-alive или короткий preStop, их разбирает статья про дренаж HTTP.

То же для узла. kubectl drain <узел> выселяет поды параллельно, и без PodDisruptionBudget с minAvailable сервис может остаться без реплик на время; проверяют этот сценарий отдельно, он отличается от выката.

Что смотреть после. kubectl get events на FailedPreStopHook и Killing раньше срока, лог uvicorn на Waiting for connections to close с временем до следующей строки, и app_shutdown_duration_seconds в Pushgateway или в логе. Этот прогон повторяют после каждой правки lifespan, иначе он стареет за месяц.

Коротко

  • Бюджет 60s: preStop 10s + uvicorn drain (до 25s) + lifespan-shutdown (до 15s) = 50s, остаток — запас.
  • Lifespan-shutdown идёт после HTTP drain, бюджет — сумма фаз; каждой фазе свой таймаут.
  • Не помещаетесь в бюджет — сокращайте размер пачек и таймауты операций, не увеличивайте terminationGracePeriodSeconds.
  • app_shutdown_duration_seconds измеряет фазу lifespan; дренаж смотрят по логам uvicorn, а значение дублируют в лог — скрейп перед смертью пода может не успеть.
  • Алерт при shutdown_duration > 50s из 60s — срабатывает до SIGKILL.
  • Причину SIGTERM видно в kubectl describe pod, не в коде приложения.
  • Нормальное закрытие (engine.dispose(), consumer.stop()) логируем на INFO, не ERROR — иначе каждый deploy генерирует ложные алерты.
  • Проверяют нагрузкой во время rollout restart: ноль ответов не 2xx в генераторе, порядок строк в логе от Waiting for connections to close до shutdown complete, отдельно kubectl drain с PodDisruptionBudget.

Что почитать дальше