Автоскейлинг в Kubernetes: HPA, VPA и KEDA

Три механизма автоскейлинга в Kubernetes и как они уживаются: HPA по CPU/памяти и кастомным метрикам, VPA и его конфликт с HPA, KEDA для event-driven-нагрузок со scale-to-zero. Что выбирать под какую задачу.

Автоскейлинг в Kubernetes звучит как «поставил HPA — и кластер сам разберётся». На практике всё быстрее упирается в вопросы: по какой метрике скейлить, что делать с потреблением памяти, почему под event-driven-нагрузку CPU — плохой сигнал, и как не устроить войну между двумя автоскейлерами. Разберём три инструмента — HPA, VPA и KEDA — и границы применимости каждого: не по описанию из документации, а по тому, как они ведут себя на живом кластере, включая случаи, когда реакция приходит позже ожидаемого или не приходит вовсе.

Это четвёртая статья серии «Kubernetes на практике», а фундамент под ней — «Ресурсы в Kubernetes: requests, limits и QoS»: HPA считает утилизацию в процентах от requests, VPA двигает сами requests, и без осмысленных чисел в манифесте автоскейлеру просто не с чего считать. Все замеры ниже сняты на живом стенде kubernetes/autoscaling в публичном репозитории digital-cookbook: Kubernetes 1.36.2 на узлах Talos Linux, metrics-server, собственный Prometheus стенда (prom/prometheus:v3.13.1), Prometheus Adapter (чарт 5.3.0, образ v0.12.0) и KEDA 2.20.1. Кластер собран так же, как в статье про четыре отказа при bootstrap на Talos: SSH на узлы нет, всё наблюдение идёт через API-сервер — kubectl get, kubectl describe, port-forward, и этого достаточно, чтобы увидеть механику целиком.

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

В статье

Как HPA принимает решение

HorizontalPodAutoscaler устроен проще, чем кажется. Раз в цикл (по умолчанию — раз в 15 секунд) контроллер читает текущее значение метрики, сравнивает с таргетом и считает желаемое число реплик:

desiredReplicas = ceil(currentReplicas × currentMetric / targetMetric)

Всё остальное — обвязка вокруг этой формулы: откуда берётся currentMetric, что считать таргетом и насколько резко разрешено двигаться к результату.

Откуда берётся метрика. Источников три, и это три разные API-группы:

  • metrics.k8s.io — ресурсные метрики (CPU и память подов), их поставляет metrics-server. Это type: Resource в манифесте HPA;
  • custom.metrics.k8s.io — метрики объектов кластера: RPS сервиса, длина очереди, p99. Кто-то должен зарегистрировать эту группу — обычно Prometheus Adapter. Это type: Object и type: Pods;
  • external.metrics.k8s.io — метрики вне кластера: длина очереди в брокере, лаг консьюмер-группы, счётчик в облачной шине. Это type: External.

Формула во всех трёх случаях одна и та же. Меняется только поставщик числа — и именно поэтому HPA умеет несравнимо больше, чем «скейлинг по CPU», которым его обычно и ограничивают.

Таргет по CPU — это процент от requests, а не от лимита. Утилизация считается как отношение потребления к сумме requests.cpu контейнеров пода. У демо-деплоймента на стенде requests.cpu: 200m и таргет averageUtilization: 50 — то есть целевое потребление составляет 100m на под. Отсюда неприятное следствие: занизили requests — и HPA начнёт добавлять реплики на нагрузке, которую один под тянет без напряжения; завысили — автоскейлер не проснётся никогда. Обе половины настройки живут в разных манифестах и правятся разными людьми, поэтому расходятся быстро.

Манифест демо целиком:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: php-apache, namespace: cookbook-k8s }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: php-apache }
  minReplicas: 1
  maxReplicas: 10
  metrics:
    - type: Resource
      resource: { name: cpu, target: { type: Utilization, averageUtilization: 50 } }
  behavior:
    scaleDown: { stabilizationWindowSeconds: 60 }

Под нагрузку — отдельный под с бесконечным циклом wget по сервису. Ряд снимался каждые 30 секунд, до старта нагрузки было cpu: 0%/50% и одна реплика; ниже — выборка из него, пропущенные точки отмечены многоточием:

$ kubectl -n cookbook-k8s apply -f manifests/11-load-gen.yaml
# далее каждые 30 с: kubectl -n cookbook-k8s get hpa php-apache
# (ряд сведён к TARGETS и REPLICAS)
# нагрузка включена
t= 30s   cpu: 250%/50%   реплик: 3
t= 60s   cpu:  94%/50%   реплик: 10
t= 90s   cpu:  59%/50%   реплик: 10
t=120s   cpu:  42%/50%   реплик: 10
t=150s   cpu:  42%/50%   реплик: 9
t=180s   cpu:  45%/50%   реплик: 9
...
t=240s   cpu:  46%/50%   реплик: 9
...
t=300s   cpu:  47%/50%   реплик: 9
t=330s   cpu:  49%/50%   реплик: 9
t=360s   cpu:  46%/50%   реплик: 9

Читать такой ряд надо аккуратно: числа в одной строке — не «причина и следствие», а срез состояния. Метрика в строке уже отражает нагрузку, размазанную по тому числу реплик, которое стоит в этой же строке, а решение по ней контроллер примет на следующем цикле. Поэтому вопрос «почему при 250% реплик всего три, а не пять» осмысленного ответа из этого ряда не имеет — к моменту снимка часть работы уже была сделана.

Что видно достоверно. Первый замер застаёт всплеск: одна реплика захлебнулась запросами быстрее, чем сработал автоскейлер, и утилизация ушла впятеро выше таргета. Ещё через тридцать секунд — потолок, все десять реплик. Дальше нагрузка размазывается, утилизация проседает ниже таргета, и к 150-й секунде HPA убирает один под. Устойчивый уровень — девять реплик, и держится он с этого момента до конца шестиминутной фазы — ещё три с половиной минуты наблюдения — при утилизации, гуляющей в диапазоне 42–49% вокруг таргета 50%.

Именно этот диапазон, а не аккуратные «ровно 50%», — нормальное поведение. HPA намеренно не реагирует на мелкие отклонения: пока отношение текущей метрики к таргету отличается от единицы меньше чем на 10%, контроллер не делает ничего. Плюс окно стабилизации на снижение: за последние stabilizationWindowSeconds берётся максимум из рекомендованных значений, и это дополнительно гасит дрожание вниз. Десять процентов допуска — дефолт, а не константа: величина задаётся флагом --horizontal-pod-autoscaler-tolerance у kube-controller-manager, а в свежих версиях Kubernetes настраивается и на уровне отдельного HPA. Автоскейлер, который выдаёт ровный таргет, — это автоскейлер, который постоянно пересоздаёт поды.

Возврат вниз — самая недооценённая часть. После снятия нагрузки:

$ kubectl -n cookbook-k8s delete pod load-gen
# далее каждые 30 с: kubectl -n cookbook-k8s get hpa php-apache
# нагрузка снята
t= 30s   cpu: 47%/50%   реплик: 9
t= 60s   cpu: 31%/50%   реплик: 9
t= 90s   cpu:  0%/50%   реплик: 9
t=120s   cpu:  0%/50%   реплик: 6
t=150s   cpu:  0%/50%   реплик: 1
t=180s   cpu:  0%/50%   реплик: 1

Сначала гаснет метрика — не мгновенно, а за 90 секунд (47% → 31% → 0%): ресурсная утилизация считается по скользящему окну, и мгновенно упасть она не может по построению. Только потом трогаются реплики: 9 → 6, затем 6 → 1. Полный возврат к одной реплике занял 150 секунд при заданном окне стабилизации в 60.

Расхождение в 90 секунд — не задержка и не баг. Окно стабилизации — не таймер «подождать и обнулить», а правило «на каждом цикле пересчёта брать максимум рекомендаций за последние 60 секунд». Пока в окне лежат старые высокие рекомендации, спуск ограничен ими; окно едет вперёд, ограничение слабеет, и снижение идёт ступенями. Прибавьте к этому время остывания самой метрики — и получите фактические 150 секунд. Общее правило: фактическое время возврата всегда больше величины окна, и планировать надо по нему.

Почему таргет по памяти опасен. Формально type: Resource работает и с памятью, но получается ловушка. Память в отличие от CPU не «отпускается» вслед за спадом нагрузки: рантайм держит кучу, аллокатор не спешит возвращать страницы ядру, страничный кеш живёт своей жизнью. Метрика, которая растёт легко и не падает, для автоскейлера означает одно — реплики поднялись и не опустятся, пока поды не пересоздадут руками. Хуже того, добавление реплик не всегда снижает потребление на под: если память занята кешем, каждый новый под сначала прогреет свой собственный. Память — сигнал для вертикального масштабирования и для лимитов, а не для горизонтального; механика подробно разобрана в статье про «Ресурсы в Kubernetes: requests, limits и QoS».

Кастомные и внешние метрики

CPU — прокси-сигнал. Он говорит «подам тяжело», но ничего не говорит о том, что именно происходит с бизнес-нагрузкой. Очередь на тысячу необработанных сообщений может не давать никакой нагрузки на CPU, если консьюмер простаивает на сетевом ожидании; наоборот, всплеск CPU может быть фоновой перестройкой индекса, на которую реагировать репликами бессмысленно. Поэтому настоящая ценность HPA открывается на кастомных метриках.

