電通総研 テックブログ

電通総研が運営する技術ブログ

Kubernetesを使ってUnrealEngine Dedicated Serverをスケーラブルに運用する【Part3】

こんにちは、金融ソリューション事業部の孫です。

前回のPart1記事に続きましてPart2では、OpenMatchとAgonesを使用して、柔軟性がありスケーラブルなゲームマッチングとゲームサーバー管理システムの構築方法を詳しく説明しました。

この記事(Part3)では、UnrealEngineを利用して、オンラインマルチプレーヤーゲームのデモを完成させます。
さらに、Part2で開発したマッチメイキングサービスをUEクライアント(UnrealEngine GameClient)に統合します。

全体アーキテクチャは以下の図の通りです。

demo-arch

UEクライアントの実装について、前の記事で作成したデモを基にさらに拡張します。
プレーヤーマッチング機能とサーバー管理機能を追加することで、この度のクライアントの実現を目指します。

実施手順

以下の順序でシステムを構築します:

  1. 前記事のデモにマッチング機能の追加
  2. Agones SDKを呼び出してGameServerの終了機能の実装
  3. パッケージ化したUEサーバーでAgones Fleetの作成
  4. 動作確認

1.前記事のデモにマッチング機能の追加

この前の記事UE5ネットワーク同期のC++実装例では、プレーヤーが「Play」ボタンをクリックすると、UEゲームサーバーに接続してゲーム体験を開始する機能をすでに完成させています。
次に、マッチング機能のボタンを追加し、このボタンをクリックするとOpenMatchの Frontend API を呼び出してマッチング要求をリクエストします。

マッチング機能のボタンの追加

以下の図のように、テキストエリア(Region入力用)とボタン(「Match」)をUnrealEngineのBluePrint Widget UIに配置します。

rebuild_ui

Frontend APIの呼びだす機能実装

  • 以下のモジュールをxxx.Build.csファイルに追加する
    今回では、Agones /HTTP/ Json /JsonUtilities の4つのモジュールを使用します。
    各モジュールは次のような役割を果たします。
    • Agones モジュールは Agones の SDK を使うために使用する
    • HTTP モジュールは Frontend API の http リクエストを送信するために使用する
    • JsonJsonUtilitiesモジュールは http からの返却Jsonデータを解析するために使用する
//xxx.Build.cs
using UnrealBuildTool;
//:(省略)

    PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay", "EnhancedInput", "Agones", "HTTP", "Json", "JsonUtilities" });

//:(省略)
  • LoginHUDWidget.hを編集する

まずは、配置したWidgetsをバインドすることから始めます。
※注意:BluePrint内のWidget名は LoginHUDWidget.h ファイル内の変数名と同じでなければならず、meta = (BindWidget) タグを追加してWidgetをバインドします

それを完了したら、httpモジュールをincludeした上でコールバック関数を作成します。

//LoginHUDWidget.h
// "Http.h"ヘッダーファイルの追加
#include "Http.h"

 UCLASS()
class TEST_DESERVER_API ULoginHUDWidget : public UUserWidget
{
    GENERATED_BODY()
public: 
//:(省略)
    UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget))
        class UEditableText* regionName;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget))
        class UTextBlock* matchLabel;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget))
        class UButton* matchBtn;

private:
    FHttpModule* Http;
void OnFetchGameServerResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful);
}
  • LoginHUDWidget.cppにマッチングボタンのクリックロジックを実装する
//LoginHUDWidget.cpp
// "Json.h", "JsonUtilities.h"ヘッダーファイルの追加
#include "Json.h"
#include "JsonUtilities.h"

// 構造関数内で関連コントロールとHttpモジュールを初期化します
// MatchボタンにOnMatchmakingButtonClickedトリガー関数をバインドします
void ULoginHUDWidget::NativePreConstruct()
{
    Super::NativePreConstruct();
//:(省略)
    matchLabel->SetText(FText::FromString("Match"));
    FScriptDelegate MatchmakingDelegate;
    MatchmakingDelegate.BindUFunction(this, "OnMatchmakingButtonClicked");
    matchBtn->OnClicked.Add(MatchmakingDelegate);
//:(省略)
    Http = &FHttpModule::Get();
}

// OnMatchmakingButtonClickedトリガー関数の処理ロジックを追加します
void ULoginHUDWidget::OnMatchmakingButtonClicked()
{
    statusLabel->SetText(FText::FromString("Start Matching!! Please wait"));

    TSharedRef<IHttpRequest, ESPMode::ThreadSafe> FetchGameServerHttpRequest = Http->CreateRequest();

    FString frontendUrl = "127.0.0.1:8081/play/" + FString(regionName->GetText().ToString());

    FetchGameServerHttpRequest->SetVerb("GET");
    FetchGameServerHttpRequest->SetURL(frontendUrl);
    FetchGameServerHttpRequest->SetHeader("Content-Type", "application/json");
    FetchGameServerHttpRequest->OnProcessRequestComplete().BindUObject(this, &ULoginHUDWidget::OnFetchGameServerResponse);
    FetchGameServerHttpRequest->ProcessRequest();
};

// Httpのコールバック関数の処理ロジックを追加します
void ULoginHUDWidget::OnFetchGameServerResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful)
{
    if (bWasSuccessful) {
        TSharedPtr<FJsonObject> JsonObject;
        TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Response->GetContentAsString());
        if (FJsonSerializer::Deserialize(Reader, JsonObject)) {
            FString IpAddress = JsonObject->GetStringField("ip");
            FString Port = JsonObject->GetStringField("port");
            FString LevelName = IpAddress + ":" + Port;
            statusLabel->SetText(FText::FromString(LevelName));
            playBtn->SetVisibility(ESlateVisibility::Visible);
        }
    }
}

// OnPlayGameButtonClickedトリガー関数の処理ロジックを変更します
// OpenMatchから返されたGameServerアドレスを接続ターゲットとして設定します
void ULoginHUDWidget::OnPlayGameButtonClicked()
{
//:(省略)
    FString LevelName = statusLabel->GetText().ToString();
//:(省略)
}

2. Agones SDKを呼び出してGameServerの終了機能の実装

すべてのプレイヤーがオフラインになった場合、AgonesSDKを呼び出して利用済のGameServerを削除した後、新しいGameServerを再作成します。

ユーザー数の管理機能の追加

UnrealEngine Gamemodeクラスにおいて PlayerNum 変数を追加して現在のプレーヤー数を保存します。
また、少なくとも1人のプレーヤーがこのGameServerに接続したことがあることを判断するため、HasPlayerConnected のBool変数を追加します。
前の記事で、UnrealEngineによく使われるサーバー側の関数について既にいくつか紹介しました。
今回はその中のPostLoginLogout 関数を用いることで、プレーヤー数の簡易的な統計データが取得します。

//xxxGameMode.h 
public:
//:(省略)
    virtual void PostLogin(APlayerController* NewPlayer) override
    virtual void Logout(AController* Exiting) override

private:
//:(省略)
    int32 PlayerNum;
    bool HasPlayerConnected;

//xxxGameMode.cpp
// 構造関数で変数を初期化します
xxxGameMode::xxxGameMode(){

//:(省略)
    int32 PlayerNum = 0;
    bool HasPlayerConnected = false;
}

void xxxGameMode::PostLogin(APlayerController* NewPlayer)
{
    Super::PostLogin(NewPlayer);
    PlayerNum++;
    HasPlayerConnected = true;
    if (!HasPlayerConnected)
    {
        HasPlayerConnected = true;
GetWorldTimerManager().SetTimer(CountDownPlayerNumHandle, this, &xxxGameMode::TickCount, 1.0f, true);
    }    
}

void xxxGameMode::Logout(AController* Exiting)
{
    Super::Logout(Exiting);
    PlayerNum--;
}

Agones SDKを利用してGameServerの終了実装

UnrealEngine Gamemodeクラスにおいて、Agones SDKのShutdown()関数を呼び出してGameServerがシャットダウンされます。
※Agonesは一定量のGameServerを維持し、GameServerが閉じられると新たなGameServerの生成がトリガーされます。

//xxxGameMode.h

public:
//:(省略)
    virtual void Tick(float DeltaTime) override
    
private:
//:(省略)
    //Agones SDKの処理結果に対応するレスポンス関数
    //成功処理後のレスポンス関数
    void HandleShutdownSuccess(const FEmptyResponse& Response);
    //成功失敗時のレスポンス関数
    void HandleShutdownError(const FAgonesError& Error);
    void TickCount();
    
//xxxGameMode.cpp
void Atest_DEServerGameMode::HandleShutdownSuccess(const FEmptyResponse& Response)
{
  //デモのため、実際の処理を行いません
    UE_LOG(LogTemp, Log, TEXT("Game server successfully shutdown"));
}

void Atest_DEServerGameMode::HandleShutdownError(const FAgonesError& Error)
{
    //デモのため、実際の処理を行いません
    UE_LOG(LogTemp, Error, TEXT("shutting down failed: %s"), *Error.ErrorMessage);
}
void xxxGameMode::TickCount()
{
    Super::Tick(DeltaTime);

    if (HasPlayerConnected && PlayerNum <= 0)
    {
        FShutdownDelegate SuccessDelegate;
        SuccessDelegate.BindUFunction(this, FName("HandleShutdownSuccess"));

        FAgonesErrorDelegate ErrorDelegate;
        ErrorDelegate.BindUFunction(this, FName("HandleShutdownError"));
        GetWorldTimerManager().ClearTimer(CountDownPlayerNumHandle);

        AgonesSDK->Shutdown(SuccessDelegate, ErrorDelegate);
    }
}

3.パッケージ化したUEサーバーでAgones Fleetの作成

UnrealEngine Editorを使用して、Linuxプラットフォーム向けのDedicated Serverをパッケージ化します。

以下の図のように、ターゲットプラットフォームLinux-> Development -> Linux(server)を選択し、パッケージします。
Windows OSではもしかするとターゲットプラットフォームの選択肢に Linux が存在しないかもしれません。
これは、Linuxプラットフォームのサポートが設定されていないためです。
具体的な設定方法については、こちらを参照してください。

package_linux

パッケージ化が完了したら、Part2で各モジュールをデプロイしたのと同じ手順で、EKSにGameServerをデプロイします。

下記のDockerfileをパッケージ化の際に指定された保存先のルートディレクトリに配置し、Dockerイメージ作成コマンドを実行します。

## DockerFile
## 「AgonesOMServer」を実際のプロジェクト名に置き換えます
FROM ubuntu:20.04

RUN apt-get update && apt-get install -y \
    libxcursor1 \
    libxrandr2 \
    libxinerama1 \
    libxi6 \
    libgl1-mesa-glx \
    && rm -rf /var/lib/apt/lists/*
    
RUN addgroup --gid 1000 gameserver && \
    adduser --gid 1000 --uid 1000 --shell /usr/sbin/nologin --home /home/gameserver --gecos "" --disabled-login --disabled-password gameserver

COPY --chown=gameserver:gameserver ./LinuxServer /home/gameserver/LinuxServer
RUN chmod -R 770 /home/gameserver/LinuxServer

WORKDIR /home/gameserver/LinuxServer

USER gameserver

EXPOSE 7777/udp

ENTRYPOINT ["/home/gameserver/LinuxServer/「AgonesOMServer」.sh"]

## Docker image build command
$ docker build -t localimage/ue_server:0.1 .
  • DockerImageをAmazon ECRにアップロードする

Part2と同様に、ue_serverという名前のAmazon ECRプライベートリポジトリを新規作成します。
次に、DockerイメージをAmazon ECRにアップロードします。

$ docker tag localimage/ue_server:0.1 {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/ue_server:0.1
$ docker push {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/ue_server:0.1
  • Amazon EKSにおいてAgones Fleetを作成する

ローカルでAgones Fleetの作成用のfleet.yamlファイルを作成します。

## fleet.yaml
---
apiVersion: "agones.dev/v1"
kind: Fleet
metadata:
  name: fleet-ap-northeast-1
spec:
  replicas: 2
  scheduling: Packed
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
  template:
    metadata:
      namespace: meta-poc
      labels:
        region: ap-northeast-1
    spec:
      players:
        initialCapacity: 4
      ports:
        - name: default
          containerPort: 7654
      health:
        initialDelaySeconds: 30
        periodSeconds: 60
      template:
        metadata:
          namespace: meta-poc
          labels:
            region: ap-northeast-1
        spec:
          containers:
            - name: ue5-server
              image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/ue_server:0.1
              resources:
                requests:
                  memory: "512Mi"
                  cpu: "500m"
                limits:
                  memory: "1Gi"
                  cpu: "1"

fleet.yamlファイルをAmazon EKSに適用します。

$ kubectl apply -f fleet.yaml

4.動作確認

  • UnrealEngine Editorを使用して4つのクライアントを起動する
    • 具体的な操作手順は、以下の図のとおりNew Editor Window(PIE) -> Number Of Players: 4 -> Play Standaloneを設定する

start4Client

  • 以下のコマンドでAgones GameServerのステータス監視を起動する
$ watch kubectl get gs
# OpenMatch Frontend サービスがローカルの8081ポートにマッピングされます
kubectl port-forward services/frontend-ednpoint 8081:80
  • マッチング機能をテストする
    • 確認ポイント:
      • 4つのクライアントが同じGameServerアドレスにマッチングされていること
      • GameServerのステータスがAllocatedになっていること

match-img

ue-openmatch-demo-match-1

  • DedicatedServerへの接続をテストする
    • 確認ポイント:
      • Playボタンをクリックした後、Dedicated Serverに成功したこと
      • Characterの頭上に表示されているNickNameが、クライアントが入力したNickNameと同じであること

ue-openmatch-demo-play

  • AgonesによるGameServerのシャットダウンをテストする
    • 確認ポイント:
      • 全てのGameClientを閉じた後、以前Allocated状態だったサーバーが削除されること
      • その代わりに新たにReady状態のGameServerが作成されること

logout-1
logout-2

ue-openmatch-demo-logout-1

終わりに

これで、マッチング機能を備え、AgonesでUnreal Engine DedicatedServerをスケジューリングするオンラインマルチプレイヤーゲームの作成が完了しました。

この一連の記事では、EKSを使ってKubernetesの特性を活用し、Unreal Engine のDedicated Serverを管理する方法について深く探求しました。

その中で、マッチングシステムとしてOpenMatchを使用し、その強力な拡張性と設定性を活用してニーズに合ったマッチングルールをカスタマイズしました。同時に、Agonesを用いてゲームサーバーのスケジューリングを行い、効率的で安定したサーバー管理を実現しました。

次は、さらなるゲームに関連するインフラ設計のソリューションを見つけ出し、ゲーム体験をさらに向上させる方法を継続に探求します。

現在ISIDはweb3領域のグループ横断組織を立ち上げ、Web3およびメタバース領域のR&Dを行っております(カテゴリー「3DCG」の記事はこちら)。
もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください!
私たちと同じチームで働いてくれる仲間を、是非お待ちしております!
ISID採用ページ(Web3/メタバース/AI)

参考

執筆:@chen.sun、レビュー:@yamashita.yuki
Shodoで執筆されました