JP / EN

AgonesとOpenMatchとK8s


この記事はUnreal Engine (UE) Advent Calendar 2025シリーズ 1 22日目の記事です。 こんにちはみずあめです。現在2025/12/21 17:25 、公開まであと6時間です!やばい! 19:14に書き終わりました~

初めに

まずこの記事の読者の方は基本UnrealEngine界隈の方だと思い、急にK8sがぁ~とかAgonesがぁ~とか話しても、オフチョベットしたテフをマブガッドしてリット状態になると思うので頑張って解説します。ぜひ最後まで読んで下さい。

実際のプレイ映像: https://x.com/mizuameisgod/status/1990358140152676466

Dockerとは

インチキなことを言うと炎上するので、筑波大学プログラム言語処理の授業を引用します。 https://kdb.tsukuba.ac.jp/syllabi/2025/GB30504/jpn

仮想化技術は,物理的なハードウェアリソース(CPU,メモリ,ストレー ジ)を仮想マシンとして抽象化し,複数の環境を実行するための技術. 仮想マシンはホストとなる物理マシン上で動作し,独立したオペレーティ ングシステムやアプリケーションを実行するための論理環境を提供する

その中でもDockerは コンテナ型に該当します。

アプリケーションとその依存関係を一つのパッケージとしてまとめ,ホストOS上で 独立して実行する技術.コンテナはホストOSのカーネルを共有するため,仮想マシ ンよりも軽量で高速に動作する.

コンテナ型はホストOSのカーネルを共有しており,ハードウェアへ直接 アクセスするのはホストOSのみ.プロセスレベルの隔離が行われており, 一つのコンテナ上のアプリケーションとその依存関係は独立している.

今回の場合は、UnrealEngineで書き出されたLinux向けバイナリを安全に、環境の差分を吸収してもらい、安定的に実行するために使用します。

K8sとは

正式名称はKubernetesです。上記のDockerをはじめとするコンテナをオーケストレーションするオープンソースシステムです。 語弊を恐れずに言うと、コンテナをいい感じにスケジューリングしたり、負荷分散したり、など皆さんが普段使っているクラウドの根幹にある技術です。クラウドは誰かのオンプレ。

Agonesとは

今回の場合はUnrealEngineのDedicated Serverを直接管理しているもの。サーバーのライフサイクルなどを管轄します。 Googleなどが主にやってるOSSです。

OpenMatchとは

名前の通り、マッチメイキングを主に行います。クライアントとAgonesの橋渡しを実質的にやっています。(Open Match がマッチ結果を AgonesAllocator に渡し、サーバーを割り当てさせる) Googleなどが主にやってるOSSです。

オーバービュー

そして、残念なことにOpenMatchはすべての機能を提供しておらず、CustomComponentとして、director,evaluator,match function,geme frontend辺りは自分で書く必要があります。(PythonかGoになります)

  • Game Frontend ゲームクライアント向けのHTTP APIサーバー プレイヤーからのマッチングリクエストを受け付ける Open Match Frontend Serviceにチケットを作成 アサインメント(サーバー割り当て)を待機して結果を返す JWT認証トークンを発行してゲームサーバー接続に使用

  • Match Function マッチの提案を作成するカスタムロジック Query Serviceから待機中のチケットを取得 マッチングアルゴリズムを実行 マッチ提案をBackendに返す

  • Evaluator Match Functionからの提案を評価・選別する

  • Director マッチメイキングのオーケストレーター Backendからマッチ結果を取得 Agones/KubernetesでGameServerを割り当て マッチしたプレイヤーにサーバー接続情報をアサイン

そしてまともに動くサンプルが見当たらなかったので、全て簡易実装しました。 https://github.com/mizuamedesu/easy-open-match

つまりどういうことだってばよ

つまり、一回サーバー側の動きは抽象化するとして、クライアント的な視点だと以下の流れになります

  • game frontendにマッチメイキングリクエストのhttpリクエストなどを送る
  • マッチメイキング完了時、サーバー側はdedicated serverのIP+PortとJWTを渡す
  • クライアントはOpenLevelで開く
  • dedicated serverはクライアントが接続してきたとき、RPCでトークンのexchangeを要請する
  • dedicated serverはもらったJWTをgame frontendのwell-known jsonを元に検証し、確認が取れたらフラグを立て、失敗orタイムアウトした場合はクライアントを削除する

キック処理の実装

static void KickPlayerInternal(APlayerController* PlayerController, const FText& Reason)
{
    if (!PlayerController) return;

    UWorld* World = PlayerController->GetWorld();
    if (!World) return;

    AGameModeBase* GameMode = World->GetAuthGameMode();
    if (!GameMode) return;

    AGameSession* GameSession = GameMode->GameSession;
    if (!GameSession) return;

    GameSession->KickPlayer(PlayerController, Reason);
}

何故かBlueprintにキック関数が公開されていないので、良しなに実装する必要があります。 AGameSession::KickPlayer()を呼ぶだけです。

又、PlayerControllerにもキック時のイベントを受け取るために良しなに実装が必要です。

void ABattlePlayerController::ClientWasKicked_Implementation(const FText& KickReason)
{
    Super::ClientWasKicked_Implementation(KickReason);

    // キック理由を保存
    LastKickReason = KickReason;
    bWasKicked = true;

    // ログ出力
    UE_LOG(LogBattlePlayerController, Warning, TEXT("Client was kicked from server. Reason: %s"), *KickReason.ToString());

    // 画面にデバッグメッセージ表示
    if (GEngine)
    {
        GEngine->AddOnScreenDebugMessage(-1, 10.0f, FColor::Red,
            FString::Printf(TEXT("Kicked: %s"), *KickReason.ToString()));
    }

    // Blueprintイベントを呼び出し
    OnKickedFromServer(KickReason);
    ReceiveKickedFromServer(KickReason);
}

これを継承したBPのクラスを作成しました。

ゲームモードの実装

まずEvent OnPostLoginをオーバーライドし、PlayerControllerを先ほど作成したカスタムクラスにcastし、RPCを送ります。流れとしてサーバー→クライアントです。

RPCを受け取ったクライアントはサーバーへ、JWTを送るRPCをサーバーに呼びます。

RPCを受け取ったサーバーはwell-known jsonな公開鍵を取得し、検証が通れば完了フラグを立て、失敗の場合はキックをします。

クライアントが何らかの理由でJWTのexchangeに返答しない場合は、一定時間でキックをします。

UEの実行コンテナイメージ

https://github.com/mizuamedesu/ue-server-env

#!/bin/bash
set -e
echo "Starting UE server setup..."

if [ ! -d "/game/Engine" ] || [ ! -d "/game/${PROJECT_DIR}" ]; then
  echo "ERROR: Required game files are not mounted. Please mount the game directory to /game."
  exit 1
fi

if [ ! -f "/game/${PROJECT_DIR}/${SERVER_SCRIPT}" ]; then
  echo "ERROR: Server start script (${SERVER_SCRIPT}) not found in /game/${PROJECT_DIR}/"
  exit 1
fi

# スクリプトに実行権限を付与
if [ -w "/game/${PROJECT_DIR}/${SERVER_SCRIPT}" ]; then
  chmod +x "/game/${PROJECT_DIR}/${SERVER_SCRIPT}" || true
fi

if [ ! -d "$SAVED_PATH" ]; then
  echo "Creating saves directory at $SAVED_PATH"
  mkdir -p "$SAVED_PATH"
fi

# 環境変数を使ってゲーム設定を修正(必要に応じて)
CONFIG_FILE="/game/${PROJECT_DIR}/Config/DefaultGame.ini"
if [ -f "$CONFIG_FILE" ]; then
  if grep -q "ServerName=" "$CONFIG_FILE"; then
    sed -i "s/ServerName=.*/ServerName=${SERVER_NAME}/" "$CONFIG_FILE"
  fi
  
  if grep -q "MaxPlayers=" "$CONFIG_FILE"; then
    sed -i "s/MaxPlayers=.*/MaxPlayers=${MAX_PLAYERS}/" "$CONFIG_FILE"
  fi
  
  echo "Updated server configuration with environment variables"
fi

echo "Starting UE dedicated server..."
echo "Server Name: $SERVER_NAME"
echo "Max Players: $MAX_PLAYERS"
echo "Game Port: $SERVER_PORT"
echo "Query Port: $QUERY_PORT"

# 起動オプションを構築
LAUNCH_OPTIONS="-log -port=${SERVER_PORT} -queryport=${QUERY_PORT} -NetServerMaxTickRate=${NET_SERVER_MAX_TICK_RATE:-100}"

# RCONが有効な場合
if [ "$RCON_ENABLED" = "true" ]; then
  LAUNCH_OPTIONS="$LAUNCH_OPTIONS -rconport=${RCON_PORT}"
  echo "RCON Port: $RCON_PORT"
fi

# 追加の起動オプションがあれば追加
if [ ! -z "$ADDITIONAL_ARGS" ]; then
  LAUNCH_OPTIONS="$LAUNCH_OPTIONS $ADDITIONAL_ARGS"
fi

# サーバー起動
cd "/game/${PROJECT_DIR}"
echo "Executing: ./${SERVER_SCRIPT} ${LAUNCH_OPTIONS}"
exec "./${SERVER_SCRIPT}" $LAUNCH_OPTIONS

UEで書き出した際の形に合わせ、ラップするシェルスクリプトを作成しました。バイナリのあるディレクトリをマウントすると起動するDockerコンテナイメージです。

AgonesFleet

apiVersion: "agones.dev/v1"
kind: Fleet
metadata:
  name: ue5-gameserver-fleet
  namespace: game
spec:
  replicas: 5
  template:
    metadata:
      labels:
        app: ue5-server
    spec:
      ports:
      - name: game
        containerPort: 7777
        protocol: UDP
        portPolicy: Dynamic
      - name: query
        containerPort: 27015
        protocol: UDP
        portPolicy: Dynamic
      
      health:
        disabled: false
        initialDelaySeconds: 30
        periodSeconds: 15
      
      eviction:
        safe: Always
      template:
        metadata:
          labels:
            app: ue5-server
        
        spec:
          runtimeClassName: kata
          nodeSelector:
            hardware: game-runner

          topologySpreadConstraints:
          - maxSkew: 1
            topologyKey: kubernetes.io/hostname
            whenUnsatisfiable: ScheduleAnyway
            labelSelector:
              matchLabels:
                app: ue5-server

          securityContext:
            runAsNonRoot: true
            runAsUser: 1000
            fsGroup: 1000
            seccompProfile:
              type: RuntimeDefault

          containers:
          - name: gameserver
            image: ghcr.io/mizuamedesu/ue-server-env:latest
            imagePullPolicy: Always

            env:
            - name: PROJECT_DIR
              value: ""
            - name: SERVER_SCRIPT
              value: "DOMINATIONServer.sh"
            - name: SERVER_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            - name: MAX_PLAYERS
              value: "16"
            - name: SERVER_PORT
              value: "7777"
            - name: QUERY_PORT
              value: "27015"

            securityContext:
              allowPrivilegeEscalation: false
              capabilities:
                drop: ["ALL"]
              readOnlyRootFilesystem: false

            resources:
              requests:
                memory: "16Gi"
                cpu: "2000m"
              limits:
                memory: "16Gi"
                cpu: "20000m"

            volumeMounts:
            - name: game-files
              mountPath: /game
              subPath: LinuxServer
              readOnly: false #非常によろしくないので、一旦falseにしているが、後で直すこと
          volumes:
          - name: game-files
            persistentVolumeClaim:
              claimName: ue5-game-files-s3-pvc

ここで勘のいい方なら気づくと思いますが、先ほどのUEのコンテナはPort:7777 で起動するため、これでは1インスタンスで1IPアドレスを使ってしまうのでは?と感じるかもしれません。 結論から言うとそんなことはなく、Agones側が自動でNATをしてくれるため、特に意識することなく、IP+Port(7000~8000)が返りそのまま接続できます。

また今回はruntimeClassにkataを使用していますが、普通はruncでいいと思います。

各種yamlは以下のリポジトリにあります。

https://github.com/mizuamedesu/ue-k8s

特に今回の面白ポイントはPersistentVolumeにCloudflare R2を使っています。

Agones SDKの導入

https://agones.dev/site/docs/guides/client-sdks/unreal/ https://github.com/googleforgames/agones/tree/release-1.54.0/sdks/unreal

Agonesはハートビートを行うため、最低限プラグインを有効化しておかないと死んでると思われて無限に再起動します。

ライフサイクル

Agonesはゲームインスタンスのライフサイクルを管理しているわけですが、主な状態遷移としてReady、Allocated、Shutdownがあります。

  • Ready クライアントの接続を待っている状態です。
  • Allocated クライアントが1つ以上割り当てられている状態です。
  • Shutdown インスタンスの終了中でです。

そして、ShutdownはUEのサーバー側でAPIをコールする必要があります。

ゲームモードのEvent OnLogoutをオーバーライドし、プレイヤーコントローラー数が0になったらSDKのshutdown関数を呼びます。するとAgones側が自動でPodの回収をしてくれ、新しいインスタンスが立ち、Ready状態になります。

最後に

今回の話でもっとK8s側にフォーカスした話をCloudNative Days Winter 2025の学生スカラーシップ枠で登壇しました。(動画は残り9分位からが自分です) https://event.cloudnativedays.jp/cndw2025/talks/2777

https://mizuame.works/slides/%E3%82%AA%E3%83%B3%E3%83%97%E3%83%AC%E3%81%A8GCP%E3%81%AE%E3%83%8F%E3%82%A4%E3%83%96%E3%83%AA%E3%83%83%E3%83%88%E3%81%A7k3s_k8s%E3%81%A7%20%E3%82%B2%E3%83%BC%E3%83%A0%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E3%82%92%E3%82%AA%E3%83%BC%E3%82%B1%E3%82%B9%E3%83%88%E3%83%AC%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3_20251119/

又本内容の一部は筑波大学情報特別演習でも行っています。 https://kdb.tsukuba.ac.jp/syllabi/2025/GB13312/jpn

https://mizuame.works/slides/Unreal.k8s%20%E4%B8%AD%E9%96%93%E5%A0%B1%E5%91%8A_20251015/

当方ネットワークとUnrealEngineとセキュリティとクラウドがある程度わかります。お仕事などは https://mizuame.works/ を参照の上ご連絡ください。

一覧へ戻る