Цепочка выглядит так:

приложение (/metrics) → Prometheus → Prometheus Adapter
  → custom.metrics.k8s.io → HPA → Deployment

Приложение. На стенде метрику отдаёт крохотный Go-сервис metricgen: одна метрика demo_queue_depth, значение двигается ручкой GET /push?n=<int>. Важная деталь: формат экспозиции Prometheus — это просто текст, никакой клиентской библиотеки для него не нужно.

func handleMetrics(w http.ResponseWriter, r *http.Request) {
	depth := atomic.LoadInt64(&queueDepth)
	w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
	fmt.Fprintf(w, "# HELP demo_queue_depth Искусственная глубина очереди для демонстрации автоскейлинга\n")
	fmt.Fprintf(w, "# TYPE demo_queue_depth gauge\n")
	fmt.Fprintf(w, "demo_queue_depth %d\n", depth)
}

Весь сервис — на стандартной библиотеке, без единой внешней зависимости: net/http, fmt, strconv, sync/atomic, os, log. Живой ответ после push?n=7:

$ kubectl -n cookbook-k8s port-forward svc/metricgen 18080:8080 &
$ curl -s "http://localhost:18080/push?n=7"
$ curl -s http://localhost:18080/metrics
# HELP demo_queue_depth Искусственная глубина очереди для демонстрации автоскейлинга
# TYPE demo_queue_depth gauge
demo_queue_depth 7

Adapter. Prometheus Adapter — это переводчик: он ходит в Prometheus по PromQL и публикует результат как ресурс custom.metrics.k8s.io. Правило описывает, какой ряд брать и с каким объектом кластера его связать:

rules:
  default: false
  external: []
  custom:
    - seriesQuery: '{__name__="demo_queue_depth",namespace!="",service!=""}'
      resources:
        overrides:
          namespace: { resource: namespace }
          service:   { resource: service }
          pod:       { resource: pod }
      name:
        matches: "^demo_queue_depth$"
        as: "demo_queue_depth"
      metricsQuery: 'sum(<<.Series>>{<<.LabelMatchers>>}) by (<<.GroupBy>>)'

Ключевое здесь — resources.overrides. Метрика в Prometheus — это набор лейблов; чтобы Kubernetes мог спросить «дай мне demo_queue_depth для Service metricgen в namespace cookbook-k8s», лейблы ряда нужно сопоставить с ресурсами API. Если лейбла нет — метрика в API просто не появится, и HPA будет вечно показывать <unknown>. На стенде лейбл service пришлось проставлять relabel’ом из лейбла пода app при скрейпе: сам по себе он не возникает.

Проверять регистрацию надо до того, как заведён HPA, — обычным запросом к API:

kubectl get --raw \
  "/apis/custom.metrics.k8s.io/v1beta1/namespaces/cookbook-k8s/services/metricgen/demo_queue_depth"
{"kind":"MetricValueList","apiVersion":"custom.metrics.k8s.io/v1beta1","metadata":{},
 "items":[{"describedObject":{"kind":"Service","namespace":"cookbook-k8s","name":"metricgen",
 "apiVersion":"/v1"},"metricName":"demo_queue_depth","timestamp":"2026-07-23T05:47:22Z",
 "value":"7","selector":null}]}

Семёрка совпала с тем, что было выставлено через push?n=7, — цепочка работает целиком.

HPA. Метрика типа Object описывает объект (Service metricgen), а масштабирует другой объект — Deployment consumer. Таргет AverageValue: "5" означает: значение метрики делится на текущее число реплик потребителя, то есть desiredReplicas = ceil(demo_queue_depth / 5).

  minReplicas: 1
  maxReplicas: 8
  metrics:
    - type: Object
      object:
        describedObject: { apiVersion: v1, kind: Service, name: metricgen }
        metric: { name: demo_queue_depth }
        target: { type: AverageValue, averageValue: "5" }

behavior здесь намеренно не задан — работают дефолты Kubernetes. Ряд по push?n=40 (ceil(40/5) = 8, упор в maxReplicas):

$ curl -s "http://localhost:18080/push?n=40"
# далее каждые 30 с: kubectl -n cookbook-k8s get hpa consumer
# метрика поднята до 40
t= 30s   TARGETS 5/5 (avg)   реплик: 8
t= 60s   TARGETS 5/5 (avg)   реплик: 8
...
t=300s   TARGETS 5/5 (avg)   реплик: 8

