こんにちは、金融ソリューション事業部の孫です。
シリーズの最初の記事(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はゲームサーバーのステージに応じて、適切な処理を実行します。
Scheduled(予定):GameServerがスケジュールされ、Nodeに割り当てられるRequested(要求):KubernetesのPodが作成され、GameServerが作成されるStarting(起動中):GameServerが起動し、プレイヤーがゲームに接続できる状態になる前の準備状態Ready(準備完了):GameServerがアクティブ状態で、プレイヤーが接続できるAllocated(割り当て済み):プレイヤーがGameServerに接続し、リソースが確保されているShutdown(シャットダウン):すべてのプレイヤーが切断され、GameServerがシャットダウンする
OpenMatchの紹介
OpenMatchは、Frontend API、Backend API、Query API、Functionなど、複数のコンポーネントから成り立っています。
これらのコンポーネントはそれぞれが独自の役割を果たしながら協調して働き、マッチングシステムを構築します。
OpenMatchのマッチングフローは以下のとおりです。
- プレイヤーがFrontend APIにマッチングリクエストを送信する
- Frontend APIはそのリクエストを内部の状態でストアに保存する
- マッチング関数がQuery APIを使用して状態ストアから条件に合うプレイヤーを問い合わせする
- マッチング関数がBackend APIにマッチング結果を返す
- Backend APIがプレイヤーにマッチング結果を返す
OpenMatchのマッチメイカーを作成する一般的なフロー
OpenMatchのマッチメイカーを作成するには主に三つのステップがあります。
- マッチングルールを定義する
- マッチング関数を作成する
- マッチメイカーの設定および運用を行う
まず、マッチングルールを定義します。
このルールはマッチングロジックを反映したもので、プレイヤーのレベル、地域、スキルなどを含めます。
次に、マッチング関数を作成します。
この関数は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人のプレイヤーで構成され、スコアが近いユーザー同士は一緒になります。
以下では、このマッチングの詳細や手順、そして適用するアルゴリズムについて具体的に説明します。
- チケット詳細
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に近いですが、ネットワークの信頼性をシミュレートするために、一部の人は無限大に設定されている
- タグ
- マッチング機能の基準
MatchFunction Criteria
今回のデモでは、マッチングの基準を以下に定義します。
以下「Director」、「MatchFunction」章の実装で利用されます。- まずはプレイヤーの地理的な地域とゲームモードを基準に、チケットプールを作成する
- 次に、各プレイヤーに対して score = skill - (latency / 1000.0) のアルゴリズムを使用してスコアを算出する
- そして、スコアに基づいてルームにプレイヤーを配置する
- 高スキル、低レイテンシのユーザーは同じルームに割り当てられる
- 1つのマッチに参加できるプレイヤーの上限は、4人と定められている
- ディレクタープロファイル
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 の全体的なアーキテクチャで、赤く囲まれた部分は独自に実装すべき部分です。

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,
}
}
- echoフレームワークを使用してFrontendのAPIServerを実装する
※ベースコード: 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でプライベートリポジトリを作成するを参照してください。
ローカルのイメージをAmazon 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サービスをローカルにマッピングする
下図の⑤エリアです。
# 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アドレスも同じです。
- また、前の4名のプレイヤーは同じグループにマッチされるため、その
- ⑥エリアでは、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で執筆されました)



