Архитектура выделенного сервера: 48 игроков на контейнер
Мы отделили авторитетный игровой сервер от бэкенда, переписали матчмейкинг и выделение комнат и измерили реальную стоимость одного онлайн-игрока.
Вечер плейтеста: 40 игроков, один процесс, один краш
В феврале прошлого года мы провели закрытый плейтест на 40 человек, в пятницу в 21:00. Игра строилась вокруг матчей на 8 человек, и в тот вечер одновременно шло пять матчей. В 23:40 треть игроков вылетела разом. От всего этого осталась одна строка: OutOfMemoryException, а под ней пять разных значений matchId.
Причиной был не баг, а архитектура. Пять матчей жили внутри одного процесса, внутри единственной headless-сборки Unity. Утечка памяти в одном матче убила остальные четыре. В тот вечер мы поняли, что изоляция на уровне комнаты — это не оптимизация, а требование корректности. Мы пробовали и лимит памяти на процесс, но умирал всё равно общий процесс.
Следующие три месяца ушли на то, чтобы построить архитектуру игрового сервера заново. Цифры и отвергнутые варианты ниже — это замеры того периода, все они сняты на прототипе соревновательного шутера на 8 игроков. Движок — Unity 2022 LTS, транспорт — наш собственный надёжный канал поверх UDP.
Почему авторитетный сервер не обсуждается
В первой версии перемещение считал клиент и сообщал результат серверу. На втором плейтесте трое из 40 игроков отредактировали пакеты и удвоили себе скорость бега. Сломанным местом был не код детекта, а сама архитектура: если позиции пишет клиент, борьба с читами превращается в гонку проверок, и эту гонку вы проигрываете. Мы пробовали ограничивать скорость на сервере, но каждая новая способность передвижения требовала заново подбирать этот предел.
На авторитетном сервере клиент отправляет только ввод. У нас это структура InputCommand: номер тика, вектор движения, углы обзора и биты кнопок — всего 14 байт. Симуляцию сервер крутит с фиксированным тиком 30 Гц и рассылает состояние. Клиент по-прежнему предсказывает у себя, но авторитет к нему никогда не переходит.
Цена вполне реальна: теперь сервер считает физику за каждого игрока. По нашим замерам сессия на 8 игроков занимала около 38% одного ядра на 3,4 ГГц, включая character controller, hit-scan и фильтрацию видимости. Именно это число стало входными данными для всех последующих расчётов масштабирования и стоимости. Причём 38% — это среднее, а не пик: в плотных перестрелках та же сессия доходила до 55%.
Игровой сервер и игровой бэкенд — это разные вещи
Игровой сервер хранит состояние, живёт недолго, и когда он умирает, вместе с ним умирает ровно один матч. Игровой бэкенд — аккаунты, инвентарь, прогресс, список друзей — состояния сессии не хранит, живёт долго, и когда падает он, задеты все. Держать их в одном процессе сэкономило нам две недели в первой версии и стоило двух месяцев потом. Правило, которым я пользуюсь: если данные переживают матч, это не забота игрового сервера.
Границу мы провели так: во время матча игровой сервер не пишет ни в одну постоянную базу данных. Когда матч заканчивается, на бэкенд уходит один POST с телом MatchResult, где по каждому игроку лежат очки, длительность, урон и использованные предметы. Вместо восьми серверов, пишущих в таблицу инвентаря в одну и ту же секунду, получается несколько сотен мелких запросов в минуту, которые можно поставить в очередь. Для тех, кто отваливается посреди матча, мы дополнительно отправляем промежуточную запись раз в 30 секунд, поэтому краш ничей прогресс не стирает.
Аутентификация тоже относится к бэкенду. При входе игрок получает sessionTicket со сроком жизни 120 секунд и передаёт его игровому серверу вместе с адресом комнаты, а тот проверяет билет на бэкенде одним вызовом. Никаких учётных данных базы у игрового сервера нет вовсе. Скомпрометированный игровой сервер до инвентаря не доберётся — максимум испортит один конкретный матч.
Как устроены очередь матчмейкинга и выделение комнат
Матчмейкинг у нас — один сервис: MatchQueue. Игрок, входящий в очередь, записывается вместе с рейтингом навыка, регионом и идентификатором пати. Подборщик проходит по очереди каждые 2 секунды, объединяет игроков одного региона в полосе ±100 рейтинга и каждые 5 секунд расширяет полосу на 50. На 45-й секунде полоса упирается в потолок, и время ожидания побеждает качество подбора.
Как только восемь игроков найдены, дело переходит к RoomAllocator. Альтернативу мы попробовали: поднимать под каждый матч отдельный контейнер. Headless-сборке Unity требовалось 6-9 секунд, чтобы загрузить сцену и быть готовой, а этого времени хватает, чтобы уже собранная группа развалилась. Вместо этого мы держим по четыре пустые сессии в горячем резерве на регион, и выделение укладывается в 300 мс.
Если горячий пул опустеет, новые сессии поднимаются в фоне, и игроки в худшем случае увидят те самые 8 секунд. Не фиксируйте размер пула: с 21:00 до 24:00 онлайн у нас в шесть раз выше дневного, поэтому пул по почасовому профилю меняется с 4 до 12. При фиксированном пуле вы либо растите очередь ночью, либо весь день платите за простаивающие машины.
Сессии на контейнер и как работает масштабирование
Один процесс игрового сервера ведёт ровно один матч и завершается вместе с ним. Замеренный расход — 180 МБ RSS на сессию и 0,42 vCPU в среднем. На узел с 2 vCPU и 4 ГБ спокойно помещается четыре сессии, а если ужать — шесть. Шесть сессий на восемь игроков дают 48 одновременных игроков на узел. Мы пробовали уплотнить сильнее: на восьмой сессии тик начал вылезать за 33 мс, и движение заметно портилось.
Не привязывайте масштабирование к проценту загрузки CPU. Правильная метрика — количество свободных сессий: когда freeSessions падает ниже 8, запрашивается новый узел, а когда поднимается выше 24, один узел ставится на слив. Наша первая версия, завязанная на CPU, каждый раз при завершении матчей и падении нагрузки пыталась гасить узлы, на которых ещё оставались игроки. Держите эту метрику отдельно по регионам, иначе запас во Франкфурте замаскирует нехватку в Стамбуле.
Выключение всегда идёт через слив. Узел перестаёт получать новые назначения, а идущие на нём матчи доживают своё естественным образом. Поскольку матч у нас длится максимум 20 минут, худший срок слива — те же 20 минут. По той же причине spot- и preemptible-машины мы используем только под горячий пул: узел с живыми матчами не должен быть дешёвым.
Логирование и сбор крашей строятся на этом же слое. Каждый процесс пишет в stdout по одному JSON-объекту на строку, и в каждой строке есть sessionId, matchId, buildId и номер тика. Поскольку при краше процесс исчезает, сборщик обязан работать независимо от него и успеть вынести наружу Player.log и дамп до того, как умрёт контейнер. Пока мы это не настроили, причину трёх отдельных крашей так и не узнали. Объём логов — около 400 строк на сессию в минуту, столько можно хранить без семплирования.
Регион, стоимость и где хватает relay
Регион выбирается по пингу, а не по желанию игрока. Времена приёма-передачи, измеренные из Бурсы: Стамбул 18-22 мс, Франкфурт 48-55 мс, Северная Вирджиния 128-140 мс. При тике 30 Гц один тик длится 33 мс, поэтому Франкфурт играбелен, а Вирджиния — нет, во всяком случае для соревновательного шутера. При входе клиент отправляет по одной UDP-пробе в три региона и встаёт в очередь с двумя лучшими. Если участники пати сидят в разных городах, выигрывает лучший общий регион, а решает худший пинг в группе.
Считайте стоимость на одновременного игрока, а не на машину. Узел с 2 vCPU и 4 ГБ обходится нам примерно в 0,09 доллара в час и несёт 48 игроков, то есть 0,0019 доллара за игрочас при полной загрузке. Полной загрузки не бывает никогда: средняя заполненность у нас 35%, а с учётом горячего пула и минимальной ёмкости на регион реальная цифра выходит 0,006 доллара. При 500 одновременных игроках и профиле с четырьмя часами пика в день это примерно 270 долларов в месяц. Трафик остался на уровне 8% всего счёта, около 20 кбит/с исходящего на игрока.
Для команды из трёх человек эта сумма мала на фоне настоящих затрат. Сервис выделения, логика слива, конвейер логов и сбор крашей отняли у нас около месяца работы в полную занятость. Если вы делаете несоревновательный кооператив на 4-6 человек и ваш онлайн не выходит за несколько сотен, listen server через relay вполне достаточен. Relay решает проблему NAT, авторитет остаётся у хоста, и единственная плата — преимущество хоста.
Порог, которым я пользуюсь, простой: если что-то влияет на таблицы лидеров, ранг или экономику, нужен выделенный и авторитетный сервер, иначе начинайте с relay. Единственное, что делает поздний переход дешёвым, — с самого начала держать игровую логику в виде, пригодном для запуска на сервере. Наш класс GameSession не зависит ни от одного MonoBehaviour, и один и тот же код крутится и в listen server, и в headless-сборке. Сделаете это разделение в первый же день — и отсрочка решения не будет стоить почти ничего.