リアルタイムサーバーで障害が起きたらどうするか? 快適なゲーム体験を支える仕組み

ゼロからリアルタイムサーバーを作るまで #2/2 >> 1はこちら

タイムスタンプの仕組み

清水佑吾氏:あとで説明すると言っていたタイムスタンプですね。タイムスタンプをなんで置いてあるかと言うと、RTTを計測したかったからです。クライアントとサーバーの間がそもそもうまく通信できているのかとか、どれぐらいの通信遅延が発生しているのかがリアルタイムでほしい。

リアルタイムでほしいのは、実はクライアント側の、自分がそもそもサーバーがつながっているはずだけどどれぐらい遅延しているかがほしいし、対戦相手が実はめちゃくちゃpingが悪いのではないかという情報もユーザにちゃんと出してあげるという意味でもほしかったです。

では、タイムスタンプが2つでどうやってRTTを計測しているかという話なんですけど、最後に受け取った相手のタイムスタンプに最後に相手からメッセージ受け取ってから、今メッセージを送るまでにかかった時間を加算したものを送る。

どういうことかと言うと、さっきやったここの部分です。右側がサーバーです。左側が通信するクライアントです。

タイムスタンプと言っているんですけど、実際はカウンターでやっています。カウンターは1秒に1,000上がっていくミリ秒単位のカウンターを用意して、このタイマーで時間を表現しています。なぜかと言うと、UNIXタイムとかをミリ秒とかで表現すると32ビットでは足りないんですよね。

なので、そもそも1970年かどうかというのはどうでもよいので、その端末の中での今の時間がわかればそれでいいので、クライアントが起動した時刻をメモリ上に保存しておいて現在時刻を取ってきて、起動した時刻と引き算して、それをタイマーとして使っています。なので、ゲームのコネクション1回1回に限ってのUNIXタイムみたいなものを作っているみたいなイメージです。

レスポンスタイムを測る

ゲームが開始してカウンターが起動して、その1ms後にメッセージを送りたいとします。そうしたときに、さっきのSenderというタイマーのところに「自分が今1ms目でメッセージを送りましたよ」というのを入れてあげます。これをサーバーに送りつけると、サーバーで受け取ったときにサーバー側のタイムスタンプは1,000msでした。

気を付けないといけないのは、別々のタイマーなので別にクライアント側でのタイムスタンプが1,000msではなくて、サーバー側のタイムスタンプが1,000msというだけです。それで、そのときに受け取りました。次に、HTTPとは違ってコール&レスポンスではないので、メッセージを受けたからと言ってメッセージを返す必要はないです。なので次にいつメッセージを送るかはわからない。

なので、たまたま次にメッセージを送るタイミングが300ミリ秒後に来ましたとなると、サーバー側でのタイムスタンプが1,300msのタイミングでメッセージを送りたいと思ったときに、最後にメッセージを受け取ってから今送るまでに300ms掛かったということがわかります。この300msを受け取った1に足して301msですとメッセージに載せてクライアントに返してあげます。

このクライアントがメッセージを受け取ったときに、このクライアントの中のタイマーが321ms目でした。こうすると、この時点で自分のタイマーが321なので、受け取った値の301を引いた結果の20msが、往復の時間で掛かった時刻になります。

ポイントは受け取ったときに321msなんですけど、通信経路で掛かったのか、サーバーが次にメッセージを送るまでに掛かったのかがわからないと、通信経路で掛かった時間がわからないんですね。なので、サーバー側が自分の掛かった時間をクライアントに教えてあげる必要があります。

そうすると、サーバーが純粋に消費した時間と行って戻って来た時間の両方がわかるので、行って戻って来た時間からサーバーが純粋に消費した時間を引けば、ここの矢印の時間がわかるというロジックになっています。このタイマーが全部のメッセージに入っているので、メッセージを受け取った瞬間に受け取った側は常に自分と相手の最後に受け取ったメッセージと今来たメッセージのRTTの合計値がわかります。行って帰っての合計値です。

なので20msだったら片道10msぐらいかなと。ちなみに、半分にすればいいというものではなくて、行きが1msで帰りが19msということも普通にあり得るので、少なくとも往復の時間はわかるというのがこのRTT計測の仕組みです。実際は両方が受け取ったときにわかるようにそれぞれの分を送っているので、サーバーもわかるしクライアントもわかるみたいな感じになっています。

あとは先ほど言った通り一方的にメッセージを送りつけることできるので、そうなるとどうなるかと言うと、321msに最後にメッセージを送ったあとにメッセージを2本クライアントから送っているんですけど、1本目と2本目がそれぞれこの分とこの分を相手のタイマーに、最後に受け取ったタイマーに足してやってサーバーに送りつける。

サーバーが受け取った時に、1本目ときは最後の送信と1本目の合計値がわかるし、2本目のときは最後の送信と2本目の合計値がわかるみたいな挙動になっています。サーバーからメッセージを受け取るクライアントでも同じことが起こるので、サーバーとクライアントのどちらもメッセージを受け取った瞬間に自分の最新のRTTがわかるということになっています。

他にサーバーでやっていることは、先ほどちょっとあったメッセージIDによる到達保証とか、UDPをTCPへフォールバックといったことをやっていますが、この辺は話を始めるとここだけで1時間ぐらい掛かってしまうので、今日は一旦ここまでです。というのが、プロトコルの話でした。

運用に向けて

こうやってプロトコルを作ってサーバーのデザインをして実装をして、「さぁ! 運用していきましょう!」となったらいろいろ作業する必要があります。

実際に作る以外には、そもそも運用でどこを目指すかですよね。ビジネスロジックをすごく簡単に載せられることを目指すのか、サーバーは落ちないことを目指すのかでぜんぜん違うと思うんですが、我々が作ったのは汎用サーバーだったので「安定してつながって、そこそこ早くて、落ちない」を目指しました。

そもそも数ビットを削るのは諦めるので、まぁまぁ速いぐらいです。HTTP APIに比べれば速いですし、UDPも使えるし便利だねというぐらいを目指しました。あとはバージョンアップするときにErlangはがんばれば切断しないようにできるんですけど、大変なのでそもそもバージョンアップは少なければ少ないほど良いに決まっているので、バージョンアップを極力少なくする。なので、そのためにやるべき責務を減らす必要があります。

いっぱいの仕様を抱えていればその分だけのバージョンアップの機会を受けますし、修正も増えます。なので、通信の状態を管理する。通信を持っているからステートフルなものはしょうがないんですけど、永続化情報は持たない。ステートフル・データレスみたいなのを言ってました。そして責務を最小限に。複雑な機能やすでにある機能はAPIサーバーをうまく利用する。

忘れてはいけないのですが、ゲームには既にたくさんの機能群があります。

クライアントがいて、ロードバランサがいて、アプリケーションサーバーがいて、MySQLがいて、Redisがいて、裏側でバッチが動いているのでそれらが全部動かしていて、MQがいて、ワーカーがいて、それらを全部ログにやっていくみたいな大規模な仕組みが既にあるので、これらと機能が重複するとめちゃくちゃ邪魔です。

こういった機能とは重複せずにリアルタイムだけを提供してほしい。既存の仕組みと喧嘩をしないシンプルな仕組みがほしいということです。

先ほどあったステートフルとデータレスですが、状態は持つけどデータは持たない。つまり永続化しない。永続化するものは全部APIサーバーがデータベースを使って管理をすればいい。ここでアクセスするユーザのIDやパスワードとかをいちいち管理してたら、IDとパスワードが変更になったときのデータの変更はどうするのかとか全部考えないといけないんですが、そういうことはしないということです。

ただ、先ほどあったIDとパスワードみたいな認証情報というのは接続を許可するために必要なので、必要なデータは外部に問い合わせるか通知をすることで解決する。結果、データを持っていないので起動したらすぐに使えます。データベースみたいにロードしなければいけないというのはないですし、固定のデータも必要ないので、起動したらすぐに使える。なぜならそもそも情報を持っていない状態で生まれてきますし、状態の管理しかしないからなんですね。

起動したらすぐに状態を受け入れて、すぐに使えるようになります。

HTTP APIとArkを使い分ける