К первому же замеру, через 30 секунд, реплик уже восемь. Промежуточных ступеней роста этот ряд не содержит — снимки идут раз в 30 секунд, и весь подъём уложился в интервал между двумя точками. Больше из ряда ничего не выводится: сколько именно шагов было и когда каждый случился, он не показывает. Достоверен факт — рост до потолка занял меньше интервала опроса, и дальше восемь реплик держались все снятые 10 точек подряд. Причина скорости в том, что стабилизация в дефолтах Kubernetes есть только на снижение: вверх контроллер идёт агрессивно, без окна.

А вот снижение — контраст, ради которого стоило снимать второй ряд. Метрика опущена в ноль:

$ curl -s "http://localhost:18080/push?n=0"
# далее каждые 30 с: kubectl -n cookbook-k8s get hpa consumer
# метрика опущена в 0
t= 30s   TARGETS 0/5 (avg)   реплик: 8
t= 60s   TARGETS 0/5 (avg)   реплик: 8
...
t=270s   TARGETS 0/5 (avg)   реплик: 8
t=300s   TARGETS 0/5 (avg)   реплик: 1
t=330s   TARGETS 0/5 (avg)   реплик: 1
t=450s   TARGETS 0/5 (avg)   реплик: 1

TARGETS упал в ноль сразу, на первом же замере: demo_queue_depth — обычный gauge, а не скользящее среднее, гаснуть ему нечем. А реплик всё ещё восемь — и остаётся восемь ровно 300 секунд, пока не истечёт дефолтное окно scaleDown.stabilizationWindowSeconds: 300, которое в этом HPA никто не переопределял. По истечении окна спуск идёт одним шагом, сразу с восьми до одной.

Сопоставьте с предыдущим разделом, и разница станет наглядной:

  • в демо по CPU окно задано явно — 60 секунд, и фактический возврат занял 150 секунд, потому что метрика ещё и остывала, а спуск шёл ступенями;
  • в демо по кастомной метрике окно осталось дефолтным — 300 секунд, метрика была нулевой с самого начала, и спуск случился одним шагом ровно по истечении окна.

Это два разных случая, и путать их не надо. Из них следует одна практическая вещь: дефолтные пять минут удержания вниз получает любой HPA, в котором behavior не описан явно. Половина жалоб на «HPA не снижает реплики» — это ровно они, ненастроенный дефолт. Настраивать окно стоит осознанно: короткое — быстрее освобождает ресурсы и легче флаппит, длинное — держит запас дольше, зато переживает ложные провалы метрики.

Когда источник метрики нельзя скейлить вместе с потребителем

Сначала о том, что здесь не проблема. Масштабировать приложение по его собственным метрикам — штатный режим HPA: метрики типа Pods (RPS на под, число активных соединений, длительность обработки) для этого и существуют. То, что после добавления реплик нагрузка на каждый под падает, — не флаппинг, а ожидаемая обратная связь: HPA для того и добавляет реплики, чтобы это значение снизить.

На стенде источник метрики (metricgen) и скейлимый объект (consumer) — разные деплойменты. metricgen всегда в одной реплике и не участвует в автоскейлинге вообще. Соблазн сэкономить и повесить HPA на сам источник понятен: зачем лишний деплоймент, если метрику отдаёт тот же под, который и обрабатывает нагрузку. Пока реплик больше нуля, в конфигурации стенда это даже работало бы: Adapter суммирует demo_queue_depth по подам, HPA делит эту Object-метрику на число реплик (AverageValue), и новые реплики с нулевым значением сумму не меняют — 40 + 0 + 0 остаётся 40, желаемое число реплик остаётся ceil(40 / 5) = 8. Ломается это на другом.

Исчезновение ряда при уходе в ноль. Если объект уходит в ноль реплик, вместе с последним подом исчезает и /metrics, а значит и ряд в Prometheus. Запрос вернёт не «ноль», а пустой результат — отсутствие метрики. Отличить «метрика говорит, что работы нет» от «метрики нет вовсе» автоматика по умолчанию не умеет, но даже если научить — поднимать реплики всё равно нечем: сигнала нет, пока не поднимется под, а под не поднимется, пока нет сигнала. Замкнутый круг, из которого выход только руками, через kubectl scale в обход автоскейлера.

Отсюда правило с узкой областью применения: для пробуждения из нуля сигнал должен приходить оттуда, что скейлингом не управляется. Для показателя общей внешней очереди это получается естественно — её длину знает брокер, а не под. Для обычного масштабирования по нагрузке самих подов это не требование. Брокер сообщений, база, шлюз, отдельный экспортёр, внешний мониторинг — что угодно, что живёт независимо от числа реплик потребителя. В типичном проде это получается само собой: лаг консьюмер-группы отдаёт Kafka, длину очереди — RabbitMQ, RPS — ingress-контроллер или API-шлюз. Проблема появляется ровно там, где метрику решили считать «внутри самого сервиса, так проще».

