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

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

Graceful shutdown (плавное завершение) для LLM serving pod в Kubernetes — это процесс, при котором под перестаёт принимать новые запросы, завершает все уже выполняющиеся (inflight) запросы, а затем корректно завершает процесс. Основные компоненты: PreStop hook для запуска drain-логики, readiness probe для исключения пода из Service, terminationGracePeriodSeconds для задания таймаута на завершение, и обработка SIGTERM внутри приложения. Без graceful shutdown пользователи получают ошибки 5xx, а модель может оставить незавершённые запросы.


1. Термин: Graceful Shutdown

Graceful shutdown — это процедура, при которой приложение завершает работу, не теряя данные и не обрывая активные соединения. В контексте LLM serving это означает:

  • Drain (слив): прекращение приёма новых запросов.
  • Inflight requests (выполняющиеся запросы): завершение всех начатых генераций.
  • Cleanup (очистка): освобождение ресурсов (GPU memory, соединения с БД).
  • Exit (выход): завершение процесса с кодом 0.

Противоположность — hard shutdown (жёсткое завершение), когда процесс убивается принудительно (SIGKILL), что приводит к ошибкам у клиентов и возможной потере данных.


2. PreStop Hook

PreStop hook — это команда или HTTP-запрос, который Kubernetes выполняет перед отправкой SIGTERM процессу. Он задаётся в spec.containers.lifecycle.preStop.

Пример конфигурации

lifecycle:
  preStop:
    exec:
      command:
        - /bin/sh
        - -c
        - "sleep 10 && curl -X POST http://localhost:8000/drain"
  • sleep 10 — даёт время kube-proxy обновить правила iptables, чтобы новые запросы перестали направляться на под.
  • curl -X POST /drain — вызывает эндпоинт приложения, который переводит его в режим «не принимать новые запросы».

Важно PreStop hook выполняется до SIGTERM. Если hook зависает или падает, Kubernetes всё равно отправляет SIGTERM после истечения terminationGracePeriodSeconds.


3. Drain Endpoint на стороне приложения

Внутри LLM сервера (например, на базе vLLM, TGI или FastAPI) нужно реализовать эндпоинт /drain, который:

  1. Устанавливает флаг draining = True.
  2. Запрещает принятие новых запросов (возвращает 503 Service Unavailable).
  3. Продолжает обрабатывать уже запущенные inflight запросы.

Пример на FastAPI

from fastapi import FastAPI, HTTPException
import asyncio

app = FastAPI()
draining = False
inflight = set()

@app.post("/drain")
async def drain():
    global draining
    draining = True
    return {"status": "draining"}

@app.post("/generate")
async def generate(prompt: str):
    if draining:
        raise HTTPException(status_code=503, detail="Server is draining")
    task = asyncio.current_task()
    inflight.add(task)
    try:
        # генерация ответа
        result = await model.generate(prompt)
        return result
    finally:
        inflight.discard(task)

4. Обработка SIGTERM

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

  1. Установить флаг draining (если не сделано ранее).
  2. Дождаться завершения всех inflight запросов.
  3. Выполнить cleanup (освободить GPU память, закрыть соединения).
  4. Выйти с кодом 0.

Пример обработки сигнала

import signal
import sys

def handle_sigterm(signum, frame):
    global draining
    draining = True
    # ждём завершения inflight запросов
    while inflight:
        time.sleep(0.1)
    # cleanup
    model.unload()
    sys.exit(0)

signal.signal(signal.SIGTERM, handle_sigterm)

Термин: SIGTERM — сигнал завершения (номер 15), который просит процесс завершиться. Процесс может его перехватить и выполнить cleanup. Если процесс не завершается за отведённое время, Kubernetes отправляет SIGKILL (номер 9), который нельзя перехватить.


5. Readiness Probe и Graceful Shutdown

Readiness probe — проверка, которая определяет, готов ли под принимать трафик. Если probe возвращает failure, под исключается из Service (endpoints).

При graceful shutdown нужно:

  1. В PreStop hook или при получении SIGTERM начать возвращать failure на readiness probe.
  2. Это гарантирует, что новые запросы не будут направлены на под.

Пример readiness probe в deployment

