Agones + Kubernetes: игровой сервер и матчмейкинг на Go

Зачем нужен 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. Буду рад любым отзывам. Спасибо, что дочитали!

© 2026 meganuke