Нагружаем и отдыхаем: load testing без стресса ч.2 — автоматизация
Привет, Хабр, это снова Валентина, которая отвечает за качество low-code платформы Eftech.Factory в компании Effective Technologies. Представляю вторую статью из серии публикаций о наших практиках нагрузочного тестирования (НТ). Первую, про поиск оптимального процесса НТ, можно прочесть здесь. На этот раз я собираюсь поделиться рекомендациями по автоматизации рутины и отчётности.
Чтобы провести нагрузочное тестирование без стресса, надо позаботиться о сохранении своего ресурса — времени и нервов. Также мне кажется правильным освободить себя от рутинных и нудных дел, чтобы заниматься интересными и сложными задачами.
Чтобы достичь этих целей, я выработала для себя антистрессовый чек-лист из пяти пунктов:
Автоматизируй запуск замеров
Делегируй рутину боту
Экономь время на отчетах
Доверяй, но проверяй результат
Заботься о тестовых данных
По ним мы сегодня и пойдём:
Приключение на 20 минут …
Автоматизируй запуск замеров
Если вы новичок в НТ, вероятнее всего, вас устроит создание простых скриптов для скорейшего получения результата. Если замеры проводятся раз в год, то локальный запуск скрипта и черновые заметки о том, как он работает, вполне допустимы.
Напомню, что в моем проекте для НТ используется инструмент k6, который требует или чистого кода JavaScript, или библиотек-плагинов, написанных под k6.
Пример локального запуска подачи нагрузки и замеров на k6
Но если в вашей команде НТ — регулярная активность, и в анализе замеров участвует команда, то есть повод задуматься об автоматизации.
Напомню, что простейший перенос вашего скрипта в любой сервис CI/CD позволит:
не тратить мыслетопливо, вспоминая, как запустить скрипт и что подставить во входные параметры;
отказаться от тонны документации для передачи своих знаний о работе скрипта коллегам.
У нас запуск замеров НТ оформлен в CI/CD:
K6 поднимается в Docker-контейнере и используется на последующих шагах:
build-k6:
stage: build
rules:
- when: always
script:
- |+
docker run --rm -u "$(id -u):$(id -g)" -v "${PWD}:/xk6" grafana/xk6 build v0.54.0 \
--with github.com/GhMartingit/xk6-mongo@v1.0.3 \
--with github.com/avitalique/xk6-file@v1.4.0 \
--with github.com/grafana/xk6-faker@v0.4.0 \
> docker.log 2>&1
cache:
policy: push
key: k6_binary
paths:
- ./k6
artifacts:
paths:
- "*.log"
До запуска замеров проверяем готовность контура для работы:
check:
stage: check
rules:
- when: always
script:
- ./k6 run checkStand.js -e configFile=config/preparation.json -e host="$STAGE.$PROJECT.$DOMEN" -e dbName="${STAGE}_${PROJECT}"
cache:
key: k6_binary
policy: pull
paths:
- ./k6
Файл проверок также реализован с учётом особенностей k6:
import {
expectedCountDocument,
mongoClass,
} from "../../methods/base.method.js";
import { Counter } from "k6/metrics";
export const CounterErrors = new Counter("Errors");
export const options = {
iterations: 1,
thresholds: {
"Errors{case:user}": [
{ threshold: "count<1", abortOnFail: true }
],
"Errors{case:load_object_read}": [
{ threshold: "count<1", abortOnFail: true },
],
"Errors{case:load_object_write}": [
{ threshold: "count<1", abortOnFail: true },
],
},
};
export default function () {
const userCount = mongoClass.count("user");
if (userCount < expectedCountUser || userCount === undefined) {
CounterErrors.add(1, { case: "user" });
}
...
const loadObject1ReadCount = mongoClass.count("load_object_read");
if (loadObject1ReadCount < expectedCountDocument) {
CounterErrors.add(1, { case: "load_object_read" });
}
...
mongoClass.deleteMany("load_object_write", {});
const loadObject1WriteCount = mongoClass.count("load_object_write");
if (loadObject1WriteCount > 0) {
CounterErrors.add(1, { case: "load_object_write" });
}
}
Скрипт проверяет достаточность данных — например, количество учётных записей для разнообразия авторизации или объём данных в коллекции MongoDB для замера чтения крупных реестров.
Кстати, достаточность — это не только про наличие, но и про отсутствие. Например, для скриптов, которые создают записи в базе, в нашем проекте обязательным условием является чистая коллекция до старта замеров.
Также в шаг проверки можно включить сверку конфигурации, параметров контура и т.д.
Функции k6 позволяют добавить обработку результатов для выполненных шагов скрипта.
Так если какая-либо из проверок «упадёт», то шаг pipeline также не выполнится, и замер не будет запущен.
Запуск НТ у нас конфигурируемый:
При запуске pipeline можно выбрать:
variables:
STAGE:
value: "15-0-x-autotest"
description: "Название контура"
SCRIPT:
description: "Сценарий нагрузки"
value: "scripts/all"
options:
- "scripts/all"
- "scripts/auth-api"
- "scripts/featureN"
- "scripts/websocket"
CONFIG:
description: "Профиль нагрузки"
value: "config/smoke"
options:
- "config/smoke"
- "config/rampRate"
- "config/stability"
Профиль нагрузки определяет, как долго и какой поток нагрузки будет идти на контур.
smoke — этот профиль используется для проверки контура и работоспособности скриптов. Выполняется 5 повторов каждого скрипта.
rampRate — целевой профиль для замеров. Нагрузка подается в несколько этапов с постепенным увеличением потока.
stability — при выборе этого профиля нагрузка подается на протяжении нескольких часов (обычно 6—8 часов) с постоянным показателем RPS.
Сценарий нагрузки позволяет выбрать набор скриптов для подачи нагрузки.
all — этот сценарий запускает регрессионное НТ; будет выполнен каждый скрипт в репозитории.
auth-api — сценарий, при выборе которого запускаются только скрипты для подачи нагрузки на сервис авторизации.
feautureN — это пример; можно запустить НТ даже для отдельной функцинальности!
У Grafana Labs есть хороший пост, в котором разъясняются отличия между разными типами профилей (правда, в статье они обозначены как Types of load testing).
Уверена, что у вас возник закономерный вопрос:, а как же определить, какая нагрузка стрессовая, а какая — нормальная?
И это чертовски хороший вопрос, ответа на который у меня нет)
Для каждого сервиса, функциональности или системы эти показатели индивидуальны. Они выясняются опытным путем через сопоставление поведения тестируемого объекта при определённых выделенных ресурсах и поэтапно растущей нагрузке.
Но вернёмся к коду)
Запуск замеров у нас обёрнут в bash-скрипт. Возможно, это излишне и является рудиментом. Ранее для запуска требовалась подготовка переменных, которыми не хотелось мусорить в gitlab-ci.yml.
run:
stage: measure
rules:
- if: $CI_PIPELINE_SOURCE == "pipeline"
- if: $CI_PIPELINE_SOURCE == "schedule"
- if: $CI_PIPELINE_SOURCE == "push"
when: never
- when: on_success
script:
- npm i
- start=$(date +%s)
- echo run.sh -h $STAGE -p $PROJECT -s $SCRIPT -d $DOMEN -c $CONFIG
- ./run.sh -h $STAGE -p $PROJECT -s $SCRIPT -d $DOMEN -c $CONFIG
- end=$(date +%s)
- echo $start > start
- echo $end > end
after_script:
- dashboardLT="${URL_GRAFANA_K6_FOR_QA}&from=$(cat start)000&to=$(cat end)000"
- dashboardServices="${URL_GRAFANA_FACTORY_SERVICES}&from=$(cat start)000&to=$(cat end)000"
- node tools/scripts/notification.js $STAGE $CI_COMMIT_BRANCH $CI_TELEGRAM_CHAT $CI_TELEGRAM_TOKEN $CI_JOB_ID $SCRIPT "$dashboardLT" "$dashboardServices" $CONFIG
cache:
key: k6_binary
policy: pull
paths:
- ./k6
artifacts:
paths:
- ./report
expire_in: 2 week
name: ${STAGE}
И сам bash-скрипт…
#!/bin/bash
while getopts h:p:s:d:c: flag
do
case "${flag}" in
h)
STAGE=${OPTARG}
;;
p)
PROJECT=${OPTARG}
;;
s)
SCRIPT=${OPTARG}
;;
d)
DOMEN=${OPTARG}
;;
c)
CONFIG=${OPTARG}
;;
*)
echo "Не корректный ключ. Проверьте введенные ключи $OPTARG";;
esac
done
[ -z "$STAGE" ] && STAGE="15-0-x-autotest"
[ -z "$PROJECT" ] && PROJECT="factory"
[ -z "$SCRIPT" ] && SCRIPT="scripts/all"
[ -z "$DOMEN" ] && DOMEN="lowcode"
[ -z "$CONFIG" ] && CONFIG="config/smoke"
if [[ $SCRIPT == "scripts/all" ]]; then
folderPath=$(find scripts/* -type d )
else
folderPath="$SCRIPT"
fi
for path in $folderPath; do
for script in "$path"/*.js; do
./k6 run "$script" -e configFile="$CONFIG" -e host="$STAGE.$PROJECT.$DOMEN" -e dbName="${STAGE}_${PROJECT}" --insecure-skip-tls-verify -o experimental-prometheus-rw
echo "$script", "$?" >>exitCode.txt
done
done
В зависимости от значения, переданного для сценария нагрузки, будут последовательно запущены скрипты из указанной папки.
После выполнения каждого скрипта в файл exitCode.txt записывается код выполнения.
Мы фиксируем как время старта, так и время окончания общего замера — эти показатели подставляются в ссылку Grafana, которую бот отправит после завершения замеров.
Про бота и использование файла exitCode.txt я подробно расскажу ниже.
Какими могут быть следующие шаги развития автозапуска и конфигурирования?
Встраивание запуска замеров при средней нагрузке в регулярный запуск проверки релиза.
Проведение НТ при динамическом масштабировании.
Перспективы тестирования выносливости, стресса и восстановления системы или продукта.
Делегируй рутину боту
Как не дёргаться, проверяя, завершились ли замеры НТ?
Нотификация нам в помощь! В Интернете много документации и примеров по реализации ботов для различных мессенджеров.
Рассмотрим реализацию нотификации в Telegram. Обязательным условием является предварительная подготовка бота.
В силу использования k6, нам потребуется или чистый код на Javascript, или библиотека-плагин, написанная под k6.
Поэтому у нас несколько вариантов:
реализовать нотификацию через API Telegram;
использовать плагин k6 — xk6-telegram.
Второй вариант кажется проще, начнём с него.
На странице документации к k6 есть раздел с расширениями.
Расширения создаются и поддерживаются как разработчиками k6, так и сообществом вокруг проекта.
xk6-telegram является одним из таких расширений. Оно разработано сообществом и упоминается в списке официально предлагаемых от k6, но его поддержка не гарантируется. Тем не менее, для старта можно использовать и его)
За основу кода берем пример из документации:
import http from "k6/http";
import telegram from "k6/x/telegram";
const conn = telegram.connect(`${__ENV.TOKEN}`, false);
const chatID = 123456789;
const environment = 'load_stand';
const link = 'https://grafana.com/';
export default function () {
http.get('http://test.k6.io');
}
export function teardown() {
const body = `Load testing ${environment} \r\n`+
`Dashboard: K6 Result`;
telegram.send(conn, chatID, body);
}
Что мы получаем?
Инструмент k6 выполняет код в скрипте последовательно: setup (пред-подготовка), function (основной код), teardown (пост-подготовка).
Как только замеры завершатся, с ошибкой или без неё, на финальном этапе (teardown) будет отправлено сообщение по указанному chatID.
Код отправки сообщения можно унифицировать для всех скриптов и вынести в отдельный наследуемый метод.
Для моего проекта простого уведомления о завершении расчетов мало.
Я бы хотела получать минимально необходимую информацию о выполненном замере.
Чтобы это стало возможным, мне потребовалось реализовать логику обработки результатов и сбор данных. А отправка уведомления реализована через вызов API Telegram.
В нашем проекте нотификация вынесена на уровень всего запуска замеров.
В .gitlab-ci.yml в блоке after_script вызывается
node tools/scripts/notification.js args
tools/scripts/notification.js содержит всю логику по подготовке и отправке сообщения боту.
Полный текст кода вы можете просмотреть по ссылке.
На вход скрипту передаются данные, объявленные в job pipeline:
название контура, на котором запускались замеры (у нас для каждого релиза готовится свой контур — для сценария, когда требуется сделать замеры в текущем и прошлом релизах);
ветка со скриптами, которые выполнялись;
название профиля нагрузки;
jobID (чтобы отправить в сообщении ссылку на артефакты Gitlab).
Далее выполняется подготовка данных для сообщения.
Стоит отметить, что exitCode от выполнения k6 у нас записывается в отдельный файл.
Для каждого скрипта вызывается k6 run script.js, и результат записывается в файл exitCode.txt.
На этапе подготовки данных для сообщения файл обрабатывается, строится цветовая градация успешности выполнения. Это не обязательно, но позволяет сразу сориентироваться в результате.
Только после этого вызывается отправка сообщения.
Экономь время на отчётах
Итак, у нас есть автоматизированный запуск в 2 клика, и бот своевременно уведомляет нас о завершении замеров. Это значит, что следующий этап — автоматизация отчётности.
Какой бы сервис для подачи нагрузки вы ни выбрали, в нём почти всегда есть стандартный отчёт с минимально необходимыми метриками.
В k6 вывод результатов выглядит так:
Такого среза достаточно, чтобы сделать выводы о замере и спланировать следующие действия.
А теперь представим, что необходимо сделать несколько разноплановых замеров и свести их результаты в удобно читаемый отчёт для руководства.
Failure story: раньше я, засучив рукава, погружалась в океан цифр и сводила их вручную. И хорошенько выгорала на этом!
Антиcтресс рекомендация: не поленитесь настроить простую интеграцию Prometheus-Grafana.
В нашем проекте k6 собирает метрики нагрузки, передаёт в Prometheus, а Grafana на основе этих данных строит графики и сводки таблиц.
В итоге мы получаем единое хранилище результатов и динамическую визуализацию:
Если вы только стартуете в НТ или присматриваетесь к сервисам нагрузки, то платные Enterprise-решения вам не доступны. Но какие же у них интересные возможности!
Например, Grafana Cloud предлагает обработать результаты замеров и показать их в сравнении между несколькими итерациями.
Когда очень хочется, но дорого, единственный выход — создать свой «велосипед».
Наши DevOps подготовили свой Grafana дашборд для анализа метрик нагрузки, в том числе с возможностью сравнения между несколькими замерами:
Изображение выглядит как снимок экрана, Мультимедийное программное обеспечение, Графическое программное обеспечение, текст
Автоматически созданное описание
Разберёмся, как это работает.
Представьте себе ситуацию, когда НТ выполнялось в несколько итераций в течение ночи.
Нагрузка росла этапами — и так для каждого из скриптов.
{
"summaryTrendStats": ["avg", "p(90)", "p(95)", "p(99)", "count"],
"scenarios": {
"rampRate": {
"executor": "ramping-arrival-rate",
"maxVUs": 400,
"preAllocatedVUs": 1,
"timeUnit": "1s",
"stages": [
{ "target": 5, "duration": "1m" },
{ "target": 5, "duration": "5m" },
{ "target": 10, "duration": "1m" },
{ "target": 10, "duration": "5m" },
{ "target": 20, "duration": "1m" },
{ "target": 20, "duration": "5m" },
{ "target": 40, "duration": "1m" },
{ "target": 40, "duration": "5m" }
]
}
}
}
Что делает такой профиль?
stage0: минутный рост нагрузки до 5 RPS
stage1: стабильная нагрузка в 5 RPS
stage2: минутный рост нагрузки до 10 RPS
stage3: стабильная нагрузка в 10 RPS
stage4: минутный рост нагрузки до 20 RPS
stage5: стабильная нагрузка в 20 RPS
stage6: минутный рост нагрузки до 40 RPS
stage7: стабильная нагрузка в 40 RPS
С утра вы садитесь за анализ результатов.
Общая сводка позволяет сделать предварительные выводы, но для заключения о качестве нужны более точные сравнения.
Идеальным было бы точечное сравнение показаний по каждому сценарию.
Например, вот такой сводной таблицей:
Но и такое решение не идеально, ведь нагрузка подавалась этапами с возрастанием потока!
Такой у нас получается визуализация выполнения скрипта «Login» с градацией по этапам нагрузки:
Код плитки: https://disk.yandex.ru/d/j80WalY5BAqZ8g
Мне как создателю скриптов понятно, что при росте нагрузки до 20 RPS (stage4) начинается деградация в работе авторизации.
А если заглянуть ещё на уровень глубже, то можно узнать, что проблема конкретно в запросе logout.
Но есть одно «но»…
Отчетом пользуюсь не я одна, и надо, чтобы графики читались легко и не вызывали вопросов.
Поэтому думаем, как сравнить графики на основе подаваемой нагрузки в RPS.
У нас начинает получаться что-то такое:
Выводы те же: до определенной нагрузки API сервиса справляется с потоком, а после начинается «расколбас».
Далее можно настроить такой же график для каждого API внутри сценария или оставить вывод в таблице.
Код плиток в обеих вариациях можно найти по ссылке.
Доверяй, но проверяй результат
Теперь нас не отвлекает механика замера, а наглядный отчёт позволяет быстрее проанализировать результаты. А значит, можно заняться проблемой качества кода в скриптах нагрузки.
Failure story
Мне достаточно вспомнить этот эпический провал, а вы представьте ситуацию.
Допустим, есть задача — протестировать скорость ответа API для запроса авторизации в сравнении между двумя релизами.
Ниже приведён код. У нас в скрипте всего 1 запрос, который выполняется для различных учётных записей:
import http from "k6/http";
import { scenario } from "k6/execution";
…
export default function (loginArray) {
const body = {
login: loginArray[scenario.iterationInTest % loginArray.length],
password: commonPassword,
};
http.post(`${host}/api/auth/login`, body);
}
Результат прошлого релиза у нас уже был, а замеры на новой версии выдают потрясающий результат. Ускорение в 2 раза!
Мы с командой празднуем победу, а я начинаю готовить отчет о проделанной работе… И тут замечаю по логам, что все 100% запросов вернули ошибку.
Больно, обидно, познавательно.
Теория НТ не рекомендует усложнять скрипты нагрузки генерацией данных и функциональными проверками. Но минимально необходимые проверки результата нужны!
Часто сервисы генерации нагрузки предоставляют функции для проверок данных и результатов, которые не отъедают ресурсы.
А после можно задуматься о выставлении порогов (критериев успеха или провала).
Например, если запрос выполняется дольше 200 мс, мы получим цветовую нотификацию о пересечении выставленной границы.
Также можно настроить остановку выполнения скрипта, если показатели вышли за заданный порог.
Согласитесь, удобнее получить сообщение от бота с предупреждением о неожиданном поведении сервиса, чем «красный» отчет после 2-х часов ожидания.
Как ещё можно развить проверки?
Комбинировать проверки и пороги (с возможностью остановки скрипта).
Настроить пороги для каждого из этапов нагрузки.
Настроить разные уровни реагирования (приемлемо / тревожно / неправильно).
Заботься о тестовых данных
Failure story
Представим ситуацию, когда вы по наитию решаете выполнить один и тот же скрипт несколько раз подряд. (Реальная история, которую я снова вспоминаю с красными щеками!)
Вы сводите результаты в одну таблицу и получаете радугу, как у меня на картинке:
Первая же мысль у нас с командой: «Допустили утечку ресурсов!»
Но, как оказалось, проблема крылась в другом)
При запуске регрессионного НТ запускались скрипты не только на чтение данных, но и на запись! Из-за этого коллекции в базе данных «пухли» с каждой итерацией.
Теория НТ настаивает на выполнении нескольких однотипных замеров друг за другом.
Какие же выводы мы сделали из этой глупейшей ошибки?
Перед стартом замеров рекомендуется:
проверять наличие, достаточность и соответствие ожидаемому объему данных
(в том числе — их отсутствие);подготовить генерацию однотипных данных.
Что ещё можно улучшить?
Автоматизировать проверки наличия настроек и других конфигураций данных.
Автоматически очищать отдельные коллекции или даже запускать сброс БД в исходное состояние.
Оценить возможность импорта БД перед замерами при потребности в большом срезе данных.
Послесловие
Повторю свою мысль из первой статьи: ошибаться — это нормально. Ошибки — это опыт, который учит нас расширять горизонты и становиться лучше.
Путь в НТ тернист, но жутко интересен! Проблем, с которыми вы можете столкнуться, гораздо больше, чем упомянуто в статье. Но это повод пересмотреть свой опыт и подумать об автоматизации.
Также хочу порекомендовать Телеграм-канал «QA — Load & Performance». С ним я познакомилась, когда готовилась к выступлению на конференции Heisenbug, и была приятно удивлена, найдя там отзывчивое сообщество специалистов в нагрузочном тестировании.