readinessProbe:
  httpGet:
    path: /health
    port: 8000
  initialDelaySeconds: 5
  periodSeconds: 5

Внутри /health:

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

Термин: Liveness probe — проверка, что под жив. При graceful shutdown её тоже можно отключить, чтобы Kubernetes не перезапускал под во время завершения.


6. terminationGracePeriodSeconds

terminationGracePeriodSeconds — параметр в spec.template.spec, задающий максимальное время (в секундах) на graceful shutdown. По умолчанию 30 секунд.

Для LLM serving нужно увеличить до 60–120 секунд, так как генерация длинных ответов может занимать десятки секунд.

Пример:

spec:
  terminationGracePeriodSeconds: 120

Если за это время процесс не завершился, Kubernetes отправляет SIGKILL.


7. Мониторинг Graceful Shutdown

Важно отслеживать метрики graceful shutdown:

МетрикаОписание
inflight_requestsКоличество выполняющихся запросов
shutdown_duration_secondsВремя от начала drain до завершения
draining_mode1 если под в режиме drain, иначе 0
killed_by_sigkill1 если под был убит принудительно

Пример экспорта метрик (Prometheus):

from prometheus_client import Gauge, Counter

inflight_gauge = Gauge('inflight_requests', 'Current inflight requests')
shutdown_duration = Gauge('shutdown_duration_seconds', 'Shutdown duration')
sigkill_counter = Counter('killed_by_sigkill', 'Count of SIGKILL terminations')

8. Тестирование Graceful Shutdown

Для проверки graceful shutdown можно:

  1. Развернуть под с LLM сервером.
  2. Отправить несколько долгих запросов (например, на генерацию 1000 токенов).
  3. Удалить под (kubectl delete pod).
  4. Проверить, что все запросы завершились успешно (статус 200), а не 502/503.
  5. Проверить логи на наличие сообщений о drain и завершении.

Скрипт для тестирования

#!/bin/bash
# Отправляем запросы в фоне
for i in {1..5}; do
  curl -X POST http://<pod-ip>:8000/generate -d '{"prompt":"long text..."}' &
done
sleep 2
# Удаляем под
kubectl delete pod llm-server-xxx --grace-period=120
wait
# Проверяем, что все ответы успешны

9. Распространённые проблемы и решения

ПроблемаПричинаРешение
502 Bad GatewayНовые запросы попали на под, который уже завершаетсяУвеличить sleep в PreStop hook, настроить readiness probe
504 Gateway TimeoutInflight запросы не успели завершиться за terminationGracePeriodSecondsУвеличить terminationGracePeriodSeconds, оптимизировать время генерации
Под зависает при shutdownПриложение не обрабатывает SIGTERMДобавить обработчик сигнала, проверить логи
GPU память не освобождаетсяCleanup не выполненВызывать model.unload() или torch.cuda.empty_cache()

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

Задача Развернуть LLM сервер (например, vLLM) в minikube с корректным graceful shutdown и проверить его работу.

Инструменты

Шаги:

  1. Напишите Dockerfile для LLM сервера с поддержкой /drain эндпоинта и обработкой SIGTERM.
  2. Создайте Kubernetes deployment с:
    • PreStop hook (sleep 10 && curl -X POST localhost:8000/drain)
    • readiness probe (проверка /health)
    • terminationGracePeriodSeconds: 120
  3. Создайте Service (ClusterIP) для доступа к поду.
  4. Напишите тестовый скрипт, который отправляет несколько долгих запросов и одновременно удаляет под.
  5. Проверьте, что все запросы завершились успешно (статус 200).
  6. Добавьте метрики и визуализируйте shutdown duration в Grafana.

Ожидаемый результат Вы увидите, что при удалении пода все inflight запросы завершаются, а новые запросы получают 503 или перенаправляются на другой под (если есть реплики). Логи покажут последовательность: drain → завершение inflight → exit 0.


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

ВопросТема
405Как деплоить LLM в production?
406Как настроить горизонтальное масштабирование LLM serving?
407Как настроить мониторинг LLM serving?
408Как обрабатывать ошибки в LLM serving?
410Как настроить autoscaling для LLM serving?
411Как обеспечить безопасность LLM serving?

Навигация