Ёмкость и нагрузка: планирование, тесты, запас
Восемь предыдущих глав были про то, что делать, когда уже сломалось. Эта — про решение, которое принимается за недели и месяцы до инцидента и определяет, случится он вообще.
Ёмкость — самая скучная и самая предсказуемая причина отказов. Скучная, потому что ничего неожиданного: трафик вырос, ресурс кончился, очередь встала. Предсказуемая, потому что рост трафика почти всегда виден заранее — в отличие от бага в новой версии или падения провайдера. И при этом «не хватило ёмкости» стабильно держится в первой тройке причин в постмортемах любой растущей компании. Причина расхождения одна: планирование ёмкости требует считать заранее и тратить деньги на то, что сегодня простаивает, а это решение уже не совсем инженерное.
Сразу разделим две дисциплины, которые постоянно путают. Трек производительности отвечает на вопрос «почему один запрос обрабатывается 40 миллисекунд, а не 8». Эта глава — на другой: «сколько таких запросов в секунду мы выдержим, не нарушив SLO, и что купить, чтобы выдержать вдвое больше». Оптимизация увеличивает ёмкость, но не заменяет планирование: удвоив производительность кода, вы купили себе примерно 18 месяцев роста при 4 % в месяц — и всё.
Ёмкость — это число, а не ощущение
Ёмкость сервиса — максимальный поток запросов, при котором SLI остаётся не хуже SLO.
Три слова несут всю нагрузку. Не «максимальная пропускная способность», не «когда начнёт падать», а привязка к SLO — тому самому, которое мы выбирали в главе про SLI и SLO. Без неё число «сколько мы держим» не определено: держим для чего? Система, отвечающая за 9 секунд на 100 % запросов, формально работает — и совершенно бесполезна.
Разница между «ёмкостью по SLO» и «максимумом» — не academic, она составляет десятки процентов:
Линейная зона. Добавили нагрузки — выросла полезная работа, латентность почти не изменилась. Здесь живёт большинство сервисов большую часть времени, и здесь формируется опасная интуиция «у нас куча запаса».
Насыщение. Полезная работа ещё растёт, но всё медленнее, а латентность — уже быстро. Именно здесь проходит порог SLO. Точка пересечения и есть ёмкость: на картинке 600 rps, тогда как максимум пропускной способности — 780 rps. Разница в 30 % на дашборде «CPU 78 %, всё хорошо» выглядит доступной, а на самом деле уже потрачена.
Коллапс. Дальше максимума полезная работа падает: система тратит ресурсы на переключение контекста, очереди, таймауты и повторные попытки клиентов — на всё, кроме работы. Это тот же механизм, что в главе про каскадные отказы, и с точки зрения системного мышления — классическая усиливающая петля: медленные ответы порождают ретраи, ретраи добавляют нагрузку, нагрузка замедляет ответы. Выйти из коллапса добавлением ресурсов обычно уже нельзя.
Практическое следствие: ёмкость нужно измерять, а не выводить из утилизации CPU. «Загрузка 60 %» не значит ничего, пока вы не знаете, при какой загрузке ваш конкретный сервис пересекает порог SLO. У сервиса с блокирующим пулом соединений это может быть 45 %, у stateless-обработчика картинок — 85 %.
Почему кривая загибается: арифметика очереди
Загиб — не свойство вашего кода, а свойство очередей. Хватает двух формул, обе считаются в уме.
Закон Литтла
$$ L = \lambda \cdot W $$
Среднее число запросов внутри системы ($L$) равно интенсивности поступления ($\lambda$), умноженной на среднее время пребывания ($W$). Формула верна для любой стационарной системы: не важны распределение, дисциплина очереди, число серверов.
Пик 1100 rps, среднее время ответа 250 мс: $L = 1100 \cdot 0{,}25 = 275$ запросов одновременно находятся внутри системы. При 21 поде это 13 запросов на под — и если в поде 16 рабочих потоков, вы уже впритык. Стоит времени ответа вырасти до 350 мс (внешний сервис затупил, база решила почистить страницы), и $L = 385$, то есть 18 на под при 16 воркерах: очередь начинает расти на входе, хотя трафик не изменился ни на запрос. Это самый дешёвый способ проверить конфигурацию пула — число воркеров должно превышать $\lambda \cdot W$ с запасом, причём $W$ надо брать не среднее, а деградировавшее.
Множитель ожидания
Для простейшей модели с одним обслуживающим узлом (M/M/1) время пребывания выражается через время обслуживания $S$ и утилизацию $\rho$ как $W = S / (1 - \rho)$. Множитель $1/(1-\rho)$ — это вся история про запас, выраженная одним числом:
| Утилизация $\rho$ | Множитель $1/(1-\rho)$ | При $S$ = 40 мс получаем $W$ |
|---|---|---|
| 50 % | 2,0 | 80 мс |
| 60 % | 2,5 | 100 мс |
| 70 % | 3,3 | 133 мс |
| 80 % | 5,0 | 200 мс |
| 90 % | 10,0 | 400 мс |
| 95 % | 20,0 | 800 мс |
| 99 % | 100,0 | 4000 мс |
Между 50 % и 70 % латентность выросла в 1,7 раза, между 90 % и 95 % — ещё вдвое, между 95 % и 99 % — в пять раз. Это нелинейность в чистом виде, и поэтому «мы работаем на 90 %, ещё чуть-чуть есть» — опасное утверждение: рост трафика на 10 % переводит вас в $\rho = 0{,}99$, то есть в десятикратное ухудшение латентности. Не на 10 %. В десять раз. Модель M/M/1 идеализирована — реальный трафик не пуассоновский, время обслуживания не экспоненциальное, — но качественная форма кривой та же, а на практике обычно хуже: дисперсия времени обслуживания у реальных сервисов выше экспоненциальной.
Больше узлов при той же загрузке — лучше
Второй вывод контринтуитивен и очень практичен. Возьмём одну и ту же утилизацию и разное число узлов, разделяющих общую очередь (модель M/M/c). Среднее ожидание в очереди в единицах времени обслуживания:
| Число узлов $c$ | $P(\text{ждать})$ при $\rho = 0{,}8$ | $W_q / S$ при $\rho = 0{,}8$ | $W_q / S$ при $\rho = 0{,}9$ |
|---|---|---|---|
| 1 | 0,80 | 4,00 | 9,00 |
| 2 | 0,71 | 1,78 | 4,26 |
| 4 | 0,60 | 0,75 | 1,97 |
| 8 | 0,46 | 0,29 | 0,88 |
| 16 | 0,30 | 0,10 | 0,37 |
| 32 | 0,16 | 0,03 | 0,14 |
Загрузка одна и та же — 80 %, а ожидание при 32 узлах в 160 раз меньше, чем при одном: чем больше узлов делят общую очередь, тем меньше вероятность, что все заняты одновременно. Отсюда два правила. Общая очередь лучше персональных: балансировщик по принципу least-outstanding-requests приближается к M/M/c, а round-robin по «липким» соединениям создаёт $c$ независимых очередей M/M/1 и теряет весь выигрыш (см. прокси и балансировка). Мелкая нарезка выгоднее крупной: двадцать подов по 2 vCPU при равной суммарной мощности дадут лучший хвост латентности, чем четыре по 10 vCPU, — и потеря одного пода отнимет 5 % ёмкости, а не 25 %.
def erlang_c(c: int, a: float) -> float:
"""Вероятность того, что запрос придётся ждать (модель M/M/c).
c — число обслуживающих узлов, a — предложенная нагрузка в эрлангах: a = c * rho.
Сложность: O(c) по времени, O(1) по памяти.
"""
if a >= c:
return 1.0 # перегрузка, очередь не стационарна
term, total = 1.0, 1.0 # term = a^k / k!
for k in range(1, c):
term *= a / k
total += term
term *= a / c # теперь term = a^c / c!
top = term * c / (c - a)
return top / (total + top) # W_q / S = erlang_c(c, a) / (c - a)
Отсюда рабочее правило по целевой утилизации, а не догма: $\rho \le 0{,}7$ для сервисов, где важен хвост латентности и узлов единицы; $\rho \le 0{,}8$, если узлов десятки и очередь общая; $\rho \le 0{,}5$ для компонентов с длинным и разбросанным временем обслуживания (фоновые обработчики, экспорт отчётов); $\rho$ близко к единице допустимо только там, где латентность вообще не входит в SLO — пакетная обработка, «успело до утра».
Считаем: от суточного объёма до числа подов
Соберём расчёт целиком. Сервис оформления заказа, SLO: 99,9 % запросов успешны за 300 мс, окно 30 дней.
Шаг 1. Спрос. 30 млн запросов в сутки, то есть $\lambda_{avg} = 30\,000\,000 / 86\,400 \approx 347$ rps. Планировать по средней нельзя. Смотрим отношение пика к среднему по графику за 90 дней: вечерний пик даёт 1100 rps, коэффициент 3,2. Для e-commerce нормальны 3–5, для B2B с рабочим днём в одном часовом поясе — 6–10, для медиа с пушами — 20 и выше на первых минутах после рассылки. Отдельно фиксируем сезонный пик: чёрная пятница или день зарплаты добавляют ещё 2–4× сверх обычного вечернего. Держать постоянную ёмкость под годовой максимум обычно нерационально — это как раз случай для заранее отрепетированной деградации.
Шаг 2. Предложение. Ёмкость одного пода измеряем нагрузочным тестом (как — ниже), а не считаем. Получили: под держит 120 rps при p99 = 280 мс; при 150 rps p99 уходит на 900 мс. Значит ёмкость пода по SLO — 120 rps, а не 150.
Шаг 3. Голое покрытие пика. $N_{raw} = 1100 / 120 = 9{,}17$, то есть десять подов. Это число обычно и называют в ответ на вопрос «сколько нам надо». Оно неверно.
Шаг 4. Запас на очередь. При десяти подах $\rho = 1100/1200 = 0{,}92$ — по таблице выше это множитель 12 к ожиданию. Целимся в $\rho \le 0{,}7$: $N_{util} = 9{,}17 / 0{,}7 = 13{,}1 \rightarrow 14$.
Шаг 5. Запас на отказ зоны. Три зоны доступности, надо пережить потерю одной; оставшиеся две несут всю нагрузку: $N_{az} = 13{,}1 / (1 - 1/3) = 19{,}7 \rightarrow 20 \rightarrow 21$ (по 7 на зону).
Шаг 6. Горизонт. Рост 4 % в месяц: через полгода пик составит $1100 \cdot 1{,}04^{6} = 1392$ rps, и тот же расчёт даёт 27 подов. Если лид-тайм расширения — две недели, планировать надо на «сегодня + лид-тайм + запас на ошибку прогноза», обычно 3–6 месяцев.
Итог: 9 подов «по-честному считая нагрузку» превратились в 21. Каждый слой куплен за конкретное свойство, и каждый можно осознанно снять — понимая, что именно вы отдаёте. Отказались от запаса на зону — согласились, что падение зоны означает инцидент. Подняли целевую утилизацию до 0,85 — согласились, что p99 в пик будет вдвое хуже.
import math
def plan_capacity(peak_rps, rps_per_pod, target_util=0.7, zones=3, survive_az=True):
"""Число подов с разбором по слоям запаса. Сложность O(1).
peak_rps берётся из графика за 90 дней, rps_per_pod — из нагрузочного теста.
"""
raw = peak_rps / rps_per_pod # голое покрытие пика
with_util = raw / target_util # запас на очередь
with_az = with_util * (zones / (zones - 1) if survive_az else 1.0)
total = math.ceil(with_az)
total += (-total) % zones # округляем до кратного зонам
return {
"пик": round(raw, 2),
"+ очередь": round(with_util - raw, 2),
"+ отказ зоны": round(total - with_util, 2),
"итого подов": total,
"утилизация в норме": round(peak_rps / (total * rps_per_pod), 3),
"утилизация без одной зоны": round(
peak_rps * zones / (total * (zones - 1) * rps_per_pod), 3),
}
print(plan_capacity(1100, 120))
# {'пик': 9.17, '+ очередь': 3.93, '+ отказ зоны': 7.9, 'итого подов': 21,
# 'утилизация в норме': 0.437, 'утилизация без одной зоны': 0.655}
Последняя строка — самая важная для разговора с финансистами: в обычный день железо загружено на 44 %. Это не бесхозяйственность, а ровно та цена, за которую куплено «переживаем потерю зоны, не нарушая SLO».
Сколько бюджета ошибок съедает нехватка ёмкости
Переведём деградацию в валюту бюджета ошибок, иначе разговор останется вкусовым. За 30 дней проходит $30 \cdot 30 = 900$ млн запросов; при SLO 99,9 % бюджет равен $900\,000\,000 \cdot 0{,}001 = 900\,000$ «плохих» запросов.
Пиковые часы — около 2 часов в сутки, в них проходит примерно 20 % суточного трафика: 6 млн в сутки, 180 млн за 30 дней. Если из-за нехватки ёмкости в пик 3 % запросов вылезают за 300 мс, это $180\,000\,000 \cdot 0{,}03 = 5\,400\,000$ — в шесть раз больше месячного бюджета целиком, при том что «сервис работал», ни один алерт по ошибкам не сработал и в постмортемах эта история не появилась ни разу.
Обратный счёт полезнее: чтобы уложиться в бюджет, доля плохих запросов в пик не должна превышать $900\,000 / 180\,000\,000 = 0{,}5\ %$ — и это при условии, что вне пика всё идеально, что неправда; реальный ориентир 0,2–0,3 %. Вывод стоит повесить на стену: нехватка ёмкости — не «медленно», а «SLO не выполняется», причём с превышением на порядок. Латентностный SLI это ловит, SLI по кодам ответа — нет; ещё один аргумент за два SLI на критическом пути.
Не только CPU: что ещё кончается
Планирование, сводящееся к «сколько ядер», ломается на первом же нетипичном инциденте.
| Ресурс | Как выглядит исчерпание | Чем мерить |
|---|---|---|
| CPU | рост latency, run queue > числа ядер |
node_load1, throttling cgroup |
| Память | OOM-kill, своп, длинные паузы GC | RSS, container_memory_working_set_bytes |
| Пул соединений к БД | таймаут на получении соединения, а не на запросе | ожидание в пуле, pg_stat_activity |
| Файловые дескрипторы | EMFILE, too many open files |
process_open_fds против ulimit -n |
| Эфемерные порты | EADDRNOTAVAIL на исходящих |
32768–60999 — всего 28 232 порта на IP |
| IOPS и полоса диска | await растёт, throughput стоит |
node_disk_io_time_seconds_total |
| Сетевая полоса | потери, retransmits, рост RTT | счётчики интерфейса, netstat -s |
| Место на диске | всё встало разом, логи не пишутся | predict_linear по свободному месту |
| Квоты облака | «нельзя создать инстанс» в нужный момент | лимиты API провайдера |
| Rate limit внешнего API | 429 от партнёра, а не от вас | счётчик 429 по каждому исходящему клиенту |
| Лицензии, IP в подсети, ключи разделов | отказ в масштабировании при свободном железе | ручной реестр |
Здесь работает подход USE из главы про мониторинг: на каждый ресурс — использование, насыщение, ошибки. Планирование ёмкости и есть непрерывный ответ на вопрос «какой ресурс упрётся первым», и ответ меняется после каждого крупного релиза.
Где горизонтальное масштабирование упирается в базу
21 под × 20 соединений в пуле = 420 соединений к PostgreSQL при max_connections = 100 по умолчанию. Даже если поднять до 500, каждый backend стоит памяти, а конкуренция за блокировки растёт нелинейно. Дальше происходит характерное: автоскейлер добавляет поды, каждый новый под открывает соединения, база замедляется, ответы становятся дольше, автоскейлер видит рост latency и добавляет ещё поды. Усиливающая петля, где инструмент борьбы с перегрузкой сам её усиливает.
Лечится не подбором чисел, а изменением топологии: пулер соединений (PgBouncer в режиме transaction pooling), жёсткий верхний предел реплик приложения, отдельные пулы для критического и фонового трафика — см. производительность БД и репликацию и шардирование. Общее правило: у автоскейлера обязан быть maxReplicas, и это число выводится из ёмкости зависимостей, а не из бюджета.
Предел масштабирования: почему узлы перестают помогать
Добавление узлов не даёт линейного роста, и это тоже считается. Универсальный закон масштабируемости (Neil Gunther, USL) описывает пропускную способность $N$ узлов как
$$ C(N) = \frac{N}{1 + \alpha (N - 1) + \beta N (N - 1)}, \qquad N^{\ast} = \sqrt{\frac{1 - \alpha}{\beta}} $$
где $\alpha$ — доля сериализованной работы (это Амдал: общая блокировка, единственный лидер, общий лог), а $\beta$ — стоимость когерентности: узлам приходится согласовываться друг с другом, и число пар растёт квадратично. Ключевое отличие от закона Амдала: при $\beta > 0$ у кривой есть максимум $N^{\ast}$, после которого добавление узлов ухудшает результат. Возьмём умеренные $\alpha = 0{,}03$ и $\beta = 0{,}0001$:
| Узлов $N$ | 1 | 10 | 50 | 98 | 150 | 200 | 400 |
|---|---|---|---|---|---|---|---|
| $C(N)$ | 1,00 | 7,82 | 18,4 | 20,2 | 19,5 | 18,3 | 13,8 |
| Эффективность | 100 % | 78 % | 37 % | 21 % | 13 % | 9 % | 3,5 % |
$N^{\ast} \approx 98$: сотня узлов даёт ускорение в 20 раз, а 400 узлов — в 14. Вы платите вчетверо и получаете хуже. Практический смысл не в том, чтобы точно оценить $\alpha$ и $\beta$ (для этого нужны замеры на 4–6 размерах кластера и подгонка кривой), а в том, чтобы перестать считать масштабирование линейным при планировании. Если тест показал 120 rps на под при 4 подах, то при 40 подах будет не 4800 rps, а, скажем, 3900, — и планировать надо от измеренного на близком размере. Теория рядом — в главах про конкурентность и производительность и партиционирование.
Нагрузочные тесты: что именно вы измеряете
Число «120 rps на под» должно откуда-то взяться. Инструментальная часть разобрана в главах нагрузочное тестирование и тестирование производительности; здесь — только то, что отличает тест «для отчёта» от теста, на который можно опереться.
| Тип | Вопрос, на который отвечает | Длительность |
|---|---|---|
| Smoke | сценарий вообще рабочий? | 1–2 мин, 1–5 VU |
| Capacity / breakpoint | при какой нагрузке нарушается SLO? | ступени по 5–10 мин |
| Stress | что ломается первым за пределом? | до отказа |
| Soak / endurance | есть ли утечки и деградация во времени? | 4–24 часа |
| Spike | переживём ли мгновенный скачок ×5? | секунды роста, минуты удержания |
Для планирования нужен прежде всего capacity test: ступенчатое повышение нагрузки с фиксацией SLI на каждой ступени. Результат — не «выдержали 1000 rps», а таблица «нагрузка → p99 → доля ошибок», из которой читается точка пересечения с порогом SLO.
Открытая модель против закрытой — главная ошибка
Это различие ломает больше нагрузочных тестов, чем все остальные ошибки вместе. Закрытая модель: $N$ виртуальных пользователей, каждый шлёт следующий запрос после ответа на предыдущий; когда сервис замедляется, генератор сам снижает интенсивность. Вы измеряете систему, которая защищена от перегрузки — в проде такой защиты нет, реальные пользователи не ждут, они жмут F5 и добавляют нагрузку. Открытая модель: запросы поступают с заданной интенсивностью независимо от того, отвечает сервис или нет. Это модель реального интернет-трафика, и только в ней видно настоящий загиб кривой.
// k6: открытая модель — фиксируем интенсивность, а не число пользователей
export const options = {
scenarios: {
capacity: {
executor: 'ramping-arrival-rate', // именно arrival-rate, не VU
startRate: 100,
timeUnit: '1s',
preAllocatedVUs: 400, // пул исполнителей заведомо избыточен,
maxVUs: 2000, // иначе генератор упрётся сам в себя
stages: [
{ target: 400, duration: '5m' },
{ target: 600, duration: '5m' }, // здесь ожидаем пересечение SLO
{ target: 800, duration: '5m' },
{ target: 1000, duration: '5m' },
],
},
},
thresholds: {
// порог формулируем ровно так же, как SLO: доля запросов, а не среднее
http_req_duration: ['p(99)<300'],
http_req_failed: ['rate<0.001'],
},
};
Аналоги: constantUsersPerSec и rampUsersPerSec в Gatling, Precise Throughput Timer в JMeter, --rate в vegeta и wrk2.
Отдельно про coordinated omission — систематическую ошибку измерений, описанную Гилом Тене. Если генератор ждёт ответа перед отправкой следующего запроса, то во время затыка он не отправляет запросы, которые должен был отправить, — и худшие измерения просто не попадают в выборку. Результат: p99 в отчёте 200 мс при реальном p99 в 4 секунды. Признак беды — подозрительно ровные перцентили и p99, близкий к медиане, при явных провалах пропускной способности на графике. Лечится открытой моделью и коррекцией гистограммы по ожидаемому интервалу (recordValueWithExpectedInterval в HdrHistogram). И помните, что генератор нагрузки сам является системой с ёмкостью: одна машина обычно не выдаёт больше 10–20 тысяч rps по HTTPS и упирается в те же эфемерные порты и CPU на TLS. Всегда снимайте метрики с генератора — если его CPU в полке, вы измеряете генератор.
Squeeze test: измерение ёмкости на живом трафике
Синтетика всегда врёт: другой профиль запросов, холодные кэши, свежая база без раздутых таблиц, отсутствие соседей по железу. Squeeze test («отжим») даёт число на настоящем трафике: постепенно уменьшаем число инстансов, обслуживающих реальный трафик, — или увеличиваем долю трафика на фиксированную группу, — и смотрим, при какой нагрузке на инстанс SLI начинает деградировать.
Начальное состояние: 21 под, 1100 rps в пик → 52 rps на под
Шаг 1: канареечная группа 3 пода, 15 % трафика → 55 rps на под (норма)
Шаг 2: 3 пода, 25 % трафика → 92 rps на под (p99 = 210 мс, норма)
Шаг 3: 3 пода, 32 % трафика → 117 rps на под (p99 = 285 мс, край)
Шаг 4: 3 пода, 38 % трафика → 139 rps на под (p99 = 640 мс — СТОП, откат)
Вывод: ёмкость пода 117–120 rps при текущем профиле трафика.
Без чего так делать нельзя: мониторинг с разрешением в секунды, автоматический откат по порогу, ограниченная доля трафика и заранее согласованное окно. Стоимость эксперимента считается в бюджете ошибок и невелика: шаг 4 длиной 90 секунд при 38 % трафика даёт около 25 тысяч плохих запросов из бюджета в 900 тысяч, то есть 2,8 %. За это вы получаете число, которому можно верить. Squeeze test — ближайший родственник учений из главы про проверку отказом и естественная надстройка над канареечными выкатками.
Где тест врёт даже при всём старании
- Копия среды меньше прода. Ёмкость нелинейна по размеру: что работает на 3 подах, на 30 упрётся в базу. Если среда меньше, тестируйте относительные изменения («новая версия на 12 % хуже»), а не абсолютные значения.
- Синтетический профиль запросов. Реальное распределение по эндпоинтам и по «весу» пользователей почти никогда не воспроизводят. Лучший источник сценариев — логи за пиковый час.
- Холодные кэши. Тест с одним набором ключей даёт 99 % попаданий и в разы завышает ёмкость (см. кэширование).
- Тест устаревает. Ёмкость надо перемерять после каждого значимого релиза: релиз, снижающий её на 30 %, не виден ни в одном функциональном тесте и обнаруживается в следующий пик.
Автоскейлинг не заменяет планирование
Соблазн понятен: зачем считать, если облако само добавит инстансов. Проблема в том, что автоскейлер — это петля обратной связи с задержкой, а такие петли ведут себя плохо, когда задержка сопоставима со скоростью изменения входа.
против дребезга H->>K: 19:02:10 желаемых реплик 14 -> 21 K->>K: 19:02:15 поиск узла с ресурсами Note over K: свободных узлов нет -> запуск ноды,
90-180 с у типичного провайдера K->>N: 19:04:30 старт контейнера N->>N: pull образа 30 с, старт JVM 40 с N->>N: прогрев: JIT, кэши, пул соединений 60 с N->>P: 19:07:00 под реально готов держать нагрузку Note over U,N: 7 минут перегрузки при полностью
исправном автоскейлере
Семь минут — оптимистичная оценка для стека с JVM и запуском новых нод. Семь минут за пределом ёмкости при 1100 rps — это 462 тысячи запросов; если половина вышла за порог, вы потратили четверть месячного бюджета ошибок на один вечерний пик.
Отсюда правила, которые делают автоскейлинг полезным. Базовый уровень покрывает пик — автоскейлинг нужен для непредвиденного и для экономии ночью, а ежедневный предсказуемый пик закрывается расписанием, которое поднимает реплики за 20 минут до события. Вверх агрессивно, вниз медленно: ошибка «подняли лишнее» стоит денег, ошибка «опустили рано» стоит SLO. maxReplicas ограничен ёмкостью зависимостей, иначе приложение задушит базу. Метрика скейлинга — про очередь, а не про CPU: длина очереди, число запросов в обработке, время ожидания в пуле реагируют быстрее и точнее усреднённого CPU. Прогрев обязателен: под, принимающий трафик до готовности JIT и кэшей, какое-то время работает вдвое хуже, и readiness-проба должна это учитывать (см. Kubernetes).
Полезно держать в голове карту состояний сервиса по отношению к нагрузке:
Главный вывод: из «Коллапса» нет ребра прямо в «Норму» — единственный выход через сброс нагрузки. Это и есть содержание следующей главы.
Прогноз и лид-тайм: где инженерия упирается в организацию
Планирование ёмкости состоит из четырёх повторяющихся шагов, и три из них не инженерные.
пик, средняя,
отношение пик/среднее"] --> F["Прогноз:
тренд + известные события
+ планы продукта"] F --> T["Измерить предложение:
capacity test или squeeze,
ёмкость на инстанс"] T --> G{"Прогноз спроса
укладывается
в ёмкость с запасом?"} G -->|да| W["Ждать.
Пересмотр через квартал"] G -->|нет| D{"Разница закрывается
железом за приемлемые
деньги?"} D -->|да| B["Заявка на ресурсы:
квоты, бюджет, лид-тайм"] D -->|нет| E["Инженерная работа:
оптимизация, кэш,
шардирование, деградация"] B --> O["Наблюдение:
алерт на прогноз
исчерпания"] E --> O W --> O O --> M E -.->|"снижает требуемое
количество железа"| T
Инженерных блоков здесь два. Остальное — прогноз (нужны планы маркетинга и продукта), заявка на ресурсы (нужен бюджет) и выбор «железо или инженерная работа» (нужен человек, сравнивающий 7000 USD в год за железо с двумя человеко-месяцами разработки). Если этих разговоров не происходит, планирование ёмкости деградирует в «докинем подов, когда упадёт».
Лид-тайм — то, из-за чего прогноз вообще нужен. Он отличается на два порядка:
| Способ | Лид-тайм | Ограничения |
|---|---|---|
| Добавить поды в существующий кластер | секунды–минуты | лимиты namespace, ёмкость нод |
| Добавить ноды у облачного провайдера | минуты–часы | квоты аккаунта, наличие типа инстанса в зоне |
| Поднять квоту у провайдера | 1–5 рабочих дней | тикет в поддержку, иногда отказ |
| Зарезервированные мощности, savings plan | недели на согласование | обязательство на 1–3 года |
| Свои серверы в стойку | 4–12 недель | закупка, доставка, монтаж, таможня |
| Новая площадка или регион | 3–9 месяцев | договоры, сеть, соответствие требованиям |
Отсюда правило: горизонт планирования = лид-тайм × 2 + запас на ошибку прогноза. Для облака с готовыми квотами это месяц, для своего железа — полгода. Компании, переезжающие из облака on-prem ради экономии, регулярно забывают, что покупают вместе с экономией шестинедельный лид-тайм.
Самый дешёвый инструмент наблюдения — алерт на прогноз исчерпания, а не на текущее значение: он даёт время на действия и не будит никого ночью.
# Диск: кончится ли место в ближайшие 4 дня
predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 4 * 24 * 3600) < 0
# Ёмкость сервиса: дойдёт ли суточный пик rps до 70 % ёмкости за 30 дней.
# capacity:checkout:rps — recording rule: ёмкость пода из последнего squeeze test,
# умноженная на текущее число готовых реплик.
predict_linear(
max_over_time(
sum(rate(http_requests_total{service="checkout"}[5m]))[1d:5m]
)[21d:1h],
30 * 24 * 3600
) > 0.7 * capacity:checkout:rps
По классификации из главы про алерты это тикет, а не пейджер: есть срок, есть действие, нет срочности ночью.
Когда прогноз показал нехватку, набор ответов конечен и раскладывается по двум осям — сколько стоит и как быстро даёт эффект:
Верхний левый квадрант — то, что должно быть готово заранее, потому что применять придётся в течение получаса. Нижний правый — то, что начинают за полгода, потому что за полчаса его не сделать.
Цена
Железо, которое простаивает. Честный расчёт дал 21 под вместо 9: при условной цене 55 USD в месяц за под это 1155 USD против 495 USD, то есть 660 USD в месяц и 7920 USD в год. Деньги куплены за конкретные свойства — p99 не разъезжается в пик, падение зоны не превращается в инцидент, — но свойства невидимы, а строка в счёте видна прекрасно, и при первом сокращении расходов срезают именно её. Единственная защита: запас должен быть записан как следствие SLO, а не как привычка — «целевая утилизация 70 % и N−1 по зонам вытекают из SLO 99,9 %; снижение запаса означает пересмотр SLO». Тогда решение принимается там, где за SLO отвечают. Экономика подробнее — в главе про стоимость облака.
Люди, которые поддерживают тесты. Нагрузочный сценарий — код, устаревающий быстрее продуктового: меняются эндпоинты, схемы, доли трафика. Сценарий, который не запускали полгода, либо не запустится, либо соврёт, и второе хуже. Реалистично это 2–4 человеко-дня в квартал плюс стоимость среды и генераторов. Если этих дней нет в плане, тестов через год тоже не будет — и решение «у нас нет ресурса на нагрузочное тестирование» надо принимать явно, а не обнаруживать постфактум.
Дежурные, которых будят предсказуемо. Нехватка ёмкости — самый частый источник ночных страниц предсказуемого типа, и это отдельная категория выгорания: человека будят не из-за редкого сложного отказа, а из-за того, что кто-то полгода назад не выделил денег. Такие страницы разрушают доверие к дежурству быстрее прочих, потому что дежурный не может их починить — у него нет полномочий на бюджет. Механизм из главы про дежурство работает и здесь: если по итогам страницы ничего не меняется, следующие начинают игнорировать. Повторяющаяся страница по ёмкости — не инженерная проблема, а нерешённое организационное решение, и в постмортеме её надо писать именно так (со стороны руководителя тот же сюжет — в главе дежурства и инциденты).
Отдельная ловушка — экономия, незаметно конвертируемая в риск. Переход на spot-инстансы снижает счёт на 60 % и добавляет вероятность одновременного отзыва половины парка. Повышение целевой утилизации с 70 % до 85 % экономит 18 % железа и удваивает p99 в пик. Оба решения выглядят как «оптимизация расходов» и оба являются изменением SLO — просто не оформленным.
Что переносится из практики Google, а что нет
Не переносится. Годовой цикл планирования с выделенной ролью: в Google этим занимаются отдельные люди, потому что речь о закупке дата-центров с лид-таймом в годы; в команде из десяти инженеров эта функция — полдня в квартал у одного человека. Детерминированное распределение квот между сотнями сервисов: внутренние рынки ресурсов осмысленны, когда сервисы конкурируют за общий пул, при пяти сервисах это бюрократия. Бинпакинг и переподписка ради утилизации: процент от миллиона машин — деньги, процент от тридцати — ничто, а сложность та же. И сами числа: «загружайте кластер на 85 %» осмысленно при тысячах узлов (см. таблицу M/M/c), при десяти то же число даст совсем другой хвост.
Переносится полностью. Определение ёмкости через SLO, а не через утилизацию, — оно вообще не зависит от масштаба. Планирование на пик, а не на среднее, с явным учётом отказа зоны. Squeeze test на живом трафике — техника отлично работает на трёх подах. Требование измерять, а не оценивать: ёмкость на инстанс — измеренное число с датой последнего измерения. И идея, что нехватка ёмкости тратит бюджет ошибок, — именно она переводит разговор из вкусового в арифметический.
Главы книг Google про capacity planning стоит читать как описание проблемы, а не как инструкцию: проблема у всех одинаковая, решения — нет.
Практика: обзор ёмкости за один день
Минимальный набор, который команда из пяти человек делает за день и который закрывает 80 % риска.
- Пик и среднее. График rps за 90 дней. Записать: пиковый rps, средний, отношение, дату и причину годового максимума.
- Ёмкость на инстанс. Squeeze test на канареечной группе в дневной пик. Записать число и дату: «под держит 120 rps при p99 < 300 мс, измерено 14.07.2026, версия 3.8.1».
- Посчитать по формуле: пик, целевая утилизация, отказ зоны, горизонт. Сравнить с тем, сколько подов сейчас; расхождение в любую сторону — новость.
- Найти ресурс, который упрётся первым. Пройти таблицу «не только CPU»: сколько сейчас, каков предел, откуда я это узнаю. Обычно на этом шаге находится пул соединений или квота провайдера.
- Поставить один алерт-тикет на прогноз исчерпания. Не пейджер.
- Записать лид-тайм — проверить, а не предположить, сколько занимает поднять квоту у вашего провайдера.
- Внести в календарь повторение через квартал и пункт в чек-лист релиза: «после крупного релиза перемерить ёмкость».
Восьмого пункта нет намеренно: всё остальное имеет смысл, только когда сделаны эти семь. Более широкий разбор того, что работает в маленькой команде, — в заключительной главе трека.
Типичные ошибки
- Планировать по средней нагрузке. Отношение пик/среднее в 3–5 раз означает, что «средняя загрузка 30 %» — это «в пик 100 %».
- Считать ёмкостью максимум пропускной способности. Между ней и максимумом обычно 20–40 %, и они уже потрачены.
- Мерить утилизацией CPU. CPU 60 % не означает 40 % запаса: узкое место может быть в пуле, диске или блокировке.
- Нагрузочный тест в закрытой модели. Генератор, ждущий ответа, маскирует деградацию и даёт coordinated omission.
- Экстраполировать с одного инстанса. По USL масштабирование не линейно, а при большом $\beta$ имеет максимум.
- Считать автоскейлинг заменой планированию. Задержка петли 5–10 минут; предсказуемый пик закрывается расписанием, а не реакцией.
- Автоскейлер без
maxReplicas— приложение задушит базу быстрее, чем справится с трафиком. И помнить, что зависимости не масштабируются вместе с вами: внешний API с лимитом 500 rps ставит потолок независимо от числа ваших подов. - Не перемерять ёмкость после релизов. Релиз, съевший 30 % ёмкости, обнаружится в следующий пик, а не в CI.
- Планировать без лид-тайма. «Докупим, когда понадобится» работает в облаке с готовой квотой и больше нигде.
- Верить абсолютным числам с тестовой среды в десять раз меньше прода. Верить можно только относительным.
- Оставить запас без письменного обоснования. Незадокументированный запас — первый кандидат на срезание в квартал экономии.
Мини-итог
Ёмкость — измеренное число: максимальный поток, при котором SLI не хуже SLO. Не максимум пропускной способности и не производная от CPU.
Кривая загибается из-за очередей, и множитель $1/(1-\rho)$ объясняет, почему 90 % загрузки — это не «ещё 10 % есть», а «плюс 10 % трафика даёт десятикратную латентность». При этом много мелких узлов с общей очередью держат ту же утилизацию заметно лучше, чем несколько крупных.
Честный расчёт превращает 9 подов «на пик» в 21: слой на очередь, слой на отказ зоны, слой на горизонт роста. Средняя загрузка железа выходит около 44 % — и это цена конкретных, называемых вслух свойств, а не бесхозяйственность. Нехватка ёмкости переводится в бюджет ошибок и обычно съедает его многократно, оставаясь невидимой для алертов по кодам ответа. Меряется ёмкость нагрузочным тестом в открытой модели, а лучше — squeeze test на живом трафике, где не врут ни кэши, ни профиль запросов, ни соседи по железу.
Автоскейлинг — петля с задержкой в 5–10 минут; он спасает от неожиданного, но не от ежедневного пика. И почти в каждом расчёте наступает момент, где инженерное решение упирается в организационное: запас стоит денег ежемесячно, лид-тайм измеряется неделями, а повторяющаяся ночная страница по ёмкости — это не баг, а незакрытый бюджетный вопрос, который надо писать в постмортем именно такими словами.
Источники
- Google. Site Reliability Engineering, глава «Handling Overload» — sre.google/sre-book/handling-overload.
- Google. The Site Reliability Workbook, «Managing Load» — sre.google/workbook/managing-load.
- Neil J. Gunther. Guerrilla Capacity Planning (Springer, 2007) — универсальный закон масштабируемости и методика подгонки $\alpha$ и $\beta$.
- Neil J. Gunther. A General Theory of Computational Scalability Based on Rational Functions — arXiv:0808.1431, формальный вывод USL.
- John D. C. Little. A Proof for the Queuing Formula L = λW — Operations Research, 1961.
- Gil Tene. How NOT to Measure Latency — доклад и HdrHistogram.
- AWS Builders’ Library. Using load shedding to avoid overload — aws.amazon.com/builders-library.
- Marc Brooker. Заметки об очередях, нагрузке и тестировании — brooker.co.za/blog.
- Документация k6 по executors, в частности arrival-rate — grafana.com/docs/k6.
- Kubernetes HPA: поведение, окна стабилизации, политики — kubernetes.io.
- PgBouncer, режимы пулинга — pgbouncer.org.
- Prometheus, функция
predict_linear— prometheus.io. - Mor Harchol-Balter. Performance Modeling and Design of Computer Systems (Cambridge, 2013) — лучший доступный учебник по теории очередей для инженеров.
Что дальше
Мы посчитали, сколько нужно, и увидели границу: за пределом ёмкости система не «немного медленнее», а входит в состояние, откуда добавление ресурсов уже не выводит. При этом любой запас конечен — придёт день, когда трафик превысит расчёт, откажет не одна зона, а две, или внешний API начнёт отвечать по десять секунд.
Вопрос не в том, как этого избежать, а в том, что система делает в этот момент. Отдаёт 20 % пользователей ошибку, а остальным — нормальный сервис? Отключает рекомендации, чтобы сохранить оформление заказа? Или честно пытается обслужить всех и не обслуживает никого? Разница определяется не мощностью, а несколькими решениями в коде: тайм-аутами, политикой повторов, размыкателями и ограничением нагрузки на входе.
Дальше — Деградация вместо отказа: таймауты, ретраи, размыкатели.