この記事は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単体での開発
マッチングを決定する主要な部分は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 matchFunctionService struct { // 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) } } return nil } func makeMatches(poolTickets map[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 []string for _, ticket := range matches[0].Tickets { matchedTicketIDs = append(matchedTicketIDs, ticket.Id) } assert.ElementsMatch(t, []string{ticket1.Id, ticket2.Id}, matchedTicketIDs) }
これでゲームの本質的な部分の実装を高速で試行錯誤することができる! このMatch Function単体でテストを記述する実装例をGitHubに公開しているので、参考として置いておく。
Kubernetes/gRPCも含めた統合的な開発
KuberentesもgRPCも使わずにマッチングのテストができたが、一方で本番の環境とはかけ離れたテストとなった。 これだけだと不安なので、もう少し本番の環境に近い実装を使った統合的なテストもしたくなる。
では、ローカル環境でKubernetesを用意してOpen Matchを用意する方法を考える。 ローカルでKubernetesといえばまず候補にあがるのはminikubeで、ドキュメントでもminikubeでの導入方法が記述されている。
さて、ドキュメント通りにやるとminikubeを使ったOpen Matchの導入はできるが、ゲーム開発者が自前で用意する必要のあるコンポーネントが2つ存在する1。
- Match Function
- Director
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の更新をするのは結構面倒な作業になる。 この作業をどうにかして楽にできないだろうか?
コンテナイメージの変更を楽にする skaffold
Kubernetesを動かしつつ、ローカルでのコード変更をできるだけすぐに反映したい!という要望に応えたすばらしいツールがある。 skaffoldというツールだ。
skaffoldの使い方はドキュメントを見てもらうとして、このツールを使うことで コードに変更があれば自動的にイメージを再ビルドし、Kubernetesクラスターに反映してくれる。 よって、Match Functionの実装を試行錯誤しつつ統合的な挙動を確認できるようになる。
このskaffoldのすごいところは、クラスターの外から内部へのPort Forwardingもいい感じにやってくれるところである。
#skaffold.yaml ... portForward: - resourceType: Service resourceName: om-frontend namespace: open-match port: 50504 localPort: 50504 - resourceType: Service resourceName: om-backend namespace: open-match port: 50505 localPort: 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 50504 Port 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.yaml omBackendAddr := "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で公開しているので、「つまり最終的にどうなるの…?」と思った方はぜひチェックしてみてほしい。