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

В этой статье разберём, как интерпретировать результаты k6 и на какие метрики смотреть в первую очередь. Поговорим о перцентилях, сравнении прогонов с baseline, разберём типичные причины деградации производительности и реальные кейсы, когда результаты тестов могут вводить в заблуждение.

Основные метрики k6 и их значение

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

Когда говорят, что API отвечает за 200 мс, чаще всего имеют в виду именно http_req_duration. Но сама по себе эта цифра не показывает причину задержки. Запрос мог долго выполняться внутри приложения, ждать ответа от базы данных или потерять время ещё до начала обработки.

Значение http_req_duration не показывает, на каком этапе появилась задержка. Для поиска причины приходится смотреть внутренние составляющие времени запроса. Рост http_req_waiting чаще всего означает, что задержка появилась после отправки запроса и до получения первого байта ответа. В этот промежуток попадает серверная обработка, но источник задержки может находиться не только в самом приложении. 

В большинстве тестов http_req_connecting остается почти незаметной частью запроса. При включенном keep-alive новые TCP-соединения создаются редко, поэтому рост этой метрики уже сам по себе меняет направление поиска. В некоторых тестах задержка появляется ещё до того, как запрос начинает уходить в систему. Например, генератор может ожидать свободного соединения или тратить время на DNS-разрешение.

Вывод k6 в консоли, таблица с метриками после завершения теста
Вывод k6 в консоли, таблица с метриками после завершения теста

Перцентили: p50, p95, p99

Среднее время ответа может выглядеть нормально даже тогда, когда часть запросов уже выходит за пределы ожидаемого времени. Например, при среднем значении 200 мс p95 может находиться на уровне 300 мс, а p99 достигать 2000 мс. Большинство запросов завершатся быстро, но небольшой процент пользователей столкнётся с задержкой около двух секунд. В требованиях к производительности часто используют p95, но одинаковое значение этой метрики может появиться при разных формах распределения. Часть запросов может быть медленной из-за редких выбросов, а может замедлиться весь поток.

Когда меняется основная масса запросов, это обычно видно уже по p50. Медиана сдвигается вместе с ним, а не из-за отдельных выбросов.  

p99 помогает увидеть редкие задержки, которые теряются в общей статистике. Стабильный p50 при растущем p99 обычно говорит, что проблема затрагивает только часть запросов.

Паузы GC

Поведение p99 не всегда связано с ростом общей нагрузки. Иногда медиана остаётся стабильной, а хвост распределения внезапно растягивается на секунды. В таких случаях полезно открыть scatter plot и проверить характер выбросов.  На практике одинаковые по длительности выбросы могут иметь разное происхождение.

Короткие паузы JVM и задержки на уровне секунд требуют разного разбора: в одном случае смотрят работу молодого поколения, в другом анализируют mixed collections и состояние старого поколения. 

При таком анализе обращают внимание не только на наличие выбросов, но и на их длительность. Задержка в несколько десятков миллисекунд и остановка приложения на несколько секунд требуют разных вариантов поиска причины. Дальнейшие действия зависят от среды выполнения. Для JVM это может быть анализ работы GC, поиск избыточных аллокаций и проверка объектов, которые создают дополнительную нагрузку на сборщик мусора.

В Go обычно смотрят на распределение аллокаций, поведение сборщика мусора и профиль использования памяти.

Scatter plot http_req_duration
Scatter plot http_req_duration

Ошибка интерпретации p95

Значение p95 само по себе не показывает, как именно распределены задержки внутри этих 95% запросов. Допустим, порог установлен на уровне 500 мс, а фактический p95 составляет 480 мс. Формально тест проходит. Но если p50 при этом находится около 450 мс, большая часть запросов уже выполняется медленно. Проблема не в редких выбросах, а в общем смещении распределения.

Другая ситуация: p50 равен 100 мс, а p95 составляет 480 мс. В этом случае большинство запросов работают быстро, а задержки сосредоточены в небольшой части распределения.

Медленные эндпоинты и общая статистика

Общий p95 приложения может скрывать проблемы отдельных операций. Большая часть API может отвечать быстро, но один редко вызываемый маршрут способен создавать задержки в несколько секунд. В общей метрике приложения такой эндпоинт может потеряться среди остальных запросов. Поэтому критичные сценарии обычно анализируют отдельно. В k6 для этого используют группы и теги, чтобы назначать thresholds не только для всего потока запросов, но и для конкретных операций.

Разбивка p95 по эндпоинтам
Разбивка p95 по эндпоинтам

Сравнение прогонов

Для сравнения делают несколько одинаковых прогонов и смотрят на разброс значений.

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

В качестве точки сравнения используют baseline. Это результат стабильной версии системы, снятый в тех же условиях, что и новый прогон.

При сравнении обычно видно не одно изменение, а сочетание нескольких сигналов: p95 может немного вырасти относительно baseline, в хвосте появляются более длинные задержки, иногда вместе с этим начинают проявляться ошибки или падение RPS. Важно не само наличие этих изменений, а их распределение между прогоном и базовой версией. Конкретные пороги зависят от системы. В одном проекте рост p95 на 20% станет причиной остановки релиза, в другом такой разброс будет обычным шумом тестового стенда.

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

Когда нужна более строгая проверка, используют статистические методы, например U-критерий Манна — Уитни. При этом одного p-value недостаточно. Статистически значимое отличие ещё не означает, что изменение важно для пользователей. При анализе k6 часто смотрят только на http_req_duration, но этого может быть недостаточно.

Эта метрика показывает время отдельного HTTP-запроса. Если сценарий пользователя состоит из нескольких шагов, важным становится и iteration_duration.

Бывает, что запросы по отдельности остаются такими же быстрыми, но весь сценарий начинает выполняться дольше. В сценарии мог появиться новый шаг, увеличилось ожидание между операциями или изменилась логика выполнения.

Сравнение пяти прогонов baseline и новой версии. Медианный p95 вырос с 147 до 181 мс, а медианное время (iteration_duration) — с 1,81 до 2,37 с.
Сравнение пяти прогонов baseline и новой версии. Медианный p95 вырос с 147 до 181 мс, а медианное время (iteration_duration) — с 1,81 до 2,37 с.

Когда Kubernetes вмешивается в тест

Иногда результаты меняются из-за самого Kubernetes. Например, три одинаковых запуска дали разные значения:

Первый прогон: p95 152 мс.
Второй прогон: p95 121 мс.
Третий прогон: p95 149 мс.

При проверке оказалось, что во время второго запуска HPA увеличил количество подов сервиса с трёх до пяти. Новые поды снизили время ответа, но одновременно появился эффект холодного старта. Часть запросов попала на новые экземпляры, из-за чего p99 вырос. Если количество подов изменилось во время нагрузки, результат помечают как нестабильный и отдельно решают, использовать его или нет.

Разница между прогонами вызвана изменением количества подов во время теста
Разница между прогонами вызвана изменением количества подов во время теста

Деградация, которая накапливается неделями

Причина может быть в данных на стенде. База увеличивается, таблицы становятся тяжелее, меняются планы выполнения запросов, индексы начинают работать менее эффективно. Не каждую проблему видно при сравнении двух соседних билдов.

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

Но если посмотреть историю p95 за месяц, становится заметен постепенный рост.

История метрик может показать рост со 100 мс до 140 мс за несколько недель, хотя код приложения не менялся. Разница между соседними релизами может составлять всего несколько процентов и укладываться в обычный шум стенда. 

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

Долгосрочный тренд p95: постепенный рост задержек с 100 до 140 мс в течение месяца.
Долгосрочный тренд p95: постепенный рост задержек с 100 до 140 мс в течение месяца.

Ошибка сравнения с baseline

Новый релиз показал p95 280 мс. Baseline был 200 мс. Рост составляет 40%. На первый взгляд это выглядит как регрессия. Через неделю выясняется, что старый baseline снимали на другом стенде. На новом окружении база больше, данных больше, отличается конфигурация инфраструктуры. В итоге сравнивали не две версии приложения, а два разных окружения.

Ложноположительный результат

Бывает и по-другому: код не менялся, тест тот же, но результат внезапно выходит за порог.

В одном из прогонов p95 находился около 450 мс при лимите 500 мс. Через неделю тот же тест показал 550 мс. Проверка окружения показала изменение нагрузки на ноду. Во время второго запуска соседний под активно потреблял ресурсы процессора.

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

Поиск узких мест

Метрики k6 помогают понять направление поиска, но не дают готового ответа.

Время до начала отправки запроса отражается в http_req_blocked. Рост этой метрики может появиться из-за ожидания соединения, DNS или ограничений самого генератора нагрузки.

За этап создания TCP-соединения отвечает метрика http_req_connecting.  Если значение растёт, смотрят на количество новых соединений, сетевые настройки и ограничения инфраструктуры. Когда основную часть http_req_duration занимает http_req_waiting, дальше обычно разбирают серверную обработку: код приложения, базу данных или внешние сервисы. Внутри http_req_duration есть несколько этапов: blocked, connecting, tls_handshaking, sending, waiting и receiving.

Когда waiting занимает 85% времени запроса, основное внимание переходит к серверной части. Если начинают расти connecting и tls_handshaking, проверяют сеть и TLS. Рост blocked вместе с увеличением VU может говорить о том, что ограничение находится на стороне генератора нагрузки.

Разбивка http_req_duration на компоненты
Разбивка http_req_duration на компоненты

Медленная база данных

