
Меня зовут Анна и я QA-инженер в Банки.ру. В этой статье хочу поделиться нашим опытом построения практичного и эффективного процесса нагрузочного тестирования на основе k6.
Далее расскажу:
Для чего мы используем нагрузочное тестирование;
Как у нас устроена платформа для нагрузочного тестирования: какие инструменты есть и как все работает;
Поделюсь кейсами по разработке тестов от простых до сложных с созданием сценариев и определением весов нагрузки;
А так же расскажу как мы определяем сценарий нагрузки на основе продовых логов сервиса.
Якори для удобной навигации по материалу
(переходите сразу к интересующему вас разделу):
Мы используем нагрузочное тестирование для решения нескольких ключевых задач:
Оценка производительности нового сервиса: позволяет понять, выдержит ли сервис плановые пиковые нагрузки и как сервис себя ведет при таких нагрузках.
Сравнение производительности при рефакторинге: например, при миграции сервиса с PHP на Go мы можем объективно оценить выигрыш в производительности и соответствие новым требованиям.
Анализ влияния новых фич на сервис: помогает определить, как новая функциональность влияет на стабильность и отзывчивость приложения, и не приведет ли ее внедрение к деградации производительности.
Выявление узких мест: после инцидента с падением сервиса нагрузочные тесты помогают воспроизвести условия сбоя, найти "слабое звено" и предотвратить повторение ситуации.
Платформа для нагрузочного тестирования: почему k6?
Однажды перед нами встала цель определиться с инструментом для нагрузочного тестирования.
Что мы искали:
Быстрый старт, простая установка, минимум зависимостей, низкое потребление ресурсов.
Важно чтобы инструмент был максимально понятен инженерам.
Поддержка распространенных протоколов (HTTP/1.1, HTTP/2, WebSockets, gRPC)
Интеграции в CI/CD
Доступные и читаемые отчеты с результатами тестирования
Ключевые преимущества которые повлияли на выбор k6:
Для написания тестовых сценариев используется JavaScript, и это сыграло не последнюю роль в выборе инструмента. Т.к. большинство наших автотестов пишутся на JS/TS, то внедрении происходило максимально бесшовно.
Удобное хранение сценариев. Тесты хранятся в Git-репозитории. Для инженеров это привычнее, нет необходимости погружаться в изучение GUI и его функционала. Т.к. команд и разработчиков нагрузочных тестов у нас много, то мы определили четкую структуру хранения тестов. В основной директории tests находятся стабильные, готовые к использованию сценарии. Для отладки и разработки новых тестов используется директория demo.
В k6 можно гибко настраивать сценарии запусков. У нас реализуются как самые простые, например, выполнение нагрузки на определенный эндпоинт сервиса с базовыми параметрами, так и более сложные с настройкой сценариев, распределением нагрузки между группой запросов.
Интеграция с Prometheus и Grafana. Все метрики (UID прогонов, теги, время ответа, статус-коды и др.) летят в Prometheus, а дашборды в Grafana обновляются в реальном времени. Результаты можно анализировать практически сразу после старта теста.
k6 + CI/CD: как мы запускаем тесты
Мы используем связку k6 и Bamboo. Есть два основных сценария запуска тестов:
1. Гибкий запуск с кастомными параметрами из основного билда
Этот вариант подходит для запуска любых сценариев. Мы стандартизировали обязательный набор параметров, чтобы покрыть большинство потребностей, оставив возможность изменять переменные самой нагрузки по необходимости.
return {
executor: 'ramping-arrival-rate',
exec: exec,
startRate: 1,
timeUnit: '1s',
preAllocatedVUs: 10,
maxVUs: 1000,
stages: options.stages,
};
Для профиля нагрузки можно задать:
DURATION– длительность теста;TARGET_RPS– целевой RPS;NUM_STAGES– количество этапов нагрузки;FILE– какой тест запускать;NAMESPACE– окружение;
Запуская план через Run Customized можно изменить дефолтные значения этих переменных:

2. Упрощенный запуск предопределенного теста для конкретного сервиса "в одно нажатие"
Для регулярных тестов конкретных сервисов показатели нагрузки прописываются прямо в bamboo-specs. Запустить такой тест можно простым Run Plan или триггеру после деплоя со всеми установленными параметрами.
Разработка тестовых сценариев в k6: от простого к сложному
У нас есть несколько шаблонов, с помощью которых можно без усилий провести нагрузочное тестирование функционала или API сервиса.
Кейс 1: простой кейс (smoke-тесты)
Равномерная нагрузка на один или несколько роутов.
Помогает просто и быстро протестировать несколько роутов, равномерно распределив между ними нагрузку.
Это базовый сценарий, который создается за 5 минут. Можно взять этот шаблон и заменить в нем эндпоинты.
export const options = {
scenarios: generateScenarios(),
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<1000'],
},
tags: {
k6: 'api-bank-info',
k6_uuid: __ENV.UUID,
},
};
const routes = ['/api/1',
'/api/2'];
export default function () {
const serviceUrl = getServiceUrl('service-name');
const route = getRandomElementFromArray(routes);
const response = http.get(`${serviceUrl}${route}`, { timeout: '2s' });
check(response, { 'status 200': response => response.status === 200 });
export function handleSummary(data) {
return generalReport(data);
}
Немного пояснений по используемым функциям:
generateScenarios()– та самая функция с определением параметров сценария тестирования и генерацией объектов сценария; если в функцию не передан массив сценариев, то она создает один сценарий "default" с параметрами из переменных окружения.getServiceUrl()– получает URL сервиса для нужного окружения.getRandomElementFromArray()– выбирает случайный эндпоинт из массива.check()– базово проверили, что запрос отдал 200 статус код.generalReport()– функция экспорта отчета в html/xml/json.
Пороги успешности нагрузочного профиля (thresholds) настраиваются под конкретные SLA сервиса:
http_req_failed < 0.01(меньше 1% ошибок).http_req_duration < 1s(95-й процентиль времени ответа меньше 1 секунды).
Кейс 2: Сценарий с зависимыми запросами
Шаблон для тестирования зависимых запросов: "создание сущности id фильтра → запрос эндпоинта с id фильтра". Здесь второй запрос зависит от результата первого.
const testBody = new SharedArray('filter body', () => {
return JSON.parse(open('./filter-data.json'));
});
export default function () {
const serviceUrl = getServiceUrl('service-name');
const data = getRandomElementFromArray(testBody);
let filterIdPostApiFilters;
const postUrl = `${serviceUrl}/api/filters/`;
const params = {
headers: {
'Content-Type': 'application/json',
},
timeout: '2s',
};
const responsePostApiFilters = http.post(postUrl, JSON.stringify(data), params);
const isChecked = check(responsePostApiFilters, { 'status is 200': r => r.status === 200 });
if (isChecked) {
filterIdPostApiFilters = responsePostApiFilters.json().id;
const getUrl = `${serviceUrl}/api/products_ids?filter_id=${filterIdPostApiFilters}`;
const responseGetProductsIds = http.get(getUrl, {
timeout: '2s',
});
check(responseGetProductsIds, { 'status is 200': r => r.status === 200 });
} else {
printLogs(serviceUrl, responsePostApiFilters);
fail('Request failed');
}
}
В тесте body для создания фильтра забираются из файла json и преобразуются в массив. В первом шаге функцией getRandomElementFromArray() выбирается случайный объект для создания фильтра и отправляется запрос.
Если запрос завершился со статусом отличным от 200, то продолжать сценарий не имеет смысла и мы выводим лог с сообщением о том, что запрос у нас не прошел и прерываем сценарий.
Кейс 3: Неравномерное распределение нагрузки по эндпоинтам
Такой функционал нужен, чтобы учесть, как клиенты и сторонние сервисы используют наш сервис. В таком тестировании мы воссоздаем рабочие кейсы и распределяем нагрузку с учетом реальных сценариев использования.
Тут у нас есть 2 варианта:
1 вариант: простые get запросы
Эндпоинты и веса для эндпоинтов декларативно задаются в csv-файле. Сумма весов должна быть равна 1.
В коде теста мы указываем путь к этому csv-файлу, и функция selectByWeight() распределяет нагрузку согласно заданным весам.
export const options = {
scenarios: generateScenarios([{ step: testCsv }]),
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<1000'],
},
tags: { k6: 'web-test-csv', k6_uuid: __ENV.UUID },
};
const routes = loadCsv('../../demo/data/test/test.csv');
export function testCsv() {
const baseUrl = getServiceUrl('service-name');
const route = selectByWeight(routes);
const response = http.get(`${baseUrl}${route}`);
check(response, { 'status 200': response => response.status === 200 });
}
На дашборде можно увидеть, что запросы в сервис пошли согласно указанным весам в csv-файле:

2 вариант: сложные сценарии с параметризацией
Это хай левел сценарии =) которые позволяют максимально приблизиться к реальным пользовательским сценариям с уникальными данными для каждого виртуального пользователя.
Тест в этом случае выглядит совсем крошечным. Вся магия хранится в функциях для каждого нагружаемого эндпоинта.
Используем возможности JavaScript для создания гибкой параметризации.
Создаем в именованных функциях описания запросов (GET, POST), для удобства и читаемости в отдельном файле steps, их мы как раз и переиспользуем в нашем тесте;
Генерируем динамические параметры (например, уникальный id для GET-запроса, или ожидаемые query-параметры, headers, body и т.п.), чтобы избежать кеширования и нагружать именно базу данных;
Комбинируем эти шаги в сценарии и назначаем каждому свой вес;
Получаем нагрузочный профиль для каждого эндпоинта согласно установленным весам.


Для того чтобы была возможность просмотреть график любого сценария изолировано, необходимо навесить уникальный тег, по которому на дашборде мы сможем идентифицировать определенный сценарий. Сделать это можно добавив тег в параметры запроса в каждой функции, например так:
const tags = { tags: { k6_method: 'getApiPartnersPartnerObjects' } };
const params = Object.assign({}, defaultParams, tags);
const response = http.get(url, params);
После этого в Grafana можно легко отфильтровать графики по тегу k6_method и увидеть производительность каждого сценария в отдельности.
На графике установлен фильтр по каждому сценарию для просмотра динамики времени ответа:


Как собрать реалистичный профиль нагрузки?
Отлично! Шаблоны есть, сценарии есть, даже веса есть, но как же определить какой вес какому сценарию отдать?
Тут нам на помощь приходит Kibana.
Воссоздать нагрузку "один в один" с прода практически невозможно, но можно смоделировать ключевые сценарии по принципу Парето: 20% сценариев создают 80% нагрузки.
Методика определения показателя RPS и сбора весов на основе логов продакшена у действующего сервиса:
Собираем логи целевого сервиса за репрезентативный период (как правило 24 часа);
Исключаем служебные запросы (healthchecks, метрики) на основе API-документации. Для этого ищем паттерны запросов в логах и фильтруем логи по ним;
Подсчитываем фактическое количество запросов по эндпоинтам в сервисе, делим на 86400(количество секунд в сутках), округляем до целого в большую сторону и получаем нижний порог показателя RPS для сервиса.
Например: у сервиса за сутки 469474 запроса на интересующих нас эндпоинтах. Это 6 RPS: 469474/86400 = 5,43372685 -> 6Далее фильтруем по запросам, находим количество запросов к каждому эндпоинту и делим на общее число запросов. Полученные значения и будут целевыми весами.
Расчитаем веса для api1 и api2:
Запросов на api1 - 81510 -> рассчитываем вес: 81510/469474 = 0,17361984
Запросов на api2 - 387964 -> рассчитываем вес: 387964/469474 = 0,82638016
Полученные значения и устанавливаются в качестве показателя веса сценария в тестах.

Планы по развитию платформы и заключение
Всегда есть что улучшить, ускорить, сделать удобнее для инженеров. Основная задача на будущее: автоматизировать подготовку сложных сценариев. Сейчас ручное копирование данных из логов в csv неэффективно и трудозатратно.
Цель: создать единый стандартизированный формат логов, на основе которого можно будет автоматически генерировать csv-файлы для k6.
Это позволит нам:
Описывать сложные запросы (с заголовками, телом) в декларативном виде.
Автоматически подставлять пороги (thresholds) для метрик "успех/неуспех".
Запускать тесты для новых сценариев простой копипастой готового файла, что ускорит процесс и снизит вероятность ошибок.
Наш путь с k6 – это повествование о том, как нагрузочное тестирование перестало быть уделом избранных "гуру производительности" и стало доступным инструментом для каждого в компании.
В общем и целом, мы внедряем культуру ответственности в командах за производительность своего сервиса на всех этапах жизненного цикла продукта.
Всем по k6!! =)
P.S.: и желаю всем, чтобы ваши сервисы после нагрузки выглядели так:

Andrewus
ИМХО, далеко не для всех сервисов полезно считать средний RPS за 24 часа. Например, если у тебя клиентский трафик приходит в рабочее время, то основная нагрузка сервиса будет с 11 до 14, и, как мне кажется, именно за этот промежуток стоит брать минимальный RPS.
Или, напротив, какой-нибудь бэкофисовый сервис, который что-то обсчитывает по ночам в течение получаса, средний RPS за сутки покажет в десять-двадцать раз меньше, чем тот, в котором ему реально нужно работать.