Автоскейлинг в 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 принимает решение
- Кастомные и внешние метрики
- Когда источник метрики нельзя скейлить вместе с потребителем
- VPA и конфликт с HPA
- KEDA: события и ноль реплик
- Цена нуля
- Два API-слота метрик
- Как выбрать
Как 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 не должны работать по одной и той же метрике.
Рабочих комбинаций две:
- HPA по трафику + VPA в
Off. Горизонтально масштабируемся по бизнес-сигналу (RPS, очередь, лаг), а рекомендации VPA используем как источник данных для ручного подбора requests при ревью манифестов. Автоматика не спорит сама с собой, потому что автоматика тут одна. - 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, и все три спокойно живут в одном кластере, как и живут на стенде.
А вот слот 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 для домашней лаборатории».
Источники
- Kubernetes Documentation — Horizontal Pod Autoscaling, HorizontalPodAutoscaler Walkthrough, Resource Metrics Pipeline
- Prometheus Adapter · Vertical Pod Autoscaler · Prometheus exposition formats
- KEDA — Scaling Deployments, Prometheus scaler
- Стенд:
kubernetes/autoscaling(Kubernetes 1.36.2, Talos Linux, metrics-server,prom/prometheus:v3.13.1, Prometheus Adapter 5.3.0 / v0.12.0, KEDA 2.20.1) - Стенд с рекомендациями VPA:
kubernetes/resources(VPA 1.7.0, режимupdateMode: Off)
Комментарии