Model Predictive Control для Kubernetes autoscaling: что получилось, где HPA оказался сильнее

от автора

Горизонтальное автоскалирование в Kubernetes обычно начинается с HPA. Это понятный и практичный механизм: контроллер смотрит на метрику, например CPU, и меняет число реплик Deployment. Для многих сервисов этого достаточно.

Проблема начинается там, где нагрузка меняется быстрее, чем контур успевает на неё отреагировать. Метрика должна быть собрана, решение должно быть принято, новые Pod’ы должны запуститься и пройти readiness. Пока всё это происходит, старые Pod’ы уже могут работать на пределе, а хвостовые задержки p95/p99 — расти.

MPC vs HPA в Kubernetes

MPC vs HPA в Kubernetes

Отсюда естественная идея: если реактивный контур запаздывает, можно добавить короткий прогноз спроса и выбирать число реплик заранее. Я проверял именно такой вариант: внешний Hybrid-контроллер, в котором прогноз и защитные эвристики объединены с QP-задачей в стиле Model Predictive Control.

Для проверки я собрал отдельный стенд: toy-load, Prometheus, базовый HPA-контур, внешний Python-контроллер, CVXPY/OSQP и набор повторяемых A/B-прогонов. Предиктивный контур действительно снизил ресурсный резерв в части сценариев, но не стал безусловно лучше HPA: на коротком 30-секундном Spike Hybrid-SA проиграл HPA60 по p95/p99.

Дальше — как устроен стенд, что именно оптимизирует контроллер, и почему один из сценариев упёрся не в QP-формулировку, а в задержки самого Kubernetes-контура: метрики приходят не сразу, Pod’ы переходят в состояние Ready не сразу, а короткий пик может закончиться раньше, чем кластер добавит ёмкость.

Почему HPA не всегда достаточно

Для начала разложим задержку реакции HPA на части. На коротком всплеске она раскладывается на несколько шагов:

  • метрика должна попасть в Prometheus / конвейер метрик;

  • контроллер должен принять решение;

  • Kubernetes должен создать новые Pod’ы;

  • Pod’ы должны пройти readiness;

  • сервис должен реально получить дополнительную ёмкость.

Физический лаг автоскалирования в Kubernetes

Физический лаг автоскалирования в Kubernetes

Можно сделать HPA осторожным: поставить низкую целевую CPU-утилизацию и держать запас. Пики он переживёт лучше, но часть реплик будет простаивать. Можно поднять целевую CPU-утилизацию: средний масштаб снизится, но риск хвостовых задержек и таймаутов вырастет.

Отсюда и идея Hybrid-контроллера: смотреть не только на текущую метрику, а на короткий горизонт. Внутри нет ML-модели: есть приближённый сигнал спроса, экспоненциальное сглаживание, QP-задача, защитный слой и обычный PATCH в Kubernetes API.

Что было построено

Стенд одноузловой. Это не промышленный контур, а контролируемая среда для повторяемых сценариев: каждый прогон сохраняет артефакты, таблицы и журналы контроллера.

Компонент

Роль

toy-load

Go HTTP-сервис с управляемой CPU-нагрузкой, jitter, payload и error-rate.

vegeta

Генератор HTTP-нагрузки с открытым контуром: задаёт фазовую интенсивность и не замедляется из-за долгих ответов.

Prometheus

Сбор RPS, гистограммы задержек, числа активных запросов и служебных метрик.

CPU-HPA

Базовый Kubernetes HPA-контур по CPU.

Hybrid controller

Внешний Python-контур: читает Prometheus, строит прогноз, решает QP, применяет защитный слой.

CVXPY/OSQP

QP-ядро оптимизации.

Grafana / артефакты

Панели, phase-summary.csv, replica-watch.csv, mpc-control-log.csv, сводные таблицы.

Схема стенда mpc-autoscaler

Схема стенда mpc-autoscaler

Ключевая деталь конфигурации: Prometheus в финальной серии собирал метрики раз в 30 секунд. Hybrid при этом работал с шагом 15 секунд для Step/Seasonality и 5 секунд для Spike. То есть на коротком Spike контроллер иногда принимал несколько решений по одному и тому же срезу телеметрии.

Что оптимизирует Hybrid-контроллер

