電通総研 テックブログ

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

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

こんにちは、金融ソリューション事業部の孫です。
シリーズの最初の記事(Part1)では、Kubernetesの強力な機能を活用するためにEKS(Elastic Kubernetes Service)をどのように設定するかについて詳しく説明しました。

EKSの設定が成功した後、ゲームのインフラでよく使われるAgonesとOpen Matchをインストールしました。
また、公式デモでテストを行い、インストールが正しく行われたことを確認しました。

Kubernetesに基づくAgonesとOpen Matchという2つのコンポーネントについて、理解していない方がいらっしゃるかもしれません。
そのため、Part2では、まずAgonesとOpen Matchの基本的な概念を簡単に紹介します。

次に、実践でマッチングシステムのデモを作成し、どのようにAgonesとOpen Matchを組み合わせて効率的で柔軟なDedicated Serverの管理とマッチングを実現するかを示します。

Agonesの紹介

Agonesは、Google CloudとUbisoftが共同で開発されて、Ubisoft内の大規模なマルチプレイヤーオンラインゲーム(MMO)で利用されているソリューションです。
また、プログラミングがOSSで公開されている為安全に独自ネットワーク内で動作させることが可能です。

Agonesは、Kubernetesの特性を活用してゲームサーバーを効率的に、そしてスケーラブルに運用および管理する方法を提供します。
Agonesの主要なコンポーネントには、GameServer、Fleetがあります。
Agonesでは、開発者は簡単なKubernetesのコマンドを用いてGameServerを作成および管理することが可能で、これによりゲームサーバーの管理の複雑さが大幅に低減されます。

Agonesがゲームサーバーのライフサイクルを管理する中で、以下の6つのステージを定義しています。
Agonesはゲームサーバーのステージに応じて、適切な処理を実行します。

  1. Scheduled(予定):GameServerがスケジュールされ、Nodeに割り当てられる
  2. Requested(要求):KubernetesのPodが作成され、GameServerが作成される
  3. Starting(起動中):GameServerが起動し、プレイヤーがゲームに接続できる状態になる前の準備状態
  4. Ready(準備完了):GameServerがアクティブ状態で、プレイヤーが接続できる
  5. Allocated(割り当て済み):プレイヤーがGameServerに接続し、リソースが確保されている
  6. Shutdown(シャットダウン):すべてのプレイヤーが切断され、GameServerがシャットダウンする

OpenMatchの紹介

OpenMatchは、Frontend API、Backend API、Query API、Functionなど、複数のコンポーネントから成り立っています。
これらのコンポーネントはそれぞれが独自の役割を果たしながら協調して働き、マッチングシステムを構築します。
OpenMatchのマッチングフローは以下のとおりです。

  1. プレイヤーがFrontend APIにマッチングリクエストを送信する
  2. Frontend APIはそのリクエストを内部の状態でストアに保存する
  3. マッチング関数がQuery APIを使用して状態ストアから条件に合うプレイヤーを問い合わせする
  4. マッチング関数がBackend APIにマッチング結果を返す
  5. Backend APIがプレイヤーにマッチング結果を返す

OpenMatchのマッチメイカーを作成する一般的なフロー

OpenMatchのマッチメイカーを作成するには主に三つのステップがあります。

  1. マッチングルールを定義する
  2. マッチング関数を作成する
  3. マッチメイカーの設定および運用を行う

まず、マッチングルールを定義します。
このルールはマッチングロジックを反映したもので、プレイヤーのレベル、地域、スキルなどを含めます。

次に、マッチング関数を作成します。
この関数はQuery APIを使用してマッチングルールに合致するプレイヤーを問い合わせ、そのマッチング結果をBackend APIに返します。

最後に、マッチメイカーの設定と運用を行います。OpenMatchは多くの設定オプションを提供しており、それらはニーズに応じて設定できます。

OpenMatchとAgonesの統合

OpenMatchとAgonesの統合は、効率的なゲームマッチングシステムを構築する上での重要な部分であり、主に二つのプロセスが関与しています。

  • OpenMatchのマッチング関数からAgonesのGameServerを呼びだすところ
  • GameServerのライフサイクルを管理するところ

OpenMatchはプレイヤーのマッチングを担当し、一方AgonesはGameServerのライフサイクルの管理を担当します。
これら二つの組み合わせにより、プレイヤーのニーズに応じてGameServerを動的に作成および割り当てることができます。

OpenMatchのマッチング関数内で、Agones SDKを通じて新しいGameServerを作成できます。
しかし、ほとんどの場合新しいGameServerを作成するだけではなく既存のGameServerをスケジュールし、割り当てることがより重要です。
GameServerのパフォーマンス、負荷、地理的な位置などを評価し、最適なGameServerを見つける必要があります。

適切なGameServerを見つけたら、そのアドレスをプレイヤーに返します、プレイヤーはそのアドレスを使用してGame Serverに接続しゲーム体験を始めます。

ここで終わりではありませんが、GameServerの状態を監視し、必要に応じて調整する必要があります。
例えば、GameServerの負荷が高すぎる場合、新しいGameServerを作成して負荷を分散できます。
一方、GameServerのプレイヤー数が減少した場合、それをシャットダウンしてリソースを節約することも可能です。

実践:ゲームマッチングシステムのデモ作成

先に紹介したOpenMatchマッチメーカーの作成プロセスに従って、デモを作成し始めます。

マッチングルールの定義

このデモでは、ユーザーのスキルレベルとレイテンシを基にスコアを算出し、同一リージョン内でスコアが近いユーザーをマッチングするというルールを実装します。
それぞれのマッチングルームは4人のプレイヤーで構成され、スコアが近いユーザー同士は一緒になります。

以下では、このマッチングの詳細や手順、そして適用するアルゴリズムについて具体的に説明します。

  1. チケット詳細 Ticket Details
    チケットは以下図のGameFrontendによって作成され、OpenMatchのFrontendにプッシュされる情報です。
    チケットにはプレイヤーに関する情報が含まれており、マッチングの際に使用されます。
    以下「GameFrontend」、「Director」、「MatchFunction」章の実装で利用されます。
    今回のデモでは、チケット詳細には以下の要素が含まれています。
    • タグtag:タグを使ってチケットを分類することが可能で、それによりマッチングシステムはより効率的に対応するキューを見つけることができる
      • 今回はゲームモードGame Modeという設計を前提に実装するため、タグはmode.sessionとする
    • リージョンRegion:これはプレイヤーがいる地理的な地域を示しているが、今回はap-northeast-1、ap-northeast-3とする
    • スキルレベルSkill Level:これはプレイヤーのスキルレベルを示しており、0.0から2.0の範囲で設定される
    • レイテンシLatency:これはプレイヤーのネットワーク遅延を示している
      • ※ほとんどの人は0に近いですが、ネットワークの信頼性をシミュレートするために、一部の人は無限大に設定されている
  2. マッチング機能の基準 MatchFunction Criteria
    今回のデモでは、マッチングの基準を以下に定義します。
    以下「Director」、「MatchFunction」章の実装で利用されます。
    • まずはプレイヤーの地理的な地域とゲームモードを基準に、チケットプールを作成する
    • 次に、各プレイヤーに対して score = skill - (latency / 1000.0) のアルゴリズムを使用してスコアを算出する
    • そして、スコアに基づいてルームにプレイヤーを配置する
      • 高スキル、低レイテンシのユーザーは同じルームに割り当てられる
    • 1つのマッチに参加できるプレイヤーの上限は、4人と定められている
  3. ディレクタープロファイル Director Profiles
    ディレクタープロファイルはディレクターが生成するオブジェクトで、マッチのリクエストに使用されます。
    以下「Director」章の実装で利用されます。
    • 今回のデモでは、ディレクターは5秒ごとにプロファイルを生成し、マッチをリクエストする。
    • また、ディレクターはプレイヤーの地理的な地域に応じて、対応する地理的な地域のGameServerをプレイヤーに割り当てる

事前準備

  • OpenMatchのリポジトリをローカル環境にクローンする
    • ベースとなるコードは、tutorials/matchmaker101のパスに存在する
git clone https://github.com/googleforgames/open-match.git
  • Golangによる実装のため、適切なIDEを設定する
    • この記事では、Visual Studio CodeにGo plugin(v.39.0)をインストールした環境で開発を進めた
  • Docker環境を準備する
    • 対象の環境でDockerをセットアップするには、Dockerのインストールのドキュメントを参照してください

マッチング関数の作成

Openmatch公式ドキュメントのTutorialをベースに、ステップバイステップでGameFrontend、Director、MatchFunctionといったコンポーネントを設計・作成します。
※TutorialはOpenmatchのフレームワークプログラムで、マッチングロジックは上記のルールに従って独自に実装する必要があります。

以下の図は、Openmatch の全体的なアーキテクチャで、赤く囲まれた部分は独自に実装すべき部分です。

openmatch-arch

GameFrontend

  • チケット生成関数を実装する

※ベースコード: https://github.com/googleforgames/open-match/blob/main/tutorials/matchmaker101/frontend/ticket.go

「マッチングルールの定義」章で定義したチケット詳細Ticket Detailsのルールに従って、mode.sessionという名前のタグを定義して、次にランダムにスキルとレイテンシの値を設定します。

リージョンの設定については、具体的なユーザーがどこから接続するかは確定していないため、外部から値を取得するように定義します。この情報はクライアントから取得する必要があります。

# ticket.go
import(
    "open-match.dev/open-match/pkg/pb"
    // 必要なパッケージ追加
    "math/rand"  
    "time"
)

func makeTicket(region string) *pb.Ticket {
    modes := []string{"mode.session"}
    ticket := &pb.Ticket{
        SearchFields: &pb.SearchFields{
            Tags: modes,
            DoubleArgs: CreateDoubleArgs(),
            StringArgs: map[string]string{
                "region": region,
            },
        },
    }
    return ticket
}

func CreateDoubleArgs() map[string]float64 {
    rand.Seed(time.Now().UTC().UnixNano())
    skill := 2 * rand.Float64()
    latency := 50.0 * rand.ExpFloat64()

    return map[string]float64{
        "skill":   skill,
        "latency": latency,
    }
}

※ベースコード: https://github.com/googleforgames/open-match/blob/main/tutorials/matchmaker101/frontend/main.go

このAPI ServerのURLは:GET /play/:region で、regionパラメータを持っています。

# frontend/main.go
import(
    "github.com/labstack/echo"
)
type matchResponce struct {
    IP      string `json:"ip"`
    Port    string `json:"port"`
    Skill   string `json:"skill"`
    Latency string `json:"latency"`
    Region  string `json:"region"`
}
var fe pb.FrontendServiceClient
var matchRes = &matchResponce{}

func main() {
    //:(ベースコートを省略する)
    // fe = pb.NewFrontendServiceClient(conn)以降のコードをコメントアウト
    e := echo.New()
    e.GET("/play/:region", handleGetMatch)
    e.Start(":80")
}
func handleGetMatch(c echo.Context) error {
    // Create Ticket.
    region := c.Param("region")
    req := &pb.CreateTicketRequest{
        Ticket: makeTicket(region),
    }

    matchRes.Skill = fmt.Sprintf("%f", req.Ticket.SearchFields.DoubleArgs["skill"])
    matchRes.Latency = fmt.Sprintf("%f", req.Ticket.SearchFields.DoubleArgs["latency"])
    matchRes.Region = req.Ticket.SearchFields.StringArgs["region"]

    resp, err := fe.CreateTicket(context.Background(), req)
    if err != nil {
        log.Fatalf("Failed to CreateTicket, got %v", err)
        return c.JSON(http.StatusInternalServerError, matchRes)
    }

    // Polling TicketAssignment.
    deleteOnAssign(fe, resp)
    return c.JSON(http.StatusOK, matchRes)
}
func deleteOnAssign(fe pb.FrontendServiceClient, t *pb.Ticket) {
//:(ベースコートを省略する)
        if got.GetAssignment() != nil {
            log.Printf("Ticket %v got assignment %v", got.GetId(), got.GetAssignment())
            conn := got.GetAssignment().Connection
            slice := strings.Split(conn, ":")
            matchRes.IP = slice[0]
            matchRes.Port = slice[1]
            break
        }
}

Director

Match Profilesの作成

※ベースコード: https://github.com/googleforgames/open-match/blob/main/tutorials/matchmaker101/director/profile.go

「マッチングルールの定義」章で定義した マッチング機能の基準MatchFunction Criteria のチケットプール作成ルールに従って、TagPresentFilters および StringEqualsFilter を定義します。
「マッチングルールの定義」章で定義したディレクタープロファイル Director Profiles のゲームサーバー選択ルールに従って、profile.Extensions を定義します。

# profile.go
type AllocatorFilterExtension struct {
    Labels map[string]string `json:"labels"`
    Fields map[string]string `json:"fields"`
}

func generateProfiles() []*pb.MatchProfile {
    var profiles []*pb.MatchProfile

    regions := []string{"ap-northeast-1", "ap-northeast-3"}

    for _, region := range regions {
        profile := &pb.MatchProfile{
            Name: fmt.Sprintf("profile_%s", region),
            Pools: []*pb.Pool{
                {
                    Name: "pool_mode_" + region,
                    TagPresentFilters: []*pb.TagPresentFilter{
                        {Tag: "mode.session"},
                    },
                    StringEqualsFilters: []*pb.StringEqualsFilter{
                        {StringArg: "region", Value: region},
                    },
                },
            },
        }
        // build filter extensions
        filter := AllocatorFilterExtension{
            Labels: map[string]string{
                "region": region,
            },
            Fields: map[string]string{
                "status.state": "Ready",
            },
        }
        // to protobuf Struct
        labelsStruct := &structpb.Struct{Fields: make(map[string]*structpb.Value)}
        for key, value := range filter.Labels {
            labelsStruct.Fields[key] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: value}}
        }
        fieldsStruct := &structpb.Struct{Fields: make(map[string]*structpb.Value)}
        for key, value := range filter.Fields {
            fieldsStruct.Fields[key] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: value}}
        }
        // put data to the protobuf Struct
        filterStruct := &structpb.Struct{Fields: map[string]*structpb.Value{
            "labels": {Kind: &structpb.Value_StructValue{StructValue: labelsStruct}},
            "fields": {Kind: &structpb.Value_StructValue{StructValue: fieldsStruct}},
        }}
        // to google.protobuf.Any object
        filterAny, err := ptypes.MarshalAny(filterStruct)
        if err != nil {
            panic(err)
        }
        profile.Extensions = map[string]*any.Any{
            "allocator_filter": filterAny,
        }
        profiles = append(profiles, profile)
    }
    return profiles
}
Agones Allocator ServiceによるOpenMatchの統合

Agonesを統合し、マッチング結果に対応するGameServerアドレスを割り当てます。
プレイヤーはこのアドレスを通じて対応するGameServerに接続します。

Agones Allocate機能の実装は独自のものであり、OpenMatchのチュートリアルには基礎となるコードが存在しません。
そのため、directorフォルダの下に新規ファイルとしてallocator_director.goを作成します。

AgonesのAllocate機能のパッケージ化

Agonesが提供する Allocator Service を使って対応するGameServerを取得します。

  • デフォルトのクライアント証明書を取得する
    Allocator Service はmTLS認証モードを使用しており、これにより証明書を使用してサービスに接続することは必須になります。
    証明書は既にhelmインストール時に作成されています。
    独自の証明書を使用することも可能で、具体的な設定はこちらを参照してください。

  • 下記のコマンドを実行してデフォルトのクライアント証明書を取得する
    ※筆者はMacの環境でコマンドを実行しています。Linuxの環境であれば、base64 -D の代わりにbase64 -d コマンドを使用してください。

# MACのコマンド
kubectl get secret allocator-client.default -n default -ojsonpath="{.data.tls\.crt}" | base64 -D > "client.crt"

kubectl get secret allocator-client.default -n default -ojsonpath="{.data.tls\.key}" | base64 -D > "client.key"

kubectl get secret allocator-tls-ca -n agones-system -ojsonpath="{.data.tls-ca\.crt}" | base64 -D > "tls-ca.crt"
  • 取得したクライアント証明書を配置します。
    上記でダウンロードした証明書を特定のパスに保存し、Pathとして定義します。
    ここでは、 allocator/certfile ディレクトリに保存することを想定しています。
# agones_allocator.go
const (
    KeyFilePath    = "allocator/certfile/client.key"
    CertFilePath   = "allocator/certfile/client.crt"
    CaCertFilePath = "allocator/certfile/tls-ca.crt"
)
  • Allocator Serviceのクライアントを作成する
    外部からAgonesのAllocate機能を呼びだす必要がある場合、NewAgonesAllocatorClient を呼びだすとクライアントが生成されます。
    ※ご注意: コードが長くなりすぎないように、エラー処理に関連するコードは削除しています。
# agones_allocator.go
type AgonesAllocatorClientConfig struct {
    KeyFile              string
    CertFile             string
    CaCertFile           string
    AllocatorServiceHost string
    AllocatorServicePort int
    Namespace            string
    MultiCluster         bool
}

type AgonesAllocatorClient struct {
    Config   *AgonesAllocatorClientConfig
    DialOpts grpc.DialOption
}

func NewAgonesAllocatorClient() (*AgonesAllocatorClient, error) {
    config := &AgonesAllocatorClientConfig{
        KeyFile:              KeyFilePath,
        CertFile:             CertFilePath,
        CaCertFile:           CaCertFilePath,
        AllocatorServiceHost: AllocatorServiceHost,
        AllocatorServicePort: AllocatorServicePort,
        Namespace:            "default",
        MultiCluster:         false,
    }

    cert, err = ioutil.ReadFile(config.CertFile)
    key, err = ioutil.ReadFile(config.KeyFile)
    ca, err = ioutil.ReadFile(config.CaCertFile)

    dialOpts, err := createRemoteClusterDialOption(cert, key, ca)

    return &AgonesAllocatorClient{
        Config:   config,
        DialOpts: dialOpts,
    }, nil
}

func createRemoteClusterDialOption(clientCert, clientKey, caCert []byte) (grpc.DialOption, error) {
    cert, err := tls.X509KeyPair(clientCert, clientKey)

    tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}, InsecureSkipVerify: true}
    if len(caCert) != 0 {
        tlsConfig.RootCAs = x509.NewCertPool()
        if !tlsConfig.RootCAs.AppendCertsFromPEM(caCert) {
            return nil, errors.New("only PEM format is accepted for server CA")
        }
    }

    return grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), nil
}
  • Allocateのメイン関数を実装する

この関数では、まずgrpc( grpc.Dialプロトコルを使用してAllocator Service に接続します。
次に、GameServerの選択ルール(assignmentGroup.Assignment.Extensions)を取得します。

このルールに基づいて対応するGameServerを取得し、最終的に各Assignmentの address フィールドにアドレスを付与します。
※ご注意: コードが長くなりすぎないように、エラー処理に関連するコードは削除しています。

# agones_allocator.go
func (c *AgonesAllocatorClient) Allocate(req *pb.AssignTicketsRequest) error {

    conn, err := grpc.Dial(fmt.Sprintf("%s:%d", c.Config.AllocatorServiceHost, c.Config.AllocatorServicePort), c.DialOpts)
    defer conn.Close()

    grpcClient := pb_agones.NewAllocationServiceClient(conn)

    for _, assignmentGroup := range req.Assignments {
        filterAny := assignmentGroup.Assignment.Extensions["allocator_filter"]
        filter := &structpb.Struct{}
        if err := ptypes.UnmarshalAny(filterAny, filter); err != nil {
            panic(err)
        }

        request := &pb_agones.AllocationRequest{
            Namespace: c.Config.Namespace,
            GameServerSelectors: []*pb_agones.GameServerSelector{
                {
                    MatchLabels: filter.Fields["labels"],
                },
            },
            MultiClusterSetting: &pb_agones.MultiClusterSetting{
                Enabled: c.Config.MultiCluster,
            },
        }

        resp, err := grpcClient.Allocate(context.Background(), request)

        if len(resp.GetPorts()) > 0 {
            address := fmt.Sprintf("%s:%d", resp.Address, resp.Ports[0].Port)
            assignmentGroup.Assignment.Connection = address
        }
    }

    return nil
}
Allocator ServiceパッケージをOpenMatchに統合

上記のOpenMatchのアーキテクチャ図に基づき、Agonesサービスを呼び出してGameServerを取得するコンポーネントはDirectorです。
そのため、呼び出しコードをDirectorに統合する必要があります。
※ベースコード: https://github.com/suecideTech/try-openmatch-agones/blob/master/OpenMatch/mod_matchmaker101/director/main.go

GameServerのassgin関数を修正します。
ここでは、上記で作成した Allocator Service パッケージを使用して実際のGameServerアドレスを取得します。
元のコードでは GameServerAllocationsを使ってGameServerアドレスを取得していますが、これはAgonesが推奨している方法ではなく、また後期の拡張にも適していません。
そのため、公式に推奨されている Allocator Service をラップしてGameServerを取得するようにしました。

# director/main.go
func assign(be pb.BackendServiceClient, matches []*pb.Match) error {
    for _, match := range matches {
        ticketIDs := []string{}
        for _, t := range match.GetTickets() {
            ticketIDs = append(ticketIDs, t.Id)
        }

        aloReq := &pb.AssignTicketsRequest{
            Assignments: []*pb.AssignmentGroup{
                {
                    TicketIds: ticketIDs,
                    Assignment: &pb.Assignment{
                        Extensions: match.Extensions,
                    },
                },
            },
        }
        client, err := allocator.NewAgonesAllocatorClient()
        client.Allocate(aloReq)
        if _, err := be.AssignTickets(context.Background(), aloReq); err != nil {
            return fmt.Errorf("AssignTickets failed for match %v, got %w", match.GetMatchId(), err)
        }

        log.Printf("Assigned server %v to match %v", conn, match.GetMatchId())
    }    
    return nil
}

MatchFunction

ユーザーマッチングルールを実装します。
※ベースコード: https://github.com/suecideTech/try-openmatch-agones/blob/master/OpenMatch/mod_matchmaker101/matchfunction/mmf/matchfunction.go

「マッチングルールの定義」章で定義した マッチング機能の基準MatchFunction Criteria のユーザーマッチングルールに従って、まずユーザーのスコアを計算し、スコアの大きさに基づいて4人部屋を割り当てます。

# matchfunction.go
const (
    matchName              = "basic-matchfunction"
    ticketsPerPoolPerMatch = 4
)

func (s *MatchFunctionService) Run(req *pb.RunRequest, stream pb.MatchFunction_RunServer) error {
//:(ベースコートを省略する)

//poolTickets, err := matchfunction.QueryPools(stream.Context(), s.queryServiceClient, req.GetProfile().GetPools())
    p := req.GetProfile()
tickets, err := matchfunction.QueryPool(stream.Context(), s.queryServiceClient, p.GetPools()[0])

//:(ベースコートを省略する)

idPrefix := fmt.Sprintf("profile-%v-time-%v", p.GetName(), time.Now().Format("2006-01-02T15:04:05.00"))
proposals, err := makeMatches(req.GetProfile(), idPrefix, tickets)

//:(ベースコートを省略する)
}

func (s *MatchFunctionService) makeMatches(ticketsPerPoolPerMatch int, profile *pb.MatchProfile, idPrefix string, tickets []*pb.Ticket) ([]*pb.Match, error) {
    if len(tickets) < ticketsPerPoolPerMatch {
        return nil, nil
    }

    ticketScores := make(map[string]float64) 
    for _, ticket := range tickets {
        ticketScores[ticket.Id] = score(ticket.SearchFields.DoubleArgs["skill"], ticket.SearchFields.DoubleArgs["latency"])
    }
    sort.Slice(tickets, func(i, j int) bool {
        return ticketScores[tickets[i].Id] > ticketScores[tickets[j].Id]
    })

    var matches []*pb.Match
    count := 0
    for len(tickets) >= ticketsPerPoolPerMatch {
        matchTickets := tickets[:ticketsPerPoolPerMatch]
        tickets = tickets[ticketsPerPoolPerMatch:]

        var matchScore float64
        for _, ticket := range matchTickets {
            matchScore += ticketScores[ticket.Id]
        }

        eval, err := anypb.New(&pb.DefaultEvaluationCriteria{Score: matchScore})
        if err != nil {
            log.Printf("Failed to marshal DefaultEvaluationCriteria into anypb: %v", err)
            return nil, fmt.Errorf("Failed to marshal DefaultEvaluationCriteria into anypb: %w", err)
        }

        newExtensions := map[string]*anypb.Any{"evaluation_input": eval}

        newExtensions
        for k, v := range origExtensions {
            newExtensions[k] = v
        }
        matches = append(matches, &pb.Match{
            MatchId:       fmt.Sprintf("%s-%d", idPrefix, count),
            MatchProfile:  profile.GetName(),
            MatchFunction: matchName,
            Tickets:       matchTickets,
            Extensions:    newExtensions,
        })
        count++
    }
    return matches, nil
}

func score(skill, latency float64) float64 {
    return skill - (latency / 1000.0)
}

ここまでで、マッチング機能とGameServerのケジューリング機能の実装が完了しました。
次に、作成したモジュールをそれぞれEKSにアップロードし、デモとしてテストします。
具体的に完成したデモのアーキテクチャは、以下の図の通りです。

デプロイと動作確認

ローカルにおいてDockerImageのコンパイル

  • GameFrontend
# OpenMatch/mod_matchmaker101/frontend/Dockerfile
docker build -t localimage/mod_frontend:0.1 .
  • Director
# OpenMatch/mod_matchmaker101/director/Dockerfile
docker build -t localimage/mod_director:0.1 .
  • MatchFunction
# OpenMatch/mod_matchmaker101/matchfunction/Dockerfile
docker build -t localimage/mod_matchfunction:0.1 .

DockerImageをAmazon ECRにアップロード

EKSでDockerImageを取得する際、ローカルのイメージにアクセスできないため、イメージをAmazon ECRサービスにアップロードする必要があります。
Amazon ECRはイメージを保管するための専用レポジトリで、Docker Hubなどと同様のサービスがあります。

Amazon ECRでプライベートイメージリポジトリを作成する具体的な方法については、ECRでプライベートリポジトリを作成するを参照してください。

# Frontend
docker tag localimage/mod_frontend:0.1 {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_frontend:0.1
docker push {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_frontend:0.1

# Director
docker tag localimage/mod_director:0.1 {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_director:0.1
docker push {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_director:0.1

# MatchFunction:
docker tag localimage/mod_matchfunction:0.1 {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_matchfunction:0.1
docker push {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_matchfunction:0.1

モジュールをEKSにデプロイ

デプロイyamlファイル内のimageアドレスを、上記で作成したAmazon ECRのアドレスに変更します。

# Frontend: 
## yaml file path 
## OpenMatch/mod_matchmaker101/frontend/frontend.yaml
image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_frontend:0.1

# Director
## yaml file path 
## OpenMatch/mod_matchmaker101/director/director.yaml
image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_director:0.1

# MatchFunction:
## yaml file path 
## OpenMatch/mod_matchmaker101/matchfunction/matchfunction.yaml
image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_matchfunction:0.1

Yamlファイルを以下のように修正します。

# Frontend.yaml
## yaml file path 
## OpenMatch/mod_matchmaker101/frontend/frontend.yaml
## KindをDeploymentに変更し、HTTP LBサービスを追加します
apiVersion: v1
kind: Service
metadata:
  name: frontend-endpoint
  annotations:
    service.alpha.kubernetes.io/app-protocols: '{"http":"HTTP"}'
  labels:
    app: frontend
spec:
  type: NodePort
  selector:
    app: frontend
  ports:
  - port: 80
    protocol: TCP
    name: http
    targetPort: frontend
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: frontend
  namespace: default
  labels:
    app: frontend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: frontend
  template:
    metadata:
      labels:
        app: frontend
    spec:
      containers:
      - name: frontend
        image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_frontend:0.1
        imagePullPolicy: Always
        ports:
        - name: frontend
          containerPort: 80
        
# Director.yaml
## yaml file path 
## OpenMatch/mod_matchmaker101/director/director.yaml
apiVersion: v1
kind: Pod
metadata:
  name: director
  namespace: openmatch-poc
spec:
  containers:
  - name: director
    image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_director:0.1
    imagePullPolicy: Always
  hostname: director

# MatchFunction.yaml
## yaml file path 
## OpenMatch/mod_matchmaker101/matchfunction/matchfunction.yaml
apiVersion: v1
kind: Pod
metadata:
  name: matchfunction
  namespace: openmatch-poc
  labels:
    app: openmatch
    component: matchfunction
spec:
  containers:
  - name: matchfunction
    image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_MatchFunction:0.1
    imagePullPolicy: Always
    ports:
    - name: grpc
      containerPort: 50502
---
kind: Service
apiVersion: v1
metadata:
  name: matchfunction
  namespace: openmatch-poc
  labels:
    app: openmatch
    component: matchfunction
spec:
  selector:
    app: openmatch
    component: matchfunction
  clusterIP: None
  type: ClusterIP
  ports:
  - name: grpc
    protocol: TCP
    port: 50502

それぞれのyamlファイルをEKSに適用し、マッチングシステムをデプロイします。

# GameFrontend
kubectl apply -f frontend.yaml
# Director
kubectl apply -f director.yaml
# MatchFunction
kubectl apply -f matchfunction.yaml

動作確認

以下の図に示すように、frontend.yaml で作成されたServiceに接続し、マッチングシステムをテストします。
このfrontend-endpoint サービスにローカルでもアクセスできるようにするため、KubernetesのPortForwadering機能を使用します。

下図の⑤エリアです。

# Frontend サービスをローカルの8081ポートにマッピングします
kubectl port-forward services/frontend-ednpoint 8081:80
  • 8つの新しいターミナルを作成して、8名のプレイヤーがFrontendサービスに接続するのをシミュレートする

下図の①エリアで4名(ap-northeast-1)+4名(ap-northeast-3)のプレイヤーが接続するのをシミュレートします。

# Get: /Frontend/play/regionname
## 4名(ap-northeast-1)
curl 127.0.0.1:8081/play/ap-northeast-1
## 4名(ap-northeast-3)
curl 127.0.0.1:8081/play/ap-northeast-3
  • エラーの有無を確認するために、matchfunction/director/frontendモジュールのログ情報を出力する

下図の②〜④エリアです。

# matchfuntion log
kubectl logs --tail 4 matchfunction -n openmatch-poc
# director log
kubectl logs --tail 4 director -n openmatch-poc
# frontend log
kubectl logs --tail 4 deployments/frontend 

上記の手順に従って、8名のプレイヤーがマッチングを開始するシミュレーションを行います。

確認ポイントは次の通りです。

  • ②〜④エリアのログにはエラー出力がありません。
  • ①の8名のクライアント全員がIP、Port情報を正常に取得します。
    • また、前の4名のプレイヤーは同じグループにマッチされるため、そのIP:Port アドレスは同じです。
    • 後の4名のプレイヤーも同じグループにマッチされ、その IP:Port アドレスも同じです。
  • ⑥エリアでは、GameServerのステータスを確認し、2台のサーバーがAllocated状態にあることを確認します。

終わりに

これまでに、AgonesとOpen Matchを使用して高可用性と拡張性、スケジューリングが可能なマッチングシステムを構築しました。

次に、Part3ではUnrealEngineを使用してGameClientを開発し、このマッチングシステムに接続する方法を説明します。
これにより、マッチング機能を持ち、Agonesを使用してDedicated Serverをスケジューリングするマルチプレイゲームの開発を完了します。

引き続き、お楽しみにしてください!

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

参考

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