Привет, Хабр! На связи снова Данила Гудынин, DevOps-инженер направления Evolution ML Inference в Cloud.ru.
В прошлой статье про снижение стоимости инференса мы пробежались по теории того, что и на каких уровнях стека можно подкрутить, чтобы повысить утилизацию своих GPU. Там вы могли определиться, какой подход в принципе подходит для ваших задач.
В этой части мы нырнем в практически-технический хар дкор и расскажем, как оптимизировать работу своих графических процессоров с KServe ModelMesh или vLLM Production Stack, подсветим, где разбросаны грабли в этом деле, а еще заглянем под капот к Cloud.ru Shared GPU и объясним, как именно он позволяет нам ставить цены на уровне западных облаков при кратно более дорогом железе в РФ.
ML-инженеры, DevOps и MLOps-архитекторы, можете сразу добавлять в закладки, чтобы возвращаться и списывать нужные конфиги. Наливайте бочку чая или чего покрепче, постарался изложить все сугубо по делу, много кода спрятал в «раскрывашки», так что не пугайтесь обозначенного выше времени чтения.

Содержание статьи
В предыдущей части мы с вами выяснили, что существует два стула подхода к обслуживанию моделей: самурай SMS и ниндзя MMS. Первый живет свою лучшую расточительную жизнь, принося все ресурсы GPU на алтарь своего сюзерена. Второй экономит на жилье, но зато трудолюбивый и не жалеет своих сил на благо нескольких моделек, впрочем, и надежностью не отличается. Также мы выяснили, что ни один из этих воинов нас не устраивает и нужно какое-то гибридное решение. Одним из кандидатов на гибрид стал KServe ModelMesh. Давайте посмотрим внимательнее, что это вообще за зверь такой.
Погружаемся в архитектуру ModelMesh, выясняем, что с ней не так
ModelMesh представляет собой сложную многоуровневую систему, которая радикально меняет подход к обслуживанию моделей. В ее основе лежит интеллектуальный роутер, который берет на себя все решения о том, где и когда запускать модели, освобождая разработчиков от необходимости думать об инфраструктуре.
Архитектурно система состоит из трех ключевых слоев:
1. Слой роутинга и кеширования. ModelMesh Router работает как диспетчер в аэропорту: анализирует входящие запросы, проверяет состояние кеша и принимает решение о том, на какой inference-сервер направить задачу. Используется распределенный LRU-кеш, который автоматически загружает «горячие» модели в память и выгружает редко используемые. Вытеснение из памяти происходит не просто по принципу «давно не обращались», оно учитывает время, частоту, размер модели и географическую близость для оптимального распределения ресурсов по кластеру. Кроме того, приоритетные модели можно всегда защитить от вытеснения.
2. Слой исполнения. Под капотом работают стандартные serving runtime: MLServer, Triton, TorchServe, которые непосредственно обрабатывают запросы к моделям. ModelMesh не изобретает велосипед, а использует проверенные решения, добавляя поверх них слой управления.
Пример конфигурации Triton runtime
apiVersion: serving.kserve.io/v1alpha1
kind: ServingRuntime
metadata:
name: triton-runtime
spec:
containers:
- name: triton
image: nvcr.io/nvidia/tritonserver:22.12-py3
args:
- tritonserver
- --model-store=/models
- --grpc-port=8001
- --http-port=8000
- --metrics-port=8002
resources:
limits:
cpu: 4
memory: 8Gi
nvidia.com/gpu: 1
requests:
cpu: 2
memory: 4Gi
multiModel: true
grpcEndpoint: port:8001
supportedModelFormats:
- name: tensorflow
version: "2"
- name: pytorch
version: "1"
- name: onnx
version: "1"
3. Слой хранения и интеграции. Storage Puller автоматически загружает модели из различных источников (S3, MinIO, PVC, HTTP), кеширует их локально и управляет очисткой неиспользуемых артефактов.
Давайте разберем компоненты детально:
ModelMesh Controller — центральный мозг системы
— управляет жизненным циклом InferenceService;
— принимает решения о размещении моделей;
— мониторит состояние всех подов и моделей;
— реагирует на изменения в кластере.ModelMesh Serving Pods — рабочие узлы
— каждый содержит несколько контейнеров;
— могут одновременно обслуживать множество моделей;
— автоматически масштабируются в зависимости от нагрузки.Storage Integration — система хранения моделей
— поддерживает S3, MinIO, PVC, HTTP;
— отвечает за автоматическую загрузку и кеширование моделей;
— обеспечивает версионирование и управление артефактами.

Звучит все это прекрасно, здорово и технологично. Так что же не так с этим решением, почему мы его попробовали и отказались?
Дело в том, что ModelMesh изначально родился в недрах корпоративного монстра IBM как инструмент для Watson — того самого, который обещал заменить всех аналитиков (спойлер: не заменил). Видя потенциал технологии, IBM приняла стратегическое решение передать ModelMesh под эгиду проекта KServe и сделать его открытым исходным кодом. Шаг был логичный: сообщество получило доступ к обкатанной в продакшне технологии, а IBM могла развивать ее совместно с Kubernetes. Однако при переносе выяснилось: если что-то годами преспокойно работало в корпоративной среде с предсказуемыми нагрузками, совершенно не факт, что оно продолжит это делать в динамической облачной среде. Выяснилось, что инструмент демонстрирует серьезные архитектурные проблемы при:
больших нагрузках с тысячами одновременных запросов;
большом распределении разных моделей по подам;
динамическом масштабировании в cloud-native окружении.
А вот, собственно, корень проблемы:

Каждый pod постоянно синхронизируется с etcd, что создает:
Высокую нагрузку на etcd при большом количестве подов.
Задержки в обновлении состояния моделей.
Проблемы с консистентностью при частых изменениях.
Ограничения масштабируемости системы в целом.
В принципе, если вы не коммерческий провайдер, и вам не нужны сверхвысокие SLA, нет жестких требований к изоляции между моделями и модельки размером больше 10 ГБ вы не обрабатываете, решением пользоваться вполне можно. Теперь рассказываем, как.
Практический пример развертывания
Давайте рассмотрим полный пример развертывания ModelMesh для обслуживания нескольких моделей:
1. Устанавливаем ModelMesh в K8s
# Клонируем репозиторий
git clone -b release-0.10 https://github.com/kserve/modelmesh-serving.git
cd modelmesh-serving
# Установка в namespace-scoped режиме
./scripts/install.sh --namespace modelmesh-serving --namespace-scope-mode --quickstart
2. Настраиваем хранилище
apiVersion: v1
kind: Secret
metadata:
name: storage-config
stringData:
minio: |
{
"type": "s3",
"access_key_id": "minio",
"secret_access_key": "minio123",
"endpoint_url": "http://minio:9000",
"default_bucket": "modelmesh-models",
"region": "us-east-1"
}
3. Создаем шаблон, по которому будет развертываться модель (serving runtime)
apiVersion: serving.kserve.io/v1alpha1
kind: ServingRuntime
metadata:
name: custom-sklearn-runtime
spec:
containers:
- name: mlserver
image: seldonio/mlserver:1.3.2
env:
- name: MLSERVER_MODELS_DIR
value: "/models"
- name: MLSERVER_LOAD_MODELS_AT_STARTUP
value: "false"
resources:
requests:
cpu: 500m
memory: 1Gi
multiModel: true
grpcEndpoint: port:8001
supportedModelFormats:
- name: sklearn
version: "1"
4. А теперь описываем, что, собственно, надо развернуть
# Модель классификации текста
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
name: text-classifier
annotations:
serving.kserve.io/deploymentMode: ModelMesh
spec:
predictor:
model:
modelFormat:
name: sklearn
storage:
key: minio
path: models/text-classifier/model.joblib
---
# Модель рекомендаций
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
name: recommendation-engine
annotations:
serving.kserve.io/deploymentMode: ModelMesh
spec:
predictor:
model:
modelFormat:
name: sklearn
storage:
key: minio
path: models/recommendations/model.joblib
---
# Модель детекции аномалий
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
name: anomaly-detector
annotations:
serving.kserve.io/deploymentMode: ModelMesh
spec:
predictor:
model:
modelFormat:
name: sklearn
storage:
key: minio
path: models/anomaly/model.joblib
Готово, модели развернуты. Но ведь теперь надо убедиться, что все работает как надо? К счастью, ModelMesh предоставляет богатые возможности мониторинга через Prometheus-метрики, например:
modelmesh_models_loaded_total — количество загруженных моделей;
modelmesh_model_loading_duration_seconds — время загрузки моделей;
modelmesh_inference_request_duration_seconds — время выполнения инференса;
modelmesh_cache_miss_total — количество промахов кеша;
modelmesh_memory_usage_bytes — использование памяти.
А еще можно сделать Grafana-дашборд
{
"dashboard": {
"title": "ModelMesh Performance",
"panels": [
{
"title": "Models per Pod",
"type": "stat",
"targets": [
{
"expr": "sum by (pod) (modelmesh_models_loaded_total)"
}
]
},
{
"title": "Cache Hit Rate",
"type": "stat",
"targets": [
{
"expr": "rate(modelmesh_cache_hit_total[5m]) / rate(modelmesh_inference_requests_total[5m]) * 100"
}
]
}
]
}
}
Ограничения и компромиссы
В целом ModelMesh нормальный, рабочий инструмент в борьбе за оптимальное использование GPU, с известными оговорками, о которых мы уже упоминали в предыдущей статье. Если избрали этот путь, имейте в виду следующие технические ограничения:
Задержки при холодном старте. Первый запрос к модели может быть очень медленным (что не удивительно, если она еще не успела осесть в кеше).
Дополнительные затраты памяти. Система управления моделями требует выделения дополнительной памяти для своих внутренних нужд — хранения метаданных, кешей, управления жизненным циклом. Это уменьшает объем памяти, доступный для моделей.
Сложность поиска и устранения ошибок. При возникновении проблем очень сложно их локализовать и понять: что-то пошло не так в самой модели, в системе распределения ресурсов или нагрузка на инфраструктуру оказалась несоответствующей? Особенно трудно отследить ошибки, если модели обслуживаются в одном пуле с разными приложениями.
Также вас могут поджидать следующие операционные трудности. Заранее подумайте, есть ли у вас в команде человек, который будет этим заниматься на этапе эксплуатации моделей:
Настройка кеширования. Для оптимальной работы требуется правильно настроить параметры кеша моделей: объемы памяти, время жизни элементов, стратегии замещения. Это требует квалификации и опыта, поскольку неправильные настройки могут привести к снижению производительности или чрезмерному потреблению ресурсов.
Версионирование. Необходимо отслеживать изменения в моделях, чтобы можно было быстро откатить обновление при ошибках, провести A/B-тестирование или анализ. Это включает версионирование веса моделей, конфигураций и данных обучения. При частых обновлениях моделей возникает сложность поддерживать актуальность и согласованность версий.
Мультитенантность. Ограниченная изоляция между клиентами приводит к ситуации, когда проблемы одного пользователя могут повлиять на работу другого, например из-за общих ресурсов памяти или вычислительных блоков. Это создает риски для безопасности и стабильности работы в условиях многоклиентского использования.
vLLM Production Stack: современный подход к инференсу
Если ModelMesh управляет всем циклом жизни запускаемых моделей централизованно, организуя все и решая, где исполнять, то vLLM Production Stack действует иначе. Он работает как распределитель запросов, который находится перед группой vLLM-инстансов, а логика планирования при этом у каждого экземпляра vLLM остается своя. В российском сегменте интернета про vLLM написано маловато, поэтому постараемся исправить эту досадную несправедливость.

