Kubernetesでステートフルなゲームサーバを動かした思い出

ハダシA
google-cloud-jp
Published in
17 min readApr 4, 2019

--

とあるスマートフォン向けMMORPGのプロジェクトで、アプリケーションサーバをほぼすべてGKE(Google Kubernetes Engine)に乗っけて動かしていました。

このゲームは、モバイル向けながら、複数プレイヤ間でそこそこリアルタイム性の高い同時プレイができるものでした。同じフィールドを誰かが歩けば、自分が見ている画面でもほぼ同時にそいつが歩いて横切っていく、同じ敵と皆で一緒に戦えば、誰かが繰り出した攻撃が参加者全員の画面に即同期される、もちろんチャットもできる、そんな具合です。今ではさほど珍しくないのかもしれませんが、PCのオンラインゲームのような機能を搭載した、リアルタイム性の高いモバイルゲームでした。

さて、こうなってくると、オーソドックスなWebサーバのような、HTTP/1でリクエスト/リプライを捌く、というサーバだけでは要件を満たすことができません。

複数プレイヤ間でリアルタイムにゲームの状態を同期するということは、知らない誰かの行動や、ゲームサーバ内で計算した状態の変化、などなど、いつ起こるかわからない任意のタイミングで発生するできごとたちが、いつでもクライアントに送信できなければいけません。というわけで、サーバとクライアントは双方向通信が必要になってきます。

このときは、TCPベースで独自のアプリケーションプロトコルを設計して動かしていました。( 今から始めるなら gRPC を使うと思います )

それから、アクションゲームほどシビアではないにしろ、1/30秒くらいのそこそこ短い周期で状態を再計算して同期します。誰それがどこへ向かってどの速度で歩いた、誰それのHPが回復した、それら、ゲームのフレーム単位でめまぐるしく変わっていく粒度の細かな状態の変化は、別のDBサーバへ書き込んで共有している暇はありません。クライアントと接続されているゲームサーバは、メモリ内にプレイヤ達のリアルタイムな状態を持ち、ゲームサーバ自身でゲームの状態を計算し、即同期する、そういう実装になっていきます。つまり、ステートフルなサーバになってしまうわけです。

まとめると、リアルタイム性の高いゲームサーバは基本的には以下のような要件がありました。

  • メモリ内に常に状態を持ち続ける必要がある
  • 双方向性のあるプロトコロル でクライアントと通信
  • 結果的に、通常のwebサーバよりCPUバウンドになる

ここだけ見ると、Dockerコンテナで走らせる一般的なアプリケーションサーバよりも、むしろ Redis とか、データを管理するサーバの特徴に寄ってきてます。

Kubernetes では、こういったサーバもコンテナに閉じ込めればなんでもデプロイできるわけですが、k8sや周辺ツールが期待している、よくあるコンテナのアプリケーションサーバからはちょっとはみだしている部分はやはりあったと思います。

ちなみに、このサーバは、C# で書かれ、.NET Core on Linux で走らせていました。C# でサーバ? そう聞くと未だにwindowsを想像する人が多いようですが、でも .NET Core と 最新の C# は、Linuxで動くモダンなサーバのランタイムです。

C#の言語組み込みの非同期サポート(とくにエラー処理)や、ヒープアロケーションを抑止する最新の機能は、サーバのスループットを上げていく上でかなり助けられました。それから、C#のコンパイラツールチェインのRoslynは、コンパイルの各段階でIDEのための情報を吐くようにデザインされているため、C#のIDEは異常に賢いです。

Web業界では、Go や JVM 言語に並んで .NET Core の名前が挙がってこないのがけっこう不思議に思います。これまでのところ、マーケティング上の敗北なんでしょうか。

Kubernetes ?

僕たちがKubernetes に期待していたことは、Dockerコンテナをデプロイできること、SaaS ではできないような複雑な構成(ステートフルなゲームサーバを組み合わせた構成)をアプリケーションエンジニア主体で試したりつくっていけること、それから、各サーバ(マイクロサービス) が平等に必要とする共通の足回りの整備の自動化、というようなところだったと思います。(実際には使ってみて理解していった面が多かったのですが)

使ってみるとやはり k8s は優れものだという感想をもちました。

  • 抽象度が高い。インフラにそこまで興味のない僕のような者でも、k8s の抽象的な概念を理解するだけで構成を変更していける。
  • 自由度が高い。抽象度が高いにも関わらず、構成要素が細かく分割されているため、組み合わせ次第で割となんでもできる。この辺は特にセンスが良いと感じた。

また、GKE が非常に便利です。

  • GKEの場合、自動化されている部分が非常に多い。新しいクラスタをつくることもボタンを押すだけでできる。サンドボックス環境を増やしたり壊したりも簡単。
  • 自動的なログの集約がとくに便利。各コンテナプロセスは、標準出力にログを吐いておけば自動的にstack driver loggingに集約される。(nodeにfluentdのサイドカーコンテナが自動で配置される)。標準出力にjsonを吐いておけばログエントリに対してクエリを実行可能。構造化/セマンティックロギングが簡単に整備できた。
  • 内部の通信や、ロードバランサの公称の性能が良い。とくに疑いを持たず、ゲームサーバのエンドポイントはGCPのL4ロードバランサ1つ置いただけだったけど、負荷をかけても問題にならなかった。

Googleらしいプロダクト?

これは個人的な感想ですが、k8s は OSSの巨人らしいプロダクトだなあと感じます。日々あれをつくってコミットしている人達と、利用者の世界観がそこまで連続していない。ようするに、僕のような、いち利用者にとっては、中身はブラックボックスに見えることが多く、ハックしやすいような類のものではないように思いました。

OSSとひとくちに言っても色々です。彼らにとって、利用者=ユーザ なわけで、透明性を上げてカスタマイズさせていく設計にしていくというよりは、超高機能をeasyに確実に使える形にパッケージして提供してくれている、という姿が近いと思います。ライトな利用者を増やし、コンテナオーケストレーション市場で勝利すること、それがGoogleのビジネスとも結びついていく、いわずもがなです。

しかし、もちろん、実用性は非常に高いです。「あるべき姿に状態が復元される」という、普通の人が思いついても、複雑すぎて頓挫しそうなコンセプトが、GKE上で実際にちゃんと動いていた。透明性とかどうでもいい。とにかく実用的に動く。この圧倒的パワーです。いちプロジェクトや会社のレベルがスクラッチでオーケストレーションレイヤのしくみづくりをしようとした場合、この便利さと肩を並べて競争することがほとんど想像できません。

k8sに乗るようなサーバに期待されること

インフラが隠蔽され、抽象度が高いだけに、k8sが合っているアプリケーションサーバの姿というものもやはりあると思います。

今回のリアルタイム性のあるゲームサーバがはみだしている部分を挙げれば以下のような点ですね。

  • コンテナで走るサーバは当然ステートレスであることが期待される
  • プロトコルが完全統一されていると望ましい
  • ワークロードをそこまで気にしないサーバの方が楽

コンテナ=ステートレス

コンテナは、imageを元にゼロから複製をつくるという概念をもっています。サーバに対して設定を順次上書きしていくといった素朴なプロビジョニング方法を引き合いにだして、イミュータブルな性質をもつ、と、語られてます。

k8sとしても、スケールアウトしたり、死んだものを新しくつくりなおした際、コンテナは全く同じ状態で起動できることを基本的には期待しています。

k8sは、各 構成要素たちが疎につながっている世界観をもっています。たとえば、Pod(コンテナ)を管理する「Deployment」オブジェクトは、各コンテナごとの差異というものを外側からは隠蔽します。外部からのエンドポイントをつくる「Service」オブジェクトから見ると、各Podにちがいなんてないのです。

僕達のゲームサーバの場合、各Podがメモリ内に別々の状態をもっていて、実質、各インスタンスが別々のサービスがごとくルーティングしてあげる必要がありました。そのため、上の図のような構成にして簡単にスケールアウトすることができませんでした。

(代わりにどうしたかは後述)

また、k8s では、クラッシュしたり、「死んでいる」と判定されたコンテナは自動的につくりなおそうとするわけですが、imageから復元できないステートを持ってしまっていると、再起動時にそれを復元することができません。自前でどうにかする必要があります。

ワークロードの隔離

アプリケーションサーバはステートレスが望ましい、これはコンテナが実用化される以前から常識的な話だと思います。しかし、僕たちのつくっているサービスが、システム全体でステートレスかというと、そういうこともないわけです。

システム全体の視点でみれば、かならずどこかに状態を永続化するサーバがある、というのが普通のシステムの姿だと思います。

つまり、ステートはDBサーバへ、ロジックはステートレスな(HTTPとかの)サーバへ持っていく。後者を簡単にスケールできるように隔離しているだけで、けっきょくどこかにステートは存在してます。

k8sを使う場合も同じです。k8sにはアプリケーションサーバを置き、DBはコンテナでは立てないのが普通ですよね。(GCPのマネージドなDBのサービスを使うとか、GCE上で自前でプロビジョニングして外側に置く) つまり、状態はk8sの外側にもっていく。

もちろん、DBも、コンテナで起動してディスクをマウントすれば、k8s上に置けないということはないと思いますが、たぶん、現状だと普通はそれはしないはずです。理由は、ひとつにはパフォーマンスで、アプリケーションサーバとDBサーバではマシンリソースの使いかたが全く異なっています。

Webサーバというのは、専ら、軽い処理、外側のサーバ(DBあるはinternalなサービス)に対して I/O するのが主な仕事ということになっていて、CPU処理の性能はあまり気にしない、という扱いがされます。ステートレスであれば簡単にスケールアウトできるから、全体としてスループットが上がればok、ということになっているんですね。

k8sは、抽象度が高いだけに、ひとつのNodeに複数のPodを置いた場合にCPUリソースがどのように使い分けられるのか、(僕がそのとき調べた限りでは)あまり明確ではないと思いますが、普通のWebサーバであれば、とくに気にしないで置いちゃいます。

ところが、DBサーバのような類のものは事情が違います。DBは、一台あたりでできる限りの低レイテンシ、高スループットを叩き出すため、マシンのリソースを使い切るように設計されているソフトウェアです。マシンリソースをどのように使われるのか透明性の高い場所に配置するのが望ましいのです。

実は、僕達のゲームサーバも、ステートフルで、かつ、CPUを使いきりたいタイプのソフトウェアなのです。この辺の事情がちょっとDBと似ているといえば似ている。

では、ゲームサーバは、k8sの外に置くのか? そういう議論もあったのですが、結果的には、k8sにデプロイすることにしました。

ゲームサーバは、DBではなく汎用的なOSS製品ではなく、あくまでもアプリケーションサーバの特徴を強くもっています。

  • アプリ固有のロジックが大半を占める
  • 継続的に修正してデプロイしたい
  • スケールアウトしたい

ワークロードの隔離に関しては、1nodeあたり1コンテナだけを載せるようにし、負荷テストをしてみる、という方向で進めました。(最終的にはその構成のままリリースまでいった)

プロトコルの統一

マイクロサービスという言葉をよく耳にするようになって久しい昨今。

サービスを分ける理由は、「チームを分割統治させ、設計の正しさもチームごとに存在することを許せば、組織がスケールする」そんな語られかたをされているのをよく耳にします。しかし、たとえ、そういった要求がなかったとしても、サーバサイドでやることはどんどん複雑化・多様化していくので、各サーバをモジュールとみなして責任を分割していくという選択肢をとりたい場面は徐々に多くなっていくと思います。

リアルタイム性の高いゲームサーバのような存在もそうです。特殊なワークロードのサーバがある場合、当然、分離したいという欲求がでてくるはずです。

しかし、ただ単にサーバを分割すると、サーバとして当然期待される機能の実装やサポートの仕事がサーバの数だけ増えるし、サーバ間通信の異常系のパターンが爆発的に増え、難易度と工数が跳ね上がるこは簡単に想像がつきます。

そこで、サーバとして当然要求される足回りについては、今までのように各サーバごとに実装するのをやめて、デプロイしたら自動で整備されるようになっていてほしいという期待があります。そうなってはじめて、マイクロサービス化が割に合う買い物になるわけです。

  • ロギング
  • エラートラッキング
  • 死活監視
  • パフォーマンスメトリクス
  • etc

これらはとくにトラフィック規模が大きいと重要です。(大きくなくても重要です)

Envoy や Istio などをはじめとする Control Plane / Data Plane という目新しい概念は、いままで一枚岩のアプリケーションで実装していた共通機能を、もうちょい下のレイヤで問題を共通化して解決するという視点を持っている存在だと思います。

k8s は、上で挙げた仕組みの導入がしやすい環境ですが、自動化していくにあたっては、やはり、アプリケーションレイヤのプロトコルが統一されている方がなにかと手間が省けると思います。

僕たちの ゲームサーバは、TCPベースの独自プロトコルだったため、カスタムメトリクやログエントリはかなり自前で設計する必要がありました。次に設計するとしたら、この辺も最初から考慮して半自動化する仕組みにしてみたいものです。

gRPCの強いところは、ものが良いということもありますが、Google内部で完全に統一されている点、圧倒的に普及している点だと思います。プロトコルが統一されていれば、上で挙げたような、サービスをまたいだトラッキングやロギング、カスタムメトリクスなどのしくみを、サービスやプロジェクトにさえも依存しない形で共通化しやすいはずです。

StatefulSet

最後に、具体的な構成をちょっとだけかいつまんで紹介してみようと思います。

実は、Kubernetes には、「StatefulSet」という名前のオブジェクトが用意されていて、こっちを使えば、各コンテナ(Pod) に別々の名前を割り合てて、別々の名前を指定してアクセスできるようになります。

僕たちのゲームサーバは、たとえば以下のようなかんじで、Podごとに別々の状態を持っています。

  • game-0 には 草原フィールド(1)があり、プレイヤA、プレイヤB が遊んでいる
  • game-1 には 砂漠フィールド(1)があり、プレイヤC、プレイヤD が遊んでいる

ということは、たとえば、プレイヤA がログインしなおした場合は、game-0 へルーティングする必要があるし、 草原(1)にいるプレイヤが 砂漠(1)にワープした場合は、game-1 へルーティングしてあげる必要がでてきます。

つまり、「ゲームサーバ」というひとつのサービスがあるのではなく、ゲームサーバの各インスタンスがいわば別々のサービスのようなもので、個別にルーティングしてあげる必要がある、と考える必要があります。(これがステートフルのややこしいところですね)

しかし、StatefulSet は、あくまでinternalな名前をつけてくれるだけなので、外側からアクセスすることができません。

そこで、このプロジェクトでは、アプリケーションロジックを知っているproxyサーバ(自前)を置くという方法をとっていました。

proxyサーバ(自前)は、ステートレスです。クライアントからは、エンドポイントがひとつだけが見えていて、接続すると、適当に空いているproxyサーバ(自前)のどれかにつながります。

proxyサーバ(自前)は、ロジックを好きなように書ける(なぜなら自前だから)ので、アプリケーションロジックを知っています。接続してきたプレイヤがどのゲームサーバへつながるべきか判定して、適切なゲームサービスへプロキシすることが役目です。

たとえば、昨日、草原1で遊んでいたAさんは、次の日にログインしたら、おなじ草原1に引き続き接続される必要があります。proxyサーバは、草原1があるgameサーバがどれか判定し、そっちへ流します。

また、Aさんが草原から砂漠へ移動したいとき、game-0 から game-1 へ移動する必要がでてきます。そんなときも、クライアント-proxyサーバ間の接続は切らず、あくまでproxyの判断でプレイヤを別のゲームサーバへワープさせます。

(別の方法もいくつか考えられると思いますが省略)

さて、この、アプリケーションロジックを知っているproxyサーバは、「どのゲームサーバにどのフィールドが置いてあるか?」「空いているゲームサーバはどれか?」といった問いに答える必要があります。

そこで、さらに図が複雑になってくるんですが、この、ゲームサーバの人工分布を管理するために、専用のサーバを分離させていました。これはプロジェクト内ではworld サーバなどと呼んでいました。

worldサーバには以下のような機能があります。

  • どのゲームサーバにどのフィールドが置かれているか管理する
  • ゲームサーバごとの人口を記憶する
  • ゲームサーバ間の人の移動をトラッキングして、アトミックに状態を更新する。(一瞬で大量に人が移動しても確実にトラッキングすることも自前実装している理由のひとつ)
  • proxyサーバの問い合わせに応じて、適切なゲームサーバを教える
  • 死んでいるゲームサーバはルーティング対象からはずす。

というわけで、ほとんど専用のService Discovery を自前でつくったよう形になりました。

worldサーバは単一障害点になりますが、ここは状態をredisへ書き込んでいて、再起動時にリストアできるようにしていました。

もっと可用性を上げるには、冗長化したり、担当するデータを複数台に分割できるような設計にしていくつもりでしたが、とりあえずは、落ちてもk8sによる再起動で復旧できればダウンタイムは許容するつもりでリリース時は一台でした。

以上、色々と考えることが多かったゲームサーバ on Kubernetes の僕の経験談でした。似たような他のプロジェクトはどんな構成にしているんだろうか。

--

--