28 авг. 2026 · 13 мин чтения

Автоскейлинг GPU-инференса в Kubernetes с Karpenter: экономия на ML-нагрузках

Всеволод
Технический писатель · IT, облачные сервисы
13 мин чтения

Счёт за GPU-узлы почти всегда растёт быстрее, чем нагрузка на модель. Карта дорогая, работает урывками, а платите вы за календарное время её существования. Ниже — конфигурация NodePool под GPU inference, выбор сигнала для скейлинга, анатомия холодного старта и методика расчёта, которая покажет, где экономия реальна.

Почему GPU-нода простаивает, а счёт растёт

Инференс-сервис редко нагружен равномерно: у внутреннего ассистента пик приходится на рабочие часы, у сервиса распознавания документов — на конец месяца. Карта занята ровно столько, сколько идут запросы, а оплачивается всё время, пока узел числится в кластере. Разрыв между этими величинами и есть та сумма, за которую идёт борьба.

Три уровня масштабирования и почему Pending-поды — не проблема HPA

Масштабирование в Kubernetes работает на трёх независимых уровнях, и путаница между ними — самая частая причина, по которой «автоскейлинг настроен, а не работает».

  1. Количество реплик. HPA или KEDA: смотрят на метрику и меняют число подов в Deployment.
  2. Размер одного пода. VPA подбирает requests и limits. Для GPU-подов почти бесполезен: nvidia.com/gpu — целочисленный ресурс, а память и процессор определяются размером весов.
  3. Количество и типы узлов. Cluster Autoscaler или Karpenter. Именно этот уровень отвечает за деньги: под без узла ничего не стоит, узел без подов стоит полную ставку.

Классическая картина: HPA поднял реплики с двух до шести, четыре пода встали в Pending с 12 Insufficient nvidia.com/gpu. HPA свою работу сделал — он не умеет создавать узлы. Дальше слово за узловым автоскейлером, и на этом стыке копится задержка: планировщик отметил под непланируемым, автоскейлер запросил машину, машина загрузилась, поставила драйверы, скачала образ. Минуты, в течение которых пользователи получают 503. Обратная ситуация не менее дорогая: пик прошёл, HPA вернул две реплики, четыре узла остались с нулевой нагрузкой и продолжают начисляться в счёте.

Karpenter против Cluster Autoscaler: где разница действительно в деньгах

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

Karpenter группами не оперирует. Контроллер читает требования непланируемых подов — ресурсы, nodeSelector, tolerations, зоны — и напрямую заказывает машину нужного размера. Дальше работает consolidation: контроллер проверяет, можно ли разместить текущие поды дешевле, и заменяет узлы.

Свойство

Cluster Autoscaler

Karpenter

Источник конфигурации

Группы узлов, заданные заранее

NodePool с диапазоном требований

Выбор типа машины

Из фиксированного набора групп

Подбирается под требования подов

Упаковка подов (bin packing)

Только при планировании

Постоянная переупаковка через consolidation

Смена типа машины на лету

Нет

Да, замена узла на более дешёвый

Прерываемые инстансы

Через отдельную группу

Через capacity-type в одном пуле

Поддержка на площадках РФ

Практически везде

Зависит от провайдера: нужен поставщик ресурсов

Где выигрыша не будет: на однородном парке подбирать не из чего, при ровной круглосуточной нагрузке consolidation нечего оптимизировать, а без поставщика ресурсов для вашего облака трудозатраты съедят экономию. Отдельная сложность — одновременная работа Karpenter и Cluster Autoscaler на одних подах: оба реагируют, кластер получает вдвое больше узлов, после чего оба начинают их удалять. Разводите нагрузки по taint'ам жёстко.

NodePool под GPU: рабочая конфигурация

NodePool описывает, какие узлы допустимо создавать, NodeClass — специфику облака (образ, сеть, диск). Конфиги с apiVersion: karpenter.sh/v1alpha5 и объектами Provisioner, AWSNodeTemplate, ttlSecondsAfterEmpty сегодня не применятся: вместо Provisioner — NodePool, вместо TTL-полей — секция disruption, вместо spec.provider — nodeClassRef.

Манифест NodePool и NodeClass с фильтрацией по GPU

Ключевая идея: разрешить только машины с ускорителями, пометить их taint'ом и ограничить пул по количеству карт, а не узлов.

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: gpu-inference
spec:
  template:
    metadata:
      labels:
        workload: inference
    spec:
      nodeClassRef:
        group: karpenter.k8s.local
        kind: CloudNodeClass
        name: gpu-default
      taints:
        - key: nvidia.com/gpu
          value: "true"
          effect: NoSchedule
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["on-demand", "spot"]
        - key: node.kubernetes.io/instance-type
          operator: In
          values: ["gpu-t4-8-32", "gpu-a100-16-128"]
        - key: topology.kubernetes.io/zone
          operator: In
          values: ["ru-1a", "ru-1b"]
      expireAfter: 168h
  limits:
    nvidia.com/gpu: "12"
    cpu: "192"
  disruption:
    consolidationPolicy: WhenEmpty
    consolidateAfter: 15m
  weight: 10
  • taints на уровне шаблона узла. Без него в дорогой пул уедет любой под без ограничений — сборщик логов, тестовый Job. Ответная сторона — tolerations в манифесте инференса плюс nodeSelector на метку workload: inference.
  • limits в единицах GPU. Лимит «4 узла» превратится в 16 карт, если Karpenter возьмёт машины с четырьмя картами. Лимит в nvidia.com/gpu — прямой потолок расходов.
  • capacity-type с прерываемыми инстансами. Прерываемая машина дешевле, но её отбирают с коротким уведомлением, а перезапуск GPU-реплики занимает минуты. Рабочая схема — минимальный слой обычных узлов и прерываемые сверху: два NodePool с разным weight.
  • expireAfter. Долгоживущие GPU-узлы копят рассинхронизацию с эталонным образом.

В Deployment инференса обязательны три вещи: запрос ровно того числа карт, которое используется, tolerations под taint пула и topologySpreadConstraints.

resources:
  limits:
    nvidia.com/gpu: 1
  requests:
    cpu: "4"
    memory: 24Gi
tolerations:
  - key: nvidia.com/gpu
    operator: Exists
    effect: NoSchedule
topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: kubernetes.io/hostname
    whenUnsatisfiable: DoNotSchedule
    labelSelector:
      matchLabels:
        app: llm-serving

Оптимальная по упаковке раскладка и отказоустойчивая раскладка — разные вещи. Karpenter охотно разместит четыре реплики на одной машине с четырьмя картами, и потеря этой машины даст стопроцентный отказ сервиса.

Почему свежая нода «готова», но GPU на ней ещё нет

Узел переходит в Ready, когда отчитался kubelet, — но ресурс nvidia.com/gpu появляется в status.allocatable только после цепочки GPU-оператора: драйвер, контейнерная среда с поддержкой ускорителей, device plugin, регистрация устройств. Между этими моментами проходит от полминуты до нескольких минут.

В этом окне планировщик видит готовый узел без GPU и держит под в Pending, а Karpenter видит непланируемый под и может заказать ещё один узел. Consolidation это позже исправляет, но вы уже заплатили за лишнюю карту и лишний холодный старт. Меры: startupTaints в NodePool — узел создаётся с временным taint'ом, который снимает GPU-оператор после разметки устройств, и пока taint на месте, Karpenter не считает узел пригодной ёмкостью; образ с предустановленным драйвером, собственный или провайдерский, указанный в NodeClass, — разница в стадии подготовки в минутах.

По какому сигналу масштабировать инференс

Сигнал определяет и качество сервиса, и счёт, а выбор метрики для GPU-инференса не очевиден.

Очередь запросов и TTFT вместо утилизации GPU

Почему не CPU: у inference-пода процессор занят токенизацией и работой HTTP-сервера. При полной загрузке карты CPU может показывать 15%, а на потоке коротких запросов — подскочить без нагрузки на ускоритель.

Почему обманчива утилизация GPU: DCGM_FI_DEV_GPU_UTIL отражает, выполнялось ли на карте хоть что-то в интервале измерения, а не то, насколько плотно она загружена. vLLM и Triton объединяют запросы в пакеты непрерывно, поэтому и при одном активном запросе, и при трёх десятках метрика может держаться у верхней границы.

Что работает:

  • Глубина очереди ожидающих запросов (у vLLM — vllm:num_requests_waiting). Очередь стабильно ненулевая — нужна реплика.
  • Время до первого токена (TTFT). Метрика, которую чувствует пользователь, деградирует раньше остальных при перегрузке.
  • Число конкурентных запросов на реплику. Нагрузочным тестом измеряете, при какой параллельности TTFT ещё укладывается в SLA, и держите этот порог.

Устойчивее всего связка: основной сигнал — конкурентность или глубина очереди, TTFT — страховочный порог для резкого масштабирования вверх.

Кастомные метрики в HPA и KEDA: от PromQL до масштабирования до нуля

Метрики сервера собирает Prometheus через ServiceMonitor, метрики карт — DCGM exporter. Дальше два пути.

Путь первый — prometheus-adapter. Компонент публикует запрос PromQL как кастомную метрику Kubernetes, после чего HPA работает с ней как со штатной.

rules:
  - seriesQuery: 'vllm:num_requests_waiting{namespace!="",pod!=""}'
    resources:
      overrides:
        namespace: {resource: namespace}
        pod: {resource: pod}
    name:
      as: "vllm_requests_waiting"
    metricsQuery: 'avg_over_time(vllm:num_requests_waiting{<<.LabelMatchers>>}[2m])'

avg_over_time с окном в две минуты здесь не украшение: мгновенное значение очереди шумит, а каждое ложное срабатывание на GPU стоит холодного старта. В самом HPA критично настроить поведение — вверх быстро, вниз с большой задержкой.

behavior:
  scaleUp:
    stabilizationWindowSeconds: 30
    policies:
      - type: Pods
        value: 2
        periodSeconds: 60
  scaleDown:
    stabilizationWindowSeconds: 900
    policies:
      - type: Pods
        value: 1
        periodSeconds: 300

Асимметрия намеренная: лишняя реплика в течение пятнадцати минут стоит доли GPU-часа, а преждевременно остановленная — нового холодного старта в несколько минут.

Путь второй — KEDA. Даёт то, чего HPA не умеет: масштабирование до нуля. Модель, к которой обращаются несколько раз в день, между обращениями не должна занимать карту вообще.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: llm-serving
spec:
  scaleTargetRef:
    name: llm-serving
  minReplicaCount: 0
  maxReplicaCount: 6
  cooldownPeriod: 600
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring:9090
        query: sum(rate(nginx_ingress_controller_requests{service="llm-serving"}[2m]))
        threshold: "0.1"

Ноль реплик означает, что Karpenter снимет и узел. Плата — первый запрос после простоя ждёт полный холодный старт. Для внутренней аналитической модели схема работает хорошо, для клиентского API — нет. Промежуточный вариант: клиент кладёт задачу в брокер, KEDA поднимает обработчик по глубине очереди, ответ забирается позже.

Холодный старт и консолидация: две цены автоскейлинга

Считать нужно обе цены автоматики: задержку появления новой реплики и риск потерять работающую.

Из чего складывается время до первого ответа новой реплики

Значения ниже — порядок величин для типовой конфигурации: узел с одной картой, модель на 7–14 миллиардов параметров, сервер vLLM, образ около 12 ГБ.

Стадия

Типовая длительность

Сокращается?

Реакция автоскейлера на Pending-под

5–20 с

Частично — интервалом опроса

Создание и загрузка виртуальной машины

60–150 с

Нет, определяется облаком

Регистрация узла, старт kubelet

20–40 с

Слабо

Драйверы и разметка устройств device plugin

30–180 с

Да — образ с драйвером

Загрузка образа контейнера (12 ГБ)

90–300 с

Да — локальное зеркало реестра

Загрузка весов модели в VRAM

40–180 с

Да — кэш весов на томе

Прогрев: компиляция ядер, первый прогон

20–90 с

Частично — прогон при старте

Итого до первого ответа

≈ 4,5–13 мин

—

Реестр контейнеров в том же регионе срезает загрузку образа в разы; веса на сетевом томе облегчают образ; собственный образ узла закрывает стадию подготовки ускорителя. Не сокращается никак время создания ВМ и загрузка весов в память карты.

Даже после всех оптимизаций реалистичный минимум — около трёх минут. Значит, нужен минимальный резерв реплик. minReplicas ставится не по средней нагрузке, а так, чтобы существующие реплики выдержали характерный рост за время холодного старта: при плавном профиле хватает одной запасной реплики, при скачках в разы — двух-трёх.

Отдельная задача — обучение моделей, конкурирующее с инференсом за карты. Держать под него постоянный узел невыгодно. Здесь помогает облачная ML-платформа Cloud4U — GPU-сервер под конкретный цикл обучения без закупки железа: обучение нейросетей идёт до восьми раз быстрее, чем на процессорных мощностях, а инференс-кластер остаётся не тронутым тяжёлыми учебными задачами.

Настройки disruption и PDB, которые защищают работающий инференс

Consolidation приносит основную экономию и создаёт основной риск. Для процессорной нагрузки выселение пода стоит секунды, для GPU-инференса — полной перезагрузки весов на новом узле.

  • consolidationPolicy: WhenEmpty для узлов с инференсом. WhenEmptyOrUnderutilized разрешает переносить работающие поды ради упаковки — на GPU почти всегда неоправданно.
  • consolidateAfter. 30–60 секунд провоцируют колебания: узел удалён, через две минуты нагрузка вернулась, платим холодный старт. Разумный ориентир — 10–20 минут.
  • PodDisruptionBudget на каждый inference-Deployment. Без него добровольное выселение может увести все реплики разом.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: llm-serving-pdb
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: llm-serving

minAvailable — в абсолютном числе, а не в процентах: при maxUnavailable: 25% и двух живых репликах округление разрешит выселить половину мощности сервиса.

Karpenter умеет ограничивать собственную активность по расписанию и по доле узлов:

disruption:
  consolidationPolicy: WhenEmpty
  consolidateAfter: 20m
  budgets:
    - nodes: "1"
    - nodes: "0"
      schedule: "0 8 * * mon-fri"
      duration: 11h

Первое правило разрешает трогать не более одного узла за раз, второе запрещает добровольные операции в рабочие часы буднего дня. Плюс прерываемые инстансы требуют обработчика уведомлений о скором изъятии, иначе реплика исчезнет без замены.

Сколько это экономит и когда не окупается

Расчёт экономии: переменные, формула и таблица сценариев

Переменные: C — стоимость GPU-часа (карта уровня A100 обходится в несколько раз дороже T4; конкретная ставка зависит от площадки и типа инстанса и остаётся в расчёте множителем C); N_peak — карт на пике, 6; N_base — постоянный резерв, 2; H_peak — часов в месяц под полным пулом, 180 из 730; T_cold — холодный старт, 0,1 ч; K — подъёмов узлов в месяц, 60.

Постоянный пул: C × N_peak × 730.

Автоскейлинг: C × N_base × 730 + C × (N_peak − N_base) × H_peak + C × (N_peak − N_base) × T_cold × K.

Сократим C и посчитаем в GPU-часах — так экономия видна независимо от ставки за карту.

Показатель (месяц, 730 ч)

Постоянный пул

Автоскейлинг

Базовые карты (2 шт. круглосуточно)

1460 GPU-ч

1460 GPU-ч

Пиковые карты (4 шт.)

2920 GPU-ч

720 GPU-ч

Накладные расходы холодных стартов

0

24 GPU-ч

Итого GPU-времени в месяц

4380 GPU-ч

2204 GPU-ч

Доля от постоянного пула

100%

≈ 50%

На эластичном слое — пиковых картах — экономия видна ярче всего: 720 GPU-часов против 2920, срезается около трёх четвертей. По всему GPU-времени автоскейлинг обходится примерно вдвое дешевле постоянного пула, а накладные расходы холодных стартов — 24 GPU-часа из 2204, порядка процента месячного счёта, — на фоне этой экономии теряются.

К GPU-времени добавляются постоянные операционные статьи: мониторинг и сбор метрик нужны в обоих вариантах примерно одинаково, а автоскейлинг требует ещё порядка восьми часов инженера в месяц на сопровождение автоматики. Рядом с сэкономленными тысячами GPU-часов эти надбавки невелики, но в ноль экономию они не обращают — держите их в расчёте.

Внедрение считается отдельно: 60–80 часов инженера, разово. На фоне экономии около половины GPU-счёта в месяц (≈ 2176 GPU-часов при этом профиле) настройка окупается в первый же месяц. Оценки ориентировочны, итог сдвинется на ±25%. Экономия пропорциональна доле простоя: если пиковых карт всего одна-две сверх базы, сэкономленное GPU-время сжимается в разы — до нескольких сотен часов, — и на этом фоне сопровождение и риски перестают оправдываться. Наш ориентир: автоскейлинг GPU начинает уверенно окупаться от четырёх-пяти карт в пуле при доле простоя выше 40%.

Когда автоскейлинг GPU не подходит и что делать при дефиците карт

  • Жёсткий SLA на задержку. Если отклик гарантирован в пределах секунд, минуты холодного старта неприемлемы: автоскейлинг работает только «сверх» гарантированного пула.
  • Большие модели с долгой загрузкой весов. Для модели на 70+ миллиардов параметров загрузка весов может занять больше времени, чем весь остальной холодный старт.
  • Дефицит карт и квоты. Karpenter заказывает узел, а облако отвечает отказом: нужного типа машин в зоне нет.

Что помогает при дефиците: несколько типов карт в requirements — пул, который умеет взять и A100, и что-то из младшей линейки, переживёт дефицит одного типа; несколько зон доступности; PriorityClass для инференса выше, чем для обучения; разделение одной карты между репликами. MIG — аппаратное деление карты на изолированные экземпляры с собственной памятью, доступное только на отдельных профессиональных ускорителях (A100, H100); time-slicing — программное разделение по времени, работает на любых картах, но без изоляции памяти. Для небольших моделей MIG позволяет разместить несколько реплик на одну карту.

Отдельный слой ограничений — данные. Если инференс работает с персональными или платёжными данными, узлы должны создаваться внутри аттестованного контура: список зон в NodePool ограничивается площадками нужного класса защищённости, а nodeClassRef указывает на образ и сеть внутри этого контура.

Главные выводы

Экономия появляется из связки трёх настроенных вещей: пул узлов, который берёт ровно нужные карты и не пускает к ним посторонние поды; сигнал масштабирования по очереди и TTFT, а не по загрузке процессора или обманчивой утилизации карты; ограничители консолидации. Karpenter выигрывает у Cluster Autoscaler там, где парк карт разнороден, а нагрузка неравномерна.

Перед внедрением честно посчитайте профиль: доля простоя, число карт сверх базового резерва, длительность холодного старта после оптимизаций. От четырёх-пяти карт в пуле и простоя выше 40% масштабирование ML-нагрузок окупается за первый же месяц. Две вещи не решаются конфигурацией: минимальный резерв реплик нужен всегда, а при физическом дефиците карт помогает заранее продуманный набор типов машин, зон и приоритетов плюс разделение карты через MIG.

Вверх!