Нагрузочное тестирование: k6, JMeter, профили нагрузки, чтение результатов
Функциональные тесты отвечают на вопрос «работает ли». Нагрузочные — на вопрос «работает ли, когда пользователей много, и что именно сломается первым». Это принципиально другой класс проверок: у них нет бинарного «прошёл/не прошёл» без заранее заданного критерия, результат — распределение, а не значение, и почти каждый первый отчёт по нагрузке в реальной жизни описывает производительность тестового стенда, а не продукта.
Эта статья — про то, как сделать нагрузочный тест, которому можно верить: как выбрать профиль, как не соврать себе средним временем отклика, как написать скрипт на k6 и JMeter, как встроить это в CI и, главное, как из графика перейти к конкретной строчке кода или конкретному индексу в БД.
Зачем это нужно и когда это дешевле не делать
Отказ под нагрузкой — самый дорогой класс дефектов после утечки данных. Он случается ровно в тот момент, когда трафика больше всего: Чёрная пятница, рассылка, попадание в топ. Стоимость минуты недоступности в такие моменты измеряется не в инцидент-часах, а в упущенной выручке и в оттоке.
При этом нагрузочное тестирование — дорогое удовольствие. Нужен стенд, похожий на прод, нужны данные объёмом как в проде, нужен человек, умеющий читать метрики JVM/GC/pg_stat_statements. Как и в остальном курсе, здесь работает риск-ориентированный подход: нагружать надо не всё, а то, где произведение «вероятность отказа × цена отказа» максимально.
Правило, которое экономит недели: если у сценария нет цифрового требования — не
начинайте тест, начните с требования. «Должно быть быстро» не тестируется.
Тестируется «p95 времени ответа POST /orders не выше 800 мс при 400 RPS в
течение 30 минут, доля ошибок ниже 0,1%».
Разработчику и тестировщику здесь удобно разделить труд: тестировщик отвечает за профиль, реалистичность сценария и корректность требований, разработчик — за то, чтобы у сервиса были метрики, трейсы и возможность профилироваться под нагрузкой. Тест без наблюдаемости на стороне сервиса даёт только один бит информации: «плохо».
Словарь, без которого разговор рассыпается
- Latency (время отклика) — сколько ждёт один запрос. Всегда распределение, никогда одно число.
- Throughput (пропускная способность) — сколько запросов система переваривает за секунду (RPS/TPS). Это свойство системы, а не теста.
- Concurrency / VU (virtual users) — сколько запросов «в полёте» одновременно или сколько виртуальных пользователей крутит сценарий.
- Think time — пауза между шагами сценария, имитирующая живого человека. Убрать её — значит превратить 100 пользователей в нагрузку тысячи.
- Saturation — насыщение ресурса (CPU, пул соединений, IOPS). Именно насыщение, а не «нагрузка», ломает систему.
Связь между ними даёт закон Литтла: среднее число заявок в системе = RPS × среднее время пребывания. Практический вывод: чтобы получить 500 RPS при времени ответа
200 мс и think time 1 с, нужно примерно 500 × (0,2 + 1) = 600 виртуальных
пользователей. Если инструмент упирается в VU, вы получите не 500 RPS, а сколько
получится — и молча измерите не то, что хотели.
Второе следствие того же закона объясняет форму всех кривых нагрузки: пока система не насыщена, рост RPS почти не меняет время отклика; после насыщения очередь растёт нелинейно, и время отклика уходит в потолок при копеечном приросте RPS. Это и есть «колено» графика, ради поиска которого делают stress-тест.
Виды нагрузочных тестов и их профили
Несколько практических замечаний.
Smoke-нагрузка обязательна. Прежде чем запускать сорокаминутный тест на 400 RPS, прогоните тот же скрипт на 1 VU и 30 секунд. Половина «интересных результатов» на самом деле — это 100% ошибок 401, потому что протух токен в скрипте.
Stress-тест ценен не числом, а поведением. Вопрос не «сколько выдержим», а «как умрём»: аккуратно вернём 503 и восстановимся за минуту после снятия нагрузки или уйдём в кому с распухшим пулом соединений и не поднимемся без рестарта. Второе — это дефект, который надо чинить, даже если предел устраивает.
Spike проверяет не сервис, а всю обвязку. Автоскейлер с холодным стартом в 90 секунд, rate limiter, коннекшен-пул к БД, warm-up JIT — всплеск ловит именно их.
Soak ловит то, чего не поймает ничто другое. Утечки памяти, незакрытые соединения, рост фрагментации, забитый диск логами, распухание временных таблиц. Если p99 к четвёртому часу вырос вдвое при постоянной нагрузке — у вас утечка, и никакой короткий тест её не увидит.
Открытая и закрытая модель нагрузки — главная методическая ошибка
Это то место, где большинство отчётов начинает врать.
Закрытая модель (closed model): фиксировано число виртуальных пользователей. Каждый VU отправляет запрос, ждёт ответа, делает паузу, отправляет следующий. Если сервис тормозит, VU просто ждёт дольше — и нагрузка автоматически снижается.
Открытая модель (open model): фиксирована интенсивность прибытия запросов, N запросов в секунду, независимо от того, ответил сервис или нет. Если сервис тормозит, запросы копятся — как копится очередь реальных пользователей, которым плевать на ваш пул.
измеренный RPS упал сам собой C->>S: следующий запрос O->>S: запрос (t=0) O->>S: запрос (t=50 мс) O->>S: запрос (t=100 мс) Note over O,S: интенсивность не падает,
очередь растёт, латентность взрывается S-->>O: ответы приходят с растущей задержкой
Реальный пользовательский трафик — почти всегда открытая модель. Люди приходят на сайт независимо от того, справляетесь ли вы. Поэтому тест в закрытой модели систематически недооценивает деградацию: он сам себя притормаживает и рисует красивую картинку у самого края обрыва.
С этим тесно связан coordinated omission — эффект, описанный Гилом Тене
(статья и доклад).
Если генератор нагрузки ждёт ответа перед следующим запросом, то самые медленные
периоды системы вообще не попадают в выборку: во время двухсекундной GC-паузы
инструмент не отправил ни одного запроса, а значит, не измерил ни одной плохой
задержки. Итог — красивый p99, за которым в проде стоит стонущий пользователь.
Лечится либо открытой моделью, либо коррекцией на ожидаемый интервал отправки
(что умеют wrk2, HdrHistogram, JMeter с throughput timer).
Практическое правило: сценарии, где вы проверяете SLO, гоните в открытой модели (constant arrival rate). Закрытая модель уместна, когда вы моделируете систему с реально ограниченным числом клиентов — терминалы кассиров, батч-джобы, конечный пул интеграций.
Как читать результат: перцентили, а не среднее
Среднее время отклика — бесполезная метрика, и вот почему:
- Распределение задержек всегда правоскошенное, с длинным хвостом. Среднее зажато между медианой и хвостом и не описывает ни то, ни другое.
- Среднее нельзя агрегировать по SLO: пользователь не переживает «среднее», он переживает свой конкретный запрос.
- В микросервисной архитектуре хвост усиливается: если страница собирается из 10
параллельных вызовов с p99 = 1 с, вероятность, что хотя бы один попадёт в
хвост, около
1 − 0,99^10 ≈ 9,6%. То есть p99 одного сервиса становится p90 страницы. Это «tail at scale» из классической статьи Дина и Барросо.
Что смотреть вместо этого:
| Метрика | Зачем |
|---|---|
| p50 | «типичный» опыт, база для сравнения релизов |
| p95 / p99 | по ним пишут SLO; p99 — это каждый сотый запрос, а у активного пользователя их сотни за сессию |
| max | подсказка про GC-паузы, таймауты, блокировки |
| error rate | всё остальное бессмысленно, если 20% запросов упали |
| фактический RPS | подтверждение, что тест реально выдал заданную нагрузку |
| dropped/timeouts | генератор нагрузки сам мог не справиться |
И отдельно: никогда не усредняйте перцентили. Среднее из p95 пяти инстансов не равно p95 по системе. Агрегировать можно только гистограммы (HdrHistogram, Prometheus histogram) — поэтому и k6, и Prometheus считают перцентили из бакетов, а не из готовых чисел.
k6: рабочий скрипт от начала до конца
k6 — на сегодня самый удобный инструмент для инженерного нагрузочного тестирования: скрипты на JavaScript/TypeScript, движок на Go (низкие накладные расходы), первоклассная поддержка открытой модели и порогов, которые превращают тест в pass/fail для CI.
// checkout.js — сценарий оформления заказа
import http from 'k6/http';
import { check, group, sleep, fail } from 'k6';
import { Trend, Rate, Counter } from 'k6/metrics';
import { SharedArray } from 'k6/data';
import exec from 'k6/execution';
// Тестовые данные читаются один раз на процесс, а не на каждого VU.
const users = new SharedArray('users', () => JSON.parse(open('./users.json')));
// Собственные метрики: нас интересует именно бизнес-шаг, а не «средний http_req».
const checkoutDuration = new Trend('checkout_duration', true);
const checkoutFailures = new Rate('checkout_failed');
const ordersCreated = new Counter('orders_created');
export const options = {
// Несколько сценариев в одном прогоне: фон + целевой профиль.
scenarios: {
browse: {
executor: 'constant-arrival-rate', // ОТКРЫТАЯ модель
rate: 300, timeUnit: '1s',
duration: '20m',
preAllocatedVUs: 200, maxVUs: 800,
exec: 'browseCatalog',
tags: { scenario: 'browse' },
},
checkout: {
executor: 'ramping-arrival-rate',
startRate: 10, timeUnit: '1s',
preAllocatedVUs: 100, maxVUs: 400,
stages: [
{ target: 50, duration: '3m' }, // разгон
{ target: 50, duration: '15m' }, // плато — по нему и судим
{ target: 0, duration: '2m' }, // плавный спад
],
exec: 'checkout',
tags: { scenario: 'checkout' },
},
},
// Пороги = критерии приёмки. Именно они дают ненулевой exit code.
thresholds: {
'http_req_failed': ['rate<0.01'],
'checkout_failed': ['rate<0.005'],
// abortOnFail экономит часы: тест падает сразу, а не через 20 минут.
'checkout_duration': [
{ threshold: 'p(95)<800', abortOnFail: true, delayAbortEval: '1m' },
'p(99)<2000',
],
'http_req_duration{scenario:browse}': ['p(95)<300'],
},
// Отсекаем разгон из статистики, чтобы холодный старт не портил перцентили.
discardResponseBodies: true,
summaryTrendStats: ['avg', 'med', 'p(90)', 'p(95)', 'p(99)', 'max'],
};
const BASE = __ENV.BASE_URL || 'https://staging.example.com';
function auth() {
// Каждый VU берёт СВОЕГО пользователя: иначе все дерутся за одну корзину
// и вы измеряете конкуренцию за блокировку, а не производительность.
const u = users[exec.vu.idInTest % users.length];
const res = http.post(`${BASE}/api/login`, JSON.stringify(u), {
headers: { 'Content-Type': 'application/json' },
tags: { name: 'POST /api/login' },
});
if (res.status !== 200) fail(`login failed: ${res.status}`);
return { Authorization: `Bearer ${res.json('token')}` };
}
export function browseCatalog() {
const res = http.get(`${BASE}/api/products?page=${Math.ceil(Math.random() * 50)}`, {
tags: { name: 'GET /api/products' }, // важно: агрегируем по шаблону URL
});
check(res, { 'каталог 200': (r) => r.status === 200 });
sleep(Math.random() * 3 + 1); // think time живого человека
}
export function checkout() {
const headers = auth();
const started = Date.now();
let ok = true;
group('оформление заказа', () => {
const cart = http.post(`${BASE}/api/cart/items`,
JSON.stringify({ sku: 'SKU-1042', qty: 1 }),
{ headers: { ...headers, 'Content-Type': 'application/json' },
tags: { name: 'POST /api/cart/items' } });
ok = check(cart, { 'товар добавлен': (r) => r.status === 201 }) && ok;
sleep(2);
const order = http.post(`${BASE}/api/orders`, JSON.stringify({ payment: 'card' }),
{ headers: { ...headers, 'Content-Type': 'application/json' },
tags: { name: 'POST /api/orders' } });
ok = check(order, {
'заказ создан': (r) => r.status === 201,
'вернулся id': (r) => r.json('id') !== undefined,
}) && ok;
if (order.status === 201) ordersCreated.add(1);
});
checkoutDuration.add(Date.now() - started);
checkoutFailures.add(!ok);
}
Запуск и вывод результатов:
# локальный прогон с отправкой метрик в Prometheus (remote write)
K6_PROMETHEUS_RW_SERVER_URL=http://prometheus:9090/api/v1/write \
k6 run --out experimental-prometheus-rw \
--tag testid=checkout-2026-07-16 \
-e BASE_URL=https://staging.example.com checkout.js
# машиночитаемая сводка для отчёта и трендов
k6 run --summary-export=summary.json checkout.js
# распределённый запуск в Kubernetes через оператор
kubectl apply -f k6-testrun.yaml
Ключевые вещи, которые делают k6-скрипт пригодным для продакшна, а не для демонстрации:
tags: { name: ... }на каждом запросе. Иначе/api/orders/17и/api/orders/93попадут в разные строки отчёта, и агрегата не будет.SharedArrayдля данных. Обычный массив копируется в каждый VU — на 800 VU это гигабайты и OOM генератора.- Пороги, а не глазами. Тест без
thresholds— это отчёт, а не тест. discardResponseBodies: true, если тело не нужно: экономит CPU генератора.- Разные пользователи и разные данные. Один и тот же SKU у 400 VU — это тест на блокировку строки в БД, а не на checkout.
JMeter: когда он по-прежнему лучший выбор
Apache JMeter старше и тяжелее, но выигрывает там, где k6 слаб: богатый набор протоколов из коробки (JDBC, JMS, LDAP, FTP, SMTP, TCP), GUI для сборки сценария людьми без программирования, огромная экосистема плагинов, привычность для корпоративных команд производительности.
Структура тест-плана:
число потоков, ramp-up, длительность] TP --> CFG[Config Elements
HTTP Defaults, CSV Data Set, Cookie Manager] TG --> LC[Logic Controllers
Transaction, If, Loop, Throughput] LC --> SMP[Samplers
HTTP Request, JDBC Request] SMP --> PRE[Pre-Processors
подстановка параметров] SMP --> POST[Post-Processors
JSON Extractor: вытащить id] SMP --> ASRT[Assertions
код ответа, тело, длительность] SMP --> TMR[Timers
think time, Constant Throughput] TG --> LST[Listeners
только в non-GUI: JTL + HTML report]
Правила, которые отличают рабочий JMeter от карго-культа:
# ЕДИНСТВЕННЫЙ корректный способ запуска нагрузки: non-GUI.
# GUI сам съедает CPU и искажает результат — в нём только редактируют план.
jmeter -n -t checkout.jmx \
-l results.jtl \
-e -o ./report \
-Jthreads=400 -Jrampup=180 -Jduration=1800 \
-Jhost=staging.example.com
# распределённый режим: контроллер раздаёт план на воркеры
jmeter -n -t checkout.jmx -R worker1:1099,worker2:1099 -l results.jtl
# JMeter — Java-приложение, ему нужна своя куча, иначе он ляжет раньше сервиса
export JVM_ARGS="-Xms2g -Xmx6g -XX:MaxMetaspaceSize=512m"
Отдельно про Constant Throughput Timer и Precise Throughput Timer: это то,
чем JMeter приближается к открытой модели. Без них Thread Group — классическая
закрытая модель со всеми её искажениями. Ещё один нюанс: Listeners типа «View
Results Tree» в нагрузочном прогоне надо выключать полностью — они пишут каждый
ответ на диск и легко становятся узким местом самого теста.
Практический критерий выбора:
| Ситуация | Инструмент |
|---|---|
| HTTP/gRPC/WebSocket, тест живёт в git рядом с кодом, нужен CI-гейт | k6 |
| Нужен JDBC, JMS, legacy-протоколы, сценарий собирает не-программист | JMeter |
| Команда пишет на Python, нужны сложные ветвления и своя логика | Locust |
| Максимальная нагрузка с одной машины, Scala/Java-команда | Gatling |
| Быстро «постучать» одним эндпоинтом из терминала | wrk2, vegeta, hey, oha |
Пример на Locust — полезен, когда нагрузочный сценарий требует настоящей логики (разбор ответов, работа с очередью, обращение к БД для подготовки данных):
import random
from locust import HttpUser, task, between, events
class ShopUser(HttpUser):
# think time между задачами — обязательная часть реалистичности
wait_time = between(1, 4)
def on_start(self) -> None:
"""Логин один раз на пользователя, а не на каждый запрос."""
creds = {"login": f"user{random.randint(1, 5000)}", "password": "secret"}
with self.client.post("/api/login", json=creds, catch_response=True) as r:
if r.status_code != 200:
r.failure(f"login {r.status_code}")
self.environment.runner.quit()
return
self.client.headers["Authorization"] = f"Bearer {r.json()['token']}"
@task(10) # вес: каталог смотрят в 10 раз чаще, чем покупают
def browse(self) -> None:
page = random.randint(1, 50)
# name= схлопывает URL с параметрами в одну строку отчёта
self.client.get(f"/api/products?page={page}", name="/api/products?page=[n]")
@task(1)
def checkout(self) -> None:
self.client.post("/api/cart/items", json={"sku": "SKU-1042", "qty": 1})
with self.client.post("/api/orders", json={"payment": "card"},
catch_response=True) as r:
# 201 — успех; всё остальное считаем провалом бизнес-операции
if r.status_code != 201:
r.failure(f"order failed: {r.status_code}")
@events.quitting.add_listener
def _(environment, **kwargs):
"""Превращаем прогон в pass/fail — иначе CI всегда зелёный."""
stats = environment.stats.total
if stats.fail_ratio > 0.01:
environment.process_exit_code = 1
elif stats.get_response_time_percentile(0.95) > 800:
environment.process_exit_code = 1
Запуск: locust -f loadtest.py --headless -u 400 -r 20 -t 20m --host https://staging.example.com.
Стенд, данные и всё, что портит результат до его получения
Самая частая причина бессмысленного отчёта — не инструмент, а окружение.
Генератор нагрузки не должен быть узким местом. Всегда снимайте с него CPU,
сеть и число открытых сокетов. Один ноутбук через корпоративный VPN не выдаст
1000 RPS — он выдаст 1000 RPS ограничений VPN. Признаки, что вы измеряете
генератор: латентность растёт, а CPU сервиса не растёт; ошибки — сплошь
connection reset и EADDRNOTAVAIL (кончились эфемерные порты).
Стенд должен быть сопоставим с продом по топологии, а не по мощности. Стенд в 1/4 мощности допустим, если сохранены пропорции: те же реплики БД, тот же тип диска, тот же балансировщик, те же лимиты подов. Экстраполяция «стенд в 4 раза слабее, значит прод выдержит в 4 раза больше» неверна почти всегда — узкие места нелинейны (блокировки, GC, размер кэша относительно объёма данных).
Данных должно быть столько же, сколько в проде. Запрос по таблице в 10 тысяч строк живёт в кэше и летает; тот же запрос по 50 миллионам строк уходит в seq scan. Это самая частая причина «на стенде было 40 мс, в проде 4 секунды».
Прогрев обязателен. JIT-компиляция JVM/.NET, прогрев кэшей приложения и БД, установление пулов соединений, первые запросы после деплоя — всё это даёт завышенные значения на первой минуте. Либо явный warm-up, либо отбрасывание первых N минут из статистики.
Изоляция. Один параллельный CI-прогон интеграционных тестов на том же стенде превращает нагрузочный результат в шум. Нагрузочный стенд занимается монопольно — это организационное требование, а не техническое пожелание.
От графика к причине: как читать результат
Отчёт «p95 = 3 секунды при 300 RPS» не является результатом. Результат — это
«p95 = 3 секунды, потому что пул соединений к PostgreSQL исчерпан (20 из 20 занято),
а занят он запросом SELECT ... ORDER BY created_at без индекса, который на 40
млн строк выполняется 240 мс».
сервиса вместе с RPS?} B -- Нет, CPU низкий --> C{Где ждём?
Смотрим трейсы APM} B -- Да, CPU ~100% --> D[Упёрлись в вычисления:
профилировщик, флеймграф,
сериализация/шифрование/regexp] C --> E{Ожидание на пуле
соединений к БД?} E -- Да --> F[Медленные запросы или мало соединений:
pg_stat_statements, EXPLAIN ANALYZE,
индексы, N+1] E -- Нет --> G{Ожидание внешнего
вызова / очереди?} G -- Да --> H[Узкое место у соседа:
таймауты, ретраи, отсутствие бэкпрешера] G -- Нет --> I{Паузы GC / stop-the-world
совпадают с всплесками p99?} I -- Да --> J[Память: аллокации, размер кучи,
смена сборщика, кэш вместо мусора] I -- Нет --> K{Ошибки сети:
reset, порты, лимит FD?} K -- Да --> L[Проблема в генераторе
или в сетевой обвязке, не в сервисе] K -- Нет --> M[Насыщение диска/IOPS
или блокировки в БД: pg_locks, iostat] D --> Z[Гипотеза → правка → повторный прогон
того же профиля] F --> Z H --> Z J --> Z M --> Z
Работающий порядок действий:
- Сначала error rate. Если ошибок больше пары процентов, латентность измеряет уже не то, что вы думаете: быстрые 500-е «улучшают» перцентили.
- Проверьте, что тест выдал заданную нагрузку. Фактический RPS должен
совпасть с целевым. Если k6 пишет
dropped_iterations— генератор не успел. - Наложите графики. RPS, p95, CPU сервиса, CPU БД, число соединений, длина очереди. Ищите, какая метрика начала расти первой — она ближе к причине.
- Ищите насыщение, а не нагрузку. Метод USE (Utilization, Saturation, Errors) Брендана Грегга: для каждого ресурса смотрите утилизацию, насыщение и ошибки (методология). Для сервисов — RED (Rate, Errors, Duration).
- Спускайтесь на уровень ниже. APM-трейс медленного запроса → конкретный
span →
EXPLAIN ANALYZE→ флеймграф профилировщика под нагрузкой. - Меняйте по одному. Две правки за один прогон делают результат неинтерпретируемым.
Здесь особенно расходятся роли: тестировщик доводит результат до воспроизводимого факта («при 320 RPS p95 пробивает SLO, воспроизводится 3 из 3»), разработчик доводит факт до причины. Отчёт вида «всё тормозит» разработчику бесполезен, отчёт вида «вот профиль, вот момент перелома, вот тайминги внутри — дальше твоя территория» превращает нагрузочный тест в инженерный инструмент.
Как выглядит отчёт, который приносят пользу
## Нагрузочный тест: оформление заказа, релиз 2.14.0-rc3
**Цель:** подтвердить SLO `POST /orders`: p95 < 800 мс, error rate < 0,5% при 50 RPS.
**Стенд:** staging, 3 пода api (2 vCPU / 4 GB), PostgreSQL 16 (4 vCPU), данных 42 млн заказов.
**Профиль:** ramping-arrival-rate 10→50 RPS за 3 мин, плато 15 мин; фон browse 300 RPS.
**Инструмент:** k6 v0.51, 2 генератора, testid=checkout-2026-07-16.
### Результат: НЕ ПРОЙДЕН
| Метрика | Требование | Факт |
|---|---|---|
| p95 checkout | < 800 мс | 2 140 мс |
| p99 checkout | < 2000 мс | 6 380 мс |
| error rate | < 0,5% | 0,3% (OK) |
| фактический RPS | 50 | 49,7 (OK) |
### Наблюдения
- До 34 RPS p95 стабилен (~410 мс). После 34 RPS — резкий излом.
- CPU подов api не превышает 45%: упор не в вычисления.
- pgbouncer: `cl_waiting` растёт с 0 до 60 начиная с 34 RPS.
- Топ по времени в pg_stat_statements: `SELECT ... FROM order_items WHERE order_id = $1`
вызывается 18 раз на один заказ — признак N+1.
### Вывод и рекомендация
Узкое место — N+1 в сборке заказа, упирается в пул соединений. Ожидаемый эффект от
батчинга — снижение обращений к БД в ~18 раз. Повторный прогон того же профиля после правки.
### Артефакты
Grafana-дашборд (testid=checkout-2026-07-16), summary.json, скрипт checkout.js@a1b2c3d.
Обратите внимание: структура ровно та же, что у хорошего баг-репорта из статьи о документации — воспроизводимость, ожидаемое против фактического, артефакты. Нагрузочный отчёт — это баг-репорт с графиками.
Нагрузочные тесты в CI
Полноценный 30-минутный тест на каждый PR — путь к тому, что нагрузочные тесты отключат через месяц. Работающая схема — три уровня.
1-2 мин, 5 VU
гейт: скрипт жив, нет 5xx"] S --> M[Merge в main] M --> N["Ночной load-тест
15-20 мин, целевой RPS
гейт: пороги SLO"] N --> T["Тренд по релизам
сравнение с baseline
гейт: регресс > 20%"] REL[Перед крупным релизом
или пиком сезона] --> B["Stress + spike + soak
вручную, с аналитиком"]
# .github/workflows/load-test.yml
name: load-test
on:
pull_request: # быстрый smoke на каждый PR
schedule:
- cron: '0 2 * * *' # полный прогон ночью, стенд свободен
workflow_dispatch: # ручной запуск перед релизом
jobs:
smoke:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: grafana/setup-k6-action@v1
- name: Smoke-нагрузка
run: k6 run --vus 5 --duration 90s --quiet load/checkout.js
env:
BASE_URL: ${{ secrets.STAGING_URL }}
nightly:
if: github.event_name != 'pull_request'
runs-on: [self-hosted, loadgen] # НЕ shared runner: нужна стабильная сеть и CPU
timeout-minutes: 45
concurrency:
group: load-stand # монопольный доступ к стенду
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: grafana/setup-k6-action@v1
- name: Полный профиль
# exit code != 0, если пробит любой threshold
run: |
k6 run load/checkout.js \
--out experimental-prometheus-rw \
--tag testid=${{ github.sha }} \
--summary-export=summary.json
env:
BASE_URL: ${{ secrets.STAGING_URL }}
K6_PROMETHEUS_RW_SERVER_URL: ${{ secrets.PROM_RW_URL }}
- name: Сравнение с baseline
if: always()
run: python3 tools/compare_baseline.py summary.json baseline.json --tolerance 0.2
- uses: actions/upload-artifact@v4
if: always()
with: { name: k6-summary, path: summary.json }
Скрипт сравнения с baseline — то, что превращает разовый прогон в защиту от регрессий производительности:
#!/usr/bin/env python3
"""Сравнивает summary.json от k6 с зафиксированным baseline."""
import json, sys, argparse
def p95(summary: dict, metric: str) -> float:
return summary["metrics"][metric]["p(95)"]
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("current"); ap.add_argument("baseline")
ap.add_argument("--tolerance", type=float, default=0.2) # 20% допустимого дрейфа
args = ap.parse_args()
cur = json.load(open(args.current))
base = json.load(open(args.baseline))
failed = False
for metric in ("http_req_duration", "checkout_duration"):
c, b = p95(cur, metric), p95(base, metric)
drift = (c - b) / b
mark = "OK " if drift <= args.tolerance else "РЕГРЕСС"
print(f"{mark} {metric}: p95 {b:.0f} мс → {c:.0f} мс ({drift:+.1%})")
if drift > args.tolerance:
failed = True
# Важно: шум стенда ±10% — норма. Порог ниже 15% даст поток ложных падений.
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
Ключевой нюанс, роднящий нагрузочные тесты с флаки-тестами из статьи о тестах в CI: нагрузочный результат всегда шумит. Общий CI-раннер, соседний под, autovacuum в БД — всё это даёт разброс 10–15% между идентичными прогонами. Поэтому:
- гейт ставится на явное превышение (регресс >20%), а не на любое ухудшение;
- baseline обновляется осознанно, коммитом, а не автоматически «по последнему»;
- падение нагрузочного гейта в ночном прогоне перепроверяется вторым прогоном прежде, чем поднимать тревогу.
Типичные ошибки
- Тест без требования. Прогнали, получили цифры, не знаем, хорошо это или плохо. Требование формулируется до теста.
- Закрытая модель там, где нужна открытая. Итог — систематически заниженная деградация и coordinated omission.
- Среднее вместо перцентилей. И, отдельно, усреднение перцентилей между инстансами — арифметически бессмысленная операция.
- Один пользователь и одна запись на всех VU. Меряете конкуренцию за блокировку, а не производительность сценария.
- Нет think time. 100 VU без пауз — это нагрузка тысяч реальных пользователей; вы «нашли» предел, которого в жизни не будет.
- Пустая база. Самая частая причина расхождения стенда и прода.
- Генератор — узкое место. Не сняли метрики с самой машины нагрузки.
- Нет прогрева. Первые минуты JIT и холодных кэшей испортили перцентили.
- Кэш на стороне CDN/приложения обслуживает 100% запросов. Красивый результат ни о чём: реальные пользователи запрашивают разное. Варьируйте параметры.
- Тест «на всякий случай» вместо тестирования рискового сценария. Нагрузили главную страницу, которая отдаётся из CDN, а упало оформление заказа.
- Разовая акция. Однократный тест перед релизом ничего не защищает: без baseline и трендов вы не увидите, что деградация накапливается по 5% за спринт.
- Игнорирование стоимости. Полный soak-прогон на прод-подобном стенде стоит денег за инфраструктуру и человеко-дни. Для 80% сервисов достаточно ночного load-теста и профилирования — stress и soak оставьте для критичных.
Мини-итог
- Нагрузочный тест начинается не со скрипта, а с цифрового требования и с выбора рискового сценария.
- Форма профиля определяет вопрос: load — про SLO, stress — про предел и характер отказа, spike — про обвязку и автоскейлинг, soak — про утечки.
- Открытая модель (constant/ramping arrival rate) моделирует реальный трафик; закрытая систематически приукрашивает деградацию.
- Читайте p95/p99 и error rate, никогда — среднее; перцентили не усредняются.
- k6 — для инженерных тестов в git и CI, JMeter — для широкой протокольной поддержки и не-программистов, Locust — когда нужна логика на Python.
- Результат — не график, а найденное узкое место: RPS → насыщение ресурса → трейс → запрос/код.
- В CI: smoke на PR, полный профиль ночью, сравнение с baseline и допуск на шум.
Что почитать
- Grafana k6 documentation — executors, thresholds, вывод метрик.
- Apache JMeter User Manual — особенно разделы про non-GUI и распределённый режим.
- Gil Tene, How NOT to Measure Latency — про coordinated omission, обязательно к просмотру.
- Jeffrey Dean, Luiz Barroso, The Tail at Scale.
- Brendan Gregg, The USE Method и книга Systems Performance.
- Google SRE Workbook, Implementing SLOs — как формулировать требования, которые потом проверяет нагрузочный тест.
Что дальше
Тестирование безопасности: OWASP Top 10, SAST, DAST, фаззинг — следующий класс нефункциональных проверок. Нагрузка показывает, что система выдерживает много честных пользователей; безопасность — что она выдерживает одного нечестного.