VPA и конфликт с HPA

Vertical Pod Autoscaler решает ортогональную задачу: не «сколько подов», а «сколько ресурсов каждому поду». Состоит он из трёх частей:

  • recommender — читает историю потребления через Metrics API и считает рекомендацию по CPU и памяти;
  • updater — выселяет поды, у которых текущие requests сильно расходятся с рекомендацией, чтобы они пересоздались с новыми;
  • admission controller — на создании пода подменяет requests рекомендованными.

Режимов три. Off — считать и показывать, ничего не менять; поды не трогаются вообще. Initial — применять рекомендацию только при создании пода, работающие не трогать. Auto (и Recreate) — выселять и пересоздавать по мере расхождения. Практическая рекомендация простая: на проде почти всегда нужен Off. Auto означает, что кто-то перезапустит ваши поды в момент, который вы не выбирали, и для stateful-нагрузок это отдельный источник инцидентов — см. «StatefulSets и хранилище: stateful-нагрузки в Kubernetes».

Конфликт с HPA. Оба автоскейлера двигают ответ на один и тот же вопрос — «мало ресурсов». HPA при высокой утилизации CPU добавляет реплики; VPA при той же высокой утилизации поднимает requests. Поднятые requests немедленно меняют знаменатель в формуле HPA: та же нагрузка внезапно даёт меньшую утилизацию, HPA снимает реплики, оставшимся становится тяжелее, VPA поднимает requests ещё раз. Система с двумя регуляторами на одной переменной без согласования не сходится — она колеблется. Поэтому правило жёсткое: VPA в режиме Auto и HPA не должны работать по одной и той же метрике.

Рабочих комбинаций две:

  1. HPA по трафику + VPA в Off. Горизонтально масштабируемся по бизнес-сигналу (RPS, очередь, лаг), а рекомендации VPA используем как источник данных для ручного подбора requests при ревью манифестов. Автоматика не спорит сама с собой, потому что автоматика тут одна.
  2. HPA по кастомной метрике + VPA по ресурсам. Формально допустимо, если HPA не смотрит на CPU и память вовсе, а VPA двигает только их. Но связь всё равно остаётся косвенной, и включать это стоит с осознанным мониторингом обоих контуров.

Отдельно стоит понимать, как читать рекомендации. Наивное «взять target и вписать в манифест» ошибается сразу по нескольким причинам: рекомендация считается по наблюдаемому потреблению, а наблюдаемое потребление сверху ограничено уже выставленным лимитом; у рекомендации по памяти есть нижняя граница, ниже которой инструмент не опускается независимо от реального аппетита сервиса; на коротком окне наблюдения верхняя граница разъезжается до величин, лишённых смысла. Всё это разобрано на живых числах в статье про «Ресурсы в Kubernetes: requests, limits и QoS» — там же видно, как выглядит рекомендация, упёршаяся в собственный пол инструмента, и почему её нельзя принимать за измерение.

KEDA: события и ноль реплик

KEDA — оператор (в терминах «Операторы и CRD: расширяем Kubernetes под себя»готовится, с 27 октября), который добавляет к автоскейлингу две вещи: длинный список готовых источников событий и то, чего ядро Kubernetes не умеет в принципе, — ноль реплик.

KEDA не заменяет HPA — она его создаёт. После применения ScaledObject в namespace появляется обычный HorizontalPodAutoscaler с именем keda-hpa-<имя>, управляемый оператором. Всё, что делает штатный HPA — диапазон min..max, шаги масштабирования, TARGETS, — делает тот же встроенный контроллер Kubernetes. Разница только в источнике метрики: её отдаёт собственный apiserver KEDA через external.metrics.k8s.io, и в поведении около нуля.

Ноль устроен отдельно. autoscaling/v2 в дефолтной сборке ниже одной реплики не опускается: minReplicas: 0 проходит валидацию только при включённом alpha-feature-gate HPAScaleToZero и только если HPA считает по custom- или external-метрике. Полагаться на это в проде рано, поэтому при minReplicaCount: 0 KEDA временно снимает HPA с управления и своим контроллером ставит spec.replicas: 0, а когда триггер снова становится активным — поднимает деплоймент и возвращает управление обычному HPA-пути. Отсюда два разных статуса в kubectl get scaledobject: READY — сам объект корректен и обслуживается, ACTIVE — триггер прямо сейчас говорит «работа есть».

Манифест стенда (триггер — PromQL к Prometheus, в проде на этом месте так же легко оказывается Kafka, RabbitMQ, NATS или cron):

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata: { name: consumer, namespace: cookbook-k8s }
spec:
  scaleTargetRef: { name: consumer }
  minReplicaCount: 0
  maxReplicaCount: 8
  cooldownPeriod: 60
  pollingInterval: 15
  triggers:
    - type: prometheus
      name: demo-queue-depth
      metadata:
        serverAddress: http://prometheus.cookbook-k8s.svc:9090
        threshold: "5"
        query: max(demo_queue_depth{namespace="cookbook-k8s"})

