Как вы проектируете graceful shutdown для LLM serving pod в Kubernetes?

Краткий тезис

Graceful shutdown — это процесс корректного завершения работы пода, при котором не теряются in-flight запросы, не нарушаются SLA и не возникают ошибки у клиентов. Для LLM serving, где инференс может длиться десятки секунд, критично правильно настроить PreStop hook, readiness probe, таймауты и обработку сигналов SIGTERM/SIGKILL. Без этого жёсткое завершение пода приводит к сбоям, повторным вычислениям и ухудшению пользовательского опыта.


1. Термин: Graceful shutdown (корректное завершение)

Graceful shutdown — это процедура, при которой приложение (в нашем случае LLM serving pod) завершает работу поэтапно:

  • перестаёт принимать новые запросы;
  • дожидается завершения уже выполняющихся запросов (drain);
  • корректно освобождает ресурсы (GPU-память, соединения, кэш);
  • только после этого завершает процесс.

В Kubernetes этот процесс управляется через Pod lifecycle: после получения команды на удаление под переходит в состояние Terminating. В этом состоянии:

  • Endpoint controller удаляет под из Service (новые запросы не направляются);
  • срабатывает PreStop hook (если задан);
  • через terminationGracePeriodSeconds (по умолчанию 30 секунд) отправляется SIGTERM, а затем SIGKILL.

Для LLM serving, где время одного инференса может составлять 5–60 секунд, стандартных 30 секунд часто недостаточно. Необходимо увеличивать terminationGracePeriodSeconds и реализовывать собственный механизм drain.


2. Проблемы при жёстком завершении LLM serving pod

Если под завершается принудительно (SIGKILL), возникают следующие проблемы:

ПроблемаПоследствия
Потеря in-flight запросовКлиент получает 5xx (502/503), запрос нужно повторять
Повреждение состоянияЕсли модель использует кэш (KV-cache), он теряется; при следующем запуске — холодный старт
Нарушение SLI/SLOУвеличивается latency, падает availability
Неосвобождённая GPU-памятьМожет привести к утечкам на узле, проблемы с другими подами
Ошибки в батчингеЕсли модель обрабатывает батчи, часть запросов может быть потеряна

Пример: vLLM при получении SIGKILL может не успеть завершить текущий батч, и все запросы в нём будут потеряны. Клиенты увидят таймаут.


3. Основные компоненты graceful shutdown в Kubernetes

  • PreStop hook — команда или HTTP-запрос, выполняемый перед отправкой SIGTERM. Используется для инициации drain.
  • Readiness probe — проверка готовности пода принимать трафик. При shutdown readiness probe должна возвращать 503, чтобы под был исключён из Service.
  • terminationGracePeriodSeconds — время, которое Kubernetes ждёт после PreStop hook и SIGTERM до принудительного SIGKILL.
  • Обработка сигналов — приложение должно перехватывать SIGTERM и запускать процедуру корректного завершения.

4. PreStop hook: остановка принятия новых запросов

Первый шаг graceful shutdown — перевести под в состояние «не готов» и перестать принимать новые запросы.

Как это работает

  1. Kubernetes вызывает PreStop hook (например, HTTP GET на /shutdown).
  2. Приложение в ответе на /shutdown:
    • устанавливает флаг shutdown_in_progress = True;
    • readiness probe начинает возвращать 503;
    • сервер перестаёт принимать новые соединения (или отклоняет их с 503).
  3. Kubernetes удаляет под из endpoints Service (обычно это происходит параллельно с PreStop hook, но не мгновенно).

Пример PreStop hook в Deployment

lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "curl -X POST http://localhost:8080/shutdown || true"]

Важно PreStop hook должен быть идемпотентным и не зависеть от внешних сервисов. Если hook зависнет, под будет ждать весь terminationGracePeriodSeconds.


5. Завершение in-flight запросов (drain)

После остановки приёма новых запросов необходимо дождаться завершения уже выполняющихся. Это называется drain.

Стратегии

  • Timeout-based задать максимальное время ожидания (например, 60 секунд). Если запрос не завершился за это время — прервать его (отменить через контекст).
  • Graceful drain дождаться завершения всех текущих батчей, но не принимать новые.

Пример на Python (FastAPI + asyncio):

import asyncio
import signal

shutdown_event = asyncio.Event()
in_flight_requests = 0

async def shutdown_handler():
    shutdown_event.set()
    # Ждём завершения всех текущих запросов (максимум 60 секунд)
    try:
        await asyncio.wait_for(
            wait_for_in_flight_requests(),
            timeout=60.0
        )
    except asyncio.TimeoutError:
        log.warning("Drain timeout, forcing shutdown")
    finally:
        # Освобождаем ресурсы
        cleanup_model()