Ключевые компоненты системы
Центральный компонент системы — Request Router, который работает как интеллектуальный шлюз перед vLLM инстансами. Его основные функции:
1. Алгоритмы маршрутизации:
Round-Robin — циклическое распределение запросов
Session-ID based — маршрутизация по идентификатору сессии
Prefix-aware — учет префиксов для максимизации KV-кеша
KV cache-aware — интеллектуальная маршрутизация на основе состояния кеша
2. Хеширование для балансировки нагрузки:
# Пример алгоритма хеширования для session-based routing
def get_target_instance(session_id: str, available_instances: List[str]) -> str:
"""
Консистентное хеширование для определения целевого инстанса
"""
hash_value = hashlib.md5(session_id.encode()).hexdigest()
instance_index = int(hash_value, 16) % len(available_instances)
return available_instances[instance_index]
3. Service Discovery:
автоматическое обнаружение vLLM подов через Kubernetes API,
health checking и автоматическое исключение неработающих инстансов,
dynamic backend registration.
Пример конфигурации роутера:
apiVersion: production-stack.vllm.ai/v1alpha1
kind: StaticRoute
metadata:
name: llama-routing
spec:
serviceDiscovery: kubernetes
routingLogic: prefix-aware
backends:
- service: vllm-llama-service-1
weight: 100
- service: vllm-llama-service-2
weight: 100
healthCheck:
enabled: true
interval: 30s
timeout: 5s
Serving Engine с оптимизацией vLLM
Поскольку в vLLM Production Stack у каждого инстанса есть возможность работать со своим нативным шедулером, это дает нам несколько важных преимуществ:
полная совместимость с upstream vLLM,
все оптимизации vLLM работают без изменений,
сontinuous batching остается эффективным,
поддержка всех vLLM-фич (Speculative Decoding, Multi-LoRA и т.д.).
servingEngineSpec:
modelSpec:
- name: "llama-3-70b"
repository: "vllm/vllm-openai"
tag: "v0.4.0"
modelURL: "meta-llama/Llama-3-70b-chat-hf"
# Включаем LoRA поддержку
enableLoRA: true
maxLoraRank: 16
# vLLM специфичные настройки
vllmConfig:
tensorParallelSize: 4
maxModelLen: 4096
enforceEager: false
gpuMemoryUtilization: 0.9
enableChunkedPrefill: true
# Kubernetes resources
replicaCount: 2
requestGPU: 4
requestMemory: "200Gi"
Чтобы наблюдать за производительностью, у vLLM есть встроенная система мониторинга, поэтому с помощью Prometheus можно отследить любые метрики, например:
vllm_request_duration_seconds — время обработки запросов;
vllm_time_to_first_token_seconds — TTFT-метрики;
vllm_cache_hit_rate — эффективность KV кеша;
vllm_memory_usage_bytes — использование GPU-памяти;
router_request_count_total — количество запросов через роутер.
Для полного счастья и прозрачности создаем Grafana-дашборд:
{
"dashboard": {
"title": "vLLM Production Stack",
"panels": [
{
"title": "Request Latency P95",
"targets": [{
"expr": "histogram_quantile(0.95, rate(vllm_request_duration_seconds_bucket[5m]))"
}]
},
{
"title": "Cache Hit Rate",
"targets": [{
"expr": "rate(vllm_cache_hit_total[5m]) / rate(vllm_inference_requests_total[5m]) * 100"
}]
}
]
}
}
Под капотом у Request Router
Теперь, когда нам более-менее понятна вся цепочка процессов в vLLM, вернемся к ключевому компоненту — Request Router и посмотрим, как он анализирует поступающие запросы, принимает решения о том, на какой из инстансов направить запросы и динамически управляет дополнительными LoRA-адаптерами для персонализации и оптимизации моделей без перезагрузки сервисов. Если интересно, разворачивайте, если нет — скачем дальше.
1. Round-Robin роутинг
Round-Robin помогает быстро и просто равномерно делить нагрузку без сложных вычислений. Этот метод последовательно отправляет каждый новый запрос на следующий сервер из списка, циклично. Примерно как крупье, раздающий по очереди карты всем участникам игры. Такой подход прост и обеспечивает равномерное распределение нагрузки при условии, что все серверы примерно одинаковы по мощности.
class RoundRobinRouter:
def __init__(self, backends: List[str]):
self.backends = backends
self.current_index = 0
def get_next_backend(self) -> str:
backend = self.backends[self.current_index]
self.current_index = (self.current_index + 1) % len(self.backends)
return backend
2. Session-ID роутинг
Этот метод гарантирует, что все запросы от одного пользователя или сессии попадут на один и тот же сервер. Это важно для улучшения производительности, потому что сервер сохраняет состояние или кеш, что ускоряет обработку последующих запросов. Важно, чтобы пользователь не «прыгал» между разными серверами, что могло бы ухудшить качество работы.
class SessionRouter:
def route_request(self, request: Request) -> str:
session_id = request.headers.get('session-id',
request.headers.get('x-session-id'))
if session_id:
return self.get_target_instance(session_id, self.backends)
return self.fallback_router.route(request)
3. Prefix-aware роутинг
Представьте, что у вас есть список экспертов (серверов) и вопросы, которые им адресуют. Вы получаете вопрос из зала «С каким счетом Бразилия выиграла у Германии на ЧМ по футболу в 2014 году?». Логично будет, направить этот вопрос Саньку, который уже отвечал вам на околофутбольные запросы, а не Марине, которая отвечала вам, кто такой Серкан Болат и в чью дверь он стучится. Здесь суть та же: система анализирует префиксы входящих запросов и направляет их на инстансы, где уже есть похожие префиксы в KV-кеше.

