Без рук: автоматизируем нагрузочное тестирование изменений в CI

от автора

Нагрузочное тестирование — одна из самых избегаемых тем, когда речь заходит о контроле качества ПО. Корпорации, конечно, не обходят его стороной, но если говорить о продуктах меньшего масштаба, то нагрузочное тестирование часто пропускается. Команда (и, в целом, справедливо) полагает, что продукт справится с нагрузкой — на малых объёмах это обычно прокатывает. А потом внезапно наступает день, когда пользователей стало больше, а система не готова.

Почему команды не тащат нагрузку в релизный цикл? Потому что это чаще всего просто не окупается: нужно выбрать движок, описать сценарий, гонять тесты вручную или тратить время на создание собственной обвязки для встраивания в CI, придумать критерии качества и анализировать результаты. Всё это занимает значительное время, а на короткой дистанции часто оказывается оверинжинирингом. Но если формирование требований упростить концептуально невозможно, то всё остальное вполне можно собрать в переиспользуемый инструмент, позволяющий командам легко интегрировать нагрузочное тестирование и регрессионный анализ в свой процесс доставки.

В CI/CD мы хотели простую штуку: на каждый PR запускать короткий перф‑смоук и получать ответ уровня «PASS / WARNING / DEGRADATION», а не 15 минут медитировать над CSV и тратить ценное время на анализ, который, вероятно, не пригодится в ближайшей перспективе. Посмотрим, к чему мы в итоге пришли.

Пример HTML‑отчёта (KPI‑карточки + графики + сравнение с baseline)
Пример HTML‑отчёта: светлая тема

Пример HTML‑отчёта: светлая тема
Тёмная тема
Кастомная тёмная тема
Брендированный отчёт
Preset: errors
Preset: throughput
Preset: latency

Зачем вообще тащить нагрузку в CI

Идея, в общем-то, лежит на поверхности: ловить падение производительности не на проде и не в ночь перед релизом, а ровно в момент, когда оно появились — в PR. Та же самая логика «раннего обнаружения», что и в юнит‑тестах: быстрый фидбек, меньше рисков, меньше ночных алертов. В индустрии это обычно называют shift-left performance testing — нагрузка становится частью пайплайна, а не отдельным ритуалом, который проводят по большим праздникам. [1]

Цель была приземлённая: сделать так, чтобы нагрузочный тест в CI по UX напоминал обычный юнит‑тест. Запустил — получил результат — пошёл дальше.

Если подробнее, нам нужно было получить следующее CI‑поведение:

  • предсказуемо запускать нагрузку (smoke на PR, длиннее на main/ночью);

  • сравнивать с baseline и сигналить о регрессиях;

  • проверять строгие пороги и валить сборку;

  • генерировать отчёт, который не стыдно кинуть в PR;

  • хранить результаты как артефакты, чтобы открыть любой прогон из истории.

Locust отлично решает свою задачу — генерировать нагрузку. Headless‑режим для CI, экспорт статистики и отчётов (--csv, --html, --json) — всё это есть из коробки. [6]

Но дальше начинается взрослая жизнь. CI нужны не числа, а решение — падать сборке или нет. Нужны пороги: p95 < X, error_rate < Y — и это должно быть машинно проверяемо. Нужен baseline и сравнение «как было / как стало», потому что абсолютные цифры без контекста обманчивы. И нужен нормальный отчёт, который можно открыть в артефактах и за тридцать секунд понять, что именно поехало.

Можно, конечно, собрать это самому — парочка скриптов, jq, питон, слёзы… Мы решили пойти другим путём и вынести всё это в отдельный слой поверх Locust.

В итоге получился Locomotive — Python‑библиотека с CLI, которая запускает нагрузку через Locust, а сверху добавляет всё то, чего обычно не хватает для CI: декларативные сценарии, baseline‑анализ, пороги и отчёты.

Что есть из коробки:

  • Декларативная конфигурация в JSON/YAML — можно описать сценарий без locustfile.py (хотя он тоже поддерживается, если нужна сложная логика).

  • Генерация конфига из OpenAPI через loco init --openapi … — удобно, чтобы стартануть быстро и не набивать руками десяток эндпоинтов.

  • Gate checks — абсолютные пороги по метрикам (скажем, p95_ms < 500, error_rate < 1). [9]

  • Regression rules — сравнение текущего прогона с baseline по настраиваемым правилам (относительные/абсолютные отклонения, направление, уровень реакции warn/fail). [10]

  • HTML‑отчёт с графиками и дельтами + настраиваемые темы.

  • Пресеты отчёта (например, errors и throughput) — если нужно быстро сфокусироваться на конкретном аспекте.

  • Готовый GitHub Action, который сам ставит пакет, подтягивает baseline, запускает тест, складывает артефакты и комментирует PR.

Дисклеймер: мы не хотим сказать, что другие инструменты плохие. Более того, при разработке собственного решения мы опирались на фичи существующих решений. У Grafana k6 есть thresholds, которые фейлят тест при нарушении условий — штука, задизайненная прямо под CI. [14]
У Taurus есть pass/fail‑критерии. [16]
JMeter умеет CLI/non‑GUI режим (GUI — только для сборки/отладки плана). [18]

Мы просто хотели сохранить Locust‑экосистему и при этом получить CI‑first поведение без самописного зоопарка.

Как устроено внутри

Теперь — к самому вкусному: что происходит между «PR opened» и «сборка упала, потому что p95 поехал».

Компонентная схема архитектуры (CI/CD Pipeline → CLI → Launcher/Analyzer/Reporter/Storage)
Компонентная схема архитектуры

Компонентная схема архитектуры

Внутри Locomotive логика разбита на несколько компонентов:

CLI — единая точка входа. Через неё происходит всё: «запусти», «сравни», «сгенерь отчёт».

Launcher — запуск Locust с нужными параметрами в headless‑режиме и сбор сырых результатов. По сути, это обёртка над тем, что Locust и так умеет делать — генерировать CSV, JSON‑статы и прочее.

Storage — складывает результаты в артефакты, чтобы CI мог их сохранить и показать.

Analyzer — берёт текущий прогон, сравнивает с baseline и применяет правила. Именно он решает: PASS, WARNING или DEGRADATION. [10]

Reporter — собирает из всего этого HTML‑отчёт, который можно открыть одним кликом в артефактах.

Sequence‑диаграмма (Developer → GitHub Actions → CLI → Locust → Analyzer → Reporter → Storage)
Sequence‑диаграмма

Sequence‑диаграмма

Как считается регрессия

Проверки бывают двух типов, и их полезно разделять.

Первый — gate checks, или абсолютные пороги. Это, по сути, SLA‑ворота: p95_ms не должен быть выше 500 мс, error_rate не должен превышать 2%. Задаёте в конфиге — и всё, пайплайн будет их проверять (пример конфига будет ниже).

Второй — regression rules, или сравнение с baseline. Тут идея другая: не «метрика выше порога», а «метрика стала хуже, чем была». Например, правило из rules.example.json может звучать так: p95_ms не должен вырасти больше, чем на 20% (fail), а рост на 10% — уже warning.

Правила формулируются максимально понятно: mode: relative — сравниваем в процентах; direction: increase — плохо, когда метрика растёт (для latency и error_rate); direction: decrease — плохо, когда падает (для RPS); warn / fail — уровни реакции, от деликатного предупреждения до «режь билд». [10]

Дальше всё просто: если зафиксирована серьёзная деградация (DEGRADATION), CI падает прямо в PR.

Минимальный запуск в CI

Пререквизиты

Установка и генерация конфига

pip install locomotiveloco init

Locomotive ставится из PyPI и требует Python 3.9+. Locust подтягивается как зависимость — отдельно ставить не нужно.

Если у вас есть OpenAPI‑спека, можно сразу сгенерировать конфиг из неё. А если хотите GitHub Actions — и заготовку workflow:

loco init --openapi openapi.jsonloco init --github-workflow

Конфиг: гейты и baseline‑правила

В конфиге обычно живут две вещи: абсолютные пороги (gate) и правила сравнения (rules). Выглядит это примерно так: [26]

{  "load": {    "host": "https://staging.example.com",    "users": 20,    "spawn_rate": 5,    "run_time": "1m"  },  "analysis": {    "gate": {      "min_requests": 200,      "thresholds": {        "p95_ms": { "fail": 500 },        "error_rate": { "fail": 2 }      }    },    "rules": [      { "metric": "p95_ms", "mode": "relative", "direction": "increase", "warn": 10, "fail": 25 },      { "metric": "rps",    "mode": "relative", "direction": "decrease", "warn": 10, "fail": 20 }    ],    "fail_on": "DEGRADATION"  },  "report": { "output": "artifacts/report.html" }}

Полный пример конфига «без locustfile» — со scenario.requests, headers, tags и прочим — лежит в репозитории.

Запуск

loco --config loconfig.json ci

Команда ci — это «полный цикл»: тест → анализ → отчёт. Всё за один вызов.

Если хотите сохранить текущий прогон как baseline (опорную точку для будущих сравнений), добавляете —set-baseline:

loco --config loconfig.json ci --set-baseline

Что вы получаете на выходе

Во‑первых — человекочитаемый ответ: PASS, WARNING или DEGRADATION. Не стена цифр, а один понятный статус. Его же Action возвращает как output, так что на него можно завязывать дальнейшую логику пайплайна.

Комментарий в PR от Action
Компонентная схема архитектуры

Компонентная схема архитектуры

Во‑вторых — артефакты, по которым можно поднять «историю болезни» без археологии. Структура выглядит так:

artifacts/├── baseline.json├── history.json└── runs/    └── <run_id>/        ├── run.json        ├── metrics.json        ├── analysis.json        ├── report.html        └── generated/

Если включить историю (artifacts.history > 0), появляется возможность строить тренды между прогонами. Например, увидеть, что p95 тихонько ползёт вверх последние 20 запусков — до того, как он прорвёт порог и всё загорится красным.

Графики трендов (p95/rps/error_rate по run_id)
Кусок отчёта с трендами

Кусок отчёта с трендами

Перед тем как тащить нагрузку в пайплайн, полезно честно ответить на два вопроса: где она даст быстрый выхлоп, а где превратится в шум. Ниже — короткий чеклист: когда использовать, когда не использовать, и какие грабли вас всё равно ждут.

Когда использовать

Это хорошо заходит, если у вас:

  • частые PR и релизы, где деградация «по чуть‑чуть» копится незаметно;

  • критичные по UX ручки/флоу (логин, поиск, корзина, платежи), которые хочется защищать так же, как функциональность;

  • есть staging/preprod, на котором можно стабильно гонять короткий smoke и хранить историю прогонов.

Лучше не тащить это в каждый PR, если:

  • нет стабильного окружения (стейджинг постоянно меняется, шумит, «соседи» убивают CPU);

  • нагрузка у вас нужна раз в квартал «проверить потолок», а не ловить регрессии;

Даже если вам «подходит по чеклисту», есть три грабли, от которых не убежит ни один пайплайн.

Ограничения и грабли

  • Locomotive не заменяет Locust. Если вам нужны сложные пользовательские сессии, динамические данные, нестандартные протоколы или хитрые stateful‑сценарии, вы по‑прежнему пишете locustfile.py, а Locomotive выступает «обвязкой качества» поверх него.

  • Locomotive — не распределённый генератор нагрузки. Если одной машины не хватает, масштабирование — это зона ответственности Locust (master/worker или —processes). [32]

  • Шум окружения никуда не девается. Стейджинг бывает капризным, соседний джоб в CI может сожрать весь CPU, сеть может подлагать. Как идея, можно поступить следующим образом: в PR — маленький стабильный смоук с мягкими правилами; в main — более жёсткие gates и, возможно, несколько прогонов с агрегацией. Впрочем, это уже вопрос стратегии, а не инструмента.

Заключение

Нагрузочные тесты перестают быть ритуалом по праздникам, когда у них появляется UX как у обычных тестов: запустил → получил вердикт → при необходимости открыл отчёт и понял, что именно поехало.

Начните с малого: один критичный флоу, короткий smoke на PR, мягкие правила (WARNING важнее, чем ложные фейлы). А дальше уже можно ужесточать пороги и наращивать регрессионную историю — когда пайплайн и окружение к этому готовы.

Ссылки

ссылка на оригинал статьи https://habr.com/ru/articles/1033590/