Нагрузочное тестирование игровых серверов / Блог компании Mail.ru Group / Хабр

https://habr.com/ru/post/572742/image

Меня зовут Дмитрий, я специалист по тестированию в студии IT Territory. За 17 лет мы выпустили более 15 успешных игровых проектов с общей аудиторией около 100 млн игроков по всему миру. Вы можете быть знакомы с нами по таким проектам, как Аллоды Онлайн, Hawk, Space Justice, World Above, Rush Royale. И в этом посте я расскажу о том, как мы проводим нагрузочное тестирование игровых серверов.

Что мы тестируем

Сегодня наша студия по большей части разрабатывает мобильные игры. Для этого мы используем движок Unity. Клиенты пишем на C#, они активно взаимодействуют с серверной частью, написанной на Java. Среди самых важных функций нашего сервера я выделяю две:

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

Мы активно поддерживаем все разрабатываемые нами игры. Примерно раз в 1—1,5 месяца обязательно выпускаем новые сборки клиентов и серверов с новыми игровыми механиками. Также время от времени корректируем конфигурацию серверов и внутриигровые серверные механики.

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

https://habr.com/ru/post/572742/image

Мы создаём серверы с расчётом на нагрузку свыше 10 тыс. одновременно играющих пользователей, которые генерируют тысячи запросов в секунду. 99 % запросов должны иметь задержку меньше 50 мс.

Инструмент для нагрузочного тестирования

Сначала мы написали на Go приложение, в рамках которого все нагрузочные тесты описывались в виде так называемых нагрузочных сценариев, которые потом запускались в количестве n экземпляров:

{ "actions":[
     { "type": "message", "message": 
          {  "request": "Cheat", "action": "unlockstory", "id": 44 }
     },
     { "type": "sleep", "duration": 100 },
     { "type": "loop", "count": 30,
          "actions": [
               { "type": "message", "message": 
                    { "request": "Buy", "resource": 3746016796, "id": 124 } 
               },
               { "type": "sleep", "duration": 1000 }
          ]
     }
] }

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

Когда мы начали применять этот инструмент, столкнулись с рядом достаточно неприятных недостатков. Во-первых, в рамках нашего сценария мы могли только отправлять на сервер некоторые запросы, а обрабатывать ответы и события не могли. То есть общение было одностороннее, что очень сильно ограничивало нас. Во-вторых, если мы меняли код протокола, то необходимо было вручную пройти по всем сценариями и исправить запрос, что очень долго и нудно. В-третьих, сложно описывать условия и циклы. Мы могли лишь какое-то количество раз запустить тот или иной фрагмент сценария, а запускать по условию или циклически до выполнения какого-либо условия уже не могли.

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

  • гибкие и простые в написании сценарии, чтобы даже junior QA мог спокойно тестировать какую-нибудь фичу;
  • использование существующего кода протокола, чтобы автоматически поддерживать в сценариях любые изменения протокола;
  • использование эксплуатационного стека, чтобы писать сценарии мог любой разработчик или тестировщик, знакомый с нашим стеком;
  • возможность запускать свыше 10 тыс. ботов, чтобы наш инструмент мог как можно полнее эмулировать профиль реальной нагрузки.

Определившись со списком требований, мы решили бегло посмотреть, какие же фреймворки сейчас есть на рынке:

  • JMeter: высокий порог вхождения, повышенное потребление ресурсов.
  • Gatling: из коробки нет TCP.
  • Loadrunner, WebLoad: всего 50 «пользователей» в бесплатной версии.
  • Grinder: высокий порог вхождения, маленькое сообщество.
  • LoadView, StressStimulus, LoadNinja: только платная версия.

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

Решили применять те же технологии, что уже используются в нашем эксплуатационном стеке. Java нас для этого вполне устраивает, потому что он позволяет гибко описывать сценарии и мы можем напрямую использовать код нашего протокола. И вторая важная технология — фреймворк Vert.x. С его помощью можно создавать распределённое приложение на основе JVM, в рамках которого запускать многочисленные экземпляры сценариев (verticle, вертиклы). Причём фреймворк потребляет немного ресурсов.

У нас получился фрагмент сценария, который выглядит очень громоздко и при этом совершает немного действий:

//Получаем с сервера некоторое задание
request(TakeQuestsRequest(SOME_QUEST), res1 -> {
    if (res1.succeeded()) {
        //Обрабатываем ответ, узнаем, что требуется для выполнения задания
        Integer counter = res1.response.counter;
        //Пытаемся “выполнить” это задание
        request(CheatRequest(Cheat.SetQuestCounter, SOME_QUEST, counter), res2 -> {
            if (res2.succeeded()) {
                //Получаем награду за выполненное задание
                request(CompleteQuestRequest(SOME_QUEST), res3 -> {
                    if (res3.succeeded()) {
                        System.out.println("Quest [" + SOME_QUEST + "] completed!");
                    }
                });
            }
        });
    }
});

Мы столкнулись с проблемой под названием callback hell, которая возникает при написании асинхронного кода, когда каждый вызов обязательно требует определения обратного вызова. В приведённом примере мы обращаемся к серверу и должны дождаться ответа, чтобы продолжать выполнять сценарий. Писать такой лестничнообразный код весьма проблематично, а читать ещё сложнее. То есть с простотой сценариев уже напряжёнка.

Поэтому мы решили обратиться к другому языку программирования — Kotlin. Он полностью совместим с Java, так что проблем с использованием боевого кода нашего протокола здесь не возникло бы. А что касается callback hell, то в Kotlin есть две довольно интересные фичи: корутины и suspend-функции. Корутины — это некий легковесный аналог потоков, которых можно создавать достаточно много; а suspend-функции — это функции, которые позволяют нам совершать асинхронные вызовы, не блокирующие текущую корутину. То есть мы можем писать асинхронный код, который будто бы выполняется линейно. К тому же Kotlin полностью поддерживается в Vert.x.

Вот как мы избавились от callback hell с помощью Kotlin:

val counter = request { TakeQuestsRequest(SOME_QUEST) }.response.counter
request { CheatRequest(Cheat.SetQuestCounter, SOME_QUEST, counter) }
request { CompleteQuestRequest(SOME_QUEST) }

При этом каждая строка является асинхронным вызовом.

Пример сценария

//Авторизация, получение сессии
val session = user.login()
with(session) {
    //Совершаем покупки в магазине
    repeat(10) { request { BuyRequest(CRYSTALS, 10) } }
    var counter = 10
    //Получаем от сервера задание и выполняем его
    if (!model.quests.contains(SOME_QUEST)) {
        counter = request { TakeQuestsRequest(SOME_QUEST) }.response.counter
    }
    request { CheatRequest(Cheat.SetQuestCounter, SOME_QUEST, counter) }
    request { CompleteQuestRequest(SOME_QUEST) }
    //Пока не наберем 100 “золота”, выходим в бой
    while (model.currencies[GOLD].value < 100) {
        request { StartMissionRequest(1, 1, SPARROW) }
        //Дожидаемся события от сервера, когда тот разрешит запустить бой
        val fight = waitForEvent<StartMissionEvent>().fight_id
        //Завершаем миссию
        request { CompleteMissionRequest(fight) }
    }
}

Возможностей Kotlin вполне хватает, чтобы описывать более сложные, витиеватые сценарии. Все наши сценарии начинаются с авторизации на сервере, в ходе которой мы получаем объект session. Он содержит всю необходимую логику для дальнейшего взаимодействия с сервером — отправки запросов и обработки ответов и событий. Также мы можем взаимодействовать с моделью нашего бота. В ней хранятся основные данные об игроке, которыми оперирует как клиент, так и сервер.

Пример конфигурации

Также наши сценарии являются конфигурируемыми. Если мы выкинем из конфига служебную информацию вроде адреса тестируемого сервера и оставим значения, которые влияют непосредственно на процесс тестирования и выполнения сценариев, то конфиг будет выглядеть примерно так:

scenario {
    story {
        type = "TrueStory“
        config { iterations = 100500 }
    }
    authentication {
        strategy {
            type = "existing“
            range {
                offset = 11000001
                limit = 3
            }
        }
    }
    rps = 1.0f
} 

Здесь мы можем указывать название запускаемого нагрузочного сценария, определять тип авторизации ботов на сервере. В конфигурации может быть описан вход ранее не существовавшим игроком (то есть создаётся новый пользователь), либо вход уже существующими игроками, как показано в примере: будет запущено три бота, id которых начинается со значения из поля offset. Мы можем указывать различные кастомные значения в разделе story.config, которые в одних сценариях используются, а в других нет. Настройка rps — количество запросов в секунду по умолчанию, отправляемых ботами. Если бы её не было, то боты непрерывно бомбили бы сервер запросами и получился бы своеобразный DDoS.

True story

Чтобы получить профиль нагрузки, аналогичный эксплуатационному, мы решили написать сценарий, который назвали True story. Для его запуска мы используем копии наших боевых баз данных. Нагрузочные боты заходят на тестовый стенд с копиями аккаунтов уже существующих игроков, у которых накопился достаточно большой прогресс и различная внутриигровая информация.

class TrueStory(config: Configuration) : Story<Model>({ user ->
    val session = user.login()
    repeat(config.value("iterations")) {
        Actions.values().pickWeighted().run(session)
    }
})

На первый взгляд сценарий выглядит довольно просто. Из основных действий можно выделить только цикл, в рамках которого N раз (значение берется из конфигурации) будет совершено случайно генерируемое действие. Описания всех действий представлены в виде enum под названием Actions:

enum class Actions(
    val weight: Float,
    val run: suspend Session<Model>.() -> Unit
) {
    Buy(3, { request { BuyRequest(CRYSTALS, 10) } }),
    PvE_Mission(7, { /* ... */ }),
    TakeQuest(1, { /* ... */ }),
    //...
}

В Actions есть два поля. Первое — это функция, которая будет описывать мини-сценарий нашего бота, а второе поле — вес, с помощью которого мы сможем масштабировать количество запросов, которые отправляет бот. Например, у нас есть мини-сценарий Buy (покупка внутриигрового предмета на сервере). Вес этого действия равен трём, то есть бот в 3 случаях из 11 совершит на сервере покупку. Действия ботов рандомизируются: берутся взвешенные значения, после чего запускается мини-сценарий и все действия прогоняются столько раз, сколько мы укажем в конфиге.

У нас есть два способа запуска сценариев:

  • Локально. На своём домашнем компьютере я могу запускать около 10 тыс. ботов, при этом загружая 25 % ресурсов процессора и потребляя 4 Гб оперативной памяти. Такой способ подходит в ситуациях, когда мы хотим либо отладить сценарий, либо протестировать разовую функциональность, но для регулярного запуска нагрузочных тестов это не подходит.
  • Для всех остальных случаев мы применяем Jenkins.

Анализ результатов нагрузочного тестирования

Для анализа мы используем:

  • Prometheus (собирает метрики серверного приложения);
  • JMX (собирает информацию о загруженности процессора, количестве занимаемой сервером памяти, количестве вызовов сборщика мусора и так далее);
  • и Grafana.

Вот динамика количества одновременных игроков на сервере:

https://habr.com/ru/post/572742/image

Можно анализировать задержку клиентских запросов с разбиением по типу:

https://habr.com/ru/post/572742/image

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

Механизм сравнения запускается ежедневно, после того, как было закончено выполнение нагрузочного сценария. Итогом работы этого механизма является отчёт, в котором описаны метрики, значение которых нас не устраивает. По каждой метрике мы указываем допустимое отклонение — в процентах, в виде константы или виде максимального/минимального порогового значения. Отчёт выглядит так:

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

Последовательность тестирования

Начинается всё с запуска в Jenkins различных сборок. Мы поднимаем тестовый стенд и сгоняем туда ботов. В ходе тестирования собираем с помощью Prometheus метрики, которые потом анализируем нашей автоматической программой. Дополнительно можем зайти в Grafana и посмотреть, есть ли какие-либо аномалии.

Что получилось

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

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

Мы на своем опыте убедились, что решать задачу нужно с холодной головой: сначала всё обдумать и понять, что ты хочешь получить от инструмента, и только после этого приступать к разработке.

Source link

Добавить комментарий

Ваш адрес email не будет опубликован.