Пример конфигурации Kubernetes для prefix-aware роутинга:
apiVersion: production-stack.vllm.ai/v1alpha1
kind: Router
metadata:
name: prefix-aware-router
spec:
routingStrategy: prefix-aware
prefixConfig:
minPrefixLength: 10
maxPrefixLength: 512
similarityThreshold: 0.8
cacheStateUpdateInterval: 1s
Алгоритм вычисления наилучшего совпадения по префиксам:
def find_best_instance_for_prefix(prefix: str, instances: List[Instance]) -> Instance:
best_match = None
best_score = 0.0
for instance in instances:
# Получаем состояние KV кеша инстанса
cache_prefixes = instance.get_cached_prefixes()
for cached_prefix in cache_prefixes:
similarity = calculate_prefix_similarity(prefix, cached_prefix)
if similarity > best_score and similarity > self.similarity_threshold:
best_score = similarity
best_match = instance
return best_match or self.fallback_instance()
4. KV cache-aware роутинг
При использовании этого метода система отслеживает состояние KV-кеша каждого инстанса, производит оценку «выгоды» от направления запроса в тот или иной инстанс на основе метрик кеша и текущей загрузки и отправляет запрос туда, где эффективность переиспользования кеша максимальна.
Как выполняется мониторинг состояния кеша каждого инстанса:
class KVCacheMonitor:
def __init__(self):
self.cache_states = {}
async def update_cache_state(self, instance_id: str):
"""Получаем состояние KV кеша от vLLM инстанса"""
response = await self.get_cache_metrics(instance_id)
self.cache_states[instance_id] = {
'hit_rate': response['cache_hit_rate'],
'utilization': response['cache_utilization'],
'active_sequences': response['active_sequences'],
'prefixes': response['cached_prefixes']
}
А вот так выстраивается логика маршрутизации с учетом состояния кеша:
def route_with_cache_awareness(self, request: Request) -> str:
request_prefix = self.extract_prefix(request.prompt)
# Ищем инстанс с максимальным потенциалом переиспользования кеша
best_instance = None
best_score = 0.0
for instance_id, cache_state in self.cache_states.items():
score = self.calculate_cache_reuse_score(
request_prefix,
cache_state['prefixes'],
cache_state['utilization']
)
if score > best_score:
best_score = score
best_instance = instance_id
return best_instance or self.get_least_loaded_instance()
Динамическая загрузка LoRA-адаптеров
Одна из самых крутых функций vLLM Production Stack — возможность динамически загружать и выгружать LoRA-адаптеры без перезапуска подов. Если вы, конечно, старовер и еще используете LoRA-адаптеры (мы не осуждаем).
Поработаю немножко кэпом и расскажу, почему это важно. LoRA — это небольшая надстройка, которая добавляется к большой предварительно обученной модели и меняет ее поведение, не перекраивая всю модель. Адаптеры модульны, поэтому каждым из них можно управлять отдельно и хранить несколько для разных задач в файловой системе или хранилище данных. Если «включать» и «выключать» нужные адаптеры мы получаем:
бесперебойный инференс и доступ к большой модели без даунтайма;
эффективное использование ресурсов, ведь загружаются только те адаптеры, которые нужны, а остальные не давят на GPU и экономят память;
модели можно кастомизировать под конкретные задачи прямо на лету без перебоев в работе всей системы;
вместо того чтобы держать у себя 2 больших версии модели, можно держать одну и переключаться между разными адаптерами для A/B-тестирования, что опять-таки экономит ресурсы.
В общем, это нам надо, берем. API для управления LoRA
# Загрузка LoRA адаптера
curl -X POST http://vllm-instance:8000/v1/load_lora_adapter \
-H "Content-Type: application/json" \
-d '{
"lora_name": "financial_advisor",
"lora_path": "/data/lora-adapters/finance-lora",
"max_lora_rank": 16
}'
# Использование LoRA в запросе
curl -X POST http://router:8080/v1/completions \
-H "Content-Type: application/json" \
-d '{
"model": "meta-llama/Llama-3-70b-chat-hf",
"prompt": "Analyze this financial report:",
"lora_adapter": "financial_advisor",
"max_tokens": 500
}'
# Выгрузка LoRA адаптера
curl -X POST http://vllm-instance:8000/v1/unload_lora_adapter \
-H "Content-Type: application/json" \
-d '{"lora_name": "financial_advisor"}'
Пример развертывания с LoRA-поддержкой
servingEngineSpec:
modelSpec:
- name: "llama-3-with-lora"
modelURL: "meta-llama/Llama-3-8B-Instruct"
# Включаем LoRA support
enableLoRA: true
maxLoraRank: 64
loraModules: 128
maxCpuLoras: 4
# Переменные окружения для runtime LoRA
env:
- name: VLLM_ALLOW_RUNTIME_LORA_UPDATING
value: "True"
- name: VLLM_LORA_CACHE_SIZE
value: "1024"
# Подключаем volume с LoRA адаптерами
volumeMounts:
- name: lora-storage
mountPath: "/data/lora-adapters"
volumes:
- name: lora-storage
persistentVolumeClaim:
claimName: lora-storage-pvc
Еще управлять LoRA можно декларативно через Kubernetes CRD. Таким образом мы говорим нашему кластеру, что вот такие LoRA-адаптеры нужны системе, а кубер сам обеспечит их корректное состояние, загрузку и управление.
Пример определения
apiVersion: production-stack.vllm.ai/v1alpha1
kind: LoRAAdapter
metadata:
name: financial-advisor-lora
spec:
name: "financial_advisor"
source:
type: "huggingface"
repository: "your-org/financial-advisor-lora"
revision: "main"
target:
modelRef: "llama-3-with-lora"
instances: ["all"] # или конкретные инстансы
config:
rank: 16
alpha: 32
modules: ["q_proj", "v_proj", "o_proj"]
autoLoad: true
status:
phase: "Ready"
loadedInstances: ["vllm-instance-1", "vllm-instance-2"]
lastUpdated: "2025-01-22T10:30:00Z"
Использование LoRA через роутер
apiVersion: production-stack.vllm.ai/v1alpha1
kind: RouteRule
metadata:
name: financial-queries-route
spec:
matcher:
headers:
"x-domain": "finance"
target:
lora: "financial_advisor"
weight: 100
Пример сценария A/B тестирования LoRA адаптеров
apiVersion: production-stack.vllm.ai/v1alpha1
kind: RouteRule
metadata:
name: lora-ab-test
spec:
matcher:
headers:
"x-experiment": "lora-comparison"
targets:
- lora: "financial_advisor_v1"
weight: 50
- lora: "financial_advisor_v2"
weight: 50
Доменно-специфичная маршрутизация через классификатор доменов
# Автоматическое определение домена и применение соответствующего LoRA
class DomainAwareRouter:
def __init__(self):
self.domain_classifiers = {
'finance': 'financial_advisor',
'medical': 'medical_assistant',
'legal': 'legal_advisor',
'code': 'code_assistant'
}
async def route_with_lora(self, request: Request) -> RouteResult:
domain = await self.classify_domain(request.prompt)
lora_adapter = self.domain_classifiers.get(domain)
return RouteResult(
instance=self.select_instance_with_lora(lora_adapter),
lora_adapter=lora_adapter
)
Динамическое масштабирование LoRA-адаптеров
apiVersion: production-stack.vllm.ai/v1alpha1
kind: LoRAScaler
metadata:
name: adaptive-lora-scaling
spec:
targetLoRA: "customer_support"
metrics:
- type: RequestRate
threshold: 100 # requests/minute
- type: QueueDepth
threshold: 10
scalePolicy:
scaleUp:
instances: 2
stabilizationWindow: 60s
scaleDown:
instances: 1
stabilizationWindow: 300s
К слову, не только LoRA-адаптерами в vLLM Production Stack можно рулить через CRD.
Можно управлять, например, развертыванием модели
apiVersion: production-stack.vllm.ai/v1beta1
kind: ModelDeployment
metadata:
name: llama-3-70b-deployment
spec:
model:
name: "meta-llama/Llama-3-70b-chat-hf"
format: "huggingface"
quantization: "fp16"
scaling:
replicas: 4
autoScaling:
enabled: true
minReplicas: 2
maxReplicas: 8
metrics:
- type: QPS
targetValue: 50
- type: QueueDepth
targetValue: 5
resources:
gpu: 4
memory: "200Gi"
storage: "100Gi"
vllm:
tensorParallelSize: 4
maxModelLen: 4096
enableChunkedPrefill: true
status:
phase: "Ready"
availableReplicas: 4
conditions:
- type: Ready
status: "True"
lastTransitionTime: "2025-01-22T10:00:00Z"
Параметры маршрутизации тоже поддаются дрессировке
apiVersion: production-stack.vllm.ai/v1alpha1
kind: Router
metadata:
name: intelligent-router
spec:
strategy: "prefix-aware"
backends:
- name: "llama-3-70b"
selector:
matchLabels:
model: "llama-3-70b"
weight: 100
rules:
- matcher:
prefixPattern: ".*coding.*"
target:
lora: "code_assistant"
- matcher:
sessionId: "persistent"
target:
strategy: "session-sticky"
healthCheck:
enabled: true
interval: 30s
observability:
metrics:
enabled: true
tracing:
enabled: true
sampling: 0.1
Для автоматизации управления жизненным циклом компонентов в vLLM Production Stack система использует Kubernetes Operator pattern, что позволяет значительно снизить ручной труд и ошибки. Основных оператора, которые могут быть полезны в задачах оптимизации инференса три:
vLLM Operator. Автоматизирует развёртывание подов vLLM, следит за их состоянием, выполняет обновления без остановок (rollouts) и оптимизирует расход ресурсов на основе метрик.
Router Operator. Управляет динамическим обновлением маршрутов запросов, обнаруживает новые backend-сервисы (service discovery), обеспечивает проверку здоровья компонентов (health checking) и защиту от сбоев (circuit breaking), а также настраивает балансировку нагрузки (load balancing).
LoRA Operator. Автоматизирует загрузку и выгрузку LoRA адаптеров, отвечает за управление их жизненным циклом, поддерживает A/B тестирование различных конфигураций и мониторит производительность адаптеров, а также их масштабирование.
Практическое развертывание
Сценарий для минимальной конфигурации
Шаг 1: Устанавливаем vLLM Production Stack в K8s
# Добавляем Helm репозиторий
helm repo add vllm https://vllm-project.github.io/production-stack
helm repo update
# Устанавливаем с минимальной конфигурацией
helm install vllm-stack vllm/vllm-stack -f values-minimal.yaml
values-minimal.yaml:
servingEngineSpec:
modelSpec:
- name: "opt125m"
repository: "vllm/vllm-openai"
tag: "latest"
modelURL: "facebook/opt-125m"
replicaCount: 2
requestCPU: 2
requestMemory: "8Gi"
requestGPU: 0.5
vllmConfig:
maxModelLen: 2048
gpuMemoryUtilization: 0.8
router:
enabled: true
strategy: "round-robin"
observability:
prometheus:
enabled: true
grafana:
enabled: true
Шаг 2: Проверяем развертывание
# Проверяем статус подов
kubectl get pods -l app=vllm-stack
# Проверяем роутер
kubectl port-forward svc/vllm-router-service 8080:80
# Тестируем API
curl http://localhost:8080/v1/models
Сценарий для развертывания нескольких моделей
servingEngineSpec:
modelSpec:
# Модель для общих задач
- name: "llama-3-8b"
modelURL: "meta-llama/Llama-3-8B-Instruct"
replicaCount: 3
requestGPU: 1
# Модель для кода
- name: "codellama-7b"
modelURL: "codellama/CodeLlama-7b-Instruct-hf"
replicaCount: 2
requestGPU: 1
# Модель для embeddings
- name: "bge-large"
modelURL: "BAAI/bge-large-en-v1.5"
replicaCount: 4
requestGPU: 0.5
router:
strategy: "intelligent"
rules:
- matcher:
promptPattern: ".*code.*|.*programming.*|.*function.*"
target:
model: "codellama-7b"
- matcher:
requestType: "embedding"
target:
model: "bge-large"
- matcher:
default: true
target:
model: "llama-3-8b"
Автоскалирование и оркестрация
Горизонтальное масштабирование
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: vllm-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: vllm-llama-deployment
minReplicas: 2
maxReplicas: 10
metrics:
- type: Pods
pods:
metric:
name: vllm_queue_depth
target:
type: AverageValue
averageValue: "5"
- type: Pods
pods:
metric:
name: vllm_gpu_utilization
target:
type: AverageValue
averageValue: "80"
behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Percent
value: 50
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 25
periodSeconds: 60
Вертикальное автомасштабирование
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: vllm-vpa
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: vllm-llama-deployment
updatePolicy:
updateMode: "Auto"
resourcePolicy:
containerPolicies:
- containerName: vllm
maxAllowed:
memory: 400Gi
nvidia.com/gpu: 8
minAllowed:
memory: 50Gi
nvidia.com/gpu: 1
Ограничения и компромиссы
Ощущения от работы с vLLM Production Stack смешанные. Набор встроенных возможностей, конечно, впечатляет: интеллектуальная маршрутизация, поддержка динамической загрузки LoRA-адаптеров и глубокая интеграция с Kubernetes дают все козыри на руки, если ваша задача инференсить большие языковые модели. Автоматизация через операторы опять-таки облегчает жизнь. Но есть много «но», из-за которых мы работать с этой технологией не стали:
Заточка под LLM. Решение полностью ориентировано на большие языковые модели. Можно, конечно, с небольшими оговорками прикрутить интеграцию с других ML-моделек, но нам принципиально хотелось более универсального решения.
Необходимость выбирать между гибкостью и изоляцией. Использование общего кэша и динамическая маршрутизация упрощают масштабирование, но снижают степень изоляции между клиентами и моделями, что может быть критично для задач с повышенными требованиями к безопасности и SLA.
Задержки и overhead. Первый запрос к модели может обрабатываться медленнее, так как модель и необходимые данные загружаются в память только при первом обращении. Это особенно заметно при динамической загрузке LoRA адаптеров. Дополнительные затраты CPU, памяти и сети для управления всем этим великолепием иногда становятся просто раздражающими.
Сложность настройки и сопровождения. Если у вас небольшой штат, то коммерческая польза от оптимизации GPU может быть сведена к минимуму из-за того, что требуются редкие квалифицированные спецы, которые смогут настроить оптимальные параметры кеширования, маршрутизации, автомасштабирования и вовремя реагировать на сбои и аномалии. Да, использование Kubernetes Operator сокращает ручной труд, но требует времени и ресурсов на разработку и поддержку операторов.
Что под капотом изобретенного нами велосипеда
Целенаправленно поискав «грабли» в готовых решениях, мы поняли, что ни одно из них нам не подходит. Задержки при первом старте в ModelMesh были просто катастрофическими. Реальный тест на наших данных показал 8+ секунд! Чтобы вы понимали, в B2B порог неприемлемого начинается уже после 2 секунд. vLLM Production Stack сильно ограничен по области применения. Мы протестировали совместимость технологии с другими типами моделей (а вдруг?), но результат оказался неутешительным:
test_results:
llm_models: ✅ # Llama, GPT работают отлично
embedding_models: ❌ # BGE, Sentence-BERT не поддерживаются
classification_models: ❌ # BERT-based классификаторы проблематичны
computer_vision: ❌ # ResNet, YOLO вообще не работают
custom_models: ❌ # Собственные архитектуры невозможны

Анализ реальных паттернов использования показал, что для большинства задач не нужна вся сложность мультимодельного подхода обслуживания, но обязательна эффективность использования GPU. Так мы пришли к выводу, что подход к обслуживанию моделей нам менять не надо, надо решить проблему на уровне виртуализации GPU.
Архитектура Cloud.ru Shared GPU и в чем суть решения
Cloud.ru Shared GPU состоит из двух основных компонентов.
1. cloudru-vgpu-plugin — компонент DaemonSet:
развертывание происходит на каждом GPU-узле,
инжектирует перехватчик vGPU в контейнеры,
управляет жизненным циклом .so библиотеки,
обеспечивает изоляцию процессов.
2. cloudru-vgpu-scheduler — центральный планировщик:
представляет физический графический процессор как кластер ресурсов.
выполняет интеллектуальные обязанности.
мониторит использование и производительность.
принимает решения по размещению рабочей нагрузки.
А теперь самая главная идея. Вместо изменения ML-фреймворков мы перехватываем вызовы CUDA на уровне библиотек и виртуализируем память GPU. Для этого мы ставим между подами и GPU наш интерцептор.

Поясняю, все обращения к GPU идут через две библиотеки:
libcuda.so.1 — основные CUDA операции.
libnvml.so.1 — управление GPU (память, мониторинг).
Мы используем middleware-версии этих библиотек, которые:
Получают запрос от приложения: «Дай мне информацию о GPU».
Подменяют ответ: вместо «У тебя 80 ГБ памяти» отвечают «У тебя 10 ГБ памяти».
Контролируют выделение: если приложение просит больше 10 ГБ — блокируют запрос.
Передают разрешенные операции настоящим NVIDIA-библиотекам.
Результат: каждое приложение «видит» персональную GPU нужного размера, хотя на самом деле все работают на одной физической карте. Приложению не нужно знать, что оно в «виртуальной» среде — для него все выглядит как обычная работа с GPU.
В итоге мы получаем:
Универсальность. Решение работает с любым ML-фреймворком (vLLM, Triton, TensorFlow, PyTorch), не боится проприетарного ПО, не требует обновлений от разработчиков фреймворков.
Прозрачность. Приложение «думает», что у него персональная GPU, при этом никаких изменений в коде приложения не требуется, а мониторинг доступен с помощью стандартных инструментов.
Изоляцию. Если приложение просит больше памяти — получает ошибку, но не крашит других, а каждый контейнер видит свою виртуальную GPU.
Теперь поговорим о технической реализации.
cloudru-vgpu-plugin: инженерный перехватчик
cloudru-vgpu-plugin развертывается как DaemonSet на каждом GPU-узле и выполняет ключевые функции:
1. Инжекция vGPU Interceptor
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: cloudru-vgpu-plugin
spec:
template:
spec:
containers:
- name: vgpu-plugin
image: cloudru/vgpu-plugin:v1.2.0
securityContext:
privileged: true
volumeMounts:
- name: vgpu-lib
mountPath: /usr/local/lib/vgpu
- name: kubelet-pods
mountPath: /var/lib/kubelet/pods
env:
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
2. Процесс инжекции перехватчика
// Pseudocode процесса инжекции
void inject_vgpu_interceptor(pid_t container_pid) {
// 1. Получаем PID контейнера от kubelet
container_info = get_container_info(container_pid);
// 2. Монтируем .so библиотеку в namespace контейнера
mount_vgpu_library(container_info.mount_namespace);
// 3. Устанавливаем LD_PRELOAD для перехвата CUDA вызовов
set_environment_variable(container_pid, "LD_PRELOAD",
"/usr/local/lib/vgpu/libvgpu-interceptor.so");
// 4. Передаем resource limits в interceptor
set_gpu_memory_limit(container_pid, container_info.gpu_memory_limit);
}
cloudru-vgpu-scheduler: интеллектуальное распределение
cloudru-vgpu-scheduler представляет собой Kubernetes Deployment, который управляет GPU ресурсами как единым кластером, реализуя алгоритмы для решения «задачи о рюкзаке».
Практические сценарии применения
Сценарий 1: Production workload (binpack-стратегия)
Оптимально для стабильных продакшн-нагрузок.
Преимущества:
максимальная утилизация ресурсов,
меньше активных узлов = экономия энергии,
упрощенный мониторинг,
лучшее использование сетевых ресурсов,
Пример развертывания:
Всего узлов: 10
Активных узлов: 6 (остальные 4 в standby)
Утилизация GPU: 85%
Экономическая эффективность: высокая
Сценарий 2: Experimental workload (spread-стратегия)
Оптимально для экспериментов и развития.
Преимущества:
изоляция экспериментов,
снижение blast radius при сбоях,
параллельное выполнение задач,
лучшая отказоустойчивость.
Пример развертывания:
Всего узлов: 10
Активных узлов: 10 (все узлы активны)
Утилизация GPU: 60%
Отказоустойчивость: высокая
Гибридная стратегия для смешанных workload:
func (s *VGPUScheduler) calculateHybridScore(node *GPUNode, gpu *PhysicalGPU, request ResourceRequest) float64 {
// Анализируем тип workload по labels
workloadType := request.Pod.Labels["workload-type"]
switch workloadType {
case "production":
return s.calculateGPUBinpackScore(gpu, request)
case "experiment":
return s.calculateGPUSpreadScore(gpu, request)
case "batch":
// Для batch заданий приоритет - доступность больших блоков памяти
return s.calculateFragmentationScore(gpu, request)
default:
// Balanced approach по умолчанию
binpackScore := s.calculateGPUBinpackScore(gpu, request)
spreadScore := s.calculateGPUSpreadScore(gpu, request)
return (binpackScore + spreadScore) / 2
}
}
vGPU Interceptor: сердце системы
1. Архитектура интерцептора
// Основная структура interceptor'а
typedef struct {
pid_t process_id;
size_t memory_limit;
size_t memory_used;
int device_id;
bool enforcement_enabled;
// Статистика
uint64_t total_allocations;
uint64_t failed_allocations;
uint64_t peak_memory_usage;
} vgpu_context_t;
2. Перехват CUDA-вызовов
// Перехват cudaMalloc
cudaError_t cudaMalloc(void **devPtr, size_t size) {
vgpu_context_t* ctx = get_process_context();
// Проверяем лимиты BEFORE allocation
if (ctx->memory_used + size > ctx->memory_limit) {
log_allocation_failure(ctx, size, ctx->memory_used, ctx->memory_limit);
return cudaErrorMemoryAllocation;
}
// Вызываем оригинальную функцию
cudaError_t result = REAL_CUDA_MALLOC(devPtr, size);
if (result == cudaSuccess) {
// Обновляем статистику
ctx->memory_used += size;
ctx->total_allocations++;
// Отправляем метрики в plugin
report_memory_usage(ctx->process_id, ctx->memory_used);
}
return result;
}
Результат и как это работает в продакшне
Если коротко — работает отлично. Утилизация GPU выросла с 23% до 78% (+339%), задержка увеличилась всего на 7% (подробные таблицы до/после можно посмотреть в предыдущей статье), зато отладка стала в пять раз быстрее. Затраты на GPU-инфраструктуру снизились на 67%, задержки держатся в допустимых пределах с коэффициентом вариации <12%. Спустя восемь месяцев после внедрения система управляет 247 моделями на 52 GPU-узлах, и за это время у нас было всего три инцидента.
Помимо отличных результатов, изобретение собственного Shared GPU принесло нам несколько важных уроков:
Урок 1: Чем проще — тем лучше
Попытки создать сложные системы управления моделями (вроде ModelMesh) часто создают больше проблем, чем решают. Зато старый добрый инженерный принцип KISS (keep it simple, stupid) работает безотказно.
Урок 2: Мониторинг — это база, а не опция
Без подробной статистики и метрик для каждого процесса невозможно эффективно управлять GPU. Нужно собирать данные о производительности с самого начала работы системы, а не добавлять мониторинг потом как дополнение.
Урок 3: Система должна быть универсальной
ML-мир меняется очень быстро — каждые полгода появляются новые фреймворки и подходы. Поэтому система виртуализации GPU должна работать с любыми инструментами, а не привязываться к конкретному фреймворку, который может быстро устареть.
Урок 4: Удобство разработчиков решает все
Если система неудобна для DS-инженеров и они тратят много времени на отладку и диагностику проблем, то они найдут способы обойти эту систему. Важно делать инструменты максимально дружелюбными для тех, кто их использует каждый день.
Вот, кажется, и все. Эй, кто осилил эту простыню вместе с «раскрывашками», вы там живы? Понимаю, объем негуманный, но уж лучше все сразу и в одном месте. Расскажите в комментах, что пробовали, чтобы выжать максимум из своих GPU, но так, чтобы они не надорвались, и какие у вас впечатления от этих решений?