Тестирование ПО Нагрузочное тестирование: k6, JMeter, профили нагрузки, чтение результатов
0%

Нагрузочное тестирование: k6, JMeter, профили нагрузки, чтение результатов

Нагрузочное тестирование: 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-тест.

Виды нагрузочных тестов и их профили

Профили нагрузки: load, stress, spike, soak

Несколько практических замечаний.

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 запросов в секунду, независимо от того, ответил сервис или нет. Если сервис тормозит, запросы копятся — как копится очередь реальных пользователей, которым плевать на ваш пул.

Реальный пользовательский трафик — почти всегда открытая модель. Люди приходят на сайт независимо от того, справляетесь ли вы. Поэтому тест в закрытой модели систематически недооценивает деградацию: он сам себя притормаживает и рисует красивую картинку у самого края обрыва.

С этим тесно связан coordinated omission — эффект, описанный Гилом Тене (статья и доклад). Если генератор нагрузки ждёт ответа перед следующим запросом, то самые медленные периоды системы вообще не попадают в выборку: во время двухсекундной GC-паузы инструмент не отправил ни одного запроса, а значит, не измерил ни одной плохой задержки. Итог — красивый p99, за которым в проде стоит стонущий пользователь. Лечится либо открытой моделью, либо коррекцией на ожидаемый интервал отправки (что умеют wrk2, HdrHistogram, JMeter с throughput timer).

Практическое правило: сценарии, где вы проверяете SLO, гоните в открытой модели (constant arrival rate). Закрытая модель уместна, когда вы моделируете систему с реально ограниченным числом клиентов — терминалы кассиров, батч-джобы, конечный пул интеграций.

Как читать результат: перцентили, а не среднее

Распределение времени отклика и перцентили

Среднее время отклика — бесполезная метрика, и вот почему:

  1. Распределение задержек всегда правоскошенное, с длинным хвостом. Среднее зажато между медианой и хвостом и не описывает ни то, ни другое.
  2. Среднее нельзя агрегировать по SLO: пользователь не переживает «среднее», он переживает свой конкретный запрос.
  3. В микросервисной архитектуре хвост усиливается: если страница собирается из 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 для сборки сценария людьми без программирования, огромная экосистема плагинов, привычность для корпоративных команд производительности.

Структура тест-плана:

Правила, которые отличают рабочий 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 мс».

Работающий порядок действий:

  1. Сначала error rate. Если ошибок больше пары процентов, латентность измеряет уже не то, что вы думаете: быстрые 500-е «улучшают» перцентили.
  2. Проверьте, что тест выдал заданную нагрузку. Фактический RPS должен совпасть с целевым. Если k6 пишет dropped_iterations — генератор не успел.
  3. Наложите графики. RPS, p95, CPU сервиса, CPU БД, число соединений, длина очереди. Ищите, какая метрика начала расти первой — она ближе к причине.
  4. Ищите насыщение, а не нагрузку. Метод USE (Utilization, Saturation, Errors) Брендана Грегга: для каждого ресурса смотрите утилизацию, насыщение и ошибки (методология). Для сервисов — RED (Rate, Errors, Duration).
  5. Спускайтесь на уровень ниже. APM-трейс медленного запроса → конкретный span → EXPLAIN ANALYZE → флеймграф профилировщика под нагрузкой.
  6. Меняйте по одному. Две правки за один прогон делают результат неинтерпретируемым.

Здесь особенно расходятся роли: тестировщик доводит результат до воспроизводимого факта («при 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 1050 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 — путь к тому, что нагрузочные тесты отключат через месяц. Работающая схема — три уровня.

# .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 обновляется осознанно, коммитом, а не автоматически «по последнему»;
  • падение нагрузочного гейта в ночном прогоне перепроверяется вторым прогоном прежде, чем поднимать тревогу.

Типичные ошибки

  1. Тест без требования. Прогнали, получили цифры, не знаем, хорошо это или плохо. Требование формулируется до теста.
  2. Закрытая модель там, где нужна открытая. Итог — систематически заниженная деградация и coordinated omission.
  3. Среднее вместо перцентилей. И, отдельно, усреднение перцентилей между инстансами — арифметически бессмысленная операция.
  4. Один пользователь и одна запись на всех VU. Меряете конкуренцию за блокировку, а не производительность сценария.
  5. Нет think time. 100 VU без пауз — это нагрузка тысяч реальных пользователей; вы «нашли» предел, которого в жизни не будет.
  6. Пустая база. Самая частая причина расхождения стенда и прода.
  7. Генератор — узкое место. Не сняли метрики с самой машины нагрузки.
  8. Нет прогрева. Первые минуты JIT и холодных кэшей испортили перцентили.
  9. Кэш на стороне CDN/приложения обслуживает 100% запросов. Красивый результат ни о чём: реальные пользователи запрашивают разное. Варьируйте параметры.
  10. Тест «на всякий случай» вместо тестирования рискового сценария. Нагрузили главную страницу, которая отдаётся из CDN, а упало оформление заказа.
  11. Разовая акция. Однократный тест перед релизом ничего не защищает: без baseline и трендов вы не увидите, что деградация накапливается по 5% за спринт.
  12. Игнорирование стоимости. Полный 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 и допуск на шум.

Что почитать

Что дальше

Тестирование безопасности: OWASP Top 10, SAST, DAST, фаззинг — следующий класс нефункциональных проверок. Нагрузка показывает, что система выдерживает много честных пользователей; безопасность — что она выдерживает одного нечестного.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов