
Как часто вы задумывались о нагрузочном тестировании (НТ), когда на подходе очередной релиз, но времени на дополнительные проверки катастрофически не хватает? В таких условиях НТ обычно выглядит ненужной роскошью, но на деле оно — неотъемлемая часть процесса, которой нельзя пренебрегать. Нагрузочное тестирование помогает избежать множества рисков, особенно в условиях роста количества пользователей и требований к производительности.
Мы — команда QA из РТЛабс. В этой статье мы расскажем, как с помощью опенсорс-инструментов создать надёжную и масштабируемую инфраструктуру для НТ, осуществлять запуск тестов в один клик, организовать высокопроизводительный мониторинг в реальном времени и долгосрочное хранение результатов.
Статья будет полезна командам, которые только начинают выстраивать процесс нагрузочного тестирования, функциональным тестировщикам, разработчикам и аналитикам. В ней приведена готовая структура, которую можно взять за основу для собственных проектов или адаптировать под текущие задачи. В статье мы постарались сохранить и отразить суть и структуру с учётом имеющихся у нас ограничений по раскрытию информации.
Нам, в свою очередь, будет интересно узнать ваше мнение и идеи по улучшению. Делитесь ими в комментариях.
Выбор инструмента для генерации нагрузки
При выборе инструмента для нагрузочного тестирования важно учитывать особенности проекта, цели тестирования и технические требования. Например:
JMeter ценится из-за простоты использования, поддержки множества протоколов и большого комьюнити;
LoadRunner — из-за обилия протоколов, возможности тестирования в разных средах, включая веб, мобильные и настольные приложения, и встроенного средства анализа результатов;
в Locust удобно работать с Python.
Мы выбрали Gatling, который лучше всего отвечает нашим задачам и обеспечивает баланс между производительностью, гибкостью и удобством работы. Его основные преимущества, которые стали для нас решающими:
высокая производительность — благодаря асинхронной архитектуре на базе Netty инструмент эффективно использует ресурсы и способен генерировать сотни тысяч запросов с одного узла. Это особенно важно для тестирования высоконагруженных систем;
поддержка открытой модели нагрузки — Gatling позволяет моделировать поведение пользователей в реальных условиях, учитывая паузы между запросами и другие аспекты, свойственные реальной пользовательской активности;
удобство командной работы — тесты можно писать на разных языках программирования, например Scala, Kotlin и Java. Мы пишем сценарии на Scala, они получаются легкочитаемыми, поддерживаемыми и интегрируемыми в процессы разработки. Это значительно упр��щает командную работу над тестами, особенно в условиях активной разработки.
Как обойти ограничения community edition-версии Gatling
При всей своей популярности community edition-версия Gatling имеет ограничения, которые могут помешать созданию полнофункциональной инфраструктуры для распределённого НТ. Эти ограничения касаются как координации работы инстансов (от англ. instance — «экземпляр» — копия объекта, класса или системы, которая создаётся под конкретные задачи и запускается отдельно от других копий), так и организации надёжного хранения метрик. Мы столкнулись со следующими проблемами:
отсутствие механизма распределённой нагрузки — требуется ручная организация нагрузки на нескольких инстансах;
синхронизация запуска тестов — необходимо запускать тесты синхронно для консистентности данных;
актуальность сценариев и конфигураций — для поддержания всех инстансов с актуальными сценариями требуется отдельная координации;
разделение тестовых данных — каждый инстанс должен работать со своим уникальным пулом данных для предотвращения ошибок;
ограниченные возможности хранения метрик — в бесплатной версии отсутствует возможность работы с некоторыми внешними базами данных для долговременного хранения результатов.
Разберём, как эти ограничения можно обойти с помощью опенсорс-инструментов и минимальных усилий.
Архитектура решения

На схеме 1 представлена упрощённая архитектура нашей инфраструктуры для распределённого НТ. У каждого компонента в ней своя роль (см. табл. 1). Это позволяет управлять тестами, генерировать нагрузку, собирать метрики и логировать данные для детализированного анализа.
Таблица 1. Компоненты и их назначение
Gatling | Инструмент нагрузочного тестирования |
Генераторы нагрузки | Виртуальные машины. Параллельно генерируют нагрузку на различные системы и среды |
Jenkins | Запускает тесты и координирует работу нод (далее в примерах инстансов - Instance) |
ClickHouse | База данных. Принимает и хранит метрики тестов для дальнейшего анализа |
Grafana | Визуализирует метрики из ClickHouse для удобного мониторинга в реальном времени |
PostgreSQL | База данных. Хранит тестовые данные |
ELK (Elasticsearch, Logstash, Kibana) | Осуществляет логирование request/response для детализированного анализа в случае каких либо проблем |
Рассмотрим каждый из компонентов, их настройку и роль в решении проблем, обозначенных выше.
Gatling
Для нагрузочного тестирования в первую очередь нужны сами тесты. Мы пишем их с использованием инструмента Gatling, параметры задаём через библиотеку Tinkoff Gatling-picatinny, которая упрощает настройку. Наиболее частые сценарии включают возрастающую ступенчато или постоянную длительную нагрузку. Для каждого типа у нас есть шаблоны с такими параметрами, как итоговая интенсивность, количество ступеней, длительность разгона и время каждого шага. Выбор типа теста вынесен в simulation.conf, что позволяет легко переключаться между сценариями.
import io.gatling.core.Predef._ import io.gatling.core.structure.ScenarioBuilder import ru.tinkoff.gatling.config.SimulationConfig._ trait LoadGen extends Simulation { val testType: String = getStringParam("type") //Данный параметр будет передан при запуске теста def load(scenario: ScenarioBuilder): SetUp = { testType match { case "max" => //Ступенчато возрастающая нагрузка setUp( scenario.inject( incrementUsersPerSec(intensity / stagesNumber) // Интенсивность на ступени .times(stagesNumber) // Количество ступеней .eachLevelLasting(stageDuration) // Длительность ступени .separatedByRampsLasting(rampDuration) // Длительность разгона ступени ) ).maxDuration(testDuration) case "stable" => //Разгон + постоянная нагрузка setUp( scenario.inject( rampUsersPerSec(0) to intensity during rampDuration, //Длительность разгона ступени constantUsersPerSec(intensity) during stageDuration //Длительность стабильной нагрузки ) ).maxDuration(testDuration) case "debug" => //Однократное выполнение сценария для отладки setUp( scenario.inject(atOnceUsers(1)) ).maxDuration(testDuration) } } }
Code Block 1 LoadGen.scala
import io.gatling.core.Predef._ import io.gatling.core.structure.ScenarioBuilder import io.gatling.http.Predef.http class TestSim extends Simulation with LoadGen { def scn: ScenarioBuilder = scenario("TEST") .exec(http("").get("")) load(scn) }
Code Block 2 TestSim.scala
На уровне тестов мы также решили задачу безопасного разделения данных между генераторами нагрузки. Тестовые данные хранятся в базе данных PostgreSQL и загружаются с помощью JDBC feeder. Для разделения данных между нодами (Instance) используется целочисленное поле: делим его значения на количество генераторов и распределяем записи по генераторам с использованием остатка от деления. Этот простой, но при наличии соответствующих индексов в больших таблицах, производительный подход, который позволяет масштабировать количество генераторов без изменения логики.
import ru.tinkoff.gatling.config.SimulationConfig.getStringParam import io.gatling.core.Predef._ import io.gatling.core.feeder.FeederBuilderBase import io.gatling.jdbc.Predef.jdbcFeeder object FeederManager { val instancesCount: String = getStringParam("instancesCount") //общее кол-во генераторов val instanceId: String = getStringParam("instanceId") //номер текущего генератора def feederSelect(sql: String): FeederBuilderBase[Any] = jdbcFeeder( "postgresUrl", "postgresUser", "postgresPassword", sql) val feeder: FeederBuilderBase[Any] = feederSelect( s"""SELECT id, field_1, field_2 |FROM data_table |WHERE id%$instancesCount = $instanceId - 1;""".stripMargin ).circular }
Code Block 3 FeederManager.scala
Генераторы нагрузки
Следующий шаг после написания тестов — запуск их на удалённых машинах. В базовой конфигурации это обычные виртуальные машины с минимальным набором компонентов, на которых используется JVM, Git, SBT или другой сборщик проектов. Код тестов хранится в корпоративном репозитории. Каждый генератор загружает его непосредственно перед запуском теста, что помогает поддерживать актуальность сценариев и конфигураций.
После загрузки исходников тесты можно запустить, просто собрав проект.
Благодаря библиотеке Gatling-picatinny параметры тестов передаются непосредственно в команду сборки, что упрощает настройку. В нашем случае это команды sbt для компиляции и запуска.
sbt -no-colors clean gatling:compile sbt -Dtype=debug -Dintensity=1 '-DrampDuration=30 seconds' '-DstageDuration=60 seconds' -DstagesNumber=1 -DinstancesCount=1 -DinstanceId=1 -no-colors 'gatling:testOnly *.TestSim'
Code Block 4 Сборка проекта тестов
Важно: если придерживаться рекомендаций по настройке ОС, описанных в документации Gatling, можно значительно повысить производительность генераторов нагрузки.
На этом этапе мы также реализовали возможность запуска тестов из Kubernetes, что позволило обеспечить гибкость и масштабируемость.
Управление и координация тестами (Jenkins)
Jenkins легко запустить в docker — в интернете много примеров docker-compose. Генераторы нагрузки при этом подключаются к Jenkins в качестве узлов (Node) любым удобным способом. Например, мы используем активацию по ssh с использованием логина и пароля.
Важно: рекомендуем ограничить количество исполняемых процессов на узле до 1, чтобы исключить одновременный запуск нескольких тестов на одной машине.
Большая часть логики выполняется в пайплайне Jenkins. Код пайплайнов мы также храним в репозитории. Если требуется много пайплайнов, можно обратить внимание на плагины, позволяющие переиспользовать код, например на Jenkins Tamplate Engine или Shared Libraries.
Пайплайн состоит из нескольких основных частей:
объявление входных параметров сборки и переменных;
распределение нагрузки между генераторами;
проверка доступности и поиск свободных генераторов;
определение шагов compile и execute;
запуск теста и завершение.
Рассмотрим каждую из этих частей более подробно.
Основные части пайплайна
Объявление входных параметров сборки и переменных
Кроме стандартных параметров нагрузочного теста, таких как интенсивность и длительность, можно указать специфические настройки:
предварительная очистка баз данных;
включение кеширования;
точка приземления трафика и другие.
Рассмотрим пример, где параметры git для скачивания исходников жёстко заданы в самом пайплайне, но их можно вынести также в properties, чтобы пользователи могли выбирать ветку и класс симуляции.
properties([ parameters([ choiceParam( name: 'type', choices: ['max', 'stable', 'debug'], description: 'Тип теста'), string( defaultValue: '1', description: 'Количество генераторов', name: 'generatorsNumber'), string( defaultValue: '1', description: 'Интенсивность подачи нагрузки на последней ступени. Равномерно распределяется между генераторами.', name: 'intensity'), string( defaultValue: '30 seconds', description: 'Длительность разгона каждой ступени', name: 'rampDuration'), string( defaultValue: '60 seconds', description: 'Длительность каждой ступени', name: 'stageDuration'), string( defaultValue: '1', description: 'Количество ступеней', name: 'stagesNumber') ]) ]) def gitUrl = "https://git.local/load-test.git" def branchName = "master" def simulationClassName = "TestSim" def grafanaUrl = "https://grafana.local/boardName" def compile = [failFast: true] def execute = [failFast: true] def startTimeUnix, endTimeUnix
Code Block 5 Объявление параметров сборки и переменных
Важно: наличие словарей compile и execute с ключом failFast: true позволит немедленно остановить всю сборку при возникновении ошибки хотя бы на одном из генераторов.
Переменные startTimeUnix и endTimeUnix потребуются для формирования ссылки на Grafana, адрес которой также задан в этом блоке.
Распределение нагрузки между генераторами
Для равномерного распределения нагрузки между генераторами целевую интенсивность нужно поделить на количество генераторов. Далее пайплайн оперирует только значением интенсивности в расчёте на 1 генератор.
generatorsNumber = Integer.parseInt(generatorsNumber) intensity = Integer.parseInt(intensity) / generatorsNumber
Code Block 6 Распределение нагрузки
Проверка доступности и поиск свободных генераторов
Наш подход не позволяет автоматически масштабировать генераторы. Предполагается, что пользователь знает, какое количество генераторов нужно для достижения целевой нагрузки, и сам задаёт их число перед запуском теста. Это создает определённые ограничения: неопытный пользователь может указать число, которое будет больше доступных генераторов. В следующем блоке мы проверяем это и составляем составляем список доступных генераторов. Если в течение заданного времени необходимое количество свободных генераторов так и не появилось, пайплайн останавливается с ошибкой.
В нашем случае к Jenkins подключены не только генераторы нагрузки, поэтому мы также проверяем соответствие названия узла шаблону «agent-000».
def jenkinsNodesSize = Jenkins.instance.nodes.findAll { node -> node.nodeName.startsWith('agent') } if (generatorsNumber > jenkinsNodesSize) { echo "The number of generators required [${generatorsNumber}] exceeds the number available. The following number will be used [${jenkinsNodesSize}]" generatorsNumber = jenkinsNodesSize } def freeAgents = [] def maxRetries = 10 def retryCount = 0 while (freeAgents.size() < generatorsNumber && retryCount < maxRetries) { freeAgents = Jenkins.instance.nodes.findAll { node -> def computer = node.getComputer() if (computer.isOffline()) { echo "[${node.nodeName}] is offline !!!" return false } if (computer.countBusy() > 0) { echo "[${node.nodeName}] is busy !!!" return false } if (!node.nodeName.contains("agent")) { echo "[${node.nodeName}] is not an agent !!!" return false } echo "[${node.nodeName}] can take jobs !!!" return true }.collect { it.nodeName.split('-')[1] } if (freeAgents.size() < generatorsNumber) { echo "Not enough free agents, retrying in 15 seconds..." sleep(15) retryCount++ } } if (freeAgents.size() < generatorsNumber) { error("Insufficient free agents after $retryCount retries.") }
Code Block 7 Проверка генераторов
Концепция с retry помогает в тех случаях, когда предыдущий тест завершается, но ещё нужно некоторое время, чтобы генераторы завершили свою предыдущую работу.
Определение шагов compile и execute
На этом этапе дополняем словари compile и execute. Для каждого генератора создаем stage, включающий в себя команды скачивания и компиляции исходников или команду на сборку проекта.
for (int i = 0; i < generatorsNumber; i++) { def vmNumber = freeAgents[i] compile["Compile node $vmNumber"] = { stage("Compile node $vmNumber") { node("agent-$vmNumber") { git branch: branchName, credentialsId: 'git-user', url: gitUrl sh "sbt -no-colors clean gatling:compile" } } } } for (int i = 0; i < generatorsNumber; i++) { def vmNumber = freeAgents[i] execute["Execute node $vmNumber"] = { stage("Execute node $vmNumber") { node("agent-$vmNumber") { sh """sbt -Dtype="${params.type}" -Dintensity="${intensity}" -DrampDuration="${params.rampDuration}" -DstageDuration="${params.stageDuration}" -DstagesNumber="${params.stagesNumber}" -no-colors "gatling:testOnly *.$simulationClassName" """ } } } }
Code Block 8 Определение шагов Compile и Execute
Запуск теста и завершение пайплайна
Выполняем параллельный запуск шагов, заданных в словаре compile. Так как ранее был задан ключ failFast: true, при возникновении ошибки хотя бы на одном из генераторов тест не будет запущен.
Пайплайн ожидает завершения компиляции на всех генераторах, после чего фиксирует момент времени перед началом непосредственно нагрузки, формирует ссылку на Grafana и прикрепляет эту ссылку к описанию сборки. Сразу после начала нагрузки пользователь может перейти по ссылке на Grafana и наблюдать результаты нагрузки в real-time. Здесь также можно прикрепить ссылки на мониторинг ресурсов или других метрик, логи и так далее.
Аналогично compile далее выполняем параллельный запуск шагов словаря execute. Этот шаг завёрнут в try, чтобы независимо от успешности теста в блоке finally сформировалась окончательная ссылка на мониторинг с указанием момента окончания теста и без refresh. В дальнейшем по ней можно будет открыть графики нагрузки из любой ранее выполненной сборки.
parallel compile try { startTimeUnix = System.currentTimeMillis().toString() currentBuild.description = """<a href="${grafanaUrl}?orgId=1&from=${startTimeUnix}&to=now&refresh=5s">Grafana report</a>""" parallel execute } finally { endTimeUnix = System.currentTimeMillis().toString() currentBuild.description = """<a href="${grafanaUrl}?orgId=1&from=${startTimeUnix}&to=${endTimeUnix}">Grafana report</a>""" }
Code Block 9 Запуск теста и завершение пайплайна
Таким образом, с помощью данного пайплайна мы решаем следующие задачи:
задаём входные параметры теста;
распределяем нагрузку равномерно по генераторам;
выполняем параллельный запуск нагрузки с синхронизацией между генераторами;
формируем удобную ссылку для real-time мониторинга.
Ниже приведён весь пайплайн целиком:
#!/usr/bin/env groovy properties([ parameters([ choiceParam( name: 'type', choices: ['max', 'stable', 'debug'], description: 'Тип теста'), string( defaultValue: '1', description: 'Количество генераторов', name: 'generatorsNumber'), string( defaultValue: '1', description: 'Интенсивность подачи нагрузки на последней ступени. Равномерно распределяется между генераторами.', name: 'intensity'), string( defaultValue: '30 seconds', description: 'Длительность разгона каждой ступени', name: 'rampDuration'), string( defaultValue: '60 seconds', description: 'Длительность каждой ступени', name: 'stageDuration'), string( defaultValue: '1', description: 'Количество ступеней', name: 'stagesNumber') ]) ]) def gitUrl = "https://git.local/load-test.git" def branchName = "master" def simulationClassName = "TesSim" def grafanaUrl = "http://grafana.local/boardName" def compile = [failFast: true] def execute = [failFast: true] def startTimeUnix, endTimeUnix generatorsNumber = Integer.parseInt(generatorsNumber) intensity = Integer.parseInt(intensity) / generatorsNumber def jenkinsNodesSize = Jenkins.instance.nodes.findAll { node -> node.nodeName.startsWith('agent') } if (generatorsNumber > jenkinsNodesSize) { echo "The number of generators required [${generatorsNumber}] exceeds the number available. The following number will be used [${jenkinsNodesSize}]" generatorsNumber = jenkinsNodesSize } def freeAgents = [] def maxRetries = 10 def retryCount = 0 while (freeAgents.size() < generatorsNumber && retryCount < maxRetries) { freeAgents = Jenkins.instance.nodes.findAll { node -> def computer = node.getComputer() if (computer.isOffline()) { echo "[${node.nodeName}] is offline !!!" return false } if (computer.countBusy() > 0) { echo "[${node.nodeName}] is busy !!!" return false } if (!node.nodeName.contains("agent")) { echo "[${node.nodeName}] is not an agent !!!" return false } echo "[${node.nodeName}] can take jobs !!!" return true }.collect { it.nodeName.split('-')[1] } if (freeAgents.size() < generatorsNumber) { echo "Not enough free agents, retrying in 15 seconds..." sleep(15) retryCount++ } } if (freeAgents.size() < generatorsNumber) { error("Insufficient free agents after $retryCount retries.") } for (int i = 0; i < generatorsNumber; i++) { def vmNumber = freeAgents[i] compile["Compile node $vmNumber"] = { stage("Compile node $vmNumber") { node("agent-$vmNumber") { git branch: branchName, credentialsId: 'git-user', url: gitUrl sh "sbt -no-colors clean gatling:compile" } } } } for (int i = 0; i < generatorsNumber; i++) { def vmNumber = freeAgents[i] execute["Execute node $vmNumber"] = { stage("Execute node $vmNumber") { node("agent-$vmNumber") { sh """sbt -Dtype="${params.type}" -Dintensity="${intensity}" -DrampDuration="${params.rampDuration}" -DstageDuration="${params.stageDuration}" -DstagesNumber="${params.stagesNumber}" -no-colors "gatling:testOnly *.$simulationClassName" """ } } } } parallel compile try { startTimeUnix = System.currentTimeMillis().toString() currentBuild.description = """<a href="${grafanaUrl}?orgId=1&from=${startTimeUnix}&to=now&refresh=5s">Grafana report</a>""" parallel execute } finally { endTimeUnix = System.currentTimeMillis().toString() currentBuild.description = """<a href="${grafanaUrl}?orgId=1&from=${startTimeUnix}&to=${endTimeUnix}">Grafana report</a>""" }
Code Block 10 Pipeline
Хранение и визуализация метрик (ClickHouse, Grafana)
Для хранения бизнес-метрик тестов и последующей визуализации графиков мы используем не самое популярное решение — аналитическую СУБД ClickHouse. Мы выбрали её из-за скорости работы ClickHouse при использовании в качестве источника данных для real-time мониторинга, то есть при высокоинтенсивных вставках и чтении. Даже в скромной конфигурации в docker ClickHouse позволяет в real-time наблюдать за результатами тестов вплоть до 100 000 RPS без ощутимых задержек. Сейчас вместо базы данных в контейнере мы используем полноценный кластер, поэтому проблемы производительности мониторинга для нас остались в прошлом. Опыт использования ClickHouse в течение нескольких лет показал, что на её скорость работы размер БД практически не влияет. Мы храним данные всех проведённых тестов за последние 3 года, таблица с метриками тестов занимает более 200 ГБ и содержит 120+ млрд строк, но это практически никак не сказывается на скорости работы нашей системы мониторинга.
Для отправки метрик из Gatling в ClickHouse мы реализовали свою библиотеку на базе clickhouse-scala-client. Если вы только начинаете свой путь внедрения нагрузочных тестов, можно поступить проще и использовать хорошо описанную связку InfluxDB+Grafana.
Совет: используйте версии gatling ниже 3.12 — в отличие от более поздних версий они ещё поддерживают InfluxDB. Это позволит настроить достаточно производительную систему real-time мониторинга.
По ссылке можно посмотреть, как настраивать дашборд Grafana, используя InfluxDB как data source. Набор метрик может меняться в зависимости от тестируемой системы и условий проведения тестов. Базовые метрики, с которых мы рекомендуем начать:
количество виртуальных пользователей VU;
общая интенсивность запросов и ошибок;
интенсивность каждого запроса;
время отклика каждого запроса - мы используем 95-й перцентиль времен отклика.
Для удобства можно выводить на дашборд:
статистику за весь тест в табличном виде;
список ошибок;
графики интенсивности ошибок каждого вида.
Ниже представлены скриншоты нашего типичного дашборда:


Хранение тестовых данных и логирование (Postgres, ELK)
Для хранения тестовых данных мы используем PostgreSQL, ELK (Elasticsearch, Logstash, Kibana) —для сбора и хранения логов по проведённым тестам. Не будем подробно описывать эти компоненты — их запуск и настройка обычно не вызывают вопросов. На Хабре есть множество инструкций по запуску PostgreSQL в docker: как простые, так и более подробные. Инструкции по настройке логирования также несложно найти, например gatling-elasticsearch-logs.
Важно: конфигурации СУБД в докер-контейнере достаточно для старта нагрузочных тестов на проекте. Но если вы планируете проводить нагрузочное тестирование на регулярной основе, рекомендуем перейти на полноценный кластер для обеспечения высокой производительности и отказоустойчивости.
Заключение
В этой статье мы рассказали о простых опенсорс-инструментах, которых достаточно для решения большинства задач по нагрузочному тестированию, и о том, как мы реализовали их у себя. Сейчас наша инфраструктура используется разными командами для тестирования десятков систем в различных средах — от DEV до PROD. Ежедневно запускаются десятки тестов от единиц до десятков тысяч RPS, часто одновременно, но при этом без взаимного влияния тестов. Наша платформа зарекомендовала себя как высокопроизводительное и надёжное решение, а НТ стало неотъемлемой частью процесса вывода новых систем в эксплуатацию.
Представленного в статье набора инструментов и компонентов вполне достаточно для построения полноценной платформы НТ на ранних этапах. После запуска системы у вас могут появиться потребности в доработке платформы, например для решения нестандартной задачи. В этом случае список компонентов может быть расширен, а набор используемых инструментов дополнен как уже готовыми, так и самостоятельно разработанными компонентами.
Надеемся, что наш опыт и описанные подходы помогут вам в становлении процесса и построении инфраструктуры НТ.
В следующей статье мы планируем рассказать о том, как организовываем процесс нагрузочного тестирования в PROD. Пишите в комментариях ваши вопросы и предложения по этой теме. Мы всегда рады новым идеям!
