Очередной репортаж от Переговоры Pixonic DevGAMM - на этот раз от наших коллег из PanzerDog. Ведущий инженер-программист компании Павел Платто проанализировал метасервер игры с сервис-ориентированной архитектурой, рассказал, какие решения и технологии были выбраны, какие и как они масштабируются и с какими трудностями пришлось столкнуться.
Текст доклада, слайды и ссылки на другие выступления с митапа, как всегда, под катом.
Сначала хочу показать небольшой трейлер нашей игры: Отчет будет состоять из 3 частей.
В первом я расскажу о том, какие технологии мы выбрали и почему, во втором я расскажу о том, как устроен наш метасервер, а в третьем я расскажу о различной поддерживающей инфраструктуре, которую мы используем, и о том, как мы реализовали обновление без простоя.
Стек технологий
Мета-сервер размещен на Amazon и написан на Elixir. Это функциональный язык программирования с моделью вычислений на основе актеров.
Поскольку у нас нет Ops, операции выполняются программистами, а большая часть инфраструктуры описывается в коде с использованием Terraform от HashiCorp. На данный момент Tacticool находится в открытой бета-версии, метасервер находится в разработке чуть больше года и в эксплуатации почти год. Давайте посмотрим, с чего все началось.
Когда я присоединился к компании, у нас уже была базовая функциональность, реализованная в виде монолита с использованием смеси C/C++ и хранилища PostageSQL. Эта реализация имела определенные проблемы.
Во-первых, из-за низкоуровневой природы языка C было немало мелких ошибок.
Например, у некоторых игроков подбор игроков зависал из-за неправильного обнуления массива перед его повторным использованием.
Конечно, найти связь между этими двумя событиями было довольно сложно.
А поскольку состояние нескольких потоков изменялось повсюду в коде, состояний Race не удалось избежать.
О параллельной обработке большого количества задач также не могло быть и речи, поскольку сервер при старте запускал около 10 рабочих процессов, которые блокировались при совершении запросов к Amazon или базе данных.
И даже если забыть об этих запросах на блокировку, сервис начал рушиться на паре сотен подключений, не выполняющих никаких операций, кроме пинга.
Кроме того, сервис невозможно было масштабировать по горизонтали.
После пары недель, потраченных на поиск и исправление наиболее критичных ошибок, мы решили, что проще переписать всё с нуля, чем пытаться исправить все недостатки текущего решения.
И когда вы начинаете с нуля, имеет смысл попытаться выбрать язык, который поможет избежать некоторых предыдущих проблем.
У нас было три кандидата:
- С#;
- Идти;
- Эликсир.
C# попал в список «по знакомству», т.к.
наш клиент и игровой сервер написаны на Unity и команда имела наибольший опыт работы с этим языком программирования.
Рассматривались Go и Elixir, поскольку это современные и достаточно популярные языки, созданные для разработки серверных приложений.
Проблемы предыдущей итерации помогли нам определить критерии оценки кандидатов.
Первым критерием была простота работы с асинхронными операциями.
В C# удобная работа с асинхронными операциями появилась не с первого раза.
Это привело к тому, что мы имеем «зоопарк» решений, которые, на мой взгляд, все же стоят немного побочно.
В Go и Elixir эта проблема была учтена при проектировании этих языков; они оба используют легковесные потоки (в Go это горутины, в Elixir — процессы).
Эти потоки имеют гораздо меньшие накладные расходы, чем системные, а поскольку мы можем создавать их десятки и сотни тысяч, мы не против их заблокировать.
Вторым критерием было умение работать с конкурентными процессами.
C# из коробки не предлагает ничего, кроме пулов потоков и разделяемой памяти, доступ к которой необходимо защищать с помощью различных примитивов синхронизации.
В Go менее подвержена ошибкам модель в виде горутин и каналов.
Эликсир, с другой стороны, предлагает модель актера без общей памяти с общением посредством обмена сообщениями.
Отсутствие разделяемой памяти позволило реализовать во время выполнения полезные для конкурентной среды выполнения технологии, такие как честное перемещение многозадачности и сбор мусора без остановки мира.
Третьим критерием было наличие инструментов для работы с неизменяемыми типами данных.
Весь мой опыт разработки показал, что довольно большая часть ошибок связана с неверным изменением данных.
Решение этой проблемы существует уже давно: неизменяемые типы данных.
В C# такие типы данных можно создать, но ценой тонны шаблонов.
В Go это вообще невозможно.
А в Elixir все типы данных неизменяемы.
И последним критерием было количество специалистов.
Здесь результаты очевидны.
В конечном итоге мы выбрали Эликсир.
С выбором хостинга все оказалось гораздо проще.
Наши игровые серверы уже размещались на Amazon GameLift, а Amazon также предлагает большое количество сервисов, которые позволят нам сократить время разработки.
Мы полностью «отдались» облаку и сами не развертываем никаких сторонних решений — баз данных, очередей сообщений — всем этим за нас управляет Amazon. На мой взгляд, это единственное решение для небольшой команды, которая хочет разработать онлайн-игру, а не инфраструктуру для нее.
Теперь, когда с выбором технологий разобрались, перейдем к тому, как работает метасервер.
В общих чертах: клиенты подключаются к балансировщику нагрузки на Amazon, используя соединения через веб-сокеты; Балансировщик распределяет эти соединения между несколькими экземплярами фронтенда, а фронтенд отправляет клиентские запросы на бэкенды.
Но интерфейс и серверная часть взаимодействуют косвенно, через очереди сообщений.
Для каждого типа сообщения существует отдельная очередь, и фронтенд определяет, куда его писать, исходя из типа сообщения, а бэкенды слушают эти очереди.
Чтобы бэкенд мог отправить клиенту ответ на запрос или какое-то событие, каждому фронтенду назначается отдельная очередь (специально выделенная для него).
И в каждом запросе бэкенд получает идентификатор фронтенда, чтобы определить, в какую очередь следует записать ответ. Если ему необходимо отправить событие, он обращается к базе данных, чтобы узнать, к какому экземпляру внешнего интерфейса подключен клиент. На этом с общими чертами закончили, переходим к деталям.
Сначала расскажу о некоторых особенностях клиент-серверного взаимодействия.
Мы используем собственный бинарный протокол, поскольку он достаточно эффективен и экономит трафик.
Во-вторых, при любых операциях с аккаунтом, меняющих его, сервер отправляет клиенту не эти изменения, а полную (обновленную) версию этого аккаунта.
Это немного менее эффективно, но все же не занимает столько места и значительно облегчает нам жизнь как на клиенте, так и на сервере.
Интерфейс также гарантирует, что клиент делает не более одного запроса за раз.
Это позволяет отлавливать ошибки на клиенте, например, когда он переходит на другой экран до того, как игрок увидит результат предыдущей операции.
Теперь немного о том, как работает фронтенд.
Интерфейс — это, по сути, веб-сервер, который прослушивает соединения через веб-сокеты.
Для каждого сеанса создаются два процесса.
Первый процесс обрабатывает само соединение через веб-сокет, а второй — это конечный автомат, который описывает текущее состояние клиента.
На основании этого состояния он определяет, действительны ли запросы от клиента.
Например, почти все запросы не могут быть выполнены до завершения авторизации.
Поскольку во внешнем интерфейсе нет другого состояния, кроме этих сеансов, очень легко добавлять новые экземпляры внешнего интерфейса, но немного сложнее удалять старые.
Перед удалением вы должны позволить всем клиентам завершить свои текущие запросы и попросить их повторно подключиться к другому экземпляру.
Теперь поговорим о том, как выглядит бэкэнд. На данный момент он состоит из пяти сервисов.
Первый занимается всем, что связано с аккаунтами – от покупок за внутриигровую валюту до выполнения квестов.
Второй работает со всем, что связано с матчами — напрямую взаимодействует с GameLift и игровыми серверами.
Третий сервис занимается покупками за реальные деньги.
Четвертый и пятый отвечают за социальные взаимодействия — один за друзей, другой за игру в компании.
Каждый из серверных сервисов с архитектурной точки зрения выглядит абсолютно идентично.
Они представляют собой набор конвейеров, каждый из которых обрабатывает один тип сообщений.
Конвейер состоит из двух элементов: производителя и потребителя.
Единственная задача производителя — читать сообщения из очереди.
Поэтому он реализован полностью в общем виде и для каждого конвейера нам нужно лишь указать, сколько производителей, из какой очереди читать и сколько потребителей будет обслуживать каждый производитель.
Consumer реализуется отдельно для каждого конвейера и представляет собой модуль с единственной обязательной функцией, который принимает одно сообщение, выполняет всю необходимую работу и возвращает клиенту или игровому серверу список сообщений, которые необходимо отправить в другие сервисы.
Производитель также реализует противодавление, чтобы при резком увеличении количества сообщений не было перегрузки, и запрашивал не больше сообщений, чем у него есть свободных потребителей.
Серверные службы не содержат никакого состояния, поэтому нам легко добавлять и удалять старые экземпляры.
Единственное, что вам нужно сделать перед удалением, — это попросить производителей прекратить чтение новых сообщений и дать потребителям некоторое время для завершения обработки активных сообщений.
Как работает взаимодействие с GameLift? GameLift состоит из нескольких компонентов.
Среди тех, что мы используем — матчмейкер FlexMatch, очередь размещения, определяющая, в каком конкретно регионе разместить игровую сессию с этими игроками, и сами флоты, состоящие из игровых серверов.
Как происходит это взаимодействие? Мета напрямую общается только со свахом, отправляя ему запросы на поиск пары.
И обо всех событиях во время матчмейкинга он уведомляет мету через те же очереди сообщений.
И как только он находит подходящую группу игроков для начала матча, он отправляет заявку в очередь размещения, которая в свою очередь подбирает для них сервер.
Взаимодействие меты с игровым сервером предельно простое.
Игровому серверу нужна информация об аккаунтах, ботах и карте, и мета отправляет всю эту информацию в очередь, созданную специально для этого матча, в одном сообщении.
И при активации игровой сервер начинает слушать эту очередь и получает все необходимые ему данные.
По окончании матча он отправляет свои результаты в общую очередь, которую слушает мета.
Теперь перейдем к дополнительной инфраструктуре, которую мы используем.
Развертывание сервисов довольно простое.
Все они работают в контейнерах Docker, а для оркестрации мы используем Amazon ECS. Он намного проще Kubernetes, конечно, менее навороченный, но те задачи, которые нам от него нужны, он выполняет. А именно: масштабирование сервисов и накатывание релизов, когда нам нужно загрузить какой-то исправление ошибок.
И последний сервис, который мы также используем, — это AWS Fargate. Это избавляет нас от необходимости самостоятельно управлять кластером машин, на которых работают наши докер-контейнеры.
Мы используем DynamoDB в качестве основного хранилища.
Мы выбрали его в первую очередь потому, что он очень прост в эксплуатации и масштабировании.
Мы также используем Redis в качестве дополнительного хранилища через управляемый сервис Amazon ElasiCache. Мы используем его для задачи глобального рейтинга игроков и для кэширования основных данных аккаунта в ситуациях, когда нам нужно вернуть клиенту данные о сотнях игровых аккаунтов сразу (например, в той же рейтинговой таблице, или в списке друзья).
Для хранения конфигов, метагеймплейных механик, описаний оружия, героев и т.д. мы используем JSON-файл, который вставляем в образы сервисов, которым он нужен.
Потому что нам гораздо проще выкатить новую версию сервиса с обновленными данными (если будет обнаружен какой-то баг), чем делать решение, которое будет динамически обновлять эти данные во время выполнения из какого-то внешнего хранилища.
Мы используем довольно много сервисов для логирования и мониторинга.
Начнем с CloudWatch. Это сервис мониторинга, который собирает метрики со всех сервисов Amazon. Поэтому мы решили отправлять туда и метрики с нашего метасервера.
А для логирования мы используем общий подход и на клиенте, и на игровом сервере, и на метасервере.
Все логи мы отправляем в сервис Amazon Kinesis Firehose, который в свою очередь передает их в Elasticseach и S3. В Elasticseach мы храним только относительно свежие данные и с помощью Kibana ищем ошибки, решаем некоторые задачи игровой аналитики и строим оперативные дашборды, например, с графиком CCU и количества новых установок.
S3 содержит все исторические данные, и мы используем их через службу Athena, которая предоставляет интерфейс SQL поверх данных в S3.
Теперь немного о том, как мы используем Terraform.
Terraform — это инструмент, который позволяет декларативно описывать вашу инфраструктуру и если описание каким-либо образом меняется, он автоматически определяет действия, которые необходимо выполнить для приведения вашей инфраструктуры к обновленному виду.
Таким образом, имея единое описание, мы получаем практически идентичную среду для постановки и производства.
Кроме того, эти среды полностью изолированы, поскольку развертываются под разными учетными записями.
Единственным существенным недостатком Terraform для нас является неполная поддержка GameLift. Также расскажу о том, как мы внедрили обновление без даунтайма.
Когда мы выпускаем обновления, мы делаем копию большинства ресурсов: сервисов, очередей сообщений, некоторых таблиц в базе данных.
И те игроки, которые скачают новую версию игры, подключатся к этому обновленному кластеру.
Но те игроки, которые еще не обновились, могут какое-то время продолжать играть на старой версии игры, подключившись к старому кластеру.
Как мы это реализовали.
Во-первых, используя механизм модулей в Terraform. Мы определили модуль, в котором описали все версионные ресурсы.
И эти модули можно импортировать несколько раз, с разными параметрами.
Соответственно, для каждой версии импортируем этот модуль, указывая номер этой версии.
Еще нам помогло отсутствие схемы в DynamoDB, что позволяет выполнять миграции данных не во время обновления, а откладывать их для каждой учетной записи до тех пор, пока ее владелец не зайдет в новую версию игры.
А в балансировщике мы просто указываем правила для каждой версии, чтобы он знал, куда маршрутизировать игроков с разными версиями.
Наконец, мы узнали пару вещей.
Во-первых, настройка всей инфраструктуры должна быть автоматизирована.
Те.
Некоторые вещи мы какое-то время настраивали вручную, но рано или поздно допускали ошибки в настройках, из-за чего случались глюки.
И наконец, вам необходимо иметь либо реплику, либо резервную копию для каждого элемента вашей инфраструктуры.
И если этого не делается для чего-то, то именно это дело нас когда-нибудь подведет.
Вопросы из зала
— Вас не беспокоит, что из-за какой-то ошибки автомасштабирование может слишком сильно масштабироваться вверх, и в итоге вы получите много денег? — Для автомасштабирования по-прежнему установлены ограничения.Мы не будем устанавливать слишком высокий лимит, чтобы не получить в итоге большие деньги.
Это основное решение + мониторинг.
Вы можете установить оповещения, если что-то слишком сильно масштабируется.
— Каковы ваши ограничения на данный момент? Относительно существующей инфраструктуры в процентном отношении.
— Мы сейчас находимся на стадии открытого бета-тестирования в 11 странах, поэтому CCU не настолько велик, чтобы можно было его как-то оценить.
Сейчас инфраструктура слишком перегружена для того количества людей, которое у нас есть.
— И ограничений еще нет? - Да, они просто в 10-100 раз больше нашего ККУ.
Вы не можете сделать меньше.
— Вы сказали, что у вас очереди между фронтом и бэкендом — это очень необычно.
Почему не напрямую? — Мы хотели, чтобы сервисы без сохранения состояния легко реализовали механизм backpresh, чтобы сервис не запрашивал больше сообщений, чем у него есть свободных обработчиков.
Также, например, при выходе из строя обработчика очередь выдаст это же сообщение другому обработчику — возможно, у него что-то получится.
— Очередь как-то сохраняется? - Да.
Это сервис SQS Amazon. — По поводу очередей: сколько каналов вы создаете во время игры? У вас есть определенное количество каналов для каждого матча? — Создаётся сравнительно немного.
Большинство очередей, например очереди запросов, являются статическими.
Есть очередь запросов на авторизацию, есть очередь на начало матча.
Из динамически создаваемых очередей у нас есть очереди только для каждого фронтенда (при запуске он создаётся для входящих сообщений для клиентов) и на каждое совпадение мы создаём одну очередь.
Эта услуга практически ничего не стоит; они берут столько же за любой запрос.
Те.
любой запрос к SQS (создать очередь, прочитать что-то из нее) стоит одинаково, и при этом в целях экономии мы не удаляем эти очереди; они будут удалены позже.
И то, что они существуют, нам ничего не стоит. — В такой архитектуре для вас это не будет пределом?
- Нет.
Больше выступлений от Pixonic DevGAMM Talks
- Использование Consul для масштабирования сервисов с отслеживанием состояния (Иван Бубнов, DevOps в BIT.GAMES);
- CICD: плавное развертывание в распределенных кластерных системах без простоев (Егор Панов, системный администратор Pixonic);
- Попрактикуйтесь в использовании модели актера на серверной платформе игры Quake Champions. (Роман Рогозин, бэкенд-разработчик Sabre Interactive);
- Как ECS, C# Job System и SRP меняют подход к архитектуре (Валентин Симонов, полевой инженер Unity);
- Принцип KISS в разработке (Константин Гладышев, ведущий игровой программист 1С Game Studios);
- Общая логика игры на клиенте и сервере (Антон Григорьев, заместитель технического директора Pixonic).
- Cucumber в облаке: использование BDD-скриптов для нагрузочного тестирования продукта (Антон Косякин, технический менеджер по продукту ALICE Platform).
-
Забота
19 Oct, 24 -
Комьюнити-Менеджер Ищет Работу
19 Oct, 24 -
Icq6.5 Против Всех Остальных: Второй Раунд
19 Oct, 24 -
Как Вы Относитесь К Киберсквоттерам?
19 Oct, 24