async def wait_for_in_flight_requests():
    while in_flight_requests > 0:
        await asyncio.sleep(0.1)

# В обработчике запроса увеличиваем счётчик
@app.post("/generate")
async def generate(request: Request):
    if shutdown_event.is_set():
        raise HTTPException(status_code=503, detail="Shutting down")
    in_flight_requests += 1
    try:
        result = await model.generate(request)
        return result
    finally:
        in_flight_requests -= 1

Для многопоточных серверов (Gunicorn, uWSGI): используйте аналогичный механизм с threading.Event и счётчиками.


6. Обработка сигналов в приложении

Kubernetes отправляет SIGTERM после PreStop hook (или сразу, если hook не задан). Приложение должно перехватить этот сигнал и запустить процедуру shutdown.

Пример обработки SIGTERM в Python

import signal
import sys

def handle_sigterm(signum, frame):
    log.info("Received SIGTERM, starting graceful shutdown")
    # Запускаем асинхронный shutdown (если есть event loop)
    asyncio.create_task(shutdown_handler())
    # Или синхронный shutdown
    # shutdown_handler_sync()

signal.signal(signal.SIGTERM, handle_sigterm)

Важно Не используйте sys.exit() сразу — это приведёт к жёсткому завершению. Вместо этого дайте приложению время на drain.


7. Graceful shutdown для GPU-памяти и моделей

LLM serving часто использует GPU. При завершении пода необходимо корректно освободить ресурсы:

  • Очистка GPU-кэша torch.cuda.empty_cache() (PyTorch), tf.keras.backend.clear_session() (TensorFlow).
  • Сохранение состояния если модель использует кэш эмбеддингов или KV-cache, можно сохранить его на диск (если это имеет смысл для холодного старта).
  • Закрытие соединений с Triton Inference Server, vLLM, TensorFlow Serving — отправка сигнала на завершение.

Пример для vLLM (через API):

import requests

def shutdown_vllm():
    try:
        requests.post("http://localhost:8000/v1/shutdown", timeout=5)
    except Exception:
        pass

Для моделей на GPU после завершения всех запросов вызовите torch.cuda.synchronize(), чтобы дождаться завершения всех операций на GPU, затем empty_cache().


8. Интеграция с Service Mesh и Load Balancer

В продакшене часто используются Service Mesh (Istio, Linkerd) или внешние Load Balancer. Они также участвуют в graceful shutdown.

  • Istio при удалении пода Istio удаляет его из своей таблицы endpoints. Но это может занимать до 5–10 секунд. PreStop hook должен подождать, чтобы убедиться, что новые запросы больше не приходят.
  • Readiness probe: должна возвращать 503 при shutdown. Это гарантирует, что даже если Service Mesh не успел обновиться, под не будет получать трафик.
  • Headless service если используется StatefulSet, нужно учитывать, что поды имеют стабильные имена, и drain может быть сложнее.

Рекомендация используйте preStop с задержкой 5–10 секунд перед началом drain, чтобы дать время балансировщику обновиться.

lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 5 && curl -X POST http://localhost:8080/shutdown"]

9. Настройка terminationGracePeriodSeconds

Этот параметр определяет общее время, которое Kubernetes ждёт завершения пода после начала terminating.

Формула расчёта

terminationGracePeriodSeconds = max_inference_time + drain_buffer + shutdown_overhead
  • max_inference_time максимальное время одного инференса (например, 30 секунд для длинных генераций).
  • drain_buffer время на завершение всех текущих запросов (обычно 10–20 секунд).
  • shutdown_overhead время на освобождение ресурсов (2–5 секунд).

Типичные значения

  • Для LLM serving: 60–120 секунд.
  • Для лёгких моделей: 30–45 секунд.

Пример в Deployment

spec:
  terminationGracePeriodSeconds: 90

Важно Если terminationGracePeriodSeconds меньше реального времени drain, Kubernetes отправит SIGKILL, и запросы будут потеряны. Лучше установить с запасом.


10. Мониторинг и алерты

Graceful shutdown должен быть наблюдаемым. Добавьте метрики и логи:

Метрики (Prometheus):

  • shutdown_in_progress (gauge, 0/1) — флаг, что под в процессе shutdown.
  • shutdown_duration_seconds (histogram) — время от начала shutdown до полного завершения.
  • requests_dropped_total (counter) — количество запросов, прерванных из-за shutdown.
  • drain_timeout_total (counter) — количество случаев, когда drain не уложился в таймаут.

Логи:

  • "Shutdown initiated" — при получении SIGTERM или вызове PreStop.
  • "Draining connections, in-flight requests: N" — каждые 5 секунд во время drain.
  • "Shutdown complete" — после освобождения ресурсов.

Алерты

  • Если shutdown_duration_seconds > 0.8 * terminationGracePeriodSeconds — предупреждение.
  • Если requests_dropped_total > 0 — критический алерт (значит, drain не сработал).

11. Тестирование graceful shutdown

Graceful shutdown нужно тестировать, особенно в условиях хаоса.

Методы тестирования

  • Chaos engineering используйте kubectl delete pod --grace-period=... или инструменты вроде Chaos Mesh, Litmus.
  • Интеграционные тесты запустите под, отправьте долгий запрос, удалите под, проверьте, что запрос завершился успешно.
  • Нагрузочное тестирование во время стресс-теста удалите под и проверьте, что клиенты не получили ошибок (или получили минимум).

Пример скрипта для теста

# Запускаем фоновый запрос
curl -X POST http://service/generate -d '{"prompt": "long text..."}' &
sleep 2
# Удаляем под
kubectl delete pod llm-serving-xxxx --grace-period=90
wait
# Проверяем, что curl завершился с кодом 200

12. Пример реализации (Python + FastAPI + Kubernetes)

Приложение (app.py):

import asyncio
import signal
from fastapi import FastAPI, HTTPException

app = FastAPI()
shutdown_event = asyncio.Event()
in_flight = 0

@app.on_event("startup")
async def startup():
    signal.signal(signal.SIGTERM, handle_sigterm)

def handle_sigterm(signum, frame):
    asyncio.create_task(graceful_shutdown())

async def graceful_shutdown():
    shutdown_event.set()
    # Ждём завершения in-flight запросов
    try:
        await asyncio.wait_for(wait_for_drain(), timeout=60.0)
    except asyncio.TimeoutError:
        pass
    # Освобождаем GPU
    import torch
    torch.cuda.empty_cache()
    # Завершаем приложение
    exit(0)

async def wait_for_drain():
    while in_flight > 0:
        await asyncio.sleep(0.1)

@app.post("/generate")
async def generate(prompt: str):
    if shutdown_event.is_set():
        raise HTTPException(status_code=503, detail="Shutting down")
    global in_flight
    in_flight += 1
    try:
        result = await model.generate(prompt)
        return {"result": result}
    finally:
        in_flight -= 1

@app.get("/health/ready")
async def readiness():
    if shutdown_event.is_set():
        return {"status": "not ready"}, 503
    return {"status": "ready"}

Deployment (deployment.yaml):

apiVersion: apps/v1
kind: Deployment
metadata:
  name: llm-serving
spec:
  replicas: 3
  template:
    spec:
      terminationGracePeriodSeconds: 90
      containers:
      - name: llm
        image: myregistry/llm-serving:latest
        ports:
        - containerPort: 8080
        readinessProbe:
          httpGet:
            path: /health/ready
            port: 8080
          initialDelaySeconds: 10
          periodSeconds: 5
        lifecycle:
          preStop:
            exec:
              command: ["/bin/sh", "-c", "sleep [[5. Как вы оцениваете качество retrieval'а в RAG-системе|5]] && curl -X POST http://localhost:8080/shutdown || true"]

Пет-проект для закрепления

Задача Разработать и протестировать graceful shutdown для LLM serving пода на Kubernetes с использованием FastAPI и vLLM.

Инструменты

Шаги:

  1. Создайте FastAPI-приложение, которое имитирует LLM инференс (asyncio.sleep(10-30 секунд)).
  2. Реализуйте graceful shutdown: PreStop hook, readiness probe, обработка SIGTERM, drain.
  3. Соберите Docker-образ и запустите в Kubernetes (Deployment с 2 репликами).
  4. Напишите скрипт, который отправляет долгий запрос и одновременно удаляет под (kubectl delete pod).
  5. Проверьте, что запрос завершился успешно (код 200), а не ошибкой.
  6. Добавьте метрики (Prometheus client) и логи.
  7. Протестируйте с разными значениями terminationGracePeriodSeconds (30, 60, 90 секунд).

Ожидаемый результат

  • При удалении пода все in-flight запросы завершаются корректно.
  • Клиенты не получают 5xx.
  • В логах видна последовательность: "Shutdown initiated" → "Draining..." → "Shutdown complete".
  • Метрика shutdown_duration_seconds не превышает 80% от terminationGracePeriodSeconds.

Связь с другими вопросами

ВопросТема
240Деплой LLM на Kubernetes
241Масштабирование LLM serving
243Health probes и liveness/readiness
244Rolling update стратегии
245Canary deployments для LLM
246Мониторинг LLM serving

Навигация