Главная концепция нагрузочного тестирования — автоматизировать все, что можно. Берёте инструмент, пишете конфиг и сценарий, запускаете имитацию реальной нагрузки. Чем меньше кода, тем лучше.

Автоматизировать нагрузочное тестирование не так сложно, как может показаться на первый взгляд. Для этого нужен правильный инструмент.

Я расскажу, почему мне не подошел Яндекс.Танк в связке с Pandora и как я в три раза сжал код своей утилиты тестирования без потери производительности.

Что такое нагрузочное тестирование?


Привет! Меня зовут Сергей, я разработчик в отделе архитектуры Tarantool. Это in-memory платформа данных, которая нацелена на очень высокие нагрузки, до сотен тысяч RPS. Поэтому нагрузочное тестирование для нас важно, я занимаюсь им каждый день. Все знают, зачем нужно нагрузочное тестирование, но на всякий случай освежим в памяти основы. По результатам нагрузочного тестирования вы получаете информацию о том, как ваша система ведет себя в разных сценариях:

  • Где будет простаивать.
  • Каким будет время отклика на запросы.
  • При какой нагрузке перестанет вести себя стабильно.
  • Какая часть вызывает сбои в работе.
  • Какая часть ограничивает общую производительность.

Почему нам нужны особые инструменты для нагрузочного тестирования?


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

Большинство инструментов тестирования поддерживают только HTTP, что не подходит для нас. Мы могли бы прикрутить какие-то ручки и тестировать так, но конечному заказчику это не поможет. Потому что ему мы отдаем сами хранимые процедуры, тестирование по HTTP будет не репрезентативно.

Какие есть стандартные инструменты нагрузочного тестирования?


Сначала мы посмотрели в сторону популярного JMeter. Он нам не понравился своей производительностью: написан на Java, а потому требовательный по памяти и медленный. Плюс ко всему, его мы использовали чтобы тестировать по HTTP. То есть тестирование проходило не напрямую, а через специальные ручки. Потом мы попробовали самописные утилиты на Go, которые подстраивались под каждый проект индивидуально. Это путь в никуда, потому что надо писать код заново. А потом его можно выкинуть, никакого системного подхода. Повторюсь, что в нагрузочном тестировании хотелось бы автоматизировать все, что можно. Так мы добрались до Яндекс.Танка и Pandora. Вроде бы идеальный инструмент, который удовлетворяет всем требованиям:

  • Его легко можно адаптировать под любой проект.
  • Он быстрый, потому что Pandora написана на Go.
  • В команде много пишут на Go, разобраться в сценариях не составит труда.

Но и тут нашлись подводные камни.

Почему мы отказались от Яндекс.Танка?


Наши отношения с Танком были недолгими, вот несколько ключевых причин, по которым мы от него отказались.

Большой объем служебного кода. Обертка для Pandora, позволяющая работать с Tarantool, содержит ~150 строк кода, большинство из которых не несет логики тестирования. Вы можете убедиться в этом сами, посмотрев сюда.

Постоянная перекомпиляция исходника. С этой проблемой мы столкнулись, когда на проекте нам понадобилось нагружать систему, генерируя при этом данные различных объемов. Мы не нашли удобного способа управления параметрами генерации извне, а прегенерация нам не подходила. Поэтому мы просто меняли данные и компилировали новый исходник. Такие махинации могли породить до 20 бинарных файлов нагрузчика на один сценарий тестирования.

Скудные данные при использовании Pandora отдельно от Танка. Танк является оберткой, позволяющей красиво визуализировать метрики. Pandora в свою очередь является движком, генерирующим нагрузку. По сути, мы таскаем две утилиты, что не всегда удобно (благо есть Docker).

Не самые понятные опции конфигурационного файла. Тема JSON и YAML конфигов сама по себе является неприятной. Еще более неприятной она становится тогда, когда не ясно, как работает та или иная опция в зависимости от установленных значений. Такой опцией для нас стал startup, который выдавал одинаковые результаты на абсолютно разных значениях, из-за чего было сложно оценить реальную производительность системы.

