Open Matchを使ったローカル開発を考える

この記事はGoogle Cloud + Gaming Advent Calendar 2020 13日目の記事です。

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

f:id:castaneai:20201206011230p:plain
Google Cloudのブログより引用 https://cloud.google.com/blog/ja/products/gaming/open-match-1-0-ready-for-deployment-in-production

しかし、この現代的な構成は困った点もある。 Cloud Native的な実運用を見据えた構成は開発の初期段階においては必要なく、むしろ複雑すぎてゲームの本質的ではない部分でつまづいてしまう可能性がある。

Open Matchはゲームのマッチングを作るための仕組みなので、ゲーム開発者はまずローカル環境で試しながら開発をしたくなる。 では早速と、Getting Startedを読むと いきなりKubernetesクラスターが必要になり、kubectlのコマンドが多数登場し、ゲームのロジックとは無関係のものまで準備しないといけない。 Kubernetesの扱いに慣れているインフラ寄りの開発者であればともかく、普段ゲームを作っている開発者からするとハードルが高い。

そこで、本記事ではゲーム開発者の視点で「Open Matchを使った実装をローカル環境で(できる限り楽に)開発する方法」を提案する。

Match Function単体での開発

マッチングを決定する主要な部分はMatch Function (MMF)という部分になる。 Match Functionはその名の通り関数的な概念で、複数のTicket(プレイヤーのマッチング要求)を入力としてそこから適切なMatchを作成する。

f:id:castaneai:20201207143932p:plain

ということは、Match Functionのみに着目すると入力となるTicket、出力となるMatchが検証できればよいので、Kubernetesは必要ない。

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

f:id:castaneai:20201207160638p:plain

具体的なコードに書き起こしてみると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に公開しているので、参考として置いておく。

github.com

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で公開しているので、「つまり最終的にどうなるの…?」と思った方はぜひチェックしてみてほしい。

github.com

f:id:castaneai:20201207185419p:plain


  1. Evaluatorも要件によっては必要だが、必須ではないためここでは省略する。