なのでリアルタイムのやり取りはARK、Pub/Subサーバーを使いますけど、重要な情報はHTTP APIを使うみたいな構造になっている。クライアント側のコードとしては、何か重要なことをやるときはAPIサーバーにコールを投げて結果をもらったりとか、APIサーバーはそれを受けて何かリアルタイムなことをしたいというときはAPIサーバーがリアルタイムサーバーにメッセージを送るみたいなことをやっています。

最初に採用例でやったVRのライブチャットで、投げ銭をすると、あれはお金を投げているのでお金をインメモリデータベースに入れたり、クライアントで処理したりとかはやりたくないので、お金はデータベースでがっつりトランザクションで守って管理しているんですが、お金を払うというAPIはHTTPでサーバーに投げてます。「この人が投げ銭しましたよ」という情報だけ、ARKを経由してリアルタイムで提供したりとかをやっています。

あとは認証の話ですね。そもそもクライアントがPub/Subサーバーにつなぐ前に認証情報がほしいんですが、Pub/Subサーバーがどこに上がってるかすら知らないんですよね。なので、クライアントはいつものノリで、「このリアルタイムのバトルをスタートしたいんですけど」と、APIサーバーに投げます。

そうしたら普通にMySQLで、このユーザがこの体力を使ってこのシナリオをスタートするね、みたいなのを書き込みます。実はAPIサーバーがPub/Subサーバーのアドレスを管理しているので、このPub/Subサーバーを使ってこのトークンでログインしてくださいみたいなことをクライアントに返します。

このもらってきたトークンとIDとポートを使って、あとはトピックに使うバトルIDみたいなのをもらってPub/Subサーバーにつなぎにいきます。Pub/Subサーバーはもらったトークンが正しいかどうかは知らないので、APIサーバーに聞きにいきます。

「この人はこのトークンをもらった人だけどつないでいいの?」みたいなことを聞いていきます。それに対してAPIサーバーはMySQLとかを使って「このトークンの人はクライアントID〇〇さんの人で、このトピックをパブリッシュしていいし、このトピックをサブスクライブしていいです」みたいなパーミッション情報をJSONで返します。

そうすると、このPub/Subサーバーは「この人は〇〇さんで、このトークンは合っていて、こういったトピックをサブスクライブしたりパブリッシュしていいんだ」というのを解釈して、これをオンメモリで保持します。結果、端末にOKを返す。こういうことをすることで、Pub/SubサーバーがDBをもたなくて済んでいます。

HW障害への対応

あとは運用しているとどうしてもハードウェア障害が起きるようになるんですが、こういうのをどう管理しているかというと、各サーバーがAPIサーバーに定期的に「俺、今生きてます」みたいなのを投げ続けてます。だいたい5秒とか10秒とか、長くても30秒ぐらいの間隔でHEART BEATを送るようにしています。

APIサーバーはそれをひたすらデータベースにため込んでいて、「最後にこの人が生きていたのは何秒です。このサーバーはまだ生きています」みたいな判定をしています。5秒に1回ですかね、それで保存もしています。

仮に、Aサーバーの認証が誤って死んだりすると、APIサーバーに保存されている時刻が当分来てなかったことになるので、一定以上経っていたらこのサーバーが死んだという情報をAPIサーバーが書き込みます。

通信障害というパターンもありますね。

実はやっていることはこれだけで、死んだサーバーは先ほど言った通り、どのサーバーのクライアントがアクセスするかというのはAPIサーバーが案内しているので、これでAPIサーバーが「このサーバーは死んだな」、「このIPは死んでいるんだな」と思ったら、新しくAサーバーにいく人はいなくなるので問題もないです。という状態になっています。

面倒くさいのは、通信障害が起きてた場合、通信障害から復旧するんですよね。サーバーそのものはピンピンしていたりするので、そうすると、またHTTPを叩きに行きます。このときに「俺、生きてるよ」を受け入れてしまうと、すごいややこしい事態が起きるので絶対に認めないようになっています。

一度死亡判定を受けたサーバーに関しては復帰してきても「死んで」と返すようになっています。

なぜ復帰を認めないのか

どういうことが起きるかというと、前提条件としてArkサーバーは同一サーバーにつながっているユーザ同士でしか通信できないです。他のリアルタイムサーバー製品によってはクラスタを組んだりして別々のサーバーにつながっているユーザ同士がつながるようなものもあるんですが、シンプルにやっていくというのと、クラスタリングのメンテナンスが大変なので1サーバーにユーザを集めることにしています。