Если http_req_waiting занимает более 80% от общего времени http_req_duration, внимание обычно переключается на обработку запроса внутри системы. При этом http_req_connecting остаётся низким, а процессор сервера приложений может выглядеть практически незагруженным.

Для начала открывают APM-трейсы и ищут самые медленные эндпоинты. Часто внутри трейса видно длинный промежуток между отправкой запроса в базу данных и получением ответа. В этот момент становится понятно, что задержка возникает не на сети и не в приложении. Одних трейсов хватает не всегда. Тогда подключают eBPF-профилировщики, например Pyroscope или Parca. Они позволяют увидеть, где процесс тратит процессорное время и на каких системных вызовах начинает накапливаться задержка.

Параллельно полезно посмотреть статистику PostgreSQL через pg_stat_bgwriter и связанные представления мониторинга. Интерес представляют частота контрольных точек, объём WAL и объём буферов, записываемых во время checkpoint.

Резкий рост checkpoint_write_time или buffers_checkpoint обычно означает, что система упёрлась в производительность дисковой подсистемы. Во время интенсивного сброса грязных страниц пользовательские запросы начинают конкурировать за операции ввода-вывода, что напрямую отражается на времени ответа.

Конкуренция на уровне БД

При нагрузке 50 VU время ответа держится около 200 мс. После увеличения нагрузки до 100 VU картина меняется. Часть запросов по-прежнему завершается за 150-200 мс, а часть начинает выполняться по две секунды.

Проверка показывает, что на медленных запросах http_req_waiting достигает 1900 мс. Практически всё время уходит на ожидание ответа от сервера.

Смотрим состояние базы данных. В одном из таких случаев причиной оказались блокировки строк. Два параллельных запроса обновляли одну и ту же запись. Один завершался быстро, второй ждал освобождения блокировки.

Разнесли обновления по разным строкам, изменили логику работы с данными. Разброс исчез.

Переполнение пула соединений

Нагрузка составляет 200 VU. Первые минуты тест проходит стабильно, затем время ответа начинает быстро расти.

http_req_waiting увеличивается. В APM видно, что запросы подолгу ожидают выполнения. Проверка базы показывает ровно 100 активных соединений. Следующее соединение приложение получить уже не может и начинает ждать освобождения существующего.

Увеличить пул с 100 до 300 соединений кажется простым решением, но для PostgreSQL такой подход редко помогает надолго.

Каждое соединение PostgreSQL создаёт отдельный backend-процесс, который потребляет память и ресурсы CPU. При большом количестве соединений сервер начинает тратить заметную часть ресурсов на переключение контекста между процессами.

Обычно проблему решают через PgBouncer или Odyssey в режиме transaction pooling. Количество серверных соединений остаётся ограниченным, а приложение продолжает обслуживать большое количество клиентских запросов.

Проблема в DNS

Время ответа держится около 300 мс.

При разборе метрик выясняется, что http_req_waiting занимает примерно 100 мс, а http_req_blocked достигает 180 мс.

Сервер и генератор находятся в одном дата-центре, поэтому сеть выглядит маловероятной причиной.

Проверка показывает, что приложение выполняет DNS-разрешение практически для каждого запроса. Время разрешения имени попадает в http_req_blocked и начинает занимать существенную часть общего времени ответа.

После включения кеширования DNS значение http_req_blocked снижается примерно до 5 мс.

Состояние TIME_WAIT на генераторе

Генератор работает под Linux и создаёт нагрузку около 300 VU.

Через некоторое время начинают появляться ошибки connection refused. Одновременно растёт http_req_connecting. Проверка сетевых соединений показывает большое количество сокетов в состоянии TIME_WAIT. В таких случаях генератор может упереться в диапазон доступных локальных портов быстрее, чем ядро успевает освобождать старые соединения.

Для сценариев, где k6 выступает клиентом, помогает параметр:

net.ipv4.tcp_tw_reuse = 1

После его включения повторное использование портов происходит быстрее и ошибки исчезают.

Однако tcp_tw_reuse применяется только к исходящим соединениям. Для генератора нагрузки этого обычно достаточно. (Параметр tcp_tw_recycle удалён из ядра Linux начиная с версии 4.12.)

Генератор упёрся в потолок

Иногда количество VU продолжает расти, а интенсивность нагрузки остается практически неизменной. В этот момент имеет смысл проверить не только тестируемую систему, но и сам генератор. Одним из первых сигналов становится рост http_req_blocked. Если значение увеличивается вместе с количеством VU, часть времени начинает уходить на ожидание ресурсов уже внутри генератора. Первым делом проверяют лимит открытых файлов: ulimit -n

Значение 1024 часто оказывается недостаточным для серьёзных нагрузочных тестов. На практике лимит обычно поднимают до 65535 или выше в зависимости от сценария. После этого проверяют использование keep-alive. Если постоянные соединения отключены, количество новых TCP-сессий начинает быстро расти, создавая дополнительную нагрузку на генератор и сеть. В таких ситуациях тест распределяют между несколькими узлами. В Kubernetes для этого часто используют k6-operator, который запускает нагрузку через несколько подов.

Иногда причина оказывается ещё проще. Процессор генератора загружен на 100%, и новые виртуальные пользователи уже не могут создавать дополнительную нагрузку.

Автоматическая блокировка деплоя

В CI/CD k6 обычно подключают через thresholds. При падении threshold тест завершается с ненулевым кодом возврата, а CI/CD-система получает результат выполнения шага.

Например:

'http_req_duration': ['p(95)<500']

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

Отдельные скрипты для разбора JSON в таком сценарии не нужны.

Чтобы гарантированно вызвать падение теста для демонстрации в CI/CD, на скриншоте ниже я зажала порог до нереалистичных 5 мс.

Вывод k6 с exit code 99 при провале порога
Вывод k6 с exit code 99 при провале порога

abortOnFail: true используют для thresholds, когда продолжение теста после нарушения условия уже не имеет смысла. Например, если критичный компонент перестал отвечать.

Пересмотр порогов

Пороговые значения пересматриваются при изменении baseline. Иногда p95 снижается с 320 мс до 250 мс. Старый порог 400 мс становится слишком мягким и может пропустить регрессию. Новое ограничение можно зафиксировать ближе к текущему стабильному уровню, например на 300 мс.

Бывает и обратная ситуация. Новая функция увеличивает время ответа из-за дополнительной бизнес-логики. Тогда baseline меняют вместе с релизом, а причину изменения фиксируют отдельно.

Минимальная конфигурация thresholds

Пример базовой конфигурации:

export const options = {
  thresholds: {
    // Ошибок должно быть меньше 1%
    http_req_failed: ['rate<0.01'],

    // 95% запросов быстрее 500 мс
    http_req_duration: ['p(95)<500'],

    // Критичные проверки должны проходить на 99%+
    'checks{critical:true}': ['rate>0.99'],

    // Некритичные — выше 95%
    'checks{critical:false}': ['rate>0.95'],
  },
};

Разделение thresholds зависит от критичности сценариев.

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

Почему падают checks при чистой метрике http_req_failed

Один из вариантов такого поведения связан с rate limiter на внешнем API. Внешний сервис может вернуть пустой JSON, сохранив HTTP-код 200. В сценарии k6 добавляют повторные запросы с экспоненциальной задержкой, а лимиты проверяют на стороне внешнего шлюза. Случается, что checks падают до 95%, хотя http_req_failed остается равной нулю и сервер возвращает HTTP 200.

В этом случае проверяют конкретные эндпоинты, где не прошли проверки, а затем смотрят логи downstream-систем.

Вывод k6
Вывод k6

Холодный старт после деплоя

Первые минуты после развертывания приложение работает медленнее. Рабочие данные ещё не прогреты в кешах приложения и базы данных. JIT-компилятор не оптимизировал горячие пути кода. Пул соединений пуст. Если включить этот период в расчет p95 и p99, итоговые значения будут отражать состояние непрогретой системы. Прогревочный этап отделяют от рабочего прогона. Первые минуты убирают из thresholds.

Если p95 на прогреве остаётся высоким, отдельно проверяют сам процесс запуска: заполнение кешей, подготовку пула соединений и прогрев JIT.

Проблемы из-за кеширования в коротких тестах

Пятиминутный тест может показать неправильную картину.

Первые две минуты время ответа составляет 200 мс. После прогрева кеша последние три минуты проходят уже с задержкой 50 мс. Среднее значение около 100 мс выглядит нормально, хотя пользователь на непрогретой системе получает 200 мс. Здесь смешиваются холодный старт и рабочий режим в одной агрегации. Эти этапы анализируют отдельно, а thresholds считают только по рабочей нагрузке.

Чек-лист анализа отчёта k6

  • Стабильность окружения. Количество подов сервиса не менялось на протяжении прогона.

  • Ошибки в http_req_failed сначала ищут в логах. Дальше уже разбирают влияние на производительность.

  • http_req_duration делится на blocked, connecting, tls_handshaking, sending, waiting, receiving По этим частям видно, где именно теряется время.

  • Scatter plot. В нем видны выбросы, которые не попадают в средние значения и искажают p95.

  • GC или редкие блокировки проявляются как рост хвоста при стабильной медиане.

  • p95 и p99 сравнивают с baseline при одинаковых условиях теста.

  • Прогревочный период выкидывается из расчёта. Иначе перцентили завышаются.

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


НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ нового VDS — HABRFIRSTVDS.

Положение об акции