Всё это привело нас к такой ситуации на одном из проектов:

  • куча исходников;
  • непонятные метрики;
  • переусложненный конфиг.


Почему мы пришли к k6?


k6 — это утилита для нагрузочного тестирования, которая как и Pandora, написана на Go. То есть за производительность переживать не стоит. Киллер-фичей k6 является его модульность. Это решает проблему с постоянной перекомпиляцией исходника. Мы просто пишем разные модули для доступа к интерфейсу Tarantool и другой логики, в нашем случае — генерация данных. Модули никак не зависят друг от друга, перекомпиляция не нужна, а параметры для генерации можно менять внутри сценария, написанного на… JavaScript! Да! Вы не ослышались — никаких больше JSON и YAML, теперь сценарий тестирования — это код! Этот сценарий можно разделить на несколько этапов, каждый из которых может моделировать свою характерную нагрузку. При изменении сценария не требуется никакой перекомпиляции бинарного файла k6, они полностью независимы. Два компонента, состоящих из кода, и полностью независимые! Теперь можно писать код и там, и там, и забыть про все конфиги.

Что у меня за приложение?


Это тестовое приложение на Lua. Оно хранит информацию о моделях машин. Я его использую, чтобы протестировать запись и чтение из БД. В приложении выделено два основных компонента: API и Storage. Компонент API дает пользователю HTTP ручки для чтения и записи, Storage отвечает за взаимодействие приложения с базой данных. Сценарий взаимодействия выглядит так: пользователь отправляет запрос, который обрабатывается ручками, а ручки вызывают функции работы с базой данных. Более подробно с приложением можно ознакомиться здесь.

А как подружить k6 с приложением?


Для того чтобы написать модуль взаимодействия k6 с Tarantool, необходимо написать Go-модуль при помощи фреймворка xk6. Этот фреймворк предоставляет инструменты для написания кастомных модулей для k6. Для начала нужно зарегистрировать модуль, чтобы k6 мог с ним работать. Для регистрации модуля так же нужно определить новый тип, на котором мы будем определять функции-ресиверы, то есть методы, которые мы сможем вызывать из JavaScript-сценария:

package tarantool

import (
    "github.com/tarantool/go-tarantool"
    "go.k6.io/k6/js/modules"
)

func init() {
    modules.Register("k6/x/tarantool", new(Tarantool))
}

// Tarantool is the k6 Tarantool extension
type Tarantool struct{}

На самом деле, на этом этапе уже можно пользоваться модулем, правда пока он ничего не умеет. Давайте научим его подключаться к Tarantool и вызывать Call, который предоставляет Go-коннектор:

// Connect creates a new Tarantool connection
func (Tarantool) Connect(addr string, opts tarantool.Opts) (*tarantool.Connection, error) {
    if addr == "" {
        addr = "localhost:3301"
    }
    conn, err := tarantool.Connect(addr, opts)
    if err != nil {
        return nil, err
    }
    return conn, nil
}

// Call calls registered tarantool function
func (Tarantool) Call(conn *tarantool.Connection, fnName string, args interface{}) (*tarantool.Response, error) {
    resp, err := conn.Call(fnName, args)
    if err != nil {
        return nil, err
    }
    return resp, err
}

Полную версию модуля вы можете посмотреть здесь.

Уже можно заметить, что код для работы с Tarantool занимает гораздо меньше места, чем код для Pandora. Было ~150 строк кода, стало 30, но это пока что без логики. Спойлер: с ней будет ~50. Всё остальное берет на себя k6.

А как взаимодействовать с модулем в сценарии?


Все предельно просто. Для начала нужно импортировать кастомный модуль в сценарий:

import tarantool from "k6/x/tarantool";

Затем создать объект подключения:

const conn = tarantool.connect("localhost:3301");

connect — это та самая функция-ресивер, которую мы объявили в модуле. Если нужно передать объект опций, хранящий опции подключения, делаем это вторым параметром в качестве простого JSON-объекта. Осталось только объявить этапы тестирования и запустить его:

export const setup = () => {
  tarantool.insert(conn, "cars", [1, "cadillac"]);
};

export default () => {
  console.log(tarantool.call(conn, "box.space.cars:select", [1]));
};

export const teardown = () => {
  tarantool.delete(conn, "cars", "pk", [1]);
};

Здесь выделены три этапа тестирования:

  • setup — то, что будет выполняться до тестирования. Здесь можно произвести подготовку данных или вывести информационные сообщения.
  • default — основной сценарий тестирования.
  • teardown — то, что будет выполняться после тестирования. Здесь можно сделать очистку тестовых данных или так же вывести информационное сообщение.

После запуска и завершения тестирования, вас встретит вот такая красивая картинка:



Из вывода можно узнать:

  • Какой сценарий выполняется.
  • Куда пишутся данные: в консоль или аггрегируются при помощи InfluxDB.
  • Параметры выполняемого сценария.
  • Вывод, организованный в сценарии при помощи console.log.
  • Процесс выполнения.
  • Метрики.

Самыми интересными для нас являются метрики iteration_duration, отображающая латенси, а также iterations, отображающая общее количество выполненных итераций и их среднее значение за секунду — тот самый желанный RPS.

Это всё, конечно, здорово, но есть ли что-то посерьезнее?


Создадим тестовый стенд из трёх узлов, два из которых объединены в кластер. На третьем узле у нас будет находиться система нагрузки k6, а также Influx и Grafana, развернутая в Docker, чтобы нам было куда отдавать метрики и визуализировать их.


Каждая нода кластера выглядит так:


Реплики и хранилища не повторяются в пределах одного узла: если первое хранилище находится на первой ноде, то его реплика будет находиться на второй ноде. Спейс (Тарантульный аналог таблицы) будет состоять из трех полей: id, bucket_id и model. По индексам всё просто: первичный ключ по id, а также индекс по bucket_id:

local car = box.schema.space.create(
        'car',
        {
            format = {
                {'car_id', 'string'},
                {'bucket_id', 'unsigned'},
                {'model', 'string'},
            },
            if_not_exists = true,
        }
    )

    car:create_index('pk', {
        parts = {'car_id'},
        if_not_exists = true,
    })

    car:create_index('bucket_id', {
        parts = {'bucket_id'},
        unique = false,
        if_not_exists = true,
    })

Будем тестировать создание машин. Напишем модуль k6 для генерации данных. Раньше я говорил про 30 служебных строк, а вот они 20 строк логики тестирования.

var bufferData = make(chan map[string]interface{}, 10000)

func (Datagen) GetData() map[string]interface{} {
    return <-bufferData
}

func (Datagen) GenerateData() {
    go func() {
        for {
            data := generateData()
            bufferData <- data
        }
    }()
}

func generateData() map[string]interface{} {
    data := map[string]interface{}{
        "car_id": uniuri.NewLen(5),
        "model":  uniuri.NewLen(5),
    }

    return data
}

Я опустил часть с функцией инициализации и объявлением типа, в котором мы будем вызывать функции. Создадим функции-ресиверы, которые сможем вызывать в JavaScript-сценарии. Самое интересное то, что мы можем работать с каналами, не теряя данных. Если вы в одной функции пишете в bufferData, а в другой читаете оттуда, то в сценарии для чтения при вызове функции у вас прочитаются данные из этого канала. Никакой потери данных не будет!

У нас есть внутренняя функция, которую мы не распространяем на наш модуль, — generateData, она непосредственно генерирует модель машины и ее id. В функции GenerateData мы запускаем горутину для того, чтобы у нас постоянно генерировались данные и их всегда было достаточно для вставки. Сценарий тестирования для этого стенда выглядит так:

import datagen from "k6/x/datagen";
import tarantool from "k6/x/tarantool";

const conn1 = tarantool.connect("172.19.0.2:3301");
const conn2 = tarantool.connect("172.19.0.3:3301");

const baseScenario = {
  executor: "constant-arrival-rate",
  rate: 10000,
  timeUnit: "1s",
  duration: "1m",
  preAllocatedVUs: 100,
  maxVUs: 100,
};

export let options = {
  scenarios: {
    conn1test: Object.assign({ exec: "conn1test" }, baseScenario),
    conn2test: Object.assign({ exec: "conn2test" }, baseScenario),
  },
};

export const setup = () => {
  console.log("Run data generation in the background");
  datagen.generateData();
};

export const conn1test = () => {
  tarantool.call(conn1, "api_car_add", [datagen.getData()]);
};

export const conn2test = () => {
  tarantool.call(conn2, "api_car_add", [datagen.getData()]);
};

export const teardown = () => {
  console.log("Testing complete");
};

Он стал немного больше, появилась переменная настроек. Она позволяет настроить поведение тестирования. Я выделил два сценария, под каждый из которых мы вызываем отдельную функцию. Так сделано потому, что кластер состоит из двух узлов, и значит нам нужно тестировать подключение к двум узлам одновременно. Если вы будете делать это внутри одной функции, которая раньше была по умолчанию, то не сможете рассчитывать на полную загрузку кластера. За единицу времени вы отправляете запрос сначала на один роутер, а второй простаивает; затем отправляете на второй, и теперь простаивает первый. Производительность падает, но этого можно избежать. Об этом чуть позже.

А пока давайте взглянем на сценарии тестирования. В каждом из них в графе executor мы указываем, какой тип тестирования сейчас хотим провести. При значении constant-arrival-rate сценарий позволяет имитировать постоянную нагрузку. То есть мы хотим выдавать 10 000 RPS в течение одной минуты для 100 виртуальных пользователей. Воспользуемся для вывода результатов не консолью, а базой данных, чтобы потом можно было отобразить информацию в дашборде:



Я хотел выжать 10 000 RPS, а получилось 8 600 RPS. Не так уж и плохо. Скорее всего просто не хватило мощности клиента, на котором располагается нагрузчик. Я тестировал все на личном MacBook Pro лета 2020. А вот данные по задержке и виртуальным пользователям:



А что по поводу гибкости?


По поводу гибкости все очень и очень хорошо. Сценарии можно модифицировать, чтобы проверять метрики, собирать другие метрики и прочее. Помимо этого можно оптимизировать сценарии:

n коннектов — n сценариев

Базовый сценарий, который мы рассмотрели выше:

const conn1 = tarantool.connect("172.19.0.2:3301");
const conn2 = tarantool.connect("172.19.0.3:3301");

const baseScenario = {
  executor: "constant-arrival-rate",
  rate: 10000,
  timeUnit: "1s",
  duration: "1m",
  preAllocatedVUs: 100,
  maxVUs: 100,
};

export let options = {
  scenarios: {
    conn1test: Object.assign({ exec: "conn1test" }, baseScenario),
    conn2test: Object.assign({ exec: "conn2test" }, baseScenario),
  },
};

n коннектов — 1 сценарий

В данном сценарии тестируемый коннект выбирается рандомно на каждой итерации. Здесь у нас единица тестирования — 1 секунда, то есть в одну секунду мы будем случайно выбирать одно подключение из всех объявленных:

const conn1 = tarantool.connect("172.19.0.2:3301");
const conn2 = tarantool.connect("172.19.0.3:3301");

const conns = [conn1, conn2];

const getRandomConn = () => conns[Math.floor(Math.random() * conns.length)];

export let options = {
  scenarios: {
    conntest: {
      executor: "constant-arrival-rate",
      rate: 10000,
      timeUnit: "1s",
      duration: "1m",
      preAllocatedVUs: 100,
      maxVUs: 100,
    },
  },
};

Данный сценарий можно свести к одному коннекту. Для этого можно настроить TCP-балансировщик (nginx, envoy, haproxy), но это уже совсем другая история.

n коннектов — n сценариев + ограничения и проверки

При помощи ограничений можно контролировать полученные метрики. Если у нас по 95 перцентилю задержка будет больше 100 мс, тестирование не будет считаться успешным. Вы можете установить для одного параметра несколько ограничений. Также можно добавлять проверки. Скажем, какая доля запросов дошла до сервера. Проценты выражаются в виде числа от 0 до 1:

const conn1 = tarantool.connect("172.19.0.2:3301");
const conn2 = tarantool.connect("172.19.0.3:3301");

const baseScenario = {
  executor: "constant-arrival-rate",
  rate: 10000,
  timeUnit: "1s",
  duration: "10s",
  preAllocatedVUs: 100,
  maxVUs: 100,
};

export let options = {
  scenarios: {
    conn1test: Object.assign({ exec: "conn1test" }, baseScenario),
    conn2test: Object.assign({ exec: "conn2test" }, baseScenario),
  },
  thresholds: {
    iteration_duration: ["p(95) < 100", "p(90) < 75"],
    checks: ["rate = 1"],
  },
};

n коннектов — n сценариев + ограничения и проверки + последовательный запуск

Самый навороченный из представленных сценариев — это сценарий с последовательным запуском. Например, вы хотите проверить n хранимых процедур, но при этом не хотите нагружать систему одновременно. Тогда просто указываете во втором сценарии время начала тестирования. При этом нужно учитывать, что ваше тестирование может завершиться не сразу. То есть при помощи параметра gracefulStop вы выставляете, сколько времени выделяется на завершение выполнения. Если указать 0 секунд, то к моменту начала второго сценария первый уже точно не будет работать:

const conn1 = tarantool.connect("172.19.0.2:3301");
const conn2 = tarantool.connect("172.19.0.3:3301");

const baseScenario = {
  executor: "constant-arrival-rate",
  rate: 10000,
  timeUnit: "1s",
  duration: "10s",
  gracefulStop: "0s",
  preAllocatedVUs: 100,
  maxVUs: 100,
};

export let options = {
  scenarios: {
    conn1test: Object.assign({ exec: "conn1test" }, baseScenario),
    conn2test: Object.assign({ exec: "conn2test", startTime: "10s" }, baseScenario),
  },
  thresholds: {
    iteration_duration: ["p(95) < 100", "p(90) < 75"],
    checks: ["rate = 1"],
  },
};

А как с производительностью в сравнении с Яндекс.Танк + Pandora?


Мы сравнили оба инструмента на рассмотренном приложении. Яндекс.Танк нагрузил процессор роутера на 53 % и процессор хранилища на 32 %, выдав нам 9 616 RPS. А k6 нагрузил процессор роутера на 54 % и процессор хранилища на 40 %, выдав 9 854 RPS. Это усредненные данные за 10 прогонов.

Почему так? Pandora и k6 под капотом используют Go. То есть основа похожа, но k6 позволяет тестировать приложения в более программистском стиле.

Итоги


k6 — это простой инструмент. Достаточно освоить один раз, и вы можете перестроить его под любой проект так, чтобы потратить минимум ресурсов. Делаете core-модуль, а потом на него навешиваете логику. Не нужно каждый раз переписывать тестирование с нуля, вы можете использовать модули из проекта в проект.

k6 — это экономный инструмент нагрузочного тестирования, мне хватило 50 строчек кода для логики тестов и обертки. Вы можете написать свой модуль, который будет удовлетворять вашей бизнес-логике, сценариям и требованиям заказчика.

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

Попробуйте Tarantool на нашем сайте и приходите с вопросами в Telegram-чат.

Ссылки


  1. https://www.tarantool.io/en/doc/latest/book/connectors/#protocol — протокол Tarantool
  2. https://habr.com/ru/post/517488/ — статья про Яндекс.Танк, Pandora и Tarantool
  3. https://k6.io/ — все про k6
  4. https://github.com/hackfeed/xk6-tarantool-example/tree/master/cars — тестируемое приложение
  5. https://github.com/k6io/xk6 — фреймворк для написания своих модулей для k6
  6. https://github.com/hackfeed/xk6-tarantool — модуль для k6 для взаимодействия с Tarantool
  7. https://github.com/hackfeed/xk6-tarantool-example — sandbox для ознакомления с приложением и с тестированием на k6