Зачем нужен Agones
Если вы, как и я, увлекаетесь Kubernetes, то наверняка заглядывали в связанные репозитории на GitHub — и, возможно, наткнулись на проект Agones. Если вы о нём не слышали: Agones — это разработка Google для управления и развёртывания серверов многопользовательских игр на Kubernetes.
Недавно я решил попробовать его в деле. Это оказалось по-настоящему интересно, и я хочу поделиться всем, что узнал. В статье мы разберём следующее:
-
Создание простого игрового сервера на Go.
-
Интеграцию с SDK Agones.
-
Развёртывание на Kubernetes с Agones.
-
Написание сервиса матчмейкинга на Go.
-
Настройку и нагрузочное тестирование автомасштабирования инфраструктуры на основе очереди игроков.
Я приведу много фрагментов кода и диаграмм, но если хотите изучить всё целиком, исходный код и манифесты Kubernetes есть в репозитории на GitHub.
Зачем нужен Agones
Прежде чем идти дальше, стоит ответить на вопрос: зачем Agones вообще существует? Именно так я отреагировал, когда наткнулся на него, — ведь в теории любой может развернуть игровой сервер обычным Deployment на кластере, верно? На деле всё несколько сложнее.
В документации Agones есть специальный раздел, который отвечает на этот вопрос. Если коротко: нагрузка игрового сервера одновременно stateful и stateless. Пустой сервер не хранит состояния и его можно спокойно удалить или перенести, а сервер с активными игроками держит состояние в памяти и не должен покидать узел.
Иными словами, Agones позволяет управлять игровыми серверами и масштабировать их не только по CPU, памяти или трафику, но и по активности игроков. Благодаря этому можно обновлять игровые серверы, не трогая те, где идут активные сессии, повторно использовать сервер после завершения игры или настроить автомасштабирование по числу занятых серверов. И это далеко не всё.
Разработка игрового сервера на Go
Если вас не интересует часть про разработку или у вас уже есть готовый игровой сервер, можете сразу перейти к разделу ниже: ↓ Интеграция Agones с игровым сервером.
Для начала нам нужна сама игра. Я сделаю простую игру в «Камень, ножницы, бумага» на Go.
Поскольку это лишь демо, грандиозности не будет: просто базовый HTTP-сервер с WebSocket, к которому подключаются два игрока. Для реальной игры вы, скорее всего, предпочтёте UDP-соединения.
Оба игрока подключаются к WebSocket и выбирают ход. Соединение остаётся открытым, пока оба не сделают выбор. Как только оба выбрали, сервер отправляет им результат.
Для этого я использую стандартные пакеты Go: net/http и github.com/gorilla/websocket.
package main
//go:embed index.html
var index embed.FS
var game *Game
func main() {
// Inititalize the game
game = NewGame()
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.FileServer(http.FS(index)).ServeHTTP(w, r)
})
http.HandleFunc("/ws", game.ws)
log.Println("Starting HTTP server on port 3000")
log.Fatal(http.ListenAndServe(":3000", nil))
}
Корневой путь (/) отдаёт файл index.html (встроенный в бинарник), а путь /ws обслуживает WebSocket-соединение.
При разработке игрового сервера избегайте портов 8080, 9357 и 9358 в вашем образе контейнера — они зарезервированы для sidecar-контейнера Agones.
Файл index.html — это простейшая страница с кнопками для каждого хода (камень, бумага, ножницы). JavaScript отправляет сообщение в WebSocket при нажатии кнопки. Результат отображается в блоке result.
<div>
<h1>Rock Paper Scissors</h1>
<button onclick="send('rock')">🪨</button>
<button onclick="send('paper')">📄</button>
<button onclick="send('scissors')">✂️</button>
<div id="result"></div>
</div>
<script>
const ws = new WebSocket("ws://" + location.host + "/ws");
ws.onmessage = (e) => {
document.getElementById("result").innerHTML = e.data;
};
function send(choice) {
ws.send(choice);
}
window.addEventListener("beforeunload", () => {
ws.close();
});
</script>
Подробно разбирать логику игры не буду — это всё-таки обычные «Камень, ножницы, бумага».
Игровой цикл полностью реализован в обработчике WebSocket и использует методы из пакета game, расположенного в ./internal/game. Вот краткий обзор того, что делает этот обработчик:
func (s *Server) ws(w http.ResponseWriter, r *http.Request) {
conn, _ := upgrader.Upgrade(w, r, nil)
defer conn.Close() // Ensure connection is always closed when the handler exits.
// Create a new player based on the connection
player := game.NewPlayer(conn)
s.game.AddPlayer(player)
// Read the first message which should contain the player's move
msgType, msg, err := conn.ReadMessage()
if err != nil {
// Client disconnected or error occurred
s.game.RemovePlayer(player)
return
}
// Play the current player's move
s.game.PlayMove(player, string(msg))
// Check if the two players have played
if s.game.Ended() {
s.game.SendResults()
return
}
// First player gets into a loop waiting for the opponent's move
player.Send("Waiting for opponent...\n")
for {
_, _, err := conn.ReadMessage()
if err != nil {
// Client disconnected or error occurred
s.game.RemovePlayer(player)
return
}
}
}
Если вы будете делать что-то похожее, не забудьте обработать отключение игрока. В моём случае игрок просто удаляется из игры, позволяя ему или кому-то другому зайти снова. В вашем варианте, возможно, стоит завершать игру или отключать остальных участников.
Для управления параллелизмом я использую простой мьютекс (mutex), который гарантирует, что список игроков и ходы не изменяются одновременно. Перед каждой операцией мьютекс блокируется и разблокируется по её завершении. Например:
type Game struct {
players []*Player
mu sync.Mutex
}
...
func (g *Game) AddPlayer(player *Player) {
if len(g.players) >= 2 || player == nil {
return
}
g.mu.Lock()
defer g.mu.Unlock()
g.players = append(g.players, player)
log.Printf("Player %p added to game", player)
}
После окончания игры WebSocket-соединения закрываются, и игровой сервер завершает работу.
Вот как это выглядит в итоге:
Впечатляет, не правда ли? Шутки в сторону — этого простого многопользовательского примера нам вполне хватит для знакомства с Agones.
В данном демо игровой сервер запускает только один игровой экземпляр, чтобы упростить задачу. На практике имеет смысл сделать «комнаты» и размещать несколько игровых экземпляров в одном контейнере. Подробнее об этом — в документации High Density GameServers | Agones.
Интеграция Agones с игровым сервером
Теперь, когда игровой сервер готов, нужно немного его доработать для развёртывания с Agones. Если попытаться запустить его прямо сейчас, он упадёт: Agones ожидает регулярные ping-сообщения от контейнера.
Коротко о том, как устроены вещи в Agones: для развёртывания игрового сервера используется ресурс GameServer. Его можно считать аналогом Pod в мире Agones — именно он будет запускать контейнер с игрой.
Главное отличие от обычных Pod в том, что GameServer запускает ваш образ вместе с sidecar-контейнером Agones SDK, который управляет жизненным циклом игрового сервера. Этот sidecar следит за тем, чтобы сервер был здоров и доступен для игроков, и взаимодействует с Kubernetes API для обновления статуса ресурса GameServer.
Минимальное требование для запуска игрового сервера с Agones — реализовать проверку работоспособности (Health Check). Для этого нужно подключить Agones Game Server Client SDK. В моём случае я использую пакет для Go, но SDK доступны и для других языков — Java, C++ — а также для игровых движков Unity и Unreal Engine.
Если ваш язык или движок не поддерживается, можно всё равно использовать Agones: разверните рядом с игровым сервером свой sidecar-контейнер, который будет общаться с Agones, а ваша игра — с ним. Либо напрямую используйте gRPC API или HTTP API — они поддерживаются большинством языков.
После установки SDK нужно реализовать саму проверку. Обычно это цикл, который каждые несколько секунд отправляет ping в Agones. В Go это выглядит так:
func main() {
...
go HealthPing(sdk, ctx)
}
func HealthPing(sdk *sdk.SDK, ctx context.Context) {
tick := time.Tick(2 * time.Second)
for {
err := sdk.Health()
if err != nil {
log.Fatalf("Could not send health ping, %v", err)
}
select {
case <-ctx.Done():
log.Print("Stopped health pings")
return
case <-tick:
}
}
}
Теперь технически можно развернуть игровой сервер на кластере с Agones, создав GameServer с нашим образом. Но до production-ready ещё далеко — нужно реализовать как минимум следующие функции Agones:
Ready() — сообщает, что сервер готов принимать подключения.
Shutdown() — сигнализирует Agones о завершении работы сервера.
Реализация Ready() проста: нужно вызвать её из SDK при запуске:
func main() {
...
// Mark server ready
if err := sdk.Ready(); err != nil {
log.Fatalf("Failed to mark Ready: %v", err)
}
go HealthPing(sdk, ctx)
log.Fatal(http.ListenAndServe(":3000", nil))
}
В теории вызов Ready() должен идти после того, как HTTP-сервер (или что вы там используете) полностью запустился. Здесь это не принципиально, поскольку http.ListenAndServe стартует практически мгновенно.
Если вы привыкли разрабатывать приложения для Kubernetes, вы, возможно, подумали о readiness и liveness probe. Но здесь они не нужны — Agones сам управляет жизненным циклом игрового сервера.
С Shutdown() всё немного сложнее. Нам нужно реализовать корректное завершение работы (graceful shutdown): сервер должен обрабатывать сигналы вроде SIGTERM «вежливо» — дождаться завершения всех операций и только потом останавливаться. Это особенно важно, чтобы не потерять данные игрока или незафиксированный результат партии.
К счастью, этот паттерн легко реализуется на Go. Мы воспользуемся пакетом context для управления отменой и таймаутами, а также пакетом signal для обработки сигналов ОС.
Сначала создадим контекст, который будет использоваться во всём сервере. Чтобы его можно было отменить по Unix-сигналам, создадим его через signal.NotifyContext из пакета os/signal. В конце функции main(), после ←ctx.Done(), разместим весь код завершения работы сервера.
func main() {
// Set up signal handling for graceful shutdown
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()
...
// Initialize HTTP server
httpServer := s.newHTTPServer()
go func() {
log.Println("Starting HTTP server on port 3000")
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Failed to start HTTP server: %v", err)
}
}()
// Wait for shutdown signal
<-ctx.Done()
// Shutting down everything
log.Println("Shutting down...")
s.sdk.Shutdown()
s.game.Shutdown()
httpServer.Shutdown(ctx)
}
Пока мы корректно реагируем на сигналы, но не вполне gracefully: в общем случае нужно гарантировать, что все текущие операции завершились до остановки. Для нашей задачи это не критично — остановка клиентского SDK и HTTP-сервера проходит без проблем.
Но представьте реальную игру: например, вы хотите сохранить результат в базу данных. В Kubernetes Pod при удалении получает сначала SIGTERM и имеет 30 секунд льготного периода. После этого Kubernetes отправляет SIGKILL, чего желательно избежать. Если база данных в этот момент испытывает проблемы, нужно успеть откатить транзакцию до принудительного завершения.
Решение — таймаут. Создадим ещё один контекст, но на этот раз через context.WithTimeout(). Его можно передать во все функции завершения работы и гарантировать, что сервер остановится за отведённое время.
Я установил таймаут в 10 секунд — этого более чем достаточно для корректной остановки клиентского SDK Agones и HTTP-сервера.
func main() {
...
// Wait for shutdown signal
<-ctx.Done()
// Create a context with a timeout for graceful shutdown
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Shutting down everything
log.Println("Shutting down...")
s.sdk.Shutdown()
s.game.Shutdown()
httpServer.Shutdown(shutdownCtx)
}
То, что мы только что реализовали, — это по сути 9-й фактор методологии Twelve-Factor App, известный как «Одноразовость» (Disposability). Если вы с ней не знакомы, очень рекомендую изучить и применять в своих проектах.
Итак, жизненный цикл игрового сервера полностью реализован. Теперь он готов к развёртыванию вместе с sidecar-контейнерами Agones SDK в реальных ресурсах GameServer.
Развёртывание игрового сервера
Теперь, когда сервер готов, можно развернуть его на кластере Kubernetes. Я использую простой кластер kind, но подойдёт любой. Главное требование — установить Agones на кластер. Проще всего это сделать через Helm:
helm repo add agones https://agones.dev/chart/stable
helm repo update
helm install my-release --namespace agones-system --create-namespace agones/agones
Игровой сервер мы развернём в пространстве имён default. Если хотите использовать другое, возможно, придётся изменить некоторые параметры в Helm-развёртывании Agones.
Когда кластер с Agones готов, создадим ресурс GameServer с нашим образом:
apiVersion: agones.dev/v1
kind: GameServer
metadata:
name: rps-game
spec:
template:
ports:
- name: default
containerPort: 3000
protocol: TCP
spec:
containers:
- name: rps-game
image: ghcr.io/noetarbouriech/agones-rps-game/game
Проверить состояние можно командой kubectl get gameservers. Вы увидите примерно следующее:
NAME STATE ADDRESS PORT NODE AGE
rps-game Ready 192.168.97.2 7278 agones-cluster-control-plane 10s
Обратите внимание: Agones выбрал случайный порт в диапазоне от 7000 до 8000. Этот порт открывается на сетевом уровне хостового узла через поле hostPort Pod, поэтому к серверу можно обратиться напрямую по IP-адресу и номеру порта.
Можно также посмотреть события ресурса, чтобы увидеть, через какие шаги он прошёл:
kubectl events --for='GameServer/rps-game'
Результат будет выглядеть примерно так:
LAST SEEN TYPE REASON OBJECT MESSAGE
3m58s Normal Creating GameServer/rps-game Pod rps-game created
3m52s Normal Scheduled GameServer/rps-game Address and port populated
3m52s Normal RequestReady GameServer/rps-game SDK state change
3m52s Normal Ready GameServer/rps-game SDK.Ready() complete
Теперь можно открыть игру в браузере по адресу http://ADDRESS:PORT.
Адрес и порт можно получить командой:
kubectl get gs -o jsonpath='{.items[0].status.address}:{.items[0].status.ports[0].port}'
Следующий шаг — развернуть сервер в Fleet. Если GameServer — аналог Pod, то Fleet можно считать аналогом Deployment или StatefulSet. Fleet позволяет иметь несколько реплик GameServer и масштабировать их без остановки активных игровых сессий. Создаётся он так:
apiVersion: agones.dev/v1
kind: Fleet
metadata:
name: rps-game
spec:
replicas: 3
template:
spec:
ports:
- name: default
containerPort: 3000
protocol: TCP
template:
metadata:
labels:
app: rps-game
spec:
containers:
- name: rps-game
image: ghcr.io/noetarbouriech/agones-rps-game/game
Посмотреть созданные GameServer можно командой:
kubectl get gameservers
NAME STATE ADDRESS PORT NODE AGE
rps-game-4sxlg-bl6bh Ready 192.168.97.2 7447 agones-cluster-control-plane 4s
rps-game-4sxlg-kfmbn Ready 192.168.97.2 7384 agones-cluster-control-plane 4s
rps-game-4sxlg-kld5r Ready 192.168.97.2 7165 agones-cluster-control-plane 4s
Fleet легко масштабировать через kubectl scale:
kubectl scale fleet rps-game --replicas=5
Однако если сейчас уменьшить число реплик, активные GameServer могут быть удалены. Чтобы этого избежать, используйте GameServerAllocation — этот ресурс переводит GameServer из состояния Ready в Allocated, защищая его от удаления. Выделим случайный сервер из флота:
kubectl create -f - <<EOF
apiVersion: allocation.agones.dev/v1
kind: GameServerAllocation
spec:
selectors:
- matchLabels:
agones.dev/fleet: rps-game
EOF
Теперь уменьшим флот до нуля реплик:
kubectl scale fleet rps-game --replicas=0
Если посмотреть на список GameServer, выделенный сервер окажется на месте:
NAME STATE ADDRESS PORT NODE AGE
rps-game-4sxlg-bl6bh Allocated 192.168.97.2 7447 agones-cluster-control-plane 9m40s
Это именно то, что нам нужно: масштабирование прошло без остановки выделенного сервера с активной игрой. Когда игра на этом сервере завершится, GameServer удалится автоматически.
Разумеется, в реальных проектах для выделения серверов используется Kubernetes API, а не kubectl — это позволяет полностью автоматизировать процесс. Также стоит обратить внимание на Allocator Service — альтернативный способ выделять GameServer из-за пределов кластера с Agones.
Сервис матчмейкинга
Итак, мы сделали игровой сервер, подключили его к Agones и развернули на Kubernetes. Всё это здорово, но примерно то же самое можно было сделать и с обычными ресурсами Kubernetes — Deployment или StatefulSet. Теперь, когда инфраструктура готова, самое время пойти дальше и задействовать возможности Agones: создать сервис матчмейкинга, который будет автоматически масштабировать игровые серверы в зависимости от нагрузки 🚀.
Но об этом — в следующей части. Сейчас сосредоточимся на создании матчмейкера, который соединяет двух игроков и выделяет им GameServer.
Погуглив, можно найти open-source решение для матчмейкинга Open Match от Google, совместимое с Agones. Однако на момент написания статьи последнее обновление было более двух лет назад. Вторая версия, Open Match 2, похоже, планируется, но релизов пока нет, а работает над ней, судя по всему, один человек.
Уточню: я не призываю избегать Open Match, но учитывая текущее состояние проекта и то, что для наших нужд он был бы избыточен, мы сделаем собственный простой сервис матчмейкинга.
Вот как будет работать наша система:
Для простоты я взял за основу структуру игрового сервера и переиспользовал её в сервисе матчмейкинга. Поэтому снова работаем с HTTP-сервером, который отдаёт WebSocket по пути /ws. На этот раз сервер перенаправляет игрока на страницу, которую вернёт матчмейкинг.
Ключевой компонент системы — очередь Pub/Sub. Как видно из диаграммы, мы работаем с двумя топиками:
-
matchmaking: запросы игроков на поиск матча.
-
match_results_{playerID}: топик с результатом поиска для конкретного игрока.
Главный процесс называется Matcher — он берёт игроков из очереди и составляет пары. Как только пара найдена, Matcher резервирует GameServer, создавая GameServerAllocation через Kubernetes API, и отправляет обоим игрокам адрес сервера через их персональные топики результатов.
Для работы с Pub/Sub в Go мы используем отличную библиотеку Watermill, которая значительно упрощает задачу. Её преимущество — поддержка множества бэкендов: Kafka, RabbitMQ, PostgreSQL. Для простоты я выбрал Go Channel, который с Watermill можно использовать как Pub/Sub.
Вот как WebSocket-обработчик запускает процесс матчмейкинга и ждёт результата с помощью Watermill:
func (s *Server) ws(w http.ResponseWriter, r *http.Request) {
conn, _ := upgrader.Upgrade(w, r, nil)
defer conn.Close() // Ensure connection is always closed when the handler exits.
playerID := rand.Text() // random player ID
playerResultTopic := fmt.Sprintf("match_results_%s", playerID)
// Publish the matchmaking request
msg := message.NewMessage(watermill.NewUUID(), []byte(playerID))
if err := s.pub.Publish("matchmaking", msg); err != nil {
log.Printf("Failed to publish matchmaking message: %v", err)
return
}
// Subscribe to the player's result topic
messages, err := s.sub.Subscribe(s.ctx, playerResultTopic)
if err != nil {
log.Printf("Failed to subscribe to player result topic: %v", err)
return
}
// Wait for a match result
select {
case <-s.ctx.Done():
return // Exit if the server is shutting down
case msg := <-messages:
matchResult := string(msg.Payload)
log.Printf("Match found for player %s: %s", playerID, matchResult)
// Send the match result back to the WebSocket client
if err := conn.WriteMessage(websocket.TextMessage, []byte(matchResult)); err != nil {
log.Printf("Failed to send match result: %v", err)
return
}
// Acknowledge the message and exit
msg.Ack()
return
}
}
Как видите, всё просто: функции Subscribe() и Publish() делают большую часть работы.
Это «фронтендная» часть матчмейкинга. Вторая часть — сам Matcher. Он запускается как горутина, хотя при использовании другого Pub/Sub его можно было бы вынести в отдельный сервис. Его задача — брать игроков из очереди matchmaking и составлять пары.
Для этого я использую Router из Watermill, который отлично подходит для построения событийно-ориентированных систем. В нашем случае мы просто добавляем обработчик для топика matchmaking:
router, _ := message.NewRouter(message.RouterConfig{}, logger)
router.AddConsumerHandler(
"matchmaking_handler", // Name of the handler
"matchmaking", // Topic to subscribe to
m.sub, // Subscriber
m.matchmakingHandler, // Handler function,
)
Функции-обработчики в Watermill работают ожидаемым образом: принимают сообщение и обрабатывают его.
func (m *Matcher) matchmakingHandler(msg *message.Message) error {
// Process the matchmaking message
playerID := string(msg.Payload)
log.Printf("Processing player: %s", playerID)
m.mu.Lock()
defer m.mu.Unlock()
if m.waiting == "" {
m.waiting = Player(playerID)
return nil
}
var matchResult string
var err error
retryInterval := 5 * time.Second
for {
matchResult, err = AllocateGameServer()
if err == nil {
break
}
log.Printf("Failed to allocate game server: %v", err)
time.Sleep(retryInterval)
}
resultMsg := message.NewMessage(watermill.NewUUID(), []byte(matchResult))
// Publish the match result to the player's topic
playerResultTopic := fmt.Sprintf("match_results_%s", playerID)
if err := m.pub.Publish(playerResultTopic, resultMsg); err != nil {
log.Printf("Failed to publish match result: %v", err)
return err
}
// Publish the match result to the waiting player's topic
waitingResultTopic := fmt.Sprintf("match_results_%s", m.waiting)
if err := m.pub.Publish(waitingResultTopic, resultMsg); err != nil {
log.Printf("Failed to publish match result: %v", err)
return err
}
// remove waiting player
m.waiting = ""
// no error
return nil
}
Ключевые части здесь — базовая логика матчмейкинга: если никто не ждёт, помечаем текущего игрока как ожидающего; когда приходит второй — соединяем их и публикуем результат обоим.
Обратите внимание: здесь нет явного вызова msg.Ack(). Это потому, что Watermill автоматически подтверждает сообщение, если обработчик не вернул ошибку.
И наконец, рассмотрим функцию AllocateGameServer(), которая резервирует случайный GameServer и возвращает его IP и порт. Для этого мы обращаемся к Kubernetes API и создаём ресурс аналогично тому, как делали раньше:
allocation := &v1.GameServerAllocation{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "game-alloc-",
Namespace: "default",
},
Spec: v1.GameServerAllocationSpec{
Selectors: []v1.GameServerSelector{{
LabelSelector: metav1.LabelSelector{
MatchLabels: map[string]string{
"agones.dev/fleet": "rps-game",
},
},
}},
},
}
Однако если просто развернуть сервис матчмейкинга через Deployment, он ничего не сделает. По умолчанию Pod использует сервисный аккаунт default, у которого нет прав на обращение к Kubernetes API. Нужно создать отдельный ServiceAccount и RoleBinding, дающий право создавать ресурсы GameServerAllocation.
apiVersion: v1
kind: ServiceAccount
metadata:
name: matchmaking-sa
namespace: default
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: gameserverallocator
namespace: default
rules:
- apiGroups: ["allocation.agones.dev"]
resources: ["gameserverallocations"]
verbs: ["create"]
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: gameserverallocator-binding
namespace: default
subjects:
- kind: ServiceAccount
name: matchmaking-sa
namespace: default
roleRef:
kind: Role
name: gameserverallocator
apiGroup: rbac.authorization.k8s.io
Теперь укажем созданный ServiceAccount в Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: matchmaking
spec:
replicas: 1
selector:
matchLabels:
app: matchmaking
template:
metadata:
labels:
app: matchmaking
spec:
serviceAccountName: matchmaking-sa
containers:
- name: matchmaking
image: ghcr.io/noetarbouriech/agones-rps-game/matchmaking
ports:
- containerPort: 3000
Создадим Service для этого Deployment и откроем доступ через port-forwarding:
kubectl port-forward service/matchmaking 3000:80
Открываем localhost:3000 и пробуем сыграть:
Как видно по мелькнувшему чёрному экрану, матчмейкинг действительно перенаправляет на игровой сервер, как только находит пару.
Важный момент: в текущем виде матчмейкинг не масштабируется горизонтально. Запустить несколько экземпляров не получится — игроки окажутся в разных Matcher’ах и никогда не встретятся.
Впрочем, это и не нужно, если разбить матчмейкинг на шарды по региону (eu, us и т.д.) или уровню игры (Elo, ранг). Тогда для каждого шарда запускается свой экземпляр — например, eu.elo100-200.matchmaking или us.elo100-200.matchmaking.
Ещё одно замечание: я снова использовал WebSocket, потому что просто скопировал структуру кода из игрового сервера. На практике лучше использовать HTTP API с выдачей тикета и поллингом результата. Или, возможно, SSE?
Настройка автомасштабирования игровых серверов
Всё работает довольно неплохо, верно? Тем не менее одна проблема остаётся нерешённой. Итак, у нас есть игра на Agones с несколькими экземплярами и матчмейкинг, который направляет игроков к ним. Но если одновременно играют 6 человек, все три GameServer окажутся выделенными — и новые игроки просто не смогут найти матч.
Чтобы решить это, нужно настроить автомасштабирование флота игровых серверов. Для этого создадим FleetAutoscaler:
apiVersion: "autoscaling.agones.dev/v1"
kind: FleetAutoscaler
metadata:
name: rps-game-autoscaler
spec:
fleetName: rps-game
policy:
# type of the policy
type: Buffer
buffer:
# Size of a buffer of "ready" game server instances
bufferSize: 10
maxReplicas: 100
sync:
type: FixedInterval
fixedInterval:
# the time in seconds between each auto scaling
seconds: 5
Я выбрал политику буфера (buffer policy), которая гарантирует постоянное наличие запаса готовых серверов. В нашем случае — 10 экземпляров, проверка каждые 5 секунд.
Есть и другие интересные политики:
-
Политика счётчика (counter policy) — масштабирует на основе счётчика GameServer. Полезна, если в одном контейнере размещено несколько комнат, как упоминалось ранее.
-
Политика вебхука (webhook policy) — позволяет реализовать собственную логику масштабирования в виде HTTP-обработчика. Например, масштабировать флот по числу игроков в очереди матчмейкинга.
-
Политика WASM (WASM policy) — масштабирование с произвольной логикой через модули WebAssembly. Сценарий использования пока не очевиден, но интересно для изучения.
-
Политика расписания (schedule policy) — задаёт политику для определённых временных периодов. Удобна для масштабирования перед релизом игры или крупным событием.
Для простоты продолжим с политикой буфера — при коротком интервале синхронизации она работает достаточно хорошо.
Теперь самое интересное — проверим автомасштабирование в деле!
Для нагрузочного тестирования есть инструмент k6 от Grafana. Он симулирует большое число пользователей, подключающихся к серверу, с помощью скриптов на JavaScript. Вот скрипт, который я написал для этого проекта:
import ws from "k6/ws";
import http from "k6/http";
import { check } from "k6";
export const options = {
vus: parseInt(__ENV.K6_VUS) || 100,
duration: __ENV.K6_DURATION || "20s",
};
export default function () {
const wsURL = "ws://localhost:3000/ws";
const params = { tags: { test: "websocket-match" } };
const res = ws.connect(wsURL, params, function (socket) {
socket.on("open", function open() {
console.log("Connected to matchmaking");
});
socket.on("message", function message(data) {
const matchURL = data.toString().trim();
console.log(`Received match URL: ${matchURL}`);
// Make HTTP GET request to the match URL
const httpRes = http.get(matchURL);
// Check if the HTTP request was successful
check(httpRes, {
"match URL status is 200": (r) => r.status === 200,
});
console.log(`HTTP Response status: ${httpRes.status}`);
socket.close();
});
socket.on("close", function close() {
console.log("WebSocket disconnected");
});
socket.on("error", function error(err) {
console.log("WebSocket error:", err);
});
});
// Check WebSocket connection status
check(res, {
"websocket status is 101": (r) => r && r.status === 101,
});
}
Этот скрипт ~~который точно не написан ИИ~~ открывает WebSocket-соединение с матчмейкингом и отправляет GET-запрос к игровому серверу.
Запустим его с 100 виртуальными пользователями на 30 секунд.
Вот результаты:
Вывод k6
█ TOTAL RESULTS
checks_total.......: 220 4.398727/s
checks_succeeded...: 100.00% 220 out of 220
checks_failed......: 0.00% 0 out of 220
✓ match URL status is 200
✓ websocket status is 101
HTTP
http_req_duration..............: avg=1.44ms min=354.87µs med=1.31ms max=5.49ms p(90)=2.14ms p(95)=2.54ms
{ expected_response:true }...: avg=1.44ms min=354.87µs med=1.31ms max=5.49ms p(90)=2.14ms p(95)=2.54ms
http_req_failed................: 0.00% 0 out of 110
http_reqs......................: 110 2.199363/s
EXECUTION
iteration_duration.............: avg=23.13s min=106.02ms med=24.19s max=46.16s p(90)=45.77s p(95)=46.14s
iterations.....................: 110 2.199363/s
vus............................: 40 min=40 max=100
vus_max........................: 100 min=100 max=100
NETWORK
data_received..................: 170 kB 3.4 kB/s
data_sent......................: 37 kB 739 B/s
WEBSOCKET
ws_connecting..................: avg=26.93ms min=3.49ms med=35.46ms max=44.52ms p(90)=41.99ms p(95)=42.34ms
ws_msgs_received...............: 110 2.199363/s
ws_session_duration............: avg=23.13s min=105.98ms med=24.19s max=46.16s p(90)=45.77s p(95)=46.14s
ws_sessions....................: 150 2.999132/s
running (50.0s), 000/100 VUs, 110 complete and 40 interrupted iterations
default ✓ [ 100% ] 100 VUs 20s
Как видно, автомасштабировщик с трудом успевает за нагрузкой. Чтобы улучшить ситуацию, можно увеличить размер буфера и уменьшить интервал синхронизации. Ещё лучше — перейти на политику вебхука и реализовать эндпоинт, который возвращает текущее число игроков в очереди.
Следующий шаг — расширить скрипт так, чтобы он отправлял реальные игровые команды серверам. Тогда можно даже представить запуск kind-кластера с Agones, игрой и k6 в рамках CI-тестов.
Заключение
Этот эксперимент с Agones занял больше времени, чем я рассчитывал, зато я многому научился и получил немало удовольствия. В целом Agones — очень интересный проект, по-новому раскрывающий возможности Kubernetes.
Думаю, создание игры и системы матчмейкинга с нуля под Agones действительно помогло мне лучше понять, как концепции сочетаются друг с другом. Я усвоил гораздо больше, реализовав всё самостоятельно, чем при первом чтении документации.
При этом многое осталось нетронутым: другие политики автомасштабирования, счётчики и списки, работа с реальным игровым сервером на UDP. Есть и смежные проекты — например, Quilkin, UDP-прокси для маршрутизации трафика к игровым серверам, хорошо сочетающийся с Agones.
Надеюсь, статья оказалась полезной и вы узнали что-то новое об Agones и Kubernetes. Буду рад любым отзывам. Спасибо, что дочитали!