この記事はGoogle Cloud + Gaming Advent Calendar 2020 13日目の記事です。
Googleからゲームのマッチング用フレームワークであるOpen Matchがリリースされた。このOpen MatchはKubernetes環境で動作することで現代のCloud Nativeな環境に適した構成となっている。

しかし、この現代的な構成は困った点もある。Cloud Native的な実運用を見据えた構成は開発の初期段階においては必要なく、むしろ複雑すぎてゲームの本質的ではない部分でつまづいてしまう可能性がある。
Open Matchはゲームのマッチングを作るための仕組みなので、ゲーム開発者はまずローカル環境で試しながら開発をしたくなる。では早速と、Getting Startedを読むといきなりKubernetesクラスターが必要になり、kubectlのコマンドが多数登場し、ゲームのロジックとは無関係のものまで準備しないといけない。Kubernetesの扱いに慣れているインフラ寄りの開発者であればともかく、普段ゲームを作っている開発者からするとハードルが高い。
そこで、本記事ではゲーム開発者の視点で「Open Matchを使った実装をローカル環境で(できる限り楽に)開発する方法」を提案する。
マッチングを決定する主要な部分はMatch Function (MMF)という部分になる。Match Functionはその名の通り関数的な概念で、複数のTicket(プレイヤーのマッチング要求)を入力としてそこから適切なMatchを作成する。

ということは、Match Functionのみに着目すると入力となるTicket、出力となるMatchが検証できればよいので、Kubernetesは必要ない。
もう少し実装に踏み込むと、Match FunctionはgRPCサーバーになっている。RunRequest というリクエストを受け取る形だが、RunRequest はただのトリガーでしかなくて、入力となるTicketはOpen Match内部のQuery ServiceというgRPCサーバーから取得する必要がある。図にすると以下のようになる。