Два параметра тут задают весь темп: pollingInterval — как часто опрашивать источник, cooldownPeriod — сколько ждать после деактивации триггера, прежде чем уходить в ноль. threshold играет роль таргета: ceil(значение / 5) реплик, тот же расчёт, что и у HPA выше.

Уход в ноль на старте. Метрика была опущена в ноль ещё до применения манифеста. Первый же снимок, снятый через 43 секунды после apply, показывает состояние уже установившимся: READY: True, ACTIVE: False, HPA REPLICAS: 0, деплоймент 0/0 — до этого у него была одна реплика. Ждать cooldownPeriod было незачем: триггер оказался неактивен ещё до первого цикла согласования.

Подъём из нуля и обратно — снимки примерно каждые 15 секунд, ниже выборка, однообразные точки середины опущены и помечены многоточием:

$ curl -s "http://localhost:18080/push?n=30"
# далее каждые ~15 с: kubectl -n cookbook-k8s get scaledobject,hpa,deploy
# (сведено к ACTIVE, репликам HPA и готовности consumer)
# метрика поднята до 30 в 06:20:26Z
06:20:45Z   ACTIVE True    hpa REPLICAS 1   consumer 4/4
06:21:05Z   ACTIVE True    hpa REPLICAS 6   consumer 6/6
...
06:22:23Z   ACTIVE True    hpa REPLICAS 6   consumer 6/6
# метрика опущена в 0 в 06:22:59Z
06:23:08Z   ACTIVE False   hpa REPLICAS 6   consumer 6/6
...
06:23:55Z   ACTIVE False   hpa REPLICAS 0   consumer 0/0

Через 19 секунд после подъёма метрики первые поды уже готовы, через 39 — деплоймент сошёлся к целевым шести (ceil(30/5) = 6) и держал их дальше без откатов, все следующие 78 секунд снимков. В первой строке зафиксировано рассогласование: HPA показывает одну реплику, а деплоймент — уже четыре готовых пода. Выглядит это как передача управления — поднимает деплоймент из нуля контроллер KEDA, а созданный ею HPA подхватывает счёт следующим циклом, — но в снимке видно ровно два расходящихся числа, событий в этом ряду не сохранено.

Обратный путь: триггер деактивировался через 9 секунд после обнуления метрики — ровно один цикл опроса. Реальный уход в 0/0 зафиксирован ещё через 47 секунд, то есть через 56 секунд от снятия нагрузки. Это согласуется с cooldownPeriod: 60 с поправкой на шаг снимков в 15 секунд и на то, что отсчёт стартовал чуть раньше первого пойманного среза с ACTIVE: False. Отдельного журнала событий у этого ряда нет — все выводы сделаны по состояниям ACTIVE, REPLICAS и READY в снимках, и на большую точность, чем шаг снятия, они не претендуют.

Сравните с обычным HPA из предыдущего раздела: там снятие нагрузки стоило пяти минут удержания восьми реплик на дефолтном окне. Здесь — минута до полного нуля. Это не потому, что KEDA «быстрее»: это разные настройки удержания и разные нижние границы, и оба поведения полностью управляемы.

Практическая деталь, на которую стоит потратить абзац. В черновике манифеста у prometheus-триггера стоял ключ metadata.metricName: demo_queue_depth — по аналогии с тем, как это выглядит у некоторых других скейлеров. В KEDA 2.20.1 это поле у prometheus-скейлера не задокументировано и не используется: движок молча его игнорирует. Имя внешней метрики KEDA генерирует сама по шаблону s<индекс триггера>-<тип> — здесь s0-prometheus, и переопределить его не удаётся ни через metadata.metricName, ни через triggers[].name (последний — идентификатор триггера для формул scaling modifiers, а не имя метрики). Увидеть настоящее имя можно в kubectl describe scaledobject в поле External Metric Names. Мораль шире конкретного поля: конфигурация, которую движок игнорирует, ведёт себя ровно как работающая — до тех пор, пока вы не начнёте на неё опираться. Единственный способ отличить — проверить фактическое поведение, а не факт того, что манифест применился без ошибки.

Цена нуля

Scale-to-zero выглядит бесплатной экономией, и на стенде так и получилось: подъём из нуля до шести готовых подов занял 39 секунд, а первые поды были готовы через 19. Только цифры эти получены в максимально льготных условиях — контейнер busybox с sleep infinity, образ уже в кеше узла, никакой инициализации. Фактически измерена нижняя граница: цикл опроса KEDA плюс шаги HPA-алгоритма плюс запуск пустого контейнера.

