Привет, Хабр, это снова Валентина, которая отвечает за качество low-code платформы Eftech.Factory в компании Effective Technologies. Представляю вторую статью из серии публикаций о наших практиках нагрузочного тестирования (НТ). Первую, про поиск оптимального процесса НТ, можно прочесть здесь. На этот раз я собираюсь поделиться рекомендациями по автоматизации рутины и отчётности. 

Чтобы провести нагрузочное тестирование без стресса, надо позаботиться о сохранении своего ресурса — времени и нервов. Также мне кажется правильным освободить себя от рутинных и нудных дел, чтобы заниматься интересными и сложными задачами.

Чтобы достичь этих целей, я выработала для себя антистрессовый чек-лист из пяти пунктов:

  1. Автоматизируй запуск замеров

  2. Делегируй рутину боту

  3. Экономь время на отчетах

  4. Доверяй, но проверяй результат

  5. Заботься о тестовых данных

По ним мы сегодня и пойдём:

Приключение на 20 минут ...
Приключение на 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 =  `<b>Load testing</b> ${environment} \r\n`+
       `<b>Dashboard</b>: <a href="${link}">K6 Result</a>`;


   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, и была приятно удивлена, найдя там отзывчивое сообщество специалистов в нагрузочном тестировании.

Комментарии (2)


  1. su_lysov
    06.11.2024 14:36

    Спасибо за статью, может быть пропустил, не увидел как реализована остановка теста? И есть ли остановка по критериям? Выход за допустимое время отклика или по количеству ошибок?


    1. cmetikova Автор
      06.11.2024 14:36

      Доброе утро)
      Прекрасный вопрос)

      Для остановки теста мы используем пороги (thresholds) с условиями реакции на их пересечение.
      Вот тут документация поясняющая работу порогов - https://grafana.com/docs/k6/latest/using-k6/thresholds/

      Пример:

      thresholds: {iteration_duration: [{ threshold: "p(95)<10000", abortOnFail: true }]} 

      В такой конфигурации k6 для каждой итерации (то есть совокупности выполнения всех шагов скрипта) будет проверять что время выполнения всего скрипта меньше 10сек
      и если больше или равно, то скрип остановится с exitCode = 99 (Пересечен порог)

      Про коды я узнала из этого issue =)

      Смысл в том, что мы объявляем условие, нарушение которого приведет к остановке скрипта, если указан abortOnFail=true, или если его нет, то пересечение порога отметится в метриках результата без прерывания.
      А условия могут быть любыми, как со стандартными метриками(длительность итерации или запроса), так и кастомные проверки с условиями (checks, например).

      Представим, что в скрипте задана проверка кода ответа запроса, код = 200

      check(res, {
          'status is 200': (r) => r.status == 200,
        });
      

      Для замера допустимо чтобы не более 10% запросов упали с кодом отличным от 200.
      Если ошибок больше - то скрипт должен быть прерван.
      Тогда порог будет выглядеть вот так:

      thresholds: {checks: [{ threshold: "rate>0.9", abortOnFail: true }]}