具体的なコードに書き起こしてみるとMatch Functionは次のようになる。
import ("fmt""open-match.dev/open-match/pkg/matchfunction""open-match.dev/open-match/pkg/pb")type matchFunctionServicestruct {// Query Service にアクセスするためのgRPCクライアント qsc pb.QueryServiceClient}func (s *matchFunctionService) Run(request *pb.RunRequest, stream pb.MatchFunction_RunServer)error {// Query ServiceからTicketを取得する poolTickets, err := matchfunction.QueryPools(stream.Context(), s.qsc, request.Profile.Pools)if err !=nil {return fmt.Errorf("failed to query pools: %+v", err) } matches, err := makeMatches(poolTickets, request.Profile)if err !=nil {return fmt.Errorf("failed to make matches: %+v", err) }for _, match :=range matches {if err := stream.Send(&pb.RunResponse{Proposal: match}); err !=nil {return fmt.Errorf("failed to send proposal: %+v", err) } }returnnil}func makeMatches(poolTicketsmap[string][]*pb.Ticket, profile *pb.MatchProfile) ([]*pb.Match,error) {// makeMatches はゲームに合わせて自由に実装する! ...}
コードのコメントにも書いたとおり、ゲームに合わせてロジックが記述されるのはTicketを受け取ってMatchを返すmakeMatches(...) の部分だけ。よってマッチングのロジックだけをテストしたいのであれば、makeMatchesの単体テストを書けばよい。gRPCサーバーは必要ない。
具体的なテストコードを書くと、次のようになる。ここではKubernetesの初期化もgRPCサーバーの初期化も必要ない。ただgo test を打つだけでテスト可能だ。
import ("testing""github.com/stretchr/testify/assert""open-match.dev/open-match/pkg/pb")// 2枚チケットを入れたらとりあえずマッチすることを確かめるテストfunc TestRandomMatchmaking(t *testing.T) { profile := &pb.MatchProfile{Name:"fake"} ticket1 := &pb.Ticket{Id:"test-ticket-1-id"} ticket2 := &pb.Ticket{Id:"test-ticket-2-id"} poolTickets :=map[string][]*pb.Ticket{"test-pool": {ticket1, ticket2}, } matches, err := makeMatches(poolTickets, profile) assert.NoError(t, err) assert.Len(t, matches,1)var matchedTicketIDs []stringfor _, ticket :=range matches[0].Tickets { matchedTicketIDs =append(matchedTicketIDs, ticket.Id) } assert.ElementsMatch(t, []string{ticket1.Id, ticket2.Id}, matchedTicketIDs)}
これでゲームの本質的な部分の実装を高速で試行錯誤することができる!このMatch Function単体でテストを記述する実装例をGitHubに公開しているので、参考として置いておく。
KuberentesもgRPCも使わずにマッチングのテストができたが、一方で本番の環境とはかけ離れたテストとなった。これだけだと不安なので、もう少し本番の環境に近い実装を使った統合的なテストもしたくなる。
では、ローカル環境でKubernetesを用意してOpen Matchを用意する方法を考える。ローカルでKubernetesといえばまず候補にあがるのはminikubeで、ドキュメントでもminikubeでの導入方法が記述されている。
さて、ドキュメント通りにやるとminikubeを使ったOpen Matchの導入はできるが、ゲーム開発者が自前で用意する必要のあるコンポーネントが2つ存在する1。
Match Functionについては前述した通り、マッチングのメインロジックをgRPCサーバーとして記述する部分。この部分はOpen Match内部のBackend Serviceからアクセスされるため、基本的に同じKubernetesクラスター内に配置する必要がある。
もうひとつのDirectorは定期的にBackend Serviceを叩いて成立したマッチングがあればゲームサーバーの接続先情報等を割り当てて返す常駐型のプロセスである。これについては、Open Matchと同じKubernetesクラスター内に配置する必要はなく、Backend Serviceへの経路さえ開けておけばよい。
なぜクラスターの内外を気にしているかというと、ローカル開発においては高頻度で実装を変更したいものはKubernetesの外側にいたほうが都合がいいからだ。Kubernetesの中で動いているコンテナの実装を変更するにはイメージの再ビルド、deploymentへの反映といった工程が必要になり面倒になる。コード変更を素早く確認するためにgo run などで開発者のマシンで直接実行したい。
しかし、繰り返すがMatch Functionはクラスター内に配置しなければならない。よってローカル環境でMatch Functionの更新をするのは結構面倒な作業になる。この作業をどうにかして楽にできないだろうか?
Kubernetesを動かしつつ、ローカルでのコード変更をできるだけすぐに反映したい!という要望に応えたすばらしいツールがある。skaffoldというツールだ。
skaffoldの使い方はドキュメントを見てもらうとして、このツールを使うことでコードに変更があれば自動的にイメージを再ビルドし、Kubernetesクラスターに反映してくれる。よって、Match Functionの実装を試行錯誤しつつ統合的な挙動を確認できるようになる。
このskaffoldのすごいところは、クラスターの外から内部へのPort Forwardingもいい感じにやってくれるところである。
#skaffold.yaml...portForward:-resourceType: ServiceresourceName: om-frontendnamespace: open-matchport:50504localPort:50504-resourceType: ServiceresourceName: om-backendnamespace: open-matchport:50505localPort:50505
このようにskaffold.yaml に記述しておき、skaffold dev --port-forward オプション付きで起動すると自動的に定義したPort Forwardingを開始してくれる。
Watching for changes...Port forwarding Service/om-frontend in namespace open-match, remote port 50504 -> address 127.0.0.1 port 50504Port forwarding Service/om-backend in namespace open-match, remote port 50505 -> address 127.0.0.1 port 50505
この機能のおかげで、Open Match Backendにクラスターの外からアクセスする経路も簡単に確保できるため、Directorはクラスターの外つまり開発者のマシン上でgo run を使ってごく普通に実行できる。
DirectorからBackend Serviceにアクセスしたい場合は、localhost:50505 のように指定すればよい。
// See portForward section in skaffold.yamlomBackendAddr :="localhost:50505"omBackend, err := newOMBackendClient(omBackendAddr)if err !=nil { log.Fatalf("failed to connect to open-match backend: %+v", err)}
また、Directorでマッチを取得する際にMatch Functionのアドレスを指定する必要がある。ここはBackend Serviceから見た時のMatch Functionのアドレス になるので注意。つまりクラスター内の通信になるため、クラスター内のDNSを使ってアクセスする形となる。
たとえばMatch Functionが配置されたnamespaceがomdemo で、Kubernetes Service名がmatchfunction の場合は、matchfunction.omdemo.svc.cluster.local. がホスト名になる。
mfConfig := &pb.FunctionConfig{ Host:"matchfunction.omdemo.svc.cluster.local.", Port:50502, Type: pb.FunctionConfig_GRPC,}stream, err := omBackend.FetchMatches(ctx, &pb.FetchMatchesRequest{Config: mfConfig, Profile: profile})これでKubernetes上でOpen Matchを動かしつつ、ローカルで比較的楽に開発できる環境が整った!ここまでで解説した内容の実装例をGitHubで公開しているので、「つまり最終的にどうなるの…?」と思った方はぜひチェックしてみてほしい。

引用をストックしました
引用するにはまずログインしてください
引用をストックできませんでした。再度お試しください
限定公開記事のため引用できません。