Триггернутые, или Как безболезненно встроить нагрузочное тестирование в ваш пайплайн
В жизни каждого тестировщика наступает момент, когда он больше не успевает тестировать все задачи, которые на него падают. Нагрузочники не являются исключением. Сначала одна пушка, потом вторая, потом их уже десять — и все надо поддерживать и запускать на очередной версии сервиса, выкатка каждые пару часов. Времени на запуски вручную не хватает, поэтому их нужно автоматизировать.
Меня зовут Саша, я работаю в команде тестирования Ozon Fintech. В прошлый раз я рассказывала о типах нагрузочного тестирования (НТ) и о том, как создавать пушки под свои нужды. Сегодня же научу запускать НТ по кнопочке в CI. Статья будет полезна тем, кто уже имеет наработки по НТ, но ещё не автоматизировал их или ищет способы запускать тесты не по крону.
Наша команда финтеха в последнее время сильно разрослась. Сервисов стало много, и тестировщиков стало не хватать. Иногда стали возникать ситуации, когда разработчики просят нагрузить новую версию сервиса, но у QA на это нет времени, быстро проверить не получается.
Чтобы не зависеть от нас, команда разработки попросила создать простой инструмент, который бы позволил им самостоятельно проводить весь цикл нагрузочного тестирования из CI. Иными словами, они попросили добавить в CI кнопку, при нажатии на которую всё само работает. Вся подкапотная магия — автоматическая генерация свежих патронов, загрузка их на сервер, билд пушки, загрузка пушки на сервер, сборка актуального конфига, загрузка конфига на сервер, запуск всего этого добра из консольки на сервере — их не интересует.
Мы рассматривали три варианта реализации этой идеи:
Сам репозиторий для нагрузки создавался как проект-библиотека для хранения шаблонов патронов и пушек: в нём не предусматривается работающий сервис, он нигде не поднимается и не крутится. Поэтому как раз первой мыслью было сделать из него сервис, добавить в него ручки, по которым всё будет генериться и стреляться. Но это оказалось слишком сложно в рамках текущего проекта.
В тестируемый сервис добавить файл с методами, которые будут обращаться к нагрузочному репозиторию как к библиотеке, всё создавать и запускать тесты. Но тогда владельцам самого тестируемого сервиса надо будет за всем следить. Это расходится с принципом «Просто нажать на кнопку — и оно само будет работать».
Создать триггеры для нагрузки, по которым на стороне нагрузочного репозитория с помощью простых команд будет выполняться алгоритм всей этой генерации и запуска нагрузки, а к тестируемому сервису этот триггер будет просто подключаться.
Да здравствуют триггеры!
В GitLab можно создавать многопроектные пайплайны, когда действие в одном пайплайне запускает пайплайн в другом проекте. Триггер — это тип джобы, которая вызывается в одном проекте, но запускает выполнение действий в другом.
В нашем примере будет два сервиса:
Сервис, который мы тестим (в терминологии триггеров — upstream-сервис), назовём его
SUT
(system under test).Сервис НТ, который стреляет (он же downstream-сервис), назовём его
LOAD
.
Так как много всего завязано на ветки гита, то в нашем примере у SUT
ветка будет называться my-upstream-branch
, а у LOAD
— my-downstream-branch
. В рамках примера мы хотим, чтобы по триггеру из проекта SUT
ветки my-upstream-branch
запускался пайплайн в проекте LOAD
ветки my-downstream-branch
.
Таким образом, для создания многопроектного пайплайна у нас есть три элемента:
тесты, которые мы гоняем (
LOAD
);триггер, по которому они запускаются;
сервис, который использует этот триггер, чтобы запустить тесты (
SUT
).
В коде это выглядит немного иначе:
Есть тесты, которые мы запускаем через обычный пайплайн, описанный в gitlab-ci.
Есть файл, который содержит описание джобы с триггером.
Есть gitlab-ci в тестовом сервисе, который наследует эту джобу.
Приступим! В LOAD
создаём триггер в отдельном файле .trigger.yml
:
.run_load_tests:
stage: build
allow_failure: true
trigger:
project: load
strategy: depend
branch: my-downstream-branch #downstream branch pipeline
Обязательно пушим изменения на сервер, иначе SUT
не даст сделать изменения в себе. В SUT
добавляем шаг, в котором наследуется джоба с триггером, в .gitlab-ci.yaml
:
load tests:
extends: .run_load_tests
allow_failure: true
include:
- project: load
ref: my-downstream-branch #downstream branch trigger file
file: .trigger.yml
и тоже пушим.
Важно: в SUT
в include: ref прописывается ветка, из которой мы будем считывать настройки триггера; в LOAD
в trigger: branch указывается название ветки, прогон которой будет активирован.
Мультивселенная
Еще раз: в .gitlab-ci.yml
сервиса SUT
указывается ветка, из которой брать условие запуска тестов, а в .trigger.yml
сервиса LOAD
— кодовая база для прогона.
Таким образом, у нас появляется кнопка для запуска LOAD-тестов в пайплайне.
Скрипт нагрузки: добавим логики
Кнопка есть, но никакие действия для downstream-проекта не прописаны. В описание джобы с триггером даже нельзя закинуть script для выполнения, ведь такого ключа нет в списке разрешённых.
В случае попытки добавить это ключевое слово вылезет ошибка:
CI lint invalid: [jobs: lnl tests config contains unknown keys: trigger]
Что же тогда будет выполняться?
Суть в том, что триггер запускает пайплайн целиком, а не конкретную задачу. Причём пайплайн ветки, заданной в описании триггера .run_load_tests:trigger:branch: my-downstream-branch.
Если в gitlab-ci нет никаких шагов, то вылезает такая ошибка:
Поэтому следующим шагом добавляем и сам .gitlab-ci.yml
, если его ещё нет, и шаги в него, например:
any_job:
stage: generate
when: always
script:
- echo "hello world"
В результате при использовании триггера срабатывает пайплайн из нужной ветки:
Дальше закидываем в .gitlab-ci.yml все стейджи и шаги, которые нам нужны для запуска пушки (полные файлы будут в конце статьи):
stages:
- generate
- upload
- config
- shoot
generate-ammo:
stage: generate
script:
- make generate-ammo service=sut
upload-ammo:
stage: upload
script:
- make upload-ammo service=sut
generate-config:
stage: config
script:
- make generate-config service=sut
shoot:
stage: shoot
script:
- make shoot service=sut
Кастомизация
Такой хардкод с названием сервиса service=sut
подходит для одного тестового сервиса, но мы тут делаем универсальный инструмент. Один и тот же пайплайн хочется запускать как из сервиса sut
, так и из сервиса another-sut
, у которых под капотом будут разные патроны, разные пушки, да просто разные адреса и условия нагрузки.
Для этого настройки пайплайна на стороне LOAD
будем определять через переменные окружения. В триггер будем передавать CI-переменную ${CI_PROJECT_NAME}
, на основании которой изменять настройки:
.trigger.yml
.run_load_tests:
stage: build
allow_failure: true
variables:
TEST_SERVICE: ${CI_PROJECT_NAME} #название upstream-сервиса
trigger:
project: load
strategy: depend
branch: my-downstream-branch
А в .gitlab-ci.yml
добавим ещё один шаг aim, на котором будем определять, куда стрелять:
.gitlab-ci.yml
stages:
- aim
- generate
- upload
- config
- shoot
load-tests:
extends: .load-tests
.load-tests:
stage: aim
rules:
- if: $TEST_SERVICE == "sut"
variables:
TARGET: "sut.service.stg:82"
MAX_RPS: "49"
- if: $TEST_SERVICE == "another-sut"
variables:
TARGET: "another-sut.service.stg:82"
MAX_RPS: "16"
script:
- echo "testing service $TEST_SERVICE"
- echo "TARGET=$TARGET" >> build.env #save TARGET for later stages
- echo "MAX_RPS=$MAX_RPS" >> build.env #save MAX_RPS for later stages
artifacts:
reports:
dotenv: build.env
generate-ammo:
stage: generate
script:
- make generate-ammo service=$TEST_SERVICE
Важно обратить внимание на необходимость артефактов и на переменные окружения.
Переменной TEST_SERVICE присваивается значение ${CI_PROJECT_NAME}
на уровне upstream-проекта; в downstream-проекте она является переменной окружения и доступна со своим значением из любого места в пайплайне. А вот переменные, которые мы определяем в .load-tests: rules: variables, являются локальными и доступны только в рамках шага, выполняемого в этот момент. Поэтому их надо записать в артефакт dotenv, а в последующих шагах этот артефакт считывать:
.gitlab-ci.yml
script:
- echo "TARGET=$TARGET" >> build.env #save TARGET for later stages
artifacts:
reports:
dotenv: build.env
generate-ammo:
stage: generate
script:
- make generate-ammo service=$TEST_SERVICE addr=$TARGET rps=$MAX_RPS
artifacts:
reports:
dotenv: build.env
Какие ещё подводные камни могут встретиться?
Во-первых, повседневная разработка нагрузочного проекта.
На текущий момент в пайплайне нет никаких ограничений на запуск. Грубо говоря, при разработке новой фичи на каждый коммит будет триггериться падающий пайп, потому что в шаге .load-tests нет правила для нашего репозитория. Значение $TEST_SERVICE в рамках запуска проекта LOAD
равно пустой строке ».
Каждый коммит будет сопровождаться вот таким безысходным зрелищем:
Поэтому можно добавить ещё правил для пайпа с нагрузкой — запускать его, только если сработал триггер.
Для этого в триггер добавляем ещё одну переменную LOAD_PIPE: «true»:
.trigger.yml
.run_load_tests:
stage: build
allow_failure: true
variables:
LOAD_PIPE: "true" #запускает именно тестовый пайп
LNL_TEST_SERVICE: ${CI_PROJECT_NAME}
А на шагах, которые мы хотим запускать по триггеру, добавляем условие
.gitlab-ci.yml:
generate-ammo:
rules:
- if: $LOAD_PIPE == "true"
Это позволяет добавить пайплайн для разработки. Ведь в нашем проекте НТ тоже должны быть тесты и линтер, которые повышают качество кода, и мы хотим их запускать на всех ветках, кроме мастера. Например:
.gitlab-ci.yml
test:
extends: .go
stage: build
script:
- go test ./...
except:
refs:
- master
allow_failure: false
Во-вторых, повседневная разработка тестируемого проекта.
НТ не требуется в каждом возможном коммите. В наших проектах мы решили его запускать уже после релиза на стейдж-окружение. То есть сначала код должен пройти юнит-тесты, функциональные тесты, ревью на merge request, интеграционные тесты — несколько проверок, позволяющие с большой уверенностью предположить, что изменения в коде сделаны нормально. Всё-таки НТ — ресурсозатратный процесс, с помощью которого хочется проверять более или менее готовый продукт. Поэтому в триггер мы добавили ограничение на тип веток: НТ может быть запущено только в релизных ветках на стадии пост-деплоя, то есть когда сервис уже раскатан, и только вручную:
.trigger.yml
.load_tests:
stage: post-deploy #upstream stage
when: manual
only: #запускать только в релизных ветках
refs:
- "/^release\\/.+$/"
В-третьих, права пользователей.
Чтобы иметь возможность запустить downstream-джобу, надо иметь достаточно прав для запуска пайплайна в downstream-проекте. То есть людей, которые будут запускать пайплайн с тестами, необходимо добавить к себе в репозиторий с необходимыми правами.
В-четвёртых, можно запутаться в ветках: upstream, downstream…
Напомню, что в изначальной концепции был акцент на простоте — пользователям тестируемого сервиса нужно было делать минимум телодвижений или минимум изменений в своём проекте. Поэтому на уровне тестируемого upstream-проекта мы просто добавляем шаг, который наследует триггер.
В проекте LOAD
пишем весь код для функционирования пушки, добавляем его в мастер — и после этого считаем, что актуальный код для НТ лежит там и нигде больше.
Финал
Итак, финальные варианты файлов.
В проекте LOAD
:
.trigger.yml
.run_load_tests:
stage: post-deploy #upstream stage
when: manual #НТ запускается вручную
allow_failure: true # НТ не влияет на прохождение пайплайна тестируемого сервиса
variables:
LOAD_PIPE: "true" #флаг запуска именно тестового пайпа
TEST_SERVICE: ${CI_PROJECT_NAME} # название upstream-проекта
SOME_OTHER_VAR:
value: "some_data"
description: "другие данные из upstream-проекта"
only: # запускать только в релизных ветках
refs:
- "/^release\\/.+$/"
trigger: # вызов тестов из другого репозитория
project: load #имя тестового репозитория НТ
strategy: depend #upstream pipe ждёт, пока пройдут все downstream-процессы
branch: master # при триггере запускается пайплайн из этой downstream-ветки
.gitlab-ci.yml
include:
- project: 'my/project/that/allows/to/use/golang'
ref: '0.0.5'
file:
- '/templates/go/.go.gitlab-ci.yml'
- local: '/.universal-ci.yml'
variables:
GO_VERSION: "1.17"
LOAD_PIPE:
value: "false"
description: "true if you want to start a load test"
stages:
- build
- aim
- generate
- upload
- config
- shoot
linter:
extends: .go
stage: build
script:
- make lint
except: # запускать только в релизных ветках
refs:
- master
allow_failure: true
test:
extends: .go
stage: build
script:
- go test ./...
except: # запускать только в релизных ветках
refs:
- master
allow_failure: true
# AIM
# описание джобы вынесено в /.universal-ci.yml для лучшей читаемости
load-tests:
extends: .load-tests
# GENERATE
generate-ammo:
stage: generate
script:
- make generate-ammo service=$TEST_SERVICE addr=$TARGET rps=$MAX_RPS
rules:
- if: $LOAD_PIPE == "true"
artifacts:
reports:
dotenv: build.env
# UPLOAD
upload-ammo:
stage: upload
script:
- make upload-ammo service=$TEST_SERVICE addr=$TARGET rps=$MAX_RPS
rules:
- if: $LOAD_PIPE == "true"
artifacts:
reports:
dotenv: build.env
#CONFIG
generate-config:
stage: config
script:
- make generate-config service=$TEST_SERVICE addr=$TARGET rps=$MAX_RPS
- make save-config-to-artifact
rules:
- if: $LOAD_PIPE == "true"
artifacts:
reports:
dotenv: build.env
paths:
- ./configs
expire_in: 1 day
#SHOOT
shoot:
stage: shoot
script:
- make shoot service=$TEST_SERVICE config=./configs
rules:
- if: $LOAD_PIPE == "true"
artifacts:
paths:
- ./configs
expire_in: 1 day
Описание джобы load-tests берётся из '/.universal-ci.yml', который указан в include в начале файла.
.universal-ci.yml
.load-tests:
stage: aim
rules:
- if: $TEST_SERVICE == "sut" #определение переменных для проекта sut
variables:
TARGET: "sut.service.stg:82"
MAX_RPS: "49"
- if: $TEST_SERVICE == "another-sut" #определение переменных для проекта another-sut
variables:
TARGET: "another-sut.service.stg:82"
MAX_RPS: "16"
script:
- echo "testing service $TEST_SERVICE"
- echo "TARGET=$TARGET" >> build.env #save TARGET for later stages
- echo "MAX_RPS=$MAX_RPS" >> build.env #save MAX_RPS for later stages
artifacts:
reports:
dotenv: build.env
В сервисе SUT
мы дополняем только .gitlab-ci.yml
:
include:
- project: load
ref: master #берётся версия триггера из мастера
file:
- .trigger.yml
LOAD tests:
extends: .run_load_tests
variables:
SOME_OTHER_VAR: "If you need it downstream"
Если нужно на уровне SUT
переопределить, например, TARGET, то это можно просто добавить в описание джобы:
load tests:
extends: .run_load_tests
variables:
SOME_OTHER_VAR: "If you need it downstream"
TARGET: $GIBSON_TARGET #change it if you want to test custom release
На выходе получаем систему с таким устройством:
Архитектура
Как вы могли заметить, в .gitlab-ci.yml в шагах генерации патронов, конфигов и пушек скрипты создания самих сущностей указаны верхнеуровнево. Для того чтобы все эти генерации работали для всех проектов, нужно и пушки с генераторами патронов сделать универсальными. Обе эти вещи выходят за пределы сегодняшней темы, поэтому и описаны без подробностей.
Вывод
Триггеры позволяют быстро автоматизировать процесс добавления функционала НТ в конечные сервисы. Регулярные обстрелы помогают определить, не уменьшилась ли пропускная способность сервиса, не ломаются ли интеграции с соседними сервисами при длительном взаимодействии, насколько оптимально работает железо. При таком встраивании НТ в жизненный цикл сервисов удаётся сэкономить время на генерации патронов и запуске вручную, а также тестировать сервис сразу после выкатки, а не ждать ночных стрельб по крону (если они вообще есть).
Но такие строгие шаги в пайплайне требуют единообразия всех генераторов. Патроны, пушки, конфиги должны создаваться одинаковым образом для разных сервисов, иметь консистентную структуру файлов. С другой стороны, это позволяет автоматизировать и их генерацию, а разработчику остаётся самое приятное — использовать своё воображение и реализовывать свои навыки под всей этой инкапсуляцией.
В этой статье я постаралась рассказать, как безболезненно встроить НТ в свой проект, как настроить и репозиторий с пушками, и репозиторий с тестируемым сервисом. Теперь вам известно, какие проблемы могут возникнуть при таком подходе и как их избежать.
Надеюсь, моя статья облегчит вам жизнь и поможет быстрее настроить окружение для НТ, автоматизировать запуск тестов и улучшить качество ваших проектов. Stay tuned!