Вводные
Публичный API уже обслуживал обычный поток запросов. Впереди было окно с дополнительным внешним трафиком для партнёрского запуска, промо или подключения нового канала. В таких событиях порядок нагрузки примерно понятен, но точная форма волны заранее не гарантирована.
Ожидание было простым. Обычный дневной трафик за несколько минут вырастает примерно до 150 HTTP RPS, то есть до 150 HTTP-запросов в секунду. Это не бесконечная нагрузка и не хаос, но рост достаточно быстрый, чтобы обычная логика “посмотрим на минутную метрику и потом добавим инстансы” могла опоздать.
Нас интересовал не рекордный максимум RPS. Вопрос был практический. Успеет ли инфраструктура добавить инстансы до того, как пользователи начнут видеть рост latency (среднее время отклика). Если новые инстансы появляются только после медленных ответов, autoscaling формально работает, но для такого launch window всё равно опаздывает.
Платформу перед событием перестраивать не хотелось. Сервис уже жил в стандартной AWS-схеме. Публичный API работает в Elastic Beanstalk, перед ним стоит Application Load Balancer, количество инстансов регулирует Auto Scaling Group, CloudWatch alarms запускают политики scale-up и scale-down, а warm pool держит заранее подготовленные инстансы, чтобы scale-out не ждал полного cold boot.
Ожидаемая цепочка выглядела просто. Запросов становится больше. Метрика показывает, что на один бэкенд приходится слишком много запросов. CloudWatch видит несколько точек выше threshold и запускает scale-up policy. Auto Scaling Group добавляет инстансы из warm pool. Балансировщик распределяет трафик шире. p95 latency, то есть время отклика, в которое укладываются 95% запросов, остаётся в нормальной зоне.
В реальном тесте сломалась не вся схема, а один участок. Warm pool был быстрым. Инстансы включались в работу. Но решение “пора добавлять” приходило слишком поздно. Поэтому в финальной версии приложение стало само публиковать request-pressure signal раз в 10 секунд и показывать, сколько запросов сейчас приходится на один реально serving instance.
Важная часть истории не в том, что “мы написали свою метрику”. Важная часть в том, какие очевидные варианты пришлось отбраковать по данным.
Что такое autoscaling в этой истории
Autoscaling здесь означает простую вещь. Система меняет количество работающих инстансов под нагрузку. В обычном состоянии она держит минимум, например две машины. Когда трафик растёт, она добавляет инстансы. Когда нагрузка долго низкая, постепенно убирает лишние.
Звучит как термостат, но в production всё грубее.
Во-первых, надо выбрать правильную метрику. CPU хорошо работает, когда сервис реально упирается в процессор. Но API часто тормозит не из-за процессора. Запрос может ждать базу данных, downstream-сервис или свободное соединение в connection pool. В такой ситуации CPU выглядит спокойным, а пользователь уже ждёт.
Во-вторых, autoscaling не мгновенный. Даже с идеальным сигналом CloudWatch должен получить несколько точек метрики, сравнить их с threshold и запустить policy. После этого ASG должен принять решение, инстанс должен стать InService, а балансировщик должен начать давать ему трафик. Warm pool сокращает время включения инстанса в работу, но не делает нулевой задержку принятия решения.
В-третьих, scale-up и scale-down обычно должны быть асимметричными. Вверх лучше быстро, потому что нагрузка уже пришла или вот-вот придёт. Вниз лучше медленно, потому что короткая пауза в трафике ещё не означает, что событие закончилось.
Как всё было устроено
Изначальная архитектура была стандартной и разумной.
Elastic Beanstalk держит приложение и окружение. Application Load Balancer принимает HTTP-трафик, считает request count и latency, распределяет запросы по healthy targets, то есть по бэкендам, которые прошли health check. Auto Scaling Group меняет количество EC2-инстансов. CloudWatch alarms смотрят на метрики и запускают политики масштабирования.
Warm pool появился в этой схеме раньше, чем началась история с быстрым сигналом. Сначала нужно было убрать самый очевидный источник задержки, полный запуск нового инстанса с нуля. Для этого рядом с Auto Scaling Group держали заранее подготовленные инстансы. Они ещё не обслуживают пользователей и не стоят за балансировщиком как active targets, но при scale-up быстрее проходят путь до InService.
На практике это выглядело так. Обычный парк держит минимальное число активных инстансов, а warm pool лежит рядом как резерв. Когда ASG решает увеличить размер группы, он берёт инстанс из warm pool, доводит его до рабочего lifecycle state, health check подтверждает, что приложение отвечает, и только после этого балансировщик начинает отправлять туда запросы.
То есть warm pool решает физическое время включения инстанса в работу. Он не решает другой вопрос. Когда система поймёт, что пора добавить этот инстанс.
Главная гипотеза в начале была консервативной. Мы думали, что стандартных AWS primitives хватит, если правильно подобрать threshold, cooldown, health check и warm pool. Поэтому сначала укрепляли именно инфраструктурный путь. Закрепили минимальный активный парк, ограничили максимум, подготовили warm pool и упростили health check, чтобы балансировщик не снимал новый инстанс из-за глубоких проверок зависимостей.
Собственная application metric выглядела как запасной вариант. По итогам тестов всё перевернулось. Именно она оказалась самым простым способом получить нужный request-pressure signal без минутной задержки стандартной ALB-метрики.
Как мы это мерили
Мы не пытались угадать, “кажется ли сервису тяжело”. Каждый прогон stress-test tool давал три слоя данных.
Первый слой был нагрузочный. Инструмент задавал форму трафика. Плавный ramp, более резкий ramp, низкий steady run и peak hold. Для сценария запуска мы стартовали примерно с 30 RPS и поднимались к 150 RPS. Отдельно был контрольный низкий прогон, где трафик есть, но он ниже threshold, и autoscaling не должен добавлять инстансы.
Второй слой был пользовательский. Мы смотрели, сколько HTTP-запросов реально проходит через ALB, и какой p95 показывает ALB TargetResponseTime. Отдельно стоял stop guard по p95. Если ответы становились слишком медленными, тест останавливался, чтобы не давить сервис дальше. Важно, что остановка принималась по ALB p95 снаружи, а не по одному случайному setup-запросу из k6.
Третий слой был инфраструктурный. Отдельный монитор каждые 10 секунд снимал, сколько инстансов ASG хочет держать, сколько уже реально принимает трафик, что решил CloudWatch alarm, как меняется request-pressure metric, что происходит с ALB p95 и как двигаются инстансы из warm pool.
Позже в tool добавили три отдельных расчёта. Первая цифра показывает задержку сигнала. Вторая показывает время включения инстанса в работу. Третья показывает, сколько прошло от превышения threshold до появления новых инстансов в парке.
Эти задержки важно разделять.
нагрузка пересекает threshold -> метрика становится видна CloudWatch -> CloudWatch подтверждает превышение -> ASG начинает добавлять инстансы -> warm-pool инстанс становится InService -> балансировщик распределяет трафик по большему парку
Если смешать всё в одну цифру, легко начать чинить не ту часть. Именно это сначала и происходило.
Первая попытка CPU
Самый стандартный путь был CPU-based scaling. Он быстро оказался плохим основным сигналом для этого сервиса.
В нагрузочных прогонах хвост медленных ответов появлялся раньше, чем CPU пересекал разумный threshold. Это типичная история для API, где дорогие участки сидят не в чистом CPU, а в ожидании базы данных, downstream, connection pool или внешнего контура.
CPU при этом не бесполезен. Он нормальный safety signal на другой класс деградации. Но как основной scale-up триггер для этой нагрузки он опаздывал.
Главный урок из CPU-ветки простой и неприятный. Пользователю всё равно, что процессор ещё не устал, если p95 уже ушёл в секунды.
Вторая попытка ALB RequestCountPerTarget
Следующая идея выглядела лучше. Масштабироваться по ALB RequestCountPerTarget. Эта метрика показывает не общий поток запросов, а нагрузку на один target, то есть на один бэкенд за балансировщиком.
Разница важная. 120 RPS на двух инстансах и 120 RPS на десяти инстансах для внешнего наблюдателя могут выглядеть одинаково, но для одной машины это совсем разная работа.
На первом этапе пробовали target tracking с высоким threshold. Это режим, где ASG старается держать выбранную метрику около заданного значения. Если запросов на один target стало слишком много, нужно добавить инстансы. Если долго мало, можно убрать.
Механизм в целом работал. ASG действительно доставал инстансы из warm pool. Но в длинном ramp было видно, что новые инстансы приходят уже после того, как часть запросов стала медленной. В одном из ранних прогонов между устойчивым превышением threshold и появлением healthy targets проходили минуты.
Всплыл и побочный конфликт. Старая CPU scale-down policy могла тянуть парк вниз, потому что процессор выглядел спокойным, хотя новая request-count policy пыталась держать парк выше из-за роста запросов. Если меняешь autoscaling model, нельзя оставлять рядом вторую policy, которая думает по старым правилам.
После этого ALB-путь начали затягивать. Target tracking заменили на StepScaling. Минимум закрепили на 2 инстансах, максимум на 10. Scale-up и scale-down разделили. Cooldowns укоротили. Thresholds опускали вниз от 1000 к 300 req/min/target. Warm pool был готов. Health check перевели на shallow endpoint, чтобы балансировщик не снимал инстансы из-за глубоких проверок зависимостей.
Казалось, теперь должно сработать.
Не сработало.
В решающем чистом прогоне ALB bucket с сильным request pressure появился примерно на 79-й секунде. Bucket здесь можно понимать как минутный интервал, за который CloudWatch собирает значения метрики. Datapoint это готовая точка за такой интервал. Эта точка уже показывала, что пора запускать scale-up.
Но alarm перешёл в ALARM только примерно на 226-й секунде. ASG policy стартовал около 229-й секунды. Включение инстанса из warm pool в работу заняло около 6 секунд. Внешний guard остановил тест около 234-й секунды, когда ALB p95 был 7.07s при примерно 52 RPS.
Этот график убрал сразу несколько ложных версий. Warm pool не был главным виновником. Когда ASG наконец получил команду, включение инстанса в работу было быстрым. Тип инстанса тоже не объяснял проблему. Health check уже был исправлен. CPU в окне деградации оставался далеко от CPU-триггера.
Проблема сидела раньше, в signal path. ALB RequestCountPerTarget оказался минутным bucket-сигналом. Если запрашивать его с Period=10 или Period=30, магии не происходит. Данные всё равно приходят как минутные значения. Даже при агрессивных alarm settings вроде “одной точки достаточно” решение могло приходить сильно позже того bucket, где threshold уже был пересечён.
Это был полезный отрицательный результат. Мы больше не тюнили autoscaling вслепую. Мы доказали, что стандартный signal path, путь от реальной нагрузки до решения добавить инстансы, имеет физический floor. В наш latency budget, запас времени до заметного ухудшения ответов, он уже не попадал.
Третья попытка request-pressure signal из приложения
Следующий вариант сначала казался менее красивым. Добавлять application metric только ради autoscaling выглядит как лишний код. Но после ALB-прогонов это стало самым простым вариантом.
В приложение добавили счётчик входящих запросов. Он считает реальные business/API requests и исключает health checks, чтобы служебные проверки не выглядели как пользовательская нагрузка. Раз в 10 секунд каждый serving instance публикует своё значение в CloudWatch как high-resolution metric. Без Redis. Без Lambda-агрегатора. Без отдельного autoscaling controller.
Ключевой трюк был в statistic Average.
Каждый инстанс публикует только свой локальный count за 10 секунд. Например, два активных инстанса приняли 45 и 55 запросов. CloudWatch Average увидит среднее 50 запросов на инстанс. Если активных инстансов стало десять, среднее всё равно показывает нагрузку на одну машину, а не общий поток.
Поэтому, если в расчёт попадают только реальные serving-инстансы, Average автоматически становится “requests per target”. Это ровно та величина, которую мы хотели получить от ALB RequestCountPerTarget, только с 10-секундной гранулярностью.
Конфиг scale-up получился таким.
-
high-resolution metric с точками каждые 10 секунд
-
alarm confirmation по нескольким коротким datapoints, а не минутным buckets
-
threshold 50 requests / 10s / target для launch window
-
StepScaling, где обычный breach добавляет инстансы умеренно, а сильный breach добавляет больше
-
ALB и CPU остаются fallback/safety signals на случай другого типа деградации
Но и здесь были сюрпризы.
Первый hi-res прогон доказал скорость сигнала. Alarm сработал примерно за 55 секунд, то есть кратно быстрее старого ALB path. Но load стартовал сразу около 30 RPS, что было примерно в три раза выше launch threshold на baseline-парке. Парк резко пошёл к максимуму, а короткий всплеск latency всё равно успел появиться.
Вывод простой. Быстрый сигнал не отменяет физику включения инстансов в работу. Если сразу бросить на две baseline-инстанса нагрузку около peak, часть пользователей всё равно попадёт в период, пока новые инстансы ещё готовятся принимать трафик.
Следующий прогон проверял ещё одно “очевидное” ускорение. Держать warm pool в состоянии Running, чтобы включение инстанса в работу было ещё быстрее. И это почти идеально сломало сигнал.
Причина была неочевидной. Running-but-idle warm-инстансы тоже публиковали метрику, но с нулевым трафиком. CloudWatch Average начал усреднять активные инстансы и idle warm-инстансы. Если два активных инстанса видят по 50 запросов, а ещё шесть warm-инстансов публикуют нули, среднее превращается не в 50, а в 12.5. Сигнал получается заниженным, и alarm снова приходит поздно.
Фикс был не в CloudWatch, а в дисциплине публикации. Метрику должен публиковать только инстанс, который реально находится в serving state. Для этого publisher проверяет lifecycle state и не отправляет нули из warm pool. Stopped warm pool в такой схеме даже удобнее, он дешевле и ничего не размывает.
Финальная форма сочетала stopped warm pool, lifecycle-gated publishing и 60-секундный warm-up ramp нагрузки. В этом варианте scale-up начался до деградации latency. Парк рос ступенями, размер группы догонял demand, а p95 оставался около 0.3s через ramp к 150 RPS.
A/B, где поменяли только сигнал
Один удачный прогон всегда подозрителен. Можно случайно попасть в хорошее окно. Поэтому мы прогнали A/B campaign. Та же среда, тот же threshold, та же форма load, но разная основная scale-up metric.
OLD использовал стандартный ALB signal. NEW использовал application-published high-resolution signal. В обоих вариантах CPU оставался fallback. Для сравнения брали не общий “тест прошёл/не прошёл”, а signal latency, сколько секунд проходит от threshold breach до решения запускать scale-up. Load warm-up из этой цифры исключался.
Результат получился скучно убедительным. На gradual ramp старый сигнал дал 183s, новый 30s. На steep ramp старый 222s, новый 35s. На 7-minute climb старый 224s, новый 38s. Порядок тот же, около 6x быстрее.
Контрольный low steady run тоже был важен. Мы отдельно проверяли скучный случай, низкую постоянную нагрузку ниже threshold. В такой ситуации autoscaling не должен добавлять инстансы, иначе быстрый signal будет стрелять от каждого обычного колебания и зря увеличивать парк. Не масштабировала ни старая, ни новая конфигурация.
Отдельный вывод был про instant spike, то есть мгновенный прыжок нагрузки к peak. Если весь поток приходит сразу на baseline-парк, короткий p95 breach возможен даже с быстрым сигналом. Не потому что autoscaling плохой, а потому что у времени включения инстансов в работу есть физический floor. Для известного launch window лучше сгладить первые секунды роста, заранее поднять минимум инстансов или держать больше активных машин перед событием.
Проверка на production-like железе
После isolated validation тот же подход проверили на production-like Linux/Graviton окружении перед переключением. Это было окружение, похожее на боевое по типу инстансов и поведению инфраструктуры. Там уже интересовали не столько “может ли идея работать”, сколько балансировка и поведение реального парка под нагрузкой, похожей на запуск.
Финальный тест стартовал с baseline-парка, то есть с обычного минимального числа активных инстансов. Нагрузка росла от небольшого уровня к целевому. Fast signal сработал примерно на 52-й секунде, alarm history показывал около 44 секунд. Парк вырос 2 -> 4 -> 8 -> 10 примерно к 156-й секунде.
Когда все инстансы прошли проверку готовности и начали принимать трафик, latency вернулась к нормальному уровню. p95 был около 0.35s на полном парке при наблюдаемом HTTP RPS около 114.
На графике видны две разные истории. Верхняя часть показывает, что autoscaling наконец реагирует на request pressure до того, как latency стала главным сигналом. Нижняя часть показывает предел, который autoscaling не чинит. При высокой mixed workload нагрузке начинает расти pressure уже ниже по цепочке, в базе данных или downstream-сервисах. В этом прогоне DB CPU дошёл примерно до 66%, и campaign остановили, чтобы не давить общую базу.
Это важное разделение. Compute scaling был решён. Это не делает базу данных бесконечной.
Контроль обычного трафика
Быстрый signal опасен, если он стреляет на обычном трафике. Поэтому отдельно прогоняли низкую стабильную нагрузку. Парк остался на baseline-размере, request pressure держался ниже threshold, scale-out не происходил.
Это скучный график, и в этом его ценность. Хороший autoscaling должен быстро реагировать на настоящую волну, но не превращать каждое утро в маленький запуск.
Цена агрессивного threshold
Launch threshold 50 requests / 10s / target был выбран намеренно агрессивным. Это значит, что если один активный инстанс за 10 секунд видит около 50 запросов, система уже начинает добавлять парк. Для известного события это нормально, потому что цена опоздания выше цены лишних инстансов. Но такой threshold плох как постоянная steady-state настройка.
На умеренном прогоне это видно особенно хорошо. Measured request pressure быстро пересекает launch threshold, парк уходит в максимум, а после остановки нагрузки scale-down остаётся медленным. Это не баг. “Fast up, slow down” защищает от flapping размера группы.
Для постоянного режима threshold должен быть выше, примерно в зоне 120-150 requests / 10s / target для этого профиля нагрузки. Scale-down при этом лучше оставлять медленным. Например, подтверждать низкую нагрузку серией минутных datapoints и только потом убирать инстансы. Дешевле немного подержать лишние инстансы, чем начать пилить парк туда-сюда во время волатильного трафика.
Baseline перед переключением
Перед переключением мы собрали baseline по статус-кодам за 30 дней. Baseline здесь означает обычную историческую картину по 2xx, 4xx и 5xx до изменений. Он нужен не для autoscaling, а для спокойной эксплуатации после переключения. Сравнивать нужно не ощущения, а error-rate против исторического окна.
Server-error rate был низким, а 4xx в основном отражали ожидаемый клиентский шум вроде auth/routing/no-key. Поэтому критерий после переключения простой. Target 5xx не должен устойчиво стать кратно выше baseline, health endpoint должен оставаться чистым, а новые exception classes нужно разбирать отдельно.
Что в итоге поменялось
Главный вывод не в том, что “CloudWatch плохой” или “надо всегда писать свои метрики”. Стандартные метрики хороши, если их скорость подходит под форму нагрузки. Для плавного дневного профиля ALB/CPU могут быть достаточно хороши. Для запуска, где нужно принять решение за десятки секунд, минутный bucket уже слишком грубый. Он видит правильную проблему, но сообщает о ней поздно.
В старой схеме нагрузка сначала попадала в минутный ALB bucket. Потом CloudWatch ждал подтверждения, ASG запускал policy, warm pool быстро включал инстанс в работу, и только после этого новый инстанс начинал принимать трафик. Само включение было быстрым, но решение приходило уже после того, как часть пользователей успевала получить медленные ответы.
В новой схеме мы передвинули точку принятия решения ближе к реальному входящему трафику. Каждый serving instance считает реальные запросы за короткий интервал и публикует метрику только тогда, когда действительно обслуживает пользователей. CloudWatch Average превращает это в “сколько запросов приходится на один работающий инстанс”, а StepScaling добавляет инстансы до того, как latency стала главным сигналом проблемы.
Из этой истории я бы вынес не “пишите свои метрики всегда”, а более осторожный вывод. Autoscaling нужно проектировать от формы нагрузки. Если нагрузка растёт медленно, стандартного сигнала может хватить. Если запуск резкий и решение нужно принять за десятки секунд, минутная метрика может быть правильной по смыслу, но слишком поздней по времени.
CPU в такой схеме остаётся полезным, но не главным. Он хорошо ловит класс проблем, где сервис действительно упирается в процессор. Но для API, который часто ждёт базу, downstream или connection pool, пользовательская боль может начаться раньше, чем CPU станет высоким. Поэтому цель лучше формулировать не как “держать CPU в норме”, а как “держать стабильное время отклика при разумном числе инстансов”.
Warm pool тоже не серебряная пуля. Он сокращает время включения инстанса в работу, но не ускоряет момент, когда система решила этот инстанс добавить. Если signal приходит поздно, warm pool просто быстро выполняет позднее решение.
Отдельная осторожность нужна с усреднением. Average удобен только если в него попадают реальные serving-инстансы. Как только в среднее попали running-but-idle инстансы из warm pool, сигнал стал занижаться нулями. Активные машины видели нагрузку, а idle-машины публиковали ноль. Система снова начала реагировать поздно. То есть проблема была не в формуле, а в дисциплине публикации метрики.
Наконец, threshold для launch window и threshold для обычной эксплуатации не обязаны быть одним числом. Перед известным запуском можно реагировать агрессивнее, потому что цена опоздания выше цены лишних инстансов. В постоянном режиме тот же threshold начнёт слишком часто гонять парк туда-сюда. Поэтому профиль нужно переснимать после заметных изменений кода, traffic mix или инфраструктуры.
В нашем случае реакция autoscaling сократилась примерно с 226 секунд до 40-50 секунд, а в A/B campaign signal latency стабильно падала с примерно 200 секунд до 30-38 секунд. Это не магия и не “проактивное масштабирование” из маркетинговых слайдов. Это просто более ранний сигнал, который ближе к реальной работе сервиса.
И важная граница. Autoscaling не делает базу данных бесконечной и не отменяет другие bottlenecks. Он только помогает compute layer, слою инстансов приложения, вовремя перестать быть первой проблемой. После этого остаётся смотреть на следующий ограничитель уже отдельно.
ссылка на оригинал статьи https://habr.com/ru/articles/1057494/