QP нужна, чтобы формализовать конфликт между несколькими требованиями: быстро добавлять Pod при риске перегрузки, не дёргать масштаб слишком часто и не держать лишние реплики.

Я вынес этот конфликт в QP-задачу. На входе — текущее число реплик r_t, прогноз спроса и виртуальное состояние накопленной перегрузки b.

Если сильно упростить, контроллер пытается минимизировать: риск перегрузки + резкость изменения масштаба + лишнюю ёмкость.

Получился такой функционал:

J = \sum_{k=1}^{N} \left( \alpha b_k^2 + \beta (x_k - x_{k-1})^2 + \gamma x_k \right)

Что балансирует QP-задача

Что балансирует QP-задача

Смысл слагаемых:

  • \alpha * b_k^2 — штраф за виртуальную перегрузку. Если прогнозируемый RPS выше целевой ёмкости одной реплики, b_k накапливает дефицит. Квадрат делает долгую перегрузку дорогой и заставляет контроллер реагировать агрессивнее. Это не p95-модель, а приближённая оценка риска хвостовых задержек;

  • \beta * (x_k - x_{k-1})^2 — штраф за резкие изменения масштаба. Каждое изменение числа реплик проходит через Kubernetes API, обновление служебных объектов и сетевую подсистему. Эти накладные расходы напрямую не измерялись, поэтому V используется как показатель плавности: траектория «добавить 5 реплик, через 5 секунд убрать 4» считается плохой;

  • \gamma * x_k — линейный штраф за ёмкость. Он прижимает решение вниз, когда нагрузка ушла;

  • x_k — планируемое число реплик на горизонте.

Почему не оптимизировать p95/p99 напрямую? Потому что хвостовые задержки зависят от сервиса, CPU limits, очередей, сети, readiness, поведения планировщика и текущего состояния кластера. Поэтому QP использует приближённый риск перегрузки, а реальные p95/p99 проверяются только на полном Kubernetes-контуре.

Важно не переинтерпретировать этот параметр: b_k — не настоящая очередь и не оценка p95. Это инженерная память о дефиците ёмкости.

Как сравнивались контроллеры

Я сравнивал не «функцию в вакууме», а полный контур: метрики, решение, Kubernetes API, готовность Pod, результаты нагрузки.

Основные сценарии:

Сценарий

Профиль

Что проверяет

Step

20 RPS -> 80 RPS -> 40 RPS

Устойчивое изменение нагрузки.

Spike

20 RPS -> 200 RPS на 30 секунд -> 20 RPS

Короткий резкий всплеск.

Seasonality

синусоидальный профиль 20–120 RPS

Плавная нестационарная нагрузка.

Финальный прогон:

  • CPU-HPA60: averageUtilization=60%, общий потолок 70 реплик;

  • Hybrid-SA: заранее выбранный сценарный профиль, тот же потолок 70 реплик;

  • по 8 повторов каждого сценария;

  • задержки смотрел по медианам и IQR;

  • если успешность ниже 95%, режим считаю непригодным, даже если он экономит реплики.

Смотрел на следующие показатели:

  • доля успешных запросов;

  • p95/p99/max задержки;

  • пропускная способность по фазам сценария;

  • среднее число реплик;

  • полная вариация масштаба V;

  • приближённая оценка стоимости по запрошенным CPU и памяти.

Отдельно прогнал диагностику: сетку целевой CPU-утилизации для CPU-HPA, Vanilla-HPA80, вариант без QP, proxy-HPA+safety и Hybrid-Common с единым профилем.

Основной результат: компромисс, а не победа

Ниже — CPU-HPA60 против Hybrid-SA при maxReplicas=70. В Step и Seasonality Hybrid-SA даёт хороший ресурсный результат. В Spike задержки полного Kubernetes-контура оказались критичнее, чем выигрыш от прогноза.

Сначала задержки:

Сценарий

Контур

p95, мс

p99, мс

Успешность

Step

HPA60

54.6

75.9

100%

Step

Hybrid-SA

53.7

56.2

100%

Spike

HPA60

87.1

131.8

100%

Spike

Hybrid-SA

172.2

251.4

100%

Seasonality

HPA60

119.2

160.5

100%

Seasonality

Hybrid-SA

88.0

127.4

100%

Теперь реплики и приближённая стоимость:

Сценарий

Контур

Средние реплики

V

AWS-оценка, $/ч

Step

HPA60

17.807

22

0.0770

Step

Hybrid-SA

6.650

22

0.0288

Spike

HPA60

16.246

22

0.0703

Spike

Hybrid-SA

16.619

67

0.0719

Seasonality

HPA60

29.662

66

0.1283

Seasonality

Hybrid-SA

5.287

8

0.0229

Короткая версия:

  • в Step Hybrid-SA почти не меняет p95, улучшает p99 и сильно снижает среднее число реплик;

  • в Seasonality Hybrid-SA улучшает хвостовые задержки и резко снижает средний масштаб;

  • в Spike Hybrid-SA проигрывает по p95/p99 и по вариации масштаба.

В агрегированной таблице сценарии свёрнуты по длительности и повторам. Это не среднее по строкам выше. В такой свёртке Hybrid-SA относительно CPU-HPA60 даёт:

  • среднее число реплик ниже на 68%;

  • приближённая ресурсная стоимость ниже на 67%;

  • V ниже с 110 до 97;

  • успешность остаётся 100%;

  • худший p95/p99 хуже из-за Spike: 172.2/251.4 мс.

Компромисс между стоимостью и p95

Компромисс между стоимостью и p95

Поэтому результат не сводится к тезису «новый контроллер лучше HPA». Hybrid-SA держит более плотный ресурсный режим, но на коротком Spike проигрывает по задержкам.

Почему короткий Spike оказался проблемным

Spike длился 30 секунд: 20 RPS, затем 200 RPS на 30 секунд, затем снова 20 RPS. В этом сценарии Hybrid-SA показал p95 172.2 мс против 87.1 мс у HPA60.

Первая гипотеза — влияние устаревшей телеметрии: Prometheus собирает метрики раз в 30 секунд, а шаг управления в пике равен 5 секундам. Эта гипотеза подтверждается частично. В этой конфигурации у Hybrid были неблагоприятные условия наблюдаемости:

  • шаг управления в Spike — 5 секунд;

  • окно rate() — 15 секунд;

  • Prometheus scrape interval — 30 секунд;

  • стрессовая фаза — тоже 30 секунд.

Несколько решений контроллера могли опираться на один и тот же срез данных. Я не проверял режим scrape interval = 2s: это уже другой эксперимент с отдельной оценкой нагрузки на TSDB. В этой серии контур проверялся при неидеальной наблюдаемости, а не при мгновенном поступлении метрик.

Затем я проверил журналы готовности Pod из локального архива экспериментов. Там хорошо видна задержка применения: лаг между командой «нужно больше реплик» и моментом, когда новые Pod реально готовы принимать трафик.

В Spike медианное окно между командой скейлиться и готовыми Pod было около 40 секунд. Для 30-секундного пика это поздно. В одном повторе лаг перекрывал 20 секунд стрессовой фазы, а p95/p99/max в этой фазе были 172.5/251.9/398.7 мс против примерно 22 мс в соседних фазах.

Запрошенные и готовые реплики во время Spike

Запрошенные и готовые реплики во время Spike

Я не утверждаю, что это единственная причина регрессии. Задержку readiness нельзя полностью отделить от устаревшей телеметрии, решений контроллера и возможного CPU тротлинга. Но для этого стенда видно ограничение: Hybrid-SA не успел помочь на импульсной нагрузке, где инфраструктура вводила новую ёмкость дольше, чем длился сам импульс.

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

Почему повышение целевой CPU-утилизации не решает проблему

Можно возразить: HPA60 слишком осторожный. Поднимем averageUtilization, и HPA сам станет дешевле.

Я проверил это отдельной CPU-HPA-сеткой с историческим потолком maxReplicas=12. Подставлять её вместо финальной A/B-серии с потолком 70 реплик нельзя, но как диагностика она показательная: рост целевой CPU-утилизации снижает средний масштаб, а в Spike начиная с HPA150 появляются массовые таймауты. В точке HPA350 медианная успешность падает до 34.42%.

То есть «меньше реплик» само по себе не победа. Политику автоскалирования нужно читать минимум по четырём осям:

  • успешность;

  • хвостовые задержки;

  • средний масштаб / приближённая стоимость;

  • динамика изменения реплик.

Одна метрика здесь недостаточна.

Компонентный анализ: QP — не единственный источник эффекта

Сравнение только с дефолтным HPA легко даёт завышенную оценку эффекта. Поэтому я добавил контрольные варианты: proxy-HPA+safety и контур без QP используют тот же приближённый сигнал спроса, а HPA-строки остаются базовыми CPU-контурами.

Контур

Средние реплики

V

AWS-оценка, $/ч

Худший p95/p99, мс

Успешность

HPA60

23.276

110

0.1007

119.2/160.5

100%

Hybrid-SA

7.555

97

0.0327

172.2/251.4

100%

proxy-HPA+safety

6.737

102

0.0291

98.4/145.8

100%

без QP

5.477

108

0.0237

143.4/194.1

100%

Таблицу не стоит читать как рейтинг. Дешёвый контур может проигрывать по хвостам или плавности, а контур с хорошими задержками может держать больше реплик.

Компонентный анализ оказался важнее основного сравнения: proxy-HPA+safety оказался дешевле Hybrid-SA и быстрее по худшим p95/p99. Он уступил только по плавности V. Значит, весь эффект нельзя приписать QP/MPC-ядру. Значимую часть дают приближённый сигнал спроса, защитный слой, параметры стабилизации, частота телеметрии и сценарный профиль.

Роль QP-ядра видна по V, полной вариации масштаба. У реактивного proxy-HPA+safety она равна 102, у контура без QP — 108, у Hybrid-SA — 97. Разница не огромная, но роль читается: QP работает как демпфер. Он видит горизонт планирования и сглаживает траекторию; ценой стали худшие p95/p99 на Spike.

Короткие высокочастотные возмущения лучше обрабатывает реактивный слой. QP полезнее там, где важна траектория на горизонте планирования, а не одиночный импульс, который заканчивается раньше, чем Kubernetes успевает поднять готовую ёмкость.

Что можно воспроизвести без кластера

Репозиторий — не готовый автоскейлер, а стенд для проверки идей.

Минимальный путь без Kubernetes:

Инструкция по запуску симулятора локально
python3 -m pip install -e analysismpc-validate-trace --trace-csv analysis/mpc_autoscaler_analysis/data/traces/baseline_spike_profile_dt15.csvmpc-offline-sim \  --trace-csv analysis/mpc_autoscaler_analysis/data/traces/baseline_spike_profile_dt15.csv \  --out-dir analysis/out/offline/spike

Этот путь не воспроизводит живой прогон в Kubernetes, но позволяет проверить формат трасс, офлайн-симулятор и базовые предположения контроллера.

Полезные ссылки:

Чего этот результат не доказывает

Это не доказательство, что MPC лучше HPA вообще. Стенд одноузловой и синтетический. Выводы зависят от интервала сбора Prometheus, задержки readiness, CPU-лимитов, поведения планировщика и параметров контроллеров.

Что дальше

Что я хочу проверить дальше:

  • базовый контур в стиле KEDA;

  • предиктивный HPA;

  • трассы, похожие на промышленную нагрузку;

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

  • многоузловой стенд и взаимодействие с Cluster Autoscaler;

  • автоматический выбор профиля нагрузки;

  • адаптация параметров QP во время прогона;

  • агрегированные таблицы по нескольким seed и прогонам.

Отдельно интересен режим нехватки узлов. Hybrid может заранее запросить больше Pod, но Cluster Autoscaler ещё не успеет добавить ёмкость узлов. В таком режиме возможны очередь Pending Pod и перерегулирование. Это ближе к промышленным проблемам, чем одноузловой стенд.

Вывод

Идеального автоскейлера нет. Есть Парето-граница между задержками, ресурсным резервом и резкими изменениями числа реплик.

Консервативный HPA60 даёт хорошие задержки, но держит много лишних Pod’ов. Реактивный контур лучше переживает короткий Spike, но сильнее дёргает масштаб. Hybrid-SA с QP-ядром снижает ресурсный резерв и сглаживает траекторию, но проигрывает там, где новая ёмкость становится Ready позже самого пика.

Мой итог: автоскалирование надо оценивать как полный контур управления, а не как оптимизационную функцию на бумаге.

ссылка на оригинал статьи https://habr.com/ru/articles/1043074/