Когда 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 | до 15s | флаг готовности, отмена asyncio-задач, остановка Kafka, закрытие пула |
| Итого | до 50s | остаток 10s — запас на нагруженный кластер |
Важный момент: фазы идут последовательно. Uvicorn сначала закрывает порт и дожимает активные HTTP-соединения, и только после этого вызывает lifespan-блок, который останавливает Kafka-потребитель и отменяет фоновые задачи. Бюджет — это сумма всех этапов, поэтому каждому нужен свой таймаут: без него одна зависшая фаза съедает всё.
Дренаж 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.
Что почитать дальше
- Конфигурация 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.