В реальном сервисе к этому добавляется всё остальное:

  • вытягивание образа, если его нет в кеше узла, — от секунд до минут в зависимости от размера и реестра;
  • старт рантайма: JIT-прогрев JVM, инициализация DI-контейнера, загрузка конфигурации;
  • установка соединений: пул к базе, подключение к брокеру, TLS-хендшейки к соседям;
  • прогрев кешей — самое коварное, потому что под уже Ready, а работает всё ещё медленно;
  • readinessProbe с initialDelaySeconds — время, добавленное вами же и часто с большим запасом «чтобы не флаппило».

Порядок величины сдвигается с десятков секунд до единиц минут, и всё это время либо клиент ждёт, либо запросы отваливаются. К слову, у самого metricgen на стенде пробы стоят с задержкой в 45 и 60 секунд, потому что под компилирует сервис при старте, — под с такой характеристикой в scale-to-zero отправлять уже нельзя.

Когда ноль оправдан:

  • редкие события: обработчик, который просыпается несколько раз в сутки;
  • пакетная обработка: ночные джобы, выгрузки, пересчёты — тут вообще уместнее ScaledJob, чем ScaledObject;
  • среды разработки и тестирования: превью-стенды, которые держат ресурсы круглосуточно, а нужны эпизодически;
  • дорогая нагрузка: поды с GPU или большими требованиями по памяти, где простой стоит заметных денег.

Когда вреден:

  • синхронные API с требованиями к задержке: холодный старт становится хвостом latency, и никакой процентиль его не спрячет;
  • нагрузка с непредсказуемыми короткими всплесками: сервис не успевает подняться раньше, чем всплеск закончится, и вы платите холодным стартом за каждый;
  • сервисы с дорогой инициализацией, где время подъёма сопоставимо с полезной работой.

Промежуточный вариант, который часто оказывается лучше обеих крайностей: minReplicaCount: 1 вместо нуля. Одна тёплая реплика стоит немного, а всю проблему холодного старта снимает — масштабирование дальше идёт уже с прогретой базы. Ноль имеет смысл там, где эта одна реплика реально дорога или реально не нужна.

Два API-слота метрик

Практический вопрос, который всплывает при первой же попытке поставить рядом Prometheus Adapter и KEDA: не подерутся ли они за API метрик.

Не подерутся — потому что регистрируют разные группы. Prometheus Adapter занимает v1beta1.custom.metrics.k8s.io, KEDA — v1beta1.external.metrics.k8s.io, metrics-server держит v1beta1.metrics.k8s.io. Это три независимых APIService, и все три спокойно живут в одном кластере, как и живут на стенде.

Сигнал — группа API — одно решение о числе репликmetrics-serverCPU и память подовmetrics.k8s.iotype: ResourcePrometheus + Adapterбизнес-метрика сервисаcustom.metrics.k8s.iotype: Object / PodsKEDA (оператор)очередь, лаг, cron, PromQLexternal.metrics.k8s.iotype: ExternalHPA-контроллерceil(реплики × метрика / таргет)цикл 15 с, окно стабилизацииDeployment: от 1 до max репликниже одной ядро HPA не опускаетсяКонтроллер KEDA ставит 0 репликв обход нижней границы HPAтри разные группы API — конфликта между ними нетно слот external в кластере один

А вот слот external.metrics.k8s.io в кластере один, и это уже настоящее ограничение. Зарегистрировать APIService с этим именем может только один компонент. Если KEDA уже заняла его, а вы включаете external-правила в Prometheus Adapter (или ставите второй провайдер внешних метрик), — они начнут конфликтовать за одну и ту же регистрацию, и проигравший останется без метрик. Симптом коварный: HPA не падает, он показывает <unknown> и не масштабирует ничего, а причина лежит в объекте уровня кластера, куда при разборе инцидента смотрят в последнюю очередь.

На стенде это разведено явно: Prometheus Adapter установлен с пустым списком external-правил (rules.external: []), поэтому группу external.metrics.k8s.io он не регистрирует вовсе и оставляет её KEDA. Проверить занятость слота — одна команда:

kubectl get apiservices | grep -i metrics

До установки KEDA в выводе видны только две строки — v1beta1.custom.metrics.k8s.io за custom-metrics/prometheus-adapter и v1beta1.metrics.k8s.io за kube-system/metrics-server; группы v1beta1.external.metrics.k8s.io нет вовсе, слот свободен. После установки KEDA появляется третья строка, за keda/keda-operator-metrics-apiserver, и все три — со статусом Available: True. Если бы слот был занят заранее, это выяснилось бы ровно здесь, а не при первом непонятном <unknown> в HPA.

Полезная привычка: заводить эту проверку в чек-лист перед установкой любого компонента, который обещает «метрики для автоскейлинга». Если внешние метрики нужны из двух источников одновременно — сводить их в один провайдер (например, отдавать всё через KEDA, у которой скейлеров заведомо больше), а не пытаться поделить регистрацию.

Как выбрать

Карта получается короткая, если отталкиваться от сигнала, а не от инструмента.

Утилизация ресурса — HPA по type: Resource. Годится там, где нагрузка действительно упирается в CPU и работа однородна: рендеринг, вычисления, обработка изображений. Отправная точка, а не конечная: CPU — прокси, а не сама нагрузка. По памяти масштабируйтесь осознанно: memory-HPA официально поддерживается (autoscaling/v2, метрика memory) и оправдан, когда память коррелирует с нагрузкой и убывает при добавлении реплик — больше реплик, меньше запросов на каждую, меньше памяти. Но для heap/cache-нагрузок, где память не убывает от масштабирования (кэш наполняется независимо от числа реплик), это ловушка: HPA по памяти может зациклиться на масштабировании — причины разобраны выше.

Бизнес-сигнал внутри кластера — HPA по custom.metrics.k8s.io. RPS на сервисе, глубина очереди, p99 обработки. Требует Prometheus Adapter и аккуратного сопоставления лейблов с ресурсами, зато скейлит по тому, что действительно определяет качество сервиса. Если нужен уход в ноль, источник метрики не должен масштабироваться вместе с потребителем; для масштабирования по нагрузке самих подов это не ограничение.

Внешнее событие — KEDA. Лаг консьюмер-группы, длина очереди в брокере, расписание, любой из готовых скейлеров. То же самое можно собрать и на Adapter’е с внешними правилами, но KEDA даёт список источников из коробки и умеет ноль.

Подбор requests — VPA в режиме Off. Не автоскейлер в привычном смысле, а инструмент наблюдения: даёт направление и порядок величины для чисел, которые всё равно ставит человек.

Типовые ошибки, каждая из которых стоила кому-то инцидента:

  • Война двух автоскейлеров. HPA по CPU плюс VPA в Auto на одном деплойменте: один добавляет реплики, второй меняет знаменатель, система не сходится. Два регулятора на одну переменную — всегда колебания.
  • Уход в ноль вместе с источником метрики. Когда последний под, отдающий метрику, исчезает, исчезает и сигнал на подъём — замкнутый круг, из которого выход только руками.
  • Дефолтный behavior. Пять минут удержания вниз и агрессивный подъём вверх — разумный компромисс по умолчанию, но именно по умолчанию, а не по вашей задаче. Если реакция на снижение кажется «сломанной», сначала посмотрите, задан ли behavior вообще.
  • Отсутствие метрики трактуется как ноль. <unknown> в TARGETS — это не «нагрузки нет», это «мы не знаем». Развалившийся Adapter, сломанный скрейп или занятый чужим компонентом API-слот дают ровно такую картину, и автоскейлер в ней тихо ничего не делает. Метрики самого автоскейлинга нужно мониторить наравне с прикладными.
  • Потолок, который не достигается. maxReplicas упирается в квоту namespace или в ёмкость узлов: HPA честно хочет больше реплик, ReplicaSet честно получает FailedCreate, а снаружи это выглядит как «автоскейлинг не работает». Проверять надо оба потолка сразу.
  • Скейлинг того, что от реплик не ускоряется. Если узкое место — база, добавление подов только увеличит на неё давление. Автоскейлер умеет размножать потребителей, а не расширять то, что за ними.

Практическая последовательность внедрения, которая экономит много времени: сначала корректные requests, потом HPA по осмысленному сигналу с явно заданным behavior, потом — при необходимости — KEDA и уже в самом конце эксперименты с нулём. Каждый следующий шаг имеет смысл только если предыдущий отработал.

Дальше по серии: вход в формулу HPA — те самые requests, и разбираются они в статье «Ресурсы в Kubernetes: requests, limits и QoS»; вопрос «куда именно поедут новые реплики» — в «Планирование подов вглубь: taints, affinity, topology и приоритеты»; механика контроллеров, частным случаем которой является и HPA, и оператор KEDA, — в «Архитектура Kubernetes: control plane и reconciliation loop» и «Операторы и CRD: расширяем Kubernetes под себя»готовится, с 27 октября. Если автоскейлинг нужен под входящий трафик — смотреть стоит в сторону сигналов со шлюза, о котором отдельная статья. А если вопрос стоит шире и звучит как «нужен ли вообще кластер под мою задачу» — на него отвечает «Kubernetes для домашней лаборатории».

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии