Релиз выкатился на staging, интеграционные тесты зелёные, страницы открываются, заказы создаются. А через два дня кто-то из команды замечает, что список заказов грузится почти вдвое дольше, чем на прошлой неделе. Ошибок нет, статусы 200, ничего не упало. Просто та же работа теперь стоит дороже: добавили выборку без индекса, начали ходить в соседний сервис синхронно или обернули каждый ответ в тяжёлую сериализацию.

Функциональный тест на такое не рассчитан. Он отвечает на вопрос «работает ли?», а не «работает ли так же быстро, как раньше?». Для второго вопроса нужен другой инструмент, и желательно такой, который встроен в тот же конвейер и сам говорит «стоп», когда что-то пошло не так. Здесь на сцену выходит k6 с порогами, но самая интересная часть истории не в самом инструменте. Она в том, как не превратить один шумный прогон в судью, который то блокирует хорошие релизы, то пропускает плохие.

Сначала решить, что именно мы измеряем

Соблазн понятен: взять нагрузочный сценарий на сорок эндпоинтов, «чтобы всё покрыть». Для проверки после каждого релиза это плохая идея. Чем длиннее сценарий, тем дольше он идёт, тем чаще его отключают «временно» и тем труднее понять, какой из сорока запросов ухудшился. Короткий тест, который выполняется несколько минут, будут запускать постоянно. Длинный будут обходить.

Разумнее выбрать небольшой набор запросов, которые отражают реальную жизнь приложения. Критерии отбора простые:

  • запрос часто встречается в реальном трафике: главная выдача, карточка, поиск, список;
  • запрос затрагивает разные слои: один бьёт в базу, другой в кеш, третий в соседний сервис;
  • запрос чувствителен к типичным регрессиям: пагинация, фильтры, сортировки, авторизованные списки;
  • запрос не меняет состояние необратимо, либо его побочные эффекты можно безопасно повторять на staging.

Три-шесть запросов обычно достаточно. Важно не только их число, но и пропорции. Если в продакшене на один заказ приходится двадцать просмотров каталога, то и в сценарии пропорция должна быть похожей: условно 70% чтений каталога, 20% поиск, 10% запись. Сценарий из равных долей хуже: он преувеличит вклад редкой тяжёлой операции и недооценит частую лёгкую, а значит, будет реагировать не на то, что болит у пользователей.

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

Что должно быть неизменным между прогонами

Идея сравнения релизов держится на одном условии: между измерениями меняется только версия приложения. Всё остальное придётся закрепить. На практике это четыре вещи.

Данные

Запрос «найти заказы клиента» на базе из тысячи строк и на базе из десяти миллионов — это два разных запроса. Поэтому staging должен иметь стабильный набор данных: тот же объём, то же распределение, те же идентификаторы, которые подставляет сценарий. Проще всего восстанавливать эталонный дамп перед серией прогонов либо держать отдельную базу, которую тест не портит. Если данные дрейфуют, любая разница во времени ответа получает сразу два объяснения, и честно выбрать между ними нельзя.

Заодно полезно ограничить набор параметров, которые подставляет сценарий. Если каждый запрос ходит за случайным идентификатором из миллиона, вы получаете смесь холодных и тёплых чтений, и состав этой смеси меняется от прогона к прогону. Фиксированный список из нескольких сотен идентификаторов даёт повторяемую картину кеша: она не обязана совпадать с продакшеном, зато совпадает сама с собой.

Кеш и прогрев

Первые секунды после деплоя приложение холодное: не прогреты пулы соединений, кеши запросов, JIT или файловый кеш. Если замерять с первой секунды, вы измеряете старт, а не работу. Хороший приём состоит в коротком прогревочном этапе с небольшой интенсивностью, результаты которого не идут в пороги, и в основном этапе, где поведение уже установилось. Правило одно: прогрев устроен одинаково для каждой проверяемой версии.

Интенсивность

Здесь есть тонкость, которую стоит понять до первого запуска. Можно задавать нагрузку числом виртуальных пользователей: «двадцать пользователей по кругу делают запросы». Тогда, когда сервис замедляется, пользователи просто ждут ответа дольше и делают меньше запросов в секунду. Нагрузка сама ослабевает ровно в тот момент, когда сервис ухудшился, и это маскирует проблему. Можно задавать интенсивность в запросах в секунду: двадцать новых запросов каждую секунду независимо от того, успевает ли сервис. Реальные посетители ведут себя скорее так: они приходят в своём темпе, а не дожидаются, пока предыдущий закончит. Для сравнения релизов постоянная интенсивность обычно честнее: при замедлении растёт очередь и задержки, и это видно сразу.

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

Где живёт генератор

Самая частая ошибка новичка: запустить k6 на той же машине, где работает приложение. Тогда генератор и проверяемый сервис делят процессор, память и сетевой интерфейс. Релиз стал чуть прожорливее по CPU, генератор получил меньше процессорного времени, и вы измеряете смесь двух эффектов. Хуже того, перегруженный генератор сам добавляет задержки, которые попадают в метрики как будто они пришли от сервиса.

Поэтому генератор лучше выносить на отдельный хост, с которого staging виден по сети так же, как виден клиентам. Тут же возникает практический вопрос ресурсов. Нужна небольшая виртуальная машина, которая в обычное время простаивает, а во время прогона выдаёт ровную нагрузку и сама не упирается в процессор. На VPS MAATRIX для такой роли можно рассматривать небольшие конфигурации из линейки: от Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) и Uab (2 vCPU, 4 ГБ, 80 ГБ) до Heka (4 vCPU, 8 ГБ, 160 ГБ), если сценарий тяжелее или интенсивность выше. Это ориентир для выбора, а не обещание ёмкости: правильность размера проверяется наблюдением за загрузкой самого генератора во время прогона. Если процессор генератора приближается к потолку, результат нельзя считать измерением сервиса. Подробнее о том, как подбирать машину под такой тест, есть в материале про нагрузочный тест API с k6 и выбор VPS.

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

Пороги: говорить о хвосте, а не о среднем

Теперь про сам механизм. Threshold в k6 — это условие на метрику: например, на задержку запросов или долю ошибок. Если условие нарушено, k6 завершается с ненулевым кодом, а значит, любой конвейер CI видит провал так же, как провал обычного теста. Это описано в документации Grafana k6 по thresholds. Там же важная оговорка: обычный check сам по себе код завершения не меняет, а чтобы проверки могли провалить прогон, нужно поставить порог на метрику checks. Иначе вы получите красивый отчёт с тысячей неудавшихся проверок и зелёную сборку.

Теперь вопрос, на какие именно метрики ставить условия. Среднее время ответа почти всегда плохой выбор. Представьте, что 95 запросов из ста выполняются за 100 мс, а пять за две секунды. Среднее будет около 195 мс, цифра вполне приличная, а каждый двадцатый пользователь ждёт две секунды. Регрессии в реальных системах нередко выглядят именно так: основная масса запросов не изменилась, а хвост распух из-за новой тяжёлой ветки кода. Перцентили, например 95-й и 99-й, ловят такие изменения, среднее сглаживает их.

Отдельная метрика — доля ошибок. Она нужна по простой причине: при деградации сервис нередко отвечает быстро, но неправильно. Тайм-ауты, 5xx и отказы соединения могут даже улучшить среднюю задержку, потому что короткий отказ приходит раньше, чем долгий успешный ответ. Поэтому порог на задержку без порога на ошибки легко обмануть, и они всегда идут парой.

Третий элемент — проверки содержимого. Ответ 200 с пустым списком вместо страницы заказов тоже формально «успех». Простая проверка, что в теле есть ожидаемое поле, плюс порог на долю успешных проверок закрывают этот случай.

Вот как это может выглядеть в сценарии. Числа здесь демонстрационные, подставьте свои, полученные на вашем стенде:

import http from 'k6/http';
import { check } from 'k6';

export const options = {
  scenarios: {
    release_check: {
      executor: 'constant-arrival-rate',
      rate: 20,
      timeUnit: '1s',
      duration: '4m',
      preAllocatedVUs: 40,
      maxVUs: 100,
    },
  },
  thresholds: {
    http_req_failed: ['rate<0.01'],
    http_req_duration: ['p(95)<400', 'p(99)<900'],
    checks: ['rate>0.99'],
  },
};

const BASE = __ENV.BASE_URL;

export default function () {
  const res = http.get(BASE + '/api/orders?limit=50');
  check(res, {
    'статус 200': (r) => r.status === 200,
    'есть список': (r) => r.body && r.body.includes('items'),
  });
}

Запуск в CI сводится к одной строке, а результат читается по коду завершения:

k6 run -e BASE_URL=https://staging.example.com release_check.js

Если какой-то из порогов нарушен, команда завершится с ненулевым кодом, и шаг конвейера станет красным. Дополнительной обвязки для этого не нужно.

Откуда берутся числа порогов

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

Здесь же стоит различать два вида условий. Абсолютный порог говорит «ответ не медленнее 400 мс на 95-м перцентиле» — это граница, за которой стенд считается непригодным для приёмки. Относительное сравнение говорит «эта сборка не хуже прошлой». Они отвечают на разные вопросы, и ими лучше пользоваться вместе.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Сравнение с известной версией и честный разброс

Допустим, абсолютный порог по 95-му перцентилю — 400 мс. Релиз показал 380 мс, порог пройден, сборка зелёная. Но вчера стабильная версия показывала 210 мс. Релиз ухудшил задержку почти вдвое и спокойно проскочил, потому что граница была поставлена слишком щедро. Одни абсолютные пороги такое не ловят. Нужна опорная точка: измерение предыдущей известной версии на том же стенде, при тех же данных и той же интенсивности.

Тут появляется вторая проблема. Два прогона одной и той же версии не дают одинаковых цифр. На них влияют соседи по гипервизору, состояние кеша, сборка мусора, фоновые задачи базы, случайный порядок параметров. Это обычный разброс, а не ошибка тестов. Если сравнить один прогон новой версии с одним прогоном старой и вынести приговор по разнице, вы будете принимать шум за сигнал. Один день вы остановите здоровый релиз, на следующий пропустите больной, и команда быстро научится не верить красному цвету.

Условный пример, чтобы увидеть масштаб. Пусть четыре прогона старой версии дали 95-й перцентиль 205, 212, 220 и 231 мс. Разброс примерно 25 мс, центр около 215. Новая версия показала 228 мс. Это внутри разброса старой версии, и по одному прогону нельзя сказать, стала ли она хуже. А если новая версия трижды подряд даёт 285, 291 и 279 мс, то картина другая: все три значения лежат заметно выше всех четырёх старых, и случайностью это уже не объяснить.

Отсюда несколько рабочих приёмов:

  • Чередовать, а не группировать. Если прогонять сначала три раза старую версию, потом три раза новую, медленный дрейф среды (скажем, к вечеру нагруженный сосед) припишет себя новой версии. Порядок «старая, новая, старая, новая» распределяет такой дрейф поровну.
  • Хранить историю. Результаты прошлых прогонов известной версии полезно сохранять как артефакты конвейера. Тогда разброс не нужно изобретать заново: он уже накоплен из реальных измерений.
  • Сравнивать медианы нескольких прогонов. Медиана трёх-пяти значений 95-го перцентиля устойчивее одного значения.
  • Задавать допуск в относительных величинах. Например, «не хуже базовой медианы более чем на заданный процент», где процент выбран по наблюдавшемуся разбросу, а не по ощущению.

