Триггернутые, или Как безболезненно встроить нагрузочное тестирование в ваш пайплайн

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

Меня зовут Саша, я работаю в команде тестирования Ozon Fintech. В прошлый раз я рассказывала о типах нагрузочного тестирования (НТ) и о том, как создавать пушки под свои нужды. Сегодня же научу запускать НТ по кнопочке в CI. Статья будет полезна тем, кто уже имеет наработки по НТ, но ещё не автоматизировал их или ищет способы запускать тесты не по крону.

9d081454d9264ccca135f93b27c160cc.jpg

Наша команда финтеха в последнее время сильно разрослась. Сервисов стало много, и тестировщиков стало не хватать. Иногда стали возникать ситуации, когда разработчики просят нагрузить новую версию сервиса, но у QA на это нет времени, быстро проверить не получается. 

Чтобы не зависеть от нас, команда разработки попросила создать простой инструмент, который бы позволил им самостоятельно проводить весь цикл нагрузочного тестирования из CI. Иными словами, они попросили добавить в CI кнопку, при нажатии на которую всё само работает. Вся подкапотная магия — автоматическая генерация свежих патронов, загрузка их на сервер, билд пушки, загрузка пушки на сервер, сборка актуального конфига, загрузка конфига на сервер, запуск всего этого добра из консольки на сервере — их не интересует.

Мы рассматривали три варианта реализации этой идеи:

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

  2. В тестируемый сервис добавить файл с методами, которые будут обращаться к нагрузочному репозиторию как к библиотеке, всё создавать и запускать тесты. Но тогда владельцам самого тестируемого сервиса надо будет за всем следить. Это расходится с принципом «Просто нажать на кнопку — и оно само будет работать».

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

Да здравствуют триггеры!

В GitLab можно создавать многопроектные пайплайны, когда действие в одном пайплайне запускает пайплайн в другом проекте. Триггер — это тип джобы, которая вызывается в одном проекте, но запускает выполнение действий в другом. 

В нашем примере будет два сервиса:  

  1. Сервис, который мы тестим (в терминологии триггеров — upstream-сервис), назовём его SUT (system under test).

  2. Сервис НТ, который стреляет (он же downstream-сервис), назовём его LOAD

Так как много всего завязано на ветки гита, то в нашем примере у SUT ветка будет называться my-upstream-branch, а у LOAD — my-downstream-branch. В рамках примера мы хотим, чтобы по триггеру из проекта SUT ветки my-upstream-branch запускался пайплайн в проекте LOAD ветки my-downstream-branch

Таким образом, для создания многопроектного пайплайна у нас есть три элемента:

  • тесты, которые мы гоняем (LOAD);

  • триггер, по которому они запускаются;

  • сервис, который использует этот триггер, чтобы запустить тесты (SUT).

В коде это выглядит немного иначе:

  1. Есть тесты, которые мы запускаем через обычный пайплайн, описанный в gitlab-ci.

  2. Есть файл, который содержит описание джобы с триггером.

  3. Есть 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 нет никаких шагов, то вылезает такая ошибка:

edf6473142bf6fc6420910877162061c.png

Поэтому следующим шагом добавляем и сам .gitlab-ci.yml, если его ещё нет, и шаги в него, например:

any_job:
  stage: generate
  when: always
  script:
    - echo "hello world"

e7550dba3b4e45959e735b39233915cc.png

В результате при использовании триггера срабатывает пайплайн из нужной ветки:

e4b201691cf0da970161fcf8b7a6f10d.png

Дальше закидываем в .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 равно пустой строке ».

Каждый коммит будет сопровождаться вот таким безысходным зрелищем:

e34f150855473f5db6ef2902e71f6e8a.png

Поэтому можно добавить ещё правил для пайпа с нагрузкой — запускать его, только если сработал триггер.

Для этого в триггер добавляем ещё одну переменную 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 пишем весь код для функционирования пушки, добавляем его в мастер — и после этого считаем, что актуальный код для НТ лежит там и нигде больше.

Финал

4cc155732aa8fd4b514a8d1da40cc054.jpg

Итак, финальные варианты файлов. 

В проекте 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!

© Habrahabr.ru