Нагрузочное тестирование: профили нагрузки, k6, чтение результатов
Всё, чем мы занимались в предыдущих статьях трека, — от профилирования CPU до сетевых задержек — было исследованием системы изнутри. Нагрузочное тестирование смотрит снаружи и задаёт единственный вопрос, который в итоге волнует бизнес: что произойдёт, когда придут все сразу?
Проблема в том, что вопрос сформулирован неправильно. «Выдержим ли мы Чёрную пятницу» — это не инженерный вопрос, на него нельзя ответить числом. Инженерные вопросы звучат иначе: при какой интенсивности прибытия запросов p99 перестаёт укладываться в 300 мс; что именно ломается первым — пул соединений к базе, CPU приложения или файловые дескрипторы балансировщика; деградирует система плавно или сваливается в коллапс, из которого сама не выходит; сколько времени занимает восстановление после того, как пик прошёл.
Главный принцип трека здесь применяется к самому инструменту измерения. Нагрузочный тест — это эксперимент, а у эксперимента есть валидность. Огромная доля отчётов, которые показывают в переговорках, недействительна не потому, что цифры посчитаны неправильно, а потому что измеряли не то: генератор упёрся в свой потолок, задержка считалась не от того момента, кэш был прогрет одним ключом, а среднее взято по прогону, в который вошёл разогрев. Эта статья примерно наполовину про то, как проводить тест, и наполовину про то, как понять, можно ли верить его результату.
Все числа в статье — иллюстрация метода, а не справочные значения. Ваши 100 мс — это чужие 4 мс и чужие 3 с. Даже классическая «таблица задержек, которую должен знать каждый программист» Джеффа Дина датируется серединой 2000-х: с тех пор NVMe вытеснил вращающиеся диски, а межрегиональные RTT почти не изменились, поэтому пропорции внутри таблицы поехали неравномерно. Пользуйтесь ей как способом прикинуть порядок величины перед измерением, а не как источником истины после него.
Тест начинается с вопроса, а не с инструмента
«Провести нагрузочное тестирование» — задача без критерия завершения. Прежде чем открывать редактор, сформулируйте вопрос и выберите под него профиль — форму, по которой интенсивность нагрузки меняется во времени.
| Вопрос | Профиль | Длительность | Что смотрим в первую очередь |
|---|---|---|---|
| Скрипт и стенд вообще живы? | smoke, 1–5 VU | 1–2 мин | ошибки, а не задержки |
| Где колено и какова ёмкость? | ступени до колена | 1–2 ч | точка, где RPS перестал расти |
| Как именно ломается? | breakpoint, рампа до срыва | 30–90 мин | характер отказа, время восстановления |
| Переживём ли резкий наплыв? | spike | 15–30 мин | реакция автоскейлинга, лавина ретраев |
| Что накапливается со временем? | soak, ровное плато | 4–24 ч | тренд RSS, p99, размера пулов |
| Выдержим ли реальный день? | реплей суточной кривой | 24 ч | всё сразу, включая фоновые задачи |
Ступенчатый профиль — рабочая лошадка, и его ценность в плато: система должна успеть прийти в стационарное состояние, иначе вы измеряете переходный процесс. Плато короче 3–5 минут почти всегда врёт (автоскейлинг не успел, JIT не прогрелся, кэши не наполнились, пул соединений не раскрылся), а плавная рампа без плато даёт красивый график и почти бесполезные числа: на каждой точке кривой система находится в разном переходном состоянии.
Главное решение: открытая модель против закрытой
Это самая важная развилка нагрузочного тестирования, и её проскакивают почти все.
Закрытая модель (в k6 — исполнители constant-vus, ramping-vus; в терминах инструментов «N виртуальных пользователей») устроена так: каждый VU в цикле шлёт запрос, ждёт ответа, думает think time, шлёт следующий. Темп запросов определяется скоростью ответа сервиса. Замедлился сервис — упала нагрузка.
Открытая модель (constant-arrival-rate, ramping-arrival-rate) задаёт темп прибытия извне: 500 итераций в секунду начинаются каждую секунду независимо от того, ответил сервер на предыдущие или нет. VU здесь — просто пул исполнителей, из которого планировщик берёт свободного.
Разница не академическая. В закрытой модели, когда сервис тормозит, генератор автоматически снижает нагрузку — и вы не измеряете самое интересное: время, которое живой пользователь провёл бы в очереди. Это coordinated omission, подробно разобранный в статьях «Измерение» и «Бенчмаркинг честно». Реальные пользователи не координируются с вашим сервером: они кликают по своему расписанию, и во время зависания очередь растёт, а не рассасывается. Практическое правило: интенсивность прибытия — это внешний параметр вашей системы, значит, в тесте она должна быть внешним параметром генератора. Открытая модель по умолчанию. Закрытая модель уместна там, где число клиентов действительно ограничено и они действительно блокируются: пул из 40 воркеров, ходящих в вашу внутреннюю gRPC-ручку; 200 соединений с базой; батч-джоб с фиксированной конкурентностью. Тогда вы моделируете реальный замкнутый контур, и constant-vus — правильный выбор. Но пользовательский HTTP-трафик так не устроен почти никогда.
Закон Литтла как рабочий инструмент
Связь между двумя моделями даёт закон Литтла: для любой стационарной системы среднее число заявок внутри равно интенсивности прибытия, умноженной на среднее время пребывания.
$$L = \lambda \cdot W$$
Здесь $L$ — конкурентность (сколько запросов «в полёте»), $\lambda$ — интенсивность (запросов в секунду), $W$ — время ответа. Закон не требует никаких предположений о распределениях — он верен для любой стационарной системы, и именно поэтому им можно пользоваться как калькулятором.
Сколько VU выделить под открытую модель. Чтобы удержать $\lambda$ итераций в секунду при среднем времени итерации $\bar{W}$ (включая think time), нужно как минимум $\lambda \cdot \bar{W}$ одновременных VU. Для 100 итераций/с при итерации в 1.3 с — 130 VU. Но это среднее по здоровой системе; в момент деградации $\bar{W}$ вырастает в 20 раз, и требуется 2600. Отсюда правило: maxVUs берите с многократным запасом относительно расчёта, иначе тест развалится ровно тогда, когда станет интересно.
Сколько конкурентности выдержит сервис. Если один инстанс держит 200 RPS при 50 мс ответа, внутри него в среднем $200 \cdot 0.05 = 10$ запросов. Значит, пул из 8 воркеров — уже узкое место, а из 200 — бессмысленная трата памяти и источник контеншна (см. «Производительность конкурентного кода» и раздел про пул соединений в «Производительность БД»).
Сколько инстансов нужно на пик. При пике $\lambda_{peak}$ и целевой утилизации $\rho$ (обычно 0.6–0.7, потому что при $\rho \to 1$ время ожидания в очереди уходит в бесконечность) число инстансов $n \ge \lambda_{peak} / (\rho \cdot \lambda_{one})$, где $\lambda_{one}$ — измеренная ёмкость одного инстанса. Именно ради $\lambda_{one}$ и проводится ступенчатый тест.
Обратите внимание: закон Литтла — ещё и детектор вранья в отчёте. Если в сводке написано «200 VU, 1000 RPS, среднее время ответа 500 мс», то по закону Литтла нужно $1000 \cdot 0.5 = 500$ VU. Отчёт внутренне противоречив, где-то ошибка — скорее всего, в отчёт попал разогрев или часть запросов не дошла.
k6: модель исполнения
k6 — генератор нагрузки на Go со скриптами на JavaScript. Скрипт исполняется не в Node.js, а во встроенном рантайме; сетевая работа целиком на стороне Go, поэтому один процесс тянет тысячи VU. Оценка потребления — единицы мегабайт RAM на VU, так что 10 000 VU это уже десятки гигабайт: планируйте генератор как отдельную мощность.
Жизненный цикл скрипта: init-контекст выполняется в каждом VU при старте — здесь нельзя слать запросы, здесь грузят данные и объявляют метрики; setup() отрабатывает один раз на прогон (прогрев, фикстуры, логин), и возвращённое им значение приходит аргументом в остальные функции; default или именованная exec-функция — это одна итерация; teardown() убирает за собой. Данные грузите только через SharedArray: обычный JSON.parse(open(...)) в init отработает в каждом VU, и 500 VU по 40 МБ дадут гарантированный OOM генератора.
Исполнители (executors) — то, чем задаётся профиль:
| Исполнитель | Модель | Когда применять |
|---|---|---|
constant-vus |
закрытая | замкнутый контур с известной конкурентностью |
ramping-vus |
закрытая | плавный рост конкурентности, не интенсивности |
constant-arrival-rate |
открытая | плато на заданном RPS — основной рабочий режим |
ramping-arrival-rate |
открытая | ступени и рампы по интенсивности |
shared-iterations |
ограничение по объёму | «прогнать 10 000 операций и посчитать время» |
per-vu-iterations |
ограничение по объёму | воспроизводимые сценарии с фиксированным числом проходов |
Полный список параметров — в документации по исполнителям.
Первый честный скрипт
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { SharedArray } from 'k6/data';
const users = new SharedArray('users', () => JSON.parse(open('./users.json')));
export const options = {
scenarios: {
steps: {
executor: 'ramping-arrival-rate', // ОТКРЫТАЯ модель
startRate: 20, timeUnit: '1s',
preAllocatedVUs: 200, // выделяются заранее, до старта
maxVUs: 3000, // потолок; см. расчёт по закону Литтла
stages: [
{ target: 50, duration: '2m' }, // рампа
{ target: 50, duration: '5m' }, // ПЛАТО — только эти данные пойдут в отчёт
{ target: 100, duration: '2m' }, { target: 100, duration: '5m' },
{ target: 150, duration: '2m' }, { target: 150, duration: '5m' },
{ target: 0, duration: '1m' }, // спад: как быстро рассасывается очередь
],
},
},
thresholds: {
// Пороги — единственное, что делает тест ПРОВАЛЬНЫМ. check() прогон не валит.
'http_req_failed': ['rate<0.01'],
'http_req_duration{group:::checkout}': ['p(95)<800', 'p(99)<2000'],
// Генератор не удержал темп => результат недействителен, рвём прогон сразу.
'dropped_iterations': [{ threshold: 'count<1', abortOnFail: true }],
},
summaryTrendStats: ['min', 'med', 'avg', 'p(90)', 'p(95)', 'p(99)', 'max', 'count'],
};
export default function (data) {
const user = users[Math.floor(Math.random() * users.length)];
// ВАЖНО: тег name склеивает параметризованные URL в одну метрику. Без него
// /catalog/item/1, /catalog/item/2 ... породят миллион уникальных метрик.
const params = { headers: { Authorization: `Bearer ${data.token}` } };
group('catalog', () => {
const res = http.get(`${__ENV.BASE_URL}/catalog/item/${user.favoriteItem}`,
{ ...params, tags: { name: 'GET /catalog/item' } });
check(res, { 'каталог 200': (r) => r.status === 200 });
});
sleep(Math.random() * 2 + 1); // think time 1–3 с: живой человек читает страницу
group('checkout', () => {
const res = http.post(`${__ENV.BASE_URL}/checkout`,
JSON.stringify({ userId: user.id, itemId: user.favoriteItem }),
{ ...params, tags: { name: 'POST /checkout' } });
check(res, {
'checkout 201': (r) => r.status === 201,
'есть order_id': (r) => r.status === 201 && r.json('order_id') !== undefined,
});
});
}
Три детали, на которых спотыкаются.
check() не валит тест. Он только считает статистику. Провалить прогон (код выхода 99) может только thresholds. Если в CI у вас стоит check() и нет порогов, ваш «нагрузочный тест в пайплайне» зелёный при любом результате.
Теги — это ось разрезов. group и tags: { name } дают возможность писать пороги вида http_req_duration{group:::checkout} и смотреть перцентили по эндпоинтам отдельно. Без них у вас один общий котёл, в котором быстрый /health разбавляет медленный /checkout и p95 выглядит прилично.
sleep() — часть модели, а не «пауза». В открытой модели think time не снижает интенсивность прибытия, но удлиняет итерацию, а значит, по закону Литтла увеличивает требуемое число VU. Забыли think time — получили нереалистично агрессивный профиль и завышенную нагрузку на кэши.
Смешанный профиль: несколько сценариев в одном прогоне
Реальный трафик — не один эндпоинт. Доли снимаются с прод-логов и воспроизводятся параллельными сценариями:
const base = { executor: 'constant-arrival-rate', timeUnit: '1s', duration: '20m' };
export const options = {
scenarios: { // доли сняты с прод-логов
browse: { ...base, rate: 400, preAllocatedVUs: 300, maxVUs: 2000, // 80%
exec: 'browse', tags: { flow: 'browse' } },
checkout: { ...base, rate: 75, preAllocatedVUs: 150, maxVUs: 1500, // 15%
exec: 'checkout', tags: { flow: 'checkout' } },
reports: { ...base, rate: 25, preAllocatedVUs: 100, maxVUs: 800, // 5%
duration: '10m', startTime: '5m', // включается посреди плато
exec: 'report', tags: { flow: 'report' } },
},
thresholds: { // у каждого потока свой бюджет задержки
'http_req_duration{flow:browse}': ['p(99)<300'],
'http_req_duration{flow:checkout}': ['p(99)<1500'],
'http_req_duration{flow:report}': ['p(99)<10000'],
},
};
export function browse() { /* ... */ } // и так далее для checkout и report
startTime у третьего сценария — не украшение. Так проверяют интерференцию: включение тяжёлых отчётов посреди ровного плато сразу показывает, вытесняют ли они горячие данные из буферного пула и как это бьёт по p99 лёгких запросов. Это буквально эксперимент про кэши и локальность, только на уровне системы.
Анатомия одного запроса: из чего складывается http_req_duration
Разложение времени по фазам — самый информативный инструмент диагностики: он говорит, где искать причину, ещё до того, как вы полезли в серверные метрики.
| Метрика | Что означает | Аномалия говорит о |
|---|---|---|
http_req_blocked |
ожидание свободного соединения и DNS | исчерпан пул сокетов генератора, эфемерные порты, медленный резолвер |
http_req_connecting |
TCP handshake | keep-alive не работает, каждый запрос — новое соединение |
http_req_tls_handshaking |
TLS handshake | нет переиспользования сессий, дорогой ECDSA/RSA на каждый запрос |
http_req_waiting |
TTFB — сервер думает | собственно ваша система: CPU, база, внешние вызовы |
http_req_receiving |
чтение тела ответа | тяжёлые ответы, узкая полоса, TCP-окно, отсутствие сжатия |
iteration_duration |
вся итерация, включая sleep() |
по нему считают требуемые VU через закон Литтла |
dropped_iterations |
итерации, которые не стартовали | генератор не удержал заданный темп — прогон недействителен |
Диагностическая ценность в пропорциях. waiting = 280 мс при connecting = 0 — проблема внутри сервиса, идите профилировать. waiting = 20 мс, а blocked = 300 мс — проблема у генератора или в сети до сервиса, сервис ни при чём. Механику установки соединений и переиспользования разбирали в «Сетевой производительности» и в статье про TCP.
Чтение отчёта: сводка k6 построчно
Вот сводка десятиминутного прогона по открытой модели с целевым темпом 100 итераций/с (форматирование в разных версиях слегка отличается, набор метрик — нет; в k6 1.x сводку перекомпоновали по секциям и добавили --summary-mode):
✓ каталог 200
✗ есть order_id
↳ 98% — ✓ 54004 / ✗ 1789
checks.........................: 98.40% ✓ 109797 ✗ 1789
data_received..................: 2.1 GB 3.5 MB/s
dropped_iterations.............: 4207 7.011667/s
http_req_blocked...............: avg=112.3µs med=2.1µs p(95)=4.9µs max=1.02s
http_req_connecting............: avg=41.2µs med=0s p(95)=0s max=612ms
http_req_duration..............: avg=284.1ms med=131.2ms p(95)=1.42s p(99)=6.81s max=29.99s
{ expected_response:true }...: avg=241.6ms med=128.7ms p(95)=1.11s p(99)=4.02s max=9.98s
http_req_failed................: 1.60% ✓ 1789 ✗ 109797
http_req_receiving.............: avg=1.9ms med=112µs p(95)=4.8ms max=1.31s
http_req_waiting...............: avg=282.2ms med=129.9ms p(95)=1.41s p(99)=6.80s max=29.98s
http_reqs......................: 111586 185.98/s
iteration_duration.............: avg=1.29s med=1.11s p(95)=2.71s max=31.2s
iterations.....................: 55793 92.99/s
vus............................: 500 min=0 max=500
vus_max........................: 500 min=500 max=500
Читать это надо не сверху вниз, а по убыванию влияния на валидность.
и фактический RPS равен плановому?"} B -- "нет" --> B1["НЕДЕЙСТВИТЕЛЕН: генератору не хватило
VU, CPU, сокетов или полосы"] B -- "да" --> D{"Доля ошибок в пределах порога?"} D -- "нет" --> D1["Разобрать природу: 5xx, таймауты, reset, 429.
Быстрые ошибки занижают перцентили"] D -- "да" --> E["Разложить duration на blocked / connecting /
tls / sending / waiting / receiving"] E --> F{"waiting доминирует?"} F -- "да" --> G["Внутри сервиса: профили CPU, GC, планы запросов"] F -- "нет" --> H["Транспорт или генератор:
keep-alive, DNS, пул сокетов, полоса"] G --> I["Перцентили ПО ФАЗАМ, а не по всему прогону"] H --> I I --> J["Сопоставить с серверными USE и RED за то же окно"] J --> K["Вывод: ёмкость, узкое место, характер отказа"]
Теперь — что видно в конкретной сводке выше.
dropped_iterations = 4207 — прогон недействителен как измерение ёмкости. Планировалось 60 000 итераций, стартовало 55 793. Планировщик не смог удержать 100 итераций/с, потому что упёрся в maxVUs = 500. Проверим законом Литтла: при iteration_duration до 31 с для удержания темпа нужно было бы под 3000 VU. Всё, что ниже в отчёте, описывает нагрузку меньше заявленной. Именно поэтому в предыдущем скрипте стоит порог dropped_iterations: count<1 с abortOnFail.
http_req_failed: 1.60% ✓ 1789 ✗ 109797 — читайте наоборот. Это метрика типа Rate: «галочка» означает, что условие «запрос провалился» выполнилось. 1789 — число упавших запросов. Каждый раз, когда кто-то радостно показывает «✓ 1789», стоит уточнить, что именно он прочитал.
Строка { expected_response:true } — детектор ошибки выжившего. p99 по всем ответам 6.81 с, по успешным — 4.02 с. Разница в два с лишним раза означает, что медленные запросы систематически заканчивались ошибкой: таймауты обрубались раньше, чем сервис отвечал. Если бы вы смотрели только на подмножество успешных, картина была бы куда оптимистичнее реальности. Обратный случай ещё коварнее: сервис отдаёт мгновенные 503 из circuit breaker, они попадают в общую выборку и улучшают перцентили. Быстрые ошибки — самый эффективный способ «оптимизировать» p99.
avg=284 мс при med=131 мс и p99=6.8 с. Классическая тяжелохвостая картина, для которой среднее бессмысленно (подробно — в «Измерении»). Отдельно отметьте max=29.99s: круглое число у максимума почти всегда означает не задержку, а сработавший таймаут. И наконец, http_reqs = 185.98/s против плановых 200 (две итерации по два запроса при цели 100 итераций/с) — расхождение согласовано с дропами.
Перцентили по фазам, а не по прогону
Сводка k6 агрегирует весь прогон целиком: разогрев, рампы, плато и спад в одном котле. Перцентиль такой смеси не имеет физического смысла — это перцентиль по системе, которая за время измерения была четырьмя разными системами; плюс перцентили в принципе нельзя усреднять и складывать. Правильный путь — считать статистику по окнам. Сырой поток точек выгружается через k6 run --out json=raw.json (JSON Lines, по строке на точку; сотни мегабайт за прогон) или, для живых графиков, через --out experimental-prometheus-rw с K6_PROMETHEUS_RW_SERVER_URL.
"""Перцентили http_req_duration по окнам прогона k6 (вывод --out json).
Сложность: O(n log n) по времени (сортировка внутри окон), O(n) по памяти.
Для многочасовых soak-прогонов все точки в памяти держать нельзя — там нужен
потоковый HdrHistogram или t-digest с фиксированной памятью O(1).
"""
import json
from collections import defaultdict
from datetime import datetime
from statistics import quantiles
WINDOW_SEC = 30 # окно агрегации; должно быть много меньше длины плато
buckets: dict[int, list[float]] = defaultdict(list)
with open("raw.json", encoding="utf-8") as fh:
for line in fh: # JSON Lines: файл целиком не читаем
rec = json.loads(line)
if rec.get("type") != "Point" or rec.get("metric") != "http_req_duration":
continue
d = rec["data"]
if d["tags"].get("expected_response") != "true":
continue # неуспешные считаем отдельным потоком
ts = datetime.fromisoformat(d["time"].replace("Z", "+00:00")).timestamp()
buckets[int(ts) // WINDOW_SEC * WINDOW_SEC].append(d["value"])
print(f"{'окно':>10} {'N':>7} {'p50, мс':>9} {'p95, мс':>9} {'p99, мс':>9}")
for bucket in sorted(buckets):
vals = buckets[bucket]
if len(vals) < 100: # мало данных — перцентиль шумит
continue
# quantiles(n=100) даёт 99 разрезов: индекс 49 -> p50, 94 -> p95, 98 -> p99
q = quantiles(vals, n=100, method="inclusive")
stamp = datetime.fromtimestamp(bucket).strftime("%H:%M:%S")
print(f"{stamp:>10} {len(vals):>7} {q[49]:>9.1f} {q[94]:>9.1f} {q[98]:>9.1f}")
Получившаяся таблица — и есть настоящий результат ступенчатого теста. По ней видно, на какой ступени p99 сорвался, и это конкретное число становится вашей измеренной ёмкостью. Одна усреднённая цифра «выдерживаем 5000 RPS» такой информации не несёт и почти всегда завышена.
Режимы системы под нагрузкой
Ступенчатый и breakpoint-тесты нужны, чтобы увидеть переходы между режимами и понять, обратимы ли они.
Переход S4 → S5 — не про мощность, а про обратные связи. Клиент таймаутится и повторяет запрос, сервер продолжает считать уже никому не нужный ответ, ретраи умножают нагрузку, очереди наполняются устаревшими заданиями. Система остаётся в отказе даже после того, как исходная нагрузка вернулась к норме, — это и есть метастабильный отказ, описанный в HotOS 2021. Лечится не железом, а сбросом нагрузки и ограничением конкурентности: очереди с дедлайнами и отбрасыванием протухших заданий, адаптивные лимиты конкурентности (разбор Netflix), бюджет ретраев вместо безусловных повторов, circuit breaker. Архитектурная сторона — в статье про паттерны устойчивости, операционная — в «Handling Overload» из SRE-книги Google. И главное: тест, который останавливается на пороге отказа «чтобы ничего не сломать», не отвечает на самый дорогой вопрос — сломается ли оно необратимо. Ломайте на стенде осознанно.
Генератор — тоже система, и он упирается первым
Самая частая причина недействительных результатов: кривая пропускной способности заворачивается вниз, все радостно фиксируют «нашли предел сервиса», а на самом деле задохнулся k6.
# 1. Потолок генератора: тот же профиль в заглушку, отдающую 200 мгновенно.
docker run -d -p 8080:80 nginx:alpine
k6 run -e BASE_URL=http://localhost:8080 tests/load/steps.js
# Заглушка держит 12k RPS => тест сервиса на 10k RPS недостоверен.
# Рабочая зона — примерно до трети измеренного потолка генератора.
# 2. Лимиты, в которые упирается генератор
ulimit -n 262144 # по умолчанию часто 1024: хватит на пару сотен сокетов
sysctl -w net.ipv4.ip_local_port_range="10000 65535" # эфемерные порты
sysctl -w net.ipv4.tcp_tw_reuse=1 # переиспользование сокетов в TIME_WAIT
# 3. Во время прогона: генератор не должен быть в потолке по CPU
top -H -p "$(pgrep -x k6)"
ss -s # сколько сокетов, сколько в TIME_WAIT
# 4. Не хватает одной машины — раскладываем нагрузку по сегментам
k6 run --execution-segment "0:1/4" --execution-segment-sequence "0,1/4,2/4,3/4,1" s.js
# либо k6-operator в Kubernetes: https://github.com/grafana/k6-operator
Чеклист «генератор не врёт»: он не в потолке по CPU (запас минимум 2×); хватает дескрипторов и эфемерных портов; он не сидит за тем же прокси или NAT, что и продовый трафик; DNS резолвится один раз, а не на каждый запрос; сеть между генератором и сервисом заведомо шире полезной нагрузки; сам генератор не соревнуется с сервисом за ядра одной машины. Последнее особенно обидно: docker compose up на ноутбуке, где k6 и приложение делят четыре ядра, измеряет исключительно ваш ноутбук.
Данные и реализм: где тест расходится с продом
Технически безупречный прогон может измерять фантазию. Типичные расхождения:
- Один пользователь на всех. 500 VU ходят с одним
user_id— все данные в буферном пуле и во всех кэшах, база отвечает за 0.2 мс. В проде так не будет никогда. Нужен реалистичный по перекосу набор ключей: не равномерный (он убивает кэш) и не одноточечный (он его обожествляет), а близкий к степенному распределению — см. разбор в «Кэшировании». - Пустая база. 10 000 строк против 300 миллионов в проде — это разные планы запросов и разная глубина B-дерева; наполняйте хотя бы до порядка, где планировщик выбирает те же планы («Производительность БД»).
- Стерильный стенд и только счастливые пути. В проде параллельно идут бэкапы, репликация и аналитические джобы, а трафик содержит 404, ботов, ретраи, невалидные токены и обрывы соединений; обработка ошибок часто дороже успешного пути.
- Уборка. Тест на 20 минут при 75 checkout/с создаёт 90 000 заказов. Если их не убирать, второй прогон идёт по базе другого размера и несравним с первым: нужен
teardown, отдельная схема или восстановление снапшота между прогонами. - Аутентификация. Логин на каждой итерации нагружает bcrypt так, что остального профиля становится не видно. В проде токены живут часами — переиспользуйте их, но не сводите к одному на всех: это скроет проблемы с кэшем сессий.
Профиль лучше не выдумывать, а снимать. Доли эндпоинтов, форму суточной кривой и распределение параметров достают из access-логов или из уже имеющихся метрик RED:
# Доли эндпоинтов за сутки — основа для весов сценариев.
# sed схлопывает идентификаторы в /{id}, иначе каждый URL уникален.
awk '{print $7}' access.log | sed -E 's#/[0-9a-f-]{8,}#/{id}#g' \
| sort | uniq -c | sort -rn | head -20
Более честный вариант — теневой трафик: копия реального потока направляется на кандидатскую версию, ответы отбрасываются. Он снимает вопрос реализма полностью, но требует аккуратности с побочными эффектами (записи, письма, платежи) и удваивает нагрузку на общие зависимости.
Наблюдаемость во время прогона: тест говорит «что», метрики — «почему»
Отчёт k6 — это взгляд клиента: он сообщит, что p99 вырос до 6 секунд, но никогда не скажет, почему. Прогон без снятия серверной телеметрии за то же временное окно — потраченное впустую время стенда. Синхронно с тестом собирают: USE по ресурсам каждого узла и RED по сервисам (расхождение серверных чисел с клиентскими числами k6 — это и есть время, проведённое в сети и очередях; методики — в «Измерении»); насыщение пулов — занятые соединения к БД, длину очереди воркеров, глубину accept-очереди сокета; pg_stat_statements со сбросом перед прогоном; на soak-прогонах — тренд RSS и пауз GC («Память»). Самое ценное — снять pprof или perf именно на плато, а не на холостом ходу: флеймграф под продовым профилем нагрузки показывает совсем другие горячие пути, чем микробенчмарк («Профилирование CPU»).
Помечайте прогоны: пишите test_id в тег каждого запроса и в аннотации Grafana, чтобы потом сопоставить окно теста с графиками. Про инфраструктуру телеметрии — «Наблюдаемость и дежурства» и «Наблюдаемость распределённых систем».
Каталог того, как нагрузочный тест врёт
| Самообман | Как выглядит | Как ловить |
|---|---|---|
| Coordinated omission | closed-модель, красивый p99 при явных зависаниях | открытая модель, dropped_iterations, wrk2, HdrHistogram |
| Генератор в потолке | кривая заворачивается ровно там же на разных сервисах | прогон против заглушки, мониторинг CPU генератора |
| Одна цифра на весь прогон | «выдерживаем 5000 RPS» | перцентили по окнам, отдельно по каждому плато |
| Ошибка выжившего | перцентили только по 2xx | сравнить общий срез с {expected_response:true} |
| Быстрые ошибки | p99 «улучшился», доля 5xx выросла | смотреть latency и error rate только вместе |
| Кэш прогрет одним ключом | hit rate 99.9%, база спит | реалистичное распределение ключей, отчёт по hit rate |
| Ретраи внутри скрипта | ошибок «нет», задержка занижена | не ретраить в тесте; ретраи — часть измеряемого поведения |
| Стенд в 10 раз меньше прода | линейная экстраполяция ёмкости | закон Амдала и USL: масштабирование сублинейно |
| Разные условия у прогонов | «стало быстрее» после переезда стенда | базовая линия в тех же условиях, повтор прогонов |
| Тестируется не то | 80% продового трафика — статика, а тест бьёт в API | веса сценариев из логов |
Отдельно про экстраполяцию. Соблазн «на стенде из 2 узлов получили 1000 RPS, значит на 20 узлах будет 10 000» разбивается о закон Амдала и универсальный закон масштабируемости Ганта: с ростом параллелизма растут и доля сериализации, и стоимость когерентности, поэтому кривая сначала отклоняется от прямой, а затем разворачивается вниз (материалы Нила Ганта, разбор — в «Производительности конкурентного кода»). Экстраполировать можно только после того, как вы измерили хотя бы три точки и увидели форму кривой.
Пороги, CI и базовая линия
Нагрузочное тестирование редко бывает одним прогоном. Типичная кампания перед сезонным пиком идёт в фиксированном порядке: ступени дают базовую линию и ёмкость, breakpoint — характер отказа, spike — реакцию автоскейлинга, soak — накопительные эффекты, невидимые за 20 минут. Правки делаются после всего блока измерений, а не параллельно с ним: иначе непонятно, какая из пяти правок что дала. Заканчивается кампания закреплением — порогами и smoke в CI, иначе через два месяца всё вернётся.
Прогон, результат которого никто не сравнивает с прошлым, — разовая эмоция; автоматизация начинается с порогов, дающих ненулевой код возврата, и хранимой базовой линии.
# .github/workflows/perf.yml
name: perf
on:
pull_request: # быстрый дым на каждый PR
schedule: [{ cron: '0 2 * * *' }] # полный ступенчатый прогон ночью
jobs:
smoke:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker compose up -d --wait
- uses: grafana/setup-k6-action@v1
# Пороги внутри скрипта. Провал порога => код возврата 99 => красный PR.
- run: k6 run --quiet tests/load/smoke.js
env: { BASE_URL: 'http://localhost:8080' }
nightly:
if: github.event_name == 'schedule'
runs-on: perf-runner # выделенный узел: shared-раннеры слишком шумные
steps:
- uses: actions/checkout@v4
- uses: grafana/setup-k6-action@v1
- run: k6 run --out json=raw.json tests/load/steps.js
env: { BASE_URL: '${{ secrets.STAGING_URL }}' }
- run: python3 tools/compare_baseline.py raw.json baselines/steps.json
- uses: actions/upload-artifact@v4
if: always()
with: { name: k6-raw, path: raw.json }
Два важных ограничения. Первое: на PR гоняется только smoke. Полноценный нагрузочный тест на общем CI-раннере даёт разброс в разы и будет флапать — про природу этого шума подробно в «Бенчмаркинге честно» и в статье про тесты в CI. Второе: сравнение с базовой линией должно учитывать разброс. Порог «p95 не хуже базовой линии» будет краснеть через раз; работающий вариант — «p95 не хуже базовой линии более чем на 10% по медиане трёх прогонов». Как строить такие ворота и как не превратить их в источник постоянного шума — тема следующей статьи.
Чеклист перед тем, как поверить отчёту
- Сформулирован вопрос и выбран профиль под него; в отчёте написано, какой именно профиль гонялся.
- Модель открытая, если только контур в реальности не замкнут.
dropped_iterations = 0; фактический RPS совпал с плановым.- Измерен потолок генератора, рабочая точка не выше трети от него; генератор не делит ресурсы с тестируемой системой.
- Плато достаточно длинное; разогрев отброшен, статистика считается по окнам.
- Приведены p50, p95, p99 и максимум по каждой ступени, а не одним числом.
- Доля и природа ошибок приведены рядом с задержками; сравнены общий срез и срез по успешным ответам.
- Данные и распределение ключей реалистичны; база сопоставима с продовой по объёму.
- Синхронно сняты серверные метрики и хотя бы один профиль на плато.
- Условия прогона зафиксированы: версия, конфиг, размер стенда, время суток, дата.
- Известно, что произошло на спаде: система вернулась в норму сама или потребовалось вмешательство.
Если пункты 2–4 не выполнены, вы измерили генератор. Если 5–7 — вы получили число, но не знаете, что оно значит.
Мини-итог
- Нагрузочный тест начинается с вопроса и профиля, а не с запуска инструмента. «Провести нагрузочное» — не задача.
- Открытая модель по умолчанию: интенсивность прибытия задаёт мир, а не ваш сервис. Закрытая — только для действительно замкнутых контуров.
- Закон Литтла $L = \lambda W$ работает как калькулятор VU, ёмкости и числа инстансов, а заодно как детектор внутренне противоречивых отчётов.
- В k6 тест валит только
thresholds, а неcheck(); теги и группы — единственный способ получить осмысленные разрезы. Разложениеhttp_req_durationпо фазам показывает, где искать причину, до похода в серверные метрики;waiting— это ваш сервис,blockedиconnecting— чаще всего нет. - Отчёт читается по убыванию влияния на валидность: сначала дропы и фактический RPS, потом ошибки, только потом перцентили — и всегда по окнам, а не агрегатом за прогон.
- Генератор упирается первым чаще, чем сервис. Пока вы не измерили его потолок, вы не знаете, что измеряете.
- Самое ценное в breakpoint-тесте — не число, а ответ на вопрос, возвращается ли система из отказа сама; а прогон без серверной телеметрии за то же окно говорит «медленно», но никогда — «почему».
Источники
- Grafana k6. Документация, исполнители и сценарии, справочник метрик, пороги, k6-operator.
- Gil Tene. How NOT to Measure Latency — доклад про coordinated omission; wrk2 и HdrHistogram.
- Brendan Gregg. Systems Performance, 2nd ed., Addison-Wesley, 2020; Neil Gunther. Universal Scalability Law и Guerrilla Capacity Planning.
- Google SRE. Handling Overload и Addressing Cascading Failures; Bronson, Aghayev, Charapko, Zhu. Metastable Failures in Distributed Systems, HotOS 2021; Netflix Technology Blog. Performance Under Load про адаптивные лимиты конкурентности.
- Альтернативные генераторы: Gatling, Locust, Apache JMeter, Vegeta, Fortio, oha, ghz для gRPC.
- Смежное на портале: «Тестирование производительности» со стороны QA-процесса, «Kubernetes» про поведение автоскейлинга, «Веб-производительность» про нагрузку со стороны браузера.
Что дальше
Мы научились получать честные числа под нагрузкой. Осталось встроить их в работу так, чтобы производительность не деградировала между релизами и чтобы оптимизация когда-нибудь заканчивалась. Следующая статья — про бюджеты производительности, ловлю регрессий в CI без бесконечных флапов, приоритизацию работ по измеренной выгоде и главный вопрос инженерной зрелости: как понять, что пора остановиться.
Рабочий процесс оптимизации: бюджеты, регрессии в CI, когда остановиться