Множество LLM на Kubernetes: llm-d, Istio и LiteLLM

Обложка статьи о развёртывании множества LLM на Kubernetes

Постановка задачи

Допустим, у вас есть несколько больших и малых языковых моделей (LLM/SLM), которые нужно обслуживать на Kubernetes. Вам требуется:

  • Единый API, чтобы клиенты не знали, где именно работает та или иная модель

  • Интеллектуальная маршрутизация с учётом локальности KV-кеша для ускорения инференса

  • Изоляция каждой модели с независимым масштабированием

  • Внешний доступ по сети, чтобы другие сервисы могли обращаться к моделям

  • Оптимизация затрат на GPU — масштабирование до нуля в простое и обратно по требованию

Большинство команд решают эту задачу, развернув vLLM за LoadBalancer, и на том останавливаются. Для одной модели это работает, но схема рассыпается, как только появляется потребность в маршрутизации между моделями, планировании с учётом prefix-кеша и экономичном управлении GPU.

В этой статье рассмотрено production-развёртывание, которое закрывает все перечисленные задачи: llm-d берёт на себя интеллектуальное планирование инференса, Istio выступает провайдером Gateway API с поддержкой Inference Extension, а LiteLLM служит единым API-шлюзом.

Обзор архитектуры

Схема архитектуры: LiteLLM

Почему именно этот стек?

llm-d — это open-source Kubernetes-нативная платформа для инференса, которая добавляет интеллектуальное планирование поверх vLLM. Вместо случайной балансировки или round-robin она направляет запросы к тому поду vLLM, в GPU-памяти которого уже закешированы наиболее релевантные KV-префиксы. Результат: прирост пропускной способности на 38,9% и сокращение времени до первого токена (TTFT, Time to First Token) на 97% по сравнению с обычными сервисами Kubernetes.

Istio версии 1.28 и выше поддерживает Gateway API вместе с Inference Extension: трафик проходит через ext-proc-фильтр к Endpoint Picker (EPP) llm-d прежде, чем попасть к подам vLLM. Именно этот механизм и обеспечивает интеллектуальную маршрутизацию.

LiteLLM стоит перед всем стеком как единый OpenAI-совместимый API-шлюз, управляя API-ключами, ограничениями запросов, логированием и маршрутизацией по моделям. Клиент указывает имя модели, и LiteLLM перенаправляет запрос на нужный хост.

Ключевая идея: зачем два уровня маршрутизации?

В архитектуре два разных уровня маршрутизации, и понимание их роли принципиально важно.

Уровень 1 — LiteLLM + ALB (выбор модели): клиент говорит «хочу обратиться к Phi-4-mini», LiteLLM перенаправляет запрос на phi4.example.com. ALB сопоставляет имя хоста и передаёт трафик нужному Istio-шлюзу. Это маршрутизация на уровне модели.

Уровень 2 — Istio + EPP (выбор пода): внутри пайплайна конкретной модели EPP выбирает, какому именно поду vLLM обработать запрос, ориентируясь на попадания в prefix-кеш и текущую нагрузку. Это маршрутизация на уровне пода — то самое интеллектуальное планирование, которое предоставляет llm-d.

Объединить эти уровни в один невозможно. ALB/Ingress нужен, потому что AWS Load Balancer Controller не поддерживает ресурсы Gateway API. Gateway API с Inference Extension нужен, потому что ALB не умеет выбирать поды с учётом prefix-кеша. Оба уровня дополняют друг друга.

Реализация

Обёрточный Helm-чарт

Вместо того чтобы применять «сырые» манифесты, всё упакованово в обёрточный Helm-чарт, включающий upstream-чарт llm-d в качестве сабчарта:

llm-d-stack/
  Chart.yaml          # Обёрточный чарт, зависит от сабчарта llm-d
  values.yaml         # Базовые значения по умолчанию
  cluster-values.yaml # Переопределения для конкретного кластера (конфигурация ALB, список моделей)
  templates/
    modelservice.yaml     # CR ModelService для каждой модели
    gateway.yaml          # CR Istio Gateway для каждой модели
    ga-inferencepool.yaml # CR InferencePool для каждой модели
    ga-httproute.yaml     # CR HTTPRoute для каждой модели
    alb-ingress.yaml      # Ресурсы ALB Ingress для каждой модели
    ...

Массив models в values управляет всем. Достаточно добавить модель — и шаблоны сгенерируют все необходимые Kubernetes-ресурсы:

models:
  - name: "Qwen/Qwen2.5-0.5B-Instruct"
    resourceName: "qwen-qwen2-5-0-5b-instruct"
    hostname: "qwen.example.com"
    enabled: true
    replicas: 1
    gpu: 1
    maxModelLen: 8192
    gpuMemoryUtilization: "0.5"
  - name: "microsoft/Phi-4-mini-instruct"
    resourceName: "phi-4-mini-instruct"
    hostname: "phi4.example.com"
    enabled: true
    replicas: 1
    gpu: 1
    maxModelLen: 4096
    gpuMemoryUtilization: "0.5"

Цепочка ресурсов для каждой модели

Для каждой модели чарт создаёт следующую цепочку:

ModelService CR (объявляет модель)
  |-- Контроллер создаёт: Deployment vLLM, Deployment EPP, CR InferenceModel
Gateway CR (gatewayClassName: istio)
  |-- Istio создаёт: под Gateway (прокси Envoy)
HTTPRoute (parentRef: Gateway, backendRef: InferencePool)
  |-- Говорит Envoy: маршрутизировать путь "/" в InferencePool
InferencePool (endpointPickerRef: EPP, selector: decode-поды)
  |-- Говорит Istio: использовать EPP как ext-proc-фильтр
Ingress (ALB, правило по имени хоста)
  |-- Говорит ALB: маршрутизировать имя хоста к сервису Gateway

Шаблон ALB Ingress

Именно этот ресурс обеспечивает внешнюю доступность моделей. Каждая модель получает собственный ресурс Ingress, который объединяется с общим ALB через аннотацию group.name:

{{- if .Values.ingress.enabled }}
{{- range .Values.models }}
{{- if and .enabled .hostname }}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ .resourceName }}-ingress
  annotations:
    alb.ingress.kubernetes.io/group.name: {{ $.Values.ingress.groupName }}
    alb.ingress.kubernetes.io/scheme: internal
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/certificate-arn: {{ $.Values.ingress.certificateArn }}
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]'
    alb.ingress.kubernetes.io/healthcheck-port: "15021"
    alb.ingress.kubernetes.io/healthcheck-path: /healthz/ready
spec:
  ingressClassName: alb
  rules:
    - host: {{ .hostname }}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: {{ .resourceName }}-gateway-istio
                port:
                  number: 80
{{- end }}
{{- end }}
{{- end }}

Ключевые архитектурные решения:

  • Аннотация group.name — несколько ресурсов Ingress с одинаковым именем группы объединяются в единый ALB. Не нужно создавать отдельный балансировщик под каждую модель.

  • target-type: ip — трафик идёт напрямую к IP-адресам подов в обход NodePort. Это эффективнее и совместимо с подами Istio-шлюза.

  • Health check на порт 15021 — это эндпоинт здоровья sidecar Istio (/healthz/ready), который возвращает 200, когда под шлюза готов к работе. Поскольку целевым объектом является под Istio-шлюза, а не под vLLM, это корректно отражает готовность шлюза.

  • Терминация TLS на ALB — трафик от ALB до Istio-шлюза идёт по чистому HTTP внутри VPC. Стандартная схема для внутренних сервисов.

Gateway API и Inference Extension

Ресурсы Gateway API указывают Istio, как маршрутизировать трафик внутри пайплайна каждой модели:

# Gateway — создаёт под Istio Envoy-прокси
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: {{ .resourceName }}-gateway
  annotations:
    networking.istio.io/service-type: ClusterIP
spec:
  gatewayClassName: istio
  listeners:
    - name: http
      port: 80
      protocol: HTTP
# HTTPRoute — направляет весь трафик в InferencePool
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: {{ .resourceName }}-route
spec:
  parentRefs:
    - name: {{ .resourceName }}-gateway
  rules:
    - backendRefs:
        - group: inference.networking.k8s.io
          kind: InferencePool
          name: {{ .resourceName }}-pool
# InferencePool — связывает EPP и decode-поды
apiVersion: inference.networking.k8s.io/v1alpha2
kind: InferencePool
metadata:
  name: {{ .resourceName }}-pool
spec:
  targetPortNumber: 8000
  selector:
    matchLabels:
      llm-d.ai/model: {{ modelLabel }}
  endpointPickerConfig:
    extensionRef:
      name: {{ .resourceName }}-epp

Когда запрос поступает на под Gateway, Envoy видит HTTPRoute, указывающий на InferencePool. У InferencePool есть endpointPickerConfig, ссылающийся на сервис EPP. Envoy вызывает EPP через ext-proc (фильтр внешней обработки), EPP запрашивает Redis о состоянии prefix-кеша, оценивает доступные поды vLLM и сообщает Envoy, к какому конкретному поду направить запрос.

Конфигурация LiteLLM

На стороне LiteLLM каждая модель описывается простой записью:

- model_name: qwen-0.5b
  litellm_params:
    model: openai/Qwen/Qwen2.5-0.5B-Instruct
    api_base: https://qwen.example.com/v1
    api_key: "not-needed"
- model_name: phi-4-mini
  litellm_params:
    model: openai/microsoft/Phi-4-mini-instruct
    api_base: https://phi4.example.com/v1
    api_key: "not-needed"

Префикс openai/ сообщает LiteLLM, что нужно использовать OpenAI-совместимый провайдер. Имя модели после префикса должно точно совпадать с именем модели на HuggingFace, поскольку vLLM проверяет его в теле запроса. Поле api_key обязательно для LiteLLM, но vLLM его не проверяет.

Клиенты обращаются к LiteLLM, используя удобный псевдоним:

curl https://litellm.example.com/v1/chat/completions \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "phi-4-mini", "messages": [{"role": "user", "content": "Hello"}]}'

LiteLLM сопоставляет phi-4-mini с phi4.example.com, ALB направляет трафик к Istio-шлюзу Phi-4, а EPP выбирает оптимальный под vLLM.

Управление затратами на GPU

GPU-инстансы дороги. С Karpenter на EKS можно масштабировать кластер до нуля в период простоя:

# Масштабирование до нуля — GPU-узлы освобождаются Karpenter примерно за 2 мин
kubectl patch modelservice qwen-0-5b -n llm-d \
  --type='merge' -p='{"spec":{"decode":{"replicas":0}}}'
kubectl scale deploy -n llm-d -l llm-d.ai/role=decode --replicas=0
# Масштабирование обратно — холодный старт ~5–8 мин (подготовка узла + загрузка образа + загрузка модели)
kubectl patch modelservice qwen-0-5b -n llm-d \
  --type='merge' -p='{"spec":{"decode":{"replicas":1}}}'
kubectl scale deploy -n llm-d -l llm-d.ai/role=decode --replicas=1

Управляющий слой (Istio-шлюзы, EPP, Redis, контроллер ModelService) продолжает работать на дешёвых CPU-узлах. Останавливаются только GPU-узлы. При масштабировании обратно Karpenter выделяет новый GPU-узел, скачивает образ vLLM (~8 ГБ) и загружает веса модели.

Важный нюанс: необходимо масштабировать и CR ModelService, и Deployment. Контроллер ModelService не синхронизирует изменения количества реплик обратно в Deployment — это известное ограничение.

Усвоенные уроки

1. В kgateway есть баг с несколькими моделями

Изначально в качестве провайдера Gateway API использовался kgateway (Solo.io). Для одной модели он работает, однако содержит баг: на один экземпляр контроллера разрешается только один InferencePool. Вторая модель всегда получает BackendNotFound. После тщательной отладки (разные модели, разный порядок, временны́е зависимости) удалось подтвердить, что баг воспроизводится в 100% случаев и не зависит от конкретной модели.

Istio 1.28 и выше с параметром ENABLE_GATEWAY_API_INFERENCE_EXTENSION=true на istiod решает эту проблему корректно — обе модели работают одновременно.

2. CRD Inference Extension для Gateway API имеют значение

Необходимы CRD Gateway API версии v1.4.0 и CRD Inference Extension версии v1.3.0. CRD Inference Extension определяют ресурсы InferencePool и InferenceModel. Без них ресурсы Gateway API молча ничего не делают.

kubectl apply -k "github.com/kubernetes-sigs/gateway-api/config/crd/?ref=v1.4.0" \
  --server-side --force-conflicts
kubectl apply -k "github.com/kubernetes-sigs/gateway-api-inference-extension/config/crd/?ref=v1.3.0" \
  --server-side --force-conflicts

3. Маршрутизация между моделями требует внешнего роутера

llm-d — не мультимодельный роутер. Каждая модель получает собственный пайплайн: Gateway + HTTPRoute + InferencePool + EPP. EPP знает только об InferenceModel в своём пуле. Если отправить запрос для модели B на шлюз модели A, он завершится ошибкой «model not found».

Для направления запросов к шлюзу нужной модели необходимо что-то внешнее: LiteLLM, обратный прокси или DNS-маршрутизация. Это сделано намеренно — llm-d добавляет ценность внутри пайплайна одной модели (KV-кеш-маршрутизация, нагрузко-осведомлённое планирование), но не между моделями.

4. Инъекция sidecar Istio должна быть отключена

Если в кластере работает Istio с автоматической инъекцией sidecar, отключите её для пространства имён llm-d:

apiVersion: v1
kind: Namespace
metadata:
  name: llm-d
  labels:
    istio-injection: disabled

Sidecar Istio мешает ext-proc-взаимодействию между подом Gateway и EPP. Сам под Gateway является прокси Envoy под управлением Istio — добавление ещё одного sidecar-прокси перед ним создаёт проблему двойного проксирования.

5. Образ vLLM по умолчанию является приватным

Образ vLLM по умолчанию в чарте llm-d (ghcr.io/llm-d/llm-d) находится в приватном реестре. Используйте вместо него публичный CUDA-вариант:

vllm:
  image:
    registry: ghcr.io
    repository: llm-d/llm-d-cuda
    tag: "v0.5.0"

6. Холодный старт — это реальность

При масштабировании с нуля рассчитывайте на 5–8 минут:

  • ~2 мин: Karpenter выделяет новый GPU-узел

  • ~2 мин: загрузка образа vLLM (~8 ГБ)

  • ~2 мин: скачивание и загрузка весов модели с HuggingFace

В production-среде рассмотрите предварительную загрузку образов (через DaemonSet), кеши pull-through в ECR или поддержание одного «тёплого» резервного узла.

Детальный разбор пути запроса

Вот что происходит, когда поступает запрос:

1. Клиент отправляет: POST https://phi4.example.com/v1/chat/completions
   {"model": "microsoft/Phi-4-mini-instruct", "messages": [...]}
2. DNS разрешает phi4.example.com во внутренний ALB
3. ALB сопоставляет правило хоста "phi4.example.com"
   -> пересылает на phi-4-mini-instruct-gateway-istio:80 (target-type: ip)
   -> TLS терминируется на ALB, к поду идёт HTTP-трафик в открытом виде
4. Под Istio Gateway (Envoy) получает запрос
   -> HTTPRoute сопоставляет путь "/"
   -> backendRef указывает на InferencePool "phi-4-mini-pool"
   -> InferencePool содержит endpointPickerConfig -> сервис EPP
5. Envoy вызывает EPP через ext-proc gRPC
   -> EPP ищет CR InferenceModel для "microsoft/Phi-4-mini-instruct"
   -> EPP запрашивает Redis: "какие поды имеют закешированный релевантный префикс?"
   -> Redis возвращает: {pod-A: попадание 85%, pod-B: попадание 20%}
   -> EPP оценивает: побеждает pod-A (высокое попадание в кеш + приемлемая нагрузка)
   -> EPP сообщает Envoy: направить запрос на pod-A по адресу 10.x.x.x:8000
6. Envoy пересылает запрос на pod-A (vLLM)
   -> vLLM обнаруживает частичное попадание в KV-кеш
   -> Вычисляет только оставшиеся токены (не весь промпт)
   -> Генерирует токены ответа
   -> Обновляет в Redis индекс закешированных префиксов
7. Ответ возвращается обратно: vLLM -> Envoy -> ALB -> Клиент

Ключевой выигрыш в эффективности — шаги 5–6: вместо случайного выбора пода EPP направляет запрос к поду с наилучшим попаданием в prefix-кеш, устраняя избыточные вычисления.

Развёртывание

# 1. Установить CRD
kubectl apply -k "github.com/kubernetes-sigs/gateway-api/config/crd/?ref=v1.4.0" \
  --server-side --force-conflicts
kubectl apply -k "github.com/kubernetes-sigs/gateway-api-inference-extension/config/crd/?ref=v1.3.0" \
  --server-side --force-conflicts
# 2. Убедиться, что установлен Istio 1.28+ с включённым inference extension
# 3. Создать секрет с токеном HuggingFace
kubectl create secret generic llm-d-hf-token \
  --from-literal=HF_TOKEN=<your-token> -n llm-d
# 4. Развернуть
helm upgrade --install llm-d-stack ./helm/charts/llm-d-stack \
  -n llm-d -f ./helm/charts/llm-d-stack/cluster-values.yaml
# 5. Проверить
kubectl get ingress -n llm-d
kubectl get pods -n llm-d
# 6. Протестировать (после настройки DNS)
curl -s https://qwen.example.com/v1/models
curl -s https://phi4.example.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"microsoft/Phi-4-mini-instruct","messages":[{"role":"user","content":"Hello"}],"max_tokens":10}'

Что дальше

Описанная конфигурация даёт надёжную мультимодельную платформу инференса с интеллектуальной маршрутизацией. В качестве следующих шагов можно рассмотреть:

  • Разделение Prefill и Decode — вынести обработку промпта и генерацию токенов в отдельные пулы подов для снижения TTFT на длинных промптах

  • Автомасштабирование — KEDA или встроенный автоскейлер llm-d для масштабирования decode-реплик на основе глубины очереди

  • Маршрутизация LoRA-адаптеров — обслуживание дообученных вариантов моделей рядом с базовыми с поддержкой кеш-осведомлённой маршрутизации LoRA

  • Мониторинг — vLLM и llm-d экспортируют метрики Prometheus для задержки инференса, пропускной способности, утилизации GPU и глубины очереди

  • GitOps — приложения ArgoCD для обнаружения дрейфа конфигурации и самовосстановления деплоев

© 2026 meganuke