そういう状態で何がさっきの状態でマズイかと言うと、AサーバーとBサーバーがあったときに普通に端末Aがつながってます。それでここら辺が死にます。

Aサーバーが死ぬとHEART BEATも当然できないですし、Aサーバーか綺麗に死んでいるのであれば、クライアントとのコネクションも切れるはずです。

そうしたら、最初にクライアント側は切断されたということに気付きます。切断されたということに気付くので、とりあえずAPIサーバーに「俺切断されたんだけど」と、聞きに行きます。そうしたらAPIサーバーが「ごめんごめん、お前はAサーバーでプレイしているはずだったけどAサーバー死んでたわ。なのでお前のいるルームはBサーバーに替えるわ」と、データベースに書き込みます。

それでAPIサーバーから「あなたの新しいサーバーはここですよ」と、Bサーバーに案内してクライアントはBサーバーにつながる。というのを、つながっている端末全員がAPIサーバーがDBで管理しているので、二重の状態とかで案内することなしに移行ができます。

サーバーAが死んでいなかったらどうなるか?

では、本当にサーバーAが死んでいたか。もし死んでなかった場合、サーバーAはHEART BEATだけがダメだったとなると、もともとつながっていたやつはつなぎっぱなしです。なのでAPIサーバーが聞きにくることができません。

だけどAPIサーバーは、そこのルームに新しく入りたい人が来たときに、もともと遊んでいる人はAサーバーにつながっているんですけど、そもそもAサーバーは死んだと判定しているので、このルームはそもそもBサーバーに移行してしまいます。そうすると、新しく来た端末はサーバーBにつながってしまうので、同じゲームで遊んでいる同じサーバーで遊ぶべきユーザが別のサーバーになってしまいます。

これを回避するために2つやらないといけないことがあります。死んだと判定したサーバーを可及的速やかにつながっている人を全員切断状態にしてあげること。こうしないと、そもそもつながりっぱなしの人たちがずっと出てしまうので、新しくこの2人が対戦できない状態になってしまうんです。なので、まずこの人たちをAサーバーから追い出してあげないといけない。

それと同時にAサーバーが復帰して復帰を認めてしまうと何が起きるかというと、Bサーバーを案内していたんだけどAサーバーが復帰したのでAサーバーのまま新しい人をさらに案内するみたいなかたちになって、1人目はAサーバーとつながっていた、2人目はBサーバーとつながった。でもやっぱりAサーバーが生き返ったからAサーバーに行くみたいな感じで、端末がそれぞればらばらのサーバーに案内してしまう可能性が出てきます。

なので一度死んだと判定した場合は、さっさと速やかに殺してあげて台数が足りなくなった分に関しては新しいサーバーを立ててあげるという運用になっています。

あとはバージョンアップとかで落としたいですみたいなときもあるんですけど、毎回メンテナンスするのには基本的に短期間のバトルのときにしか使わないサーバーが多いので、今使っている人もいつかは切断します。なので、先に新しいバージョンのサーバーを追加で立ててしまい、APIサーバーが新しい接続先としてそちらに優先的にクライアントを配置するようにします。

それで落とす予定のサーバーに新しい案内をしなければ、いつかはすべてのクライアントが落ちるので、そしたらサーバーを落としておしまいみたいな手順を取っています。

負荷試験のこと

あとは負荷試験をしました。このあたりは当初の負荷試験なのでErlangで作ったサーバーに対してPythonのLocustというツールを使って、さっきのPython用のプロトコルを実装してその上に負荷試験ツールの上にそれを乗せて負荷試験をしたりしました。

説明は飛ばしますが、TCPのスループットがなかなか出たわりにUDPが出なかったみたいなことがあったりとか、それをプロファイリングして直していってというのを負荷試験をしながらやっていきました。最終的に1サーバー、AWSのc4.2xlargeで10万メッセージ/秒ぐらいを1台のサーバーで安定して捌けるようになったところでローンチをしました。

実はこれは話としては2016、7年ぐらいの話で、そのあとは何回かバージョンアップをしていてその度に負荷試験をしています。

最初はLocustというツールの上で自作のプロトコルで負荷試験をしたんですが、最近は負荷試験ツールから自作して行いました。なぜ作る必要があったと言えば、いろいろな通信パターンが出てきて、Locustだけだとコントロールがしづらかったりとか、PythonのパフォーマンスがErlangに比べてあまりよくなかったとか、というのがあったので、負荷試験ツールの自作からやりました。

このときは新卒2年目のエンジニアが1人で対応しました。いろいろあったんですけど細かい話はDevelopers Boostで彼自身が発表した内容が記事として上がっているので、こちらをご覧いただけるといいかなと思います。スライドの中のムチャぶりしている上司が私です。

(会場笑)

作ってみてなんですけど、思ったよりスピードが出ました。もともと5万メッセージ/秒ぐらい捌ければいいかなと思っていたんですが、実際は10万メッセージぐらい普通に捌いてしまったので、思っていたよりも速度が出ました。

あとはけっこうしっかりと修正がある度に負荷試験をやってリリースをしていたので、デグレさせなかったです。これはクライアント側も含めてテストをしていったので、ここら辺はゲームの開発者としっかりと信頼関係を築く上ですごい大事な土台かなと思います。

あとは、けっこう運用するときにこのサーバーの「どうやって運用するんだっけ?」みたいなインフラのAWSをふだん見ているチームとやりとりして、「じゃあ、このパターンはどうしよう」みたいなものも、彼らの要望もしっかり聞きながら設計とかをしていきました。

ということで発表は以上になります。ありがとうございました。

(会場拍手)

トピックスの分け方

司会者:ありがとうございます。少しだけこの場でご質問を1つ、2つお受けしようと思いますが、ご質問のある方はいらっしゃいますか?

(会場挙手)

質問者1:トピックはどういう粒度で分けているのかなというのが聞きたくて、例えば1:1だったらとか、1:全員とかだったらというのがあると思うんですけど、そういう粒度で分けているのか、それとも用途ごとに、これは攻撃用のトピックですとか、これはHP用のトピックですとか、そういう用途が決まっているのか、通信相手ごとの粒度で分けているのか。

清水:正直実装しているプロジェクトごとに違うんですけど、実はクライアント側のライブラリとして同レベルのライブラリの上にもう1個ラッパーライブラリを作ってルームを実現してしまいました。

トピックのモデルを勝手に作ってくれるライブラリを作って、それを使っている場合は自動的に開発者がトピックを意識しないでルームっぽく使ってしまうので、その場合はあまり用途ごとに分けるとかではなく、全員に投げたいのか、誰に投げたいのかという観点からトピックを使っていることになります。

他の場合でVRライブチャットみたいなものだと、ライブをしている本人から全員にブロードキャストしたいというデータと、逆に全員からN:1で受け取るデータみたいな感じで、種類ごとというよりは誰に送りたいかというので切ることが多かったので、トピック名も「クライアント to サーバー」みたいな、トピック名そのものがどういうルートのものかという使い方でやっていることが多いです。

あとは、どのトピックから来たか受け取るメッセージの中に必ず入っているので、用途ごとで分けるのが悪い設計かというと別にそんなことはありません。あとは先ほども言ったように、実はオンメモリKVSにプロセスキーが入っているだけなのでトピックが5万とか10万とかあってもメモリはぜんぜん使わないので、そこは別にゲームのボトルネックだったりはしないですね。

Pub/Subのセキュリティについて

質問者2:2つお聞きしたいんですけど、1つ目はPub/Subのセキュリティについてです。今回説明していただいた図の中ですね。Aさん、Bさん、Cさんに配信したい場合はそれぞれにトピックIDを置くという話があったと思います。そのやり方だと、例えば他人のトピックIDが推測できてしまえば、そのトピックをサブスクライブしちゃえば他人のメッセージが読めるんじゃないかなと思ったんですけど、それについてはどうなんでしょうか?

清水:少しだけ出たんですけど、認証のときにAPIサーバーから返しているJSONの中に、どのトピックをサブスクライブできるかとか、どのトピックにパブリッシュできるかというのをホワイトリストで提供できるようになっています。なのでAPIサーバーが「このユーザにはこのトピックとこのトピックをサブスクライブさせることができて、このトピックにパブリッシュできる」というJSONを返せばコネクション……。

質問者2:クライアント側で判断しているということですか?

清水:サーバーが判断します。

質問者2:ホワイトリストはクライアント側に送られるんですか?

清水:ゲームスタートAPIとMySQLあって、こう返してトークンを持ってくるわけですけど、ここでクライアントがAPIサーバーからもらったトークンをクライアントからPub/Subサーバーにもらって、そのPub/Subサーバーが、APIサーバーに直接通信でHTTPでトークンを送っています。このトークンを基にAPIサーバーはMySQLに「このユーザはどのルームに入っている〇〇さんで、どういう役割か」というのは全部APIサーバーが知っています。

なので、このOKというレスポンスは実際はもっと複雑で、このクライアントIDはこのクライアントは誰さんで、IDと、どのトピックをサブスクライブしていいか、どのトピックをパブリッシュしていいかという情報がここで入ってきます。

質問者2:その5番のOKというところにホワイトリストが入ってくる?

清水:入ってます。

質問者2:なるほど。わかりました。

清水:前方一致のパターンが使えるようになっています。

TCPをメインで使っている理由

質問者2:わかりました。あともう1つなんですけど、TCPとUDPの使い分けをどうしているかをお聞きしたいです。今お話を聞くとリアルタイム通信って、1パケットに収まるのかどうかわからないんですけど。

清水:収まります。1パケットで収まるようにしてもらっています。最大長はTCPのほうは16ビットでバイト数を表現できるところまでいけるんですけど、UDPのほうはプロトコルのところで900バイト以下に制限を掛けています。

質問者2:ということはほぼUDPでいけるということでしょうか?

清水:UDPでもいけるんですけど、TCPをメインにしている理由はいくつかあって、一番はつながることです。サーバーが立っていて、例えばそこに80番ポートをTCPで接続しようとすると、だいたいの場合はほぼつながります。つながる可能性が少し高い。別のポート番号だとしてもTCPでサーバーが立っているほうが基本的にはつながりやすいです。

P2PでなければTCPのほうが圧倒的につながりやすいので、TCPを用意しています。というのと、TCPがそもそも到達保証とか順序保証、輻輳制御とかをしてくれるので、到達保証、順序保証、輻輳制御がほしいデータであればTCPを使うほうが、そもそも我々はネットワークが本職ではないので。

そもそも世界中のあらゆる人がTCPを改善してくれているわけなので、なるべくTCPに乗ったほうが余計なことは少ないかなと思っていて、なるべくTCPを使う。

ただ、再送制御も順序保証も輻輳制御もいらない。輻輳制御ぐらいほしいと思うんですけど、いらないものに関してはUDPを使いたい。というのがあるので、UDPをサブとして使えるようにしているという考え方になっています。

質問者2:もしよろしければ、そのUDPを使う具体的に「こういうときに使う」みたいなことはありますか?

清水:そうですね、リアルタイムゲームの先ほどのバトルゲームの場合は位置情報を送るところは全部UDPになっています。アクション部分に関してはほぼUDPで送っていて、サブスクライブしたりとか、そういうところ。あとは接続・切断時の認証回りぐらいしかTCPを使っていないというゲームが多いです。

アクションゲームの場合はほとんどUDPで送っています。なぜかは、再送がいらないんですね。50フレーム前の位置情報があってもしょうがないので、そういうのは全部捨ててもいいのでUDPで送っているし、逆にこのチャンネルにジョインするみたいなメッセージはちゃんとTCPでも送ってあげるし、TCPが切断されても大丈夫なようにメッセージIDを付けてサーバー側である程度補助・補足してあげています。

質問者2:助かりました。ありがとうございます。

清水:逆にライブのアプリなんかは全部TCPです。音声はTCPのほうが圧倒的に向いているので。そもそも切れたときや順序保証とかを考えるとTCPで送ったほうが圧倒的に速いので、UDPを使って途中を抜いたところでそこのリアルタイム性がしなくはないんですが、そこまで重要ではなくて、それよりも綺麗に再生できるほうが望まれるのでいいということですね。

司会者:はい、ありがとうございました。