Цена такого подхода очевидна: прогон занимает не четыре минуты, а десять-пятнадцать. Для большинства команд это приемлемо, потому что проверка идёт после деплоя на staging, параллельно с остальными проверками, и ничего не блокирует, пока не найдёт повод. Если же времени жалко, компромисс такой: быстрый одиночный прогон с абсолютными порогами на каждый коммит и расширенное сравнение с чередованием только для сборок, которые идут в релиз.

Заметим мимоходом, что аналогичная мысль давно живёт в функциональном тестировании. Документация Playwright рекомендует начинать CI с одного worker ради воспроизводимости и только потом, на мощном собственном CI, включать параллельность; ещё там описан глобальный таймаут, который позволяет самому тестовому раннеру завершить зависший прогон и выдать отчёт (Playwright: Continuous Integration). Для нагрузочной проверки уроки те же. Если хочется воспроизводимости, не соревнуйтесь за скорость и не запускайте несколько тестов рядом на одном и том же стенде. И пусть у прогона будет предельное время, чтобы застрявший сценарий не держал конвейер часами: в k6 подобную границу удобно ставить на уровне самого шага CI.

Что делать, когда порог нарушен

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

Можно выделить три типичных исхода.

Реакция CI на нарушенный порог
КартинаЧто это скорее всего значитРазумное действие
Порог нарушен слегка, значение в пределах обычного разброса базовой версииШум стенда или соседейПовторить измерение, лучше с чередованием версий; один повтор не повод для остановки
Нарушение повторяется, новая версия стабильно хуже базовой в нескольких прогонахРеальная регрессия при сопоставимых условияхОстановить выпуск и искать изменение, которое могло дать эффект
Резко выросла доля ошибок или проваливаются проверки содержимогоФункциональная поломка под нагрузкой: тайм-ауты, исчерпанные соединения, неверные ответыОстановить выпуск сразу, без повторов; смотреть логи приложения и базы

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

Теперь о расследовании. k6 показывает, что задержка выросла и на каких запросах, но причину он не называет. Он измеряет снаружи: сколько клиент ждал ответа. Чтобы понять, где потерялось время, нужны данные изнутри: логи, метрики приложения и базы, трассировки, планы запросов. Хорошая практика состоит в том, чтобы упрощать этот переход: когда проверка падает, в вывод CI попадает список запросов с худшими перцентилями, время прогона и версия сборки, а метрики стенда за этот интервал уже можно посмотреть в панелях мониторинга. Тогда расследование начинается с вопроса «что изменилось в этом конкретном запросе между двумя сборками», а не с чистого листа.

Если сценарий хорошо подобран, сама картина провала подсказывает направление. Выросла задержка только в запросе со списком — смотрите выборку и индексы. Вся картина поползла равномерно вверх — подозревайте общий слой: сериализацию, middleware, пул соединений. Вырос только хвост на 99-м перцентиле — ищите редкую тяжёлую ветку или блокировки. Но это гипотезы для следующего шага, а не выводы, которые делает k6.

Чего такой тест не доказывает

Теперь о границе, за которую короткую проверку выносить нельзя. Четырёхминутный прогон умеренной интенсивности на staging не говорит, сколько пользователей выдержит production. Он не проверяет максимальную ёмкость, не показывает, при какой нагрузке система начнёт деградировать, и не гарантирует поведения в день распродажи. Причины конкретны.

  • Другое железо и конфигурация. Staging редко повторяет продакшен по числу ядер, объёму памяти, настройкам пула соединений и репликам базы.
  • Другие данные. Эталонный набор меньше и ровнее реальной базы, а значит, планы запросов и поведение кеша отличаются.
  • Другая сеть. Путь от генератора до staging короче и спокойнее пути от тысяч клиентов до продакшена.
  • Другая нагрузка. Умеренная постоянная интенсивность не воспроизводит всплески, длинные хвосты сессий и смесь разных клиентов.
  • Другое время. Четыре минуты не выявляют утечек памяти, накопления очередей и медленной деградации, которая проявляется через часы.

Поэтому задача этого теста сформулирована скромно: сравнить две версии в одинаковых условиях и заметить, что одна стала хуже другой. Это сравнение, а не измерение потолка. Если нужно ответить на вопрос «сколько выдержим», потребуется отдельный, более длинный и ступенчатый тест на стенде, максимально похожем на продакшен, с наращиванием интенсивности и наблюдением за точкой излома. Это другая работа, с другими целями и другим бюджетом времени.

Есть и обратная ловушка. Если пороги слишком мягкие и всё время зелёные, команда начинает считать зелёный цвет доказательством запаса прочности. Он не доказывает ничего, кроме того, что при данной скромной интенсивности сервис укладывается в выбранные границы. Об этом стоит помнить, когда кто-то спрашивает: «тест же прошёл, значит, выдержим рекламу?».

Как собрать это в конвейер, не усложняя жизнь

Структура получается простой. После деплоя сборки на staging конвейер запускает с отдельного генератора короткий сценарий с фиксированным набором запросов и фиксированной интенсивностью. Если нарушены абсолютные пороги, шаг падает сразу. Если они пройдены, сборка сравнивается с базовой версией: либо по накопленной истории, либо через чередующиеся прогоны. Итог публикуется как артефакт с версией сборки, временем и ключевыми перцентилями, чтобы завтра было с чем сравнивать.

Несколько практических деталей, которые экономят нервы:

  1. Версия приложения должна быть прописана в результате прогона. Через месяц, листая историю, вы не захотите гадать, что стояло на стенде.
  2. Перед основным этапом полезна быстрая проверка готовности staging: эталонные данные на месте, приложение отвечает, генератор не загружен. Если стенд не готов, это отдельная ошибка конвейера, а не «регрессия производительности».
  3. Базовую версию стоит обновлять осознанно: когда релиз принят и стал новой нормой, его результаты становятся новой точкой отсчёта. Автоматическое «сдвигание нормы» после каждого прогона опасно: медленное ухудшение на пять процентов за релиз никогда не сработает, зато через двадцать релизов вы окажетесь в совсем другом месте.
  4. Пороги и сценарий лежат в репозитории рядом с кодом и меняются через обычное ревью. Тогда правка порога видна и обсуждаема, а не происходит тихо ради зелёной сборки.

Для выделенной инфраструктуры под такие прогоны хватает небольшого VPS с root-доступом: на нём стоит k6 и агент вашего CI, он находится рядом со staging по сети и не делит с ним процессор. Для постоянной роли генератора удобно, что виртуальная машина создаётся после оплаты за пару минут: ставится одна и живёт столько, сколько нужно проверке. Локацию стоит выбирать по принципу близости к staging и постоянства: UK, US, RU и FR перечислены в публичном предложении MAATRIX, но доступность конкретной ОС и образа в конкретной локации проверяется в форме заказа.

Основание, по которому релиз можно задержать

Всё сказанное сводится к одному критерию. Задержать релиз по результатам такой проверки правомерно тогда, когда одна и та же работа стала выполняться хуже, и вы можете это показать: тот же набор запросов, те же данные, та же интенсивность, тот же отдельный генератор, и новая версия по 95-му и 99-му перцентилям устойчиво хуже известной, причём не в одном прогоне, а выходя за обычный разброс базовой версии, либо доля ошибок выросла там, где раньше её не было.

Если же в разговоре звучит «один раз получилось медленнее», «на другой машине показало иначе» или «у нас среднее чуть выросло», это повод повторить измерение и сузить условия, но не повод тормозить выпуск. А если условия сопоставимы, разница воспроизводится, а красный порог сопровождается понятным списком запросов, которые стали дороже, то остановить релиз и разобраться с ним — нормальное, спокойное инженерное решение, ради которого такая проверка и ставится в конвейер.