正しいゲーミングWebサーバの作り方

幾田雅仁氏(以下、幾田):株式会社gumiの幾田と申します。よろしくお願いいたします。

本日は「正しいゲーミングWebサーバの作り方 ~あるいは、正しいデータストアとの付き合い方の話~」というタイトルで、マイクロサービスでどうやって設計したのか、そもそもゲーム本体のRDSやNoSQLなどをどう組み合わせて運用しているかという話をさせていただこうと思います。

まず、会社の紹介を簡単にさせていただきます。gumiは2007年に設立された会社で、主にスマートフォン向けのソーシャルゲームを提供しています。

規模としては、東京と福岡に2つ拠点があるほか、アメリカやシンガポール、フィリピン、台湾などにもオフィスを持っています。

次に、自己紹介をさせていただきます。私、幾田と申します。

2012年に入社して2016年にCTOになり、現在にいたります。入ったころは、ソーシャルゲーム開発の複数の現場で使用するライブラリやツール、サービスなどの開発をしていました。その後、ゲーム開発の現場でゲームサーバをしばらく作っていましたが、クラウドインフラのチームを見るようになり、そのうちに、いろんなところに手を広げていった感じです。

CTOになってからは組織設計や評価基準の制定、あとは社内技術アピールの活動などをしています。気が付くと雑用係になっていました。

いろいろ登壇させていただいていると、どうしても綺麗で前向きな話をしなくちゃいけなくなったりとかいろいろあるんですけど、今日はとても自由な気持ちで話していこうと思います。

マイクロサービスの利点

ということで、今日のお話です。まず、みなさんの職場ではマイクロサービス設計を採用しているでしょうか? 挙手をお願いします。

(会場挙手)

ゲーム開発をしている方はどのくらいいらっしゃいますか?

(会場挙手)

半分ぐらいなんですね。サーバサイドのエンジニアの方はどれくらいいますか?

(会場挙手)

よかった、全員ですね。ありがとうございます。じゃあ今日の話は、たぶん役に立つと思います。

まず、マイクロサービスについては、規模の大きなサービスを小さいサービスの集合として設計する手法ということでいいですよね。

モノリシックの大きなサービスがあったとします。その中に、お知らせ、お財布(ゲーム内通貨機能)、探索、戦闘といった要素があったとします。これをマイクロサービスで分けると、1個1個の機能でサービスを分割できます。

これの利点は、何でしょうか。

まず、サービスごとに適した技術を使えます。ここでは言語しか表示していませんが、実際はクラウドサービスも変えられます。例えば、お知らせのところだけはGCPで作ろうとか、探索はやっぱりAWSだよねとか、そういった選択の仕方もできます。

それから、障害が起きても一部の機能だけに限定されます。

スライドの例だと、探索は落ちているけど、お知らせは表示し続けられます。それに、サービス全体で停止することがないですよね。さらに、言語やクラウドサービスを部分的に入れ替えることもできるので、お知らせ機能の仕組みを全部作り変えて、そこだけ部分的にデプロイする、なんてこともできます。

さらに、複数のゲームから使える共通サービスが偶然生まれるかもしれませんし、規模が小さいから、しがらみが少ないのでがんがんデプロイできます。しがらみというのは、サービスとサービスが密結合で依存してしまうと「このサービスを更新したら、こっちのサービスにどんな影響があるかわからないからデプロイできない」という状態になりがちなんですけど、疎結合に設計しておけば、すぐに更新できるということです。

それから、少人数で開発できるので意思統一がしやすくて、品質も担保しやすいです。

システムを綺麗に作るとき一番いい方法は、少ない人数で長期間開発をすることなんです。大人数かつ短期間で開発するとバグだらけのひどいシステムになるので、本来は少ない人数かつ長期間で作るのが一番いい開発の仕方です。

今の話を聞くと、マイクロサービスってとってもいいですよね。

ユーザ情報保存のメインDBにNoSQL

次に、メインのデータベースで、「RDBMSは捨てて、NoSQLにしたいな」と思っている方はいらっしゃいますか?

(会場挙手)

2人。マイクロサービスに加えてNoSQLって、けっこう攻めてますね(笑)。

メインデータベースにはしないまでも、RDBMSとがっつり組み合わせて使っているという方は、どのぐらいいらっしゃいますか?

(会場挙手)

半数ぐらいですか。まぁ、普通だと思います。

特定の要素に特化したRDBMS以外のストアの総称を、NoSQLと呼びますよね。

RDBMSだとこんな感じになります。これを、仮にドキュメント指向データベースに置き換えたとするとこんな感じになります。

ドキュメント指向は構造が柔軟です。スキーマを初めから定義しなくても、CREATE TABLE 文を発行しなくても、いきなりドキュメントをポーンと入れればいいんですよね。

ドキュメント指向データベースとは違いますが、グラフデータベースみたいに、関係性に特化したものもあります。例えばAというエンティティを自分が持っていて、友達という関係性がつながっていて、Eまでたどり着く。こういう検索を行うクエリを投げるとEにたどり着くということもできます。

何ホップも関係性をたどって検索するのが速いです。

他にも、ゲームは、複数テーブルの1レコードだけを更新する処理が非常に多いため、RDBMSじゃなくて単純なKVSでも作れるんじゃないか、という意見もあります。

ゲームの特性として、ちょっとした1回のWebリクエストが複数の単純な更新処理に変わってしまうんですね。「ヒットポイントが削られた」「ボスにダメージ与えた」みたいな。やたら更新リクエストがくるんですけど、1リクエストの中で、複数の単純な更新処理を行うことが多いです。

プロセスをどんどん分散して処理を並列化したり、データ構造を行から列に変えて検索速度を上げたりと、特定の用途に特化したNoSQLも存在しています。

あと、いくつもレプリカを用意してデータを保護するデータベースというものもあります。例えば、いくつか並び立ったノードが3つぐらいあるとして、1回更新の処理を受け取るけど「2つ更新かかればOK」として結果を返してしまう。つまり、3つに更新処理を投げて、2つOKが返ってきたらOK。これは「Quorumベース」という仕組みです。

また、障害で更新されなかったら、ゴシップベースであとからばらまく方法もあるんですね。「僕は今バージョンいくつだよ」「お前古いバージョンだよ」みたいなかたちでばら撒いていって、ストア全体で最終のバージョンにもっていく。こういうNoSQLも、世の中には存在しています。

要は、いろんなアルゴリズムでいろんな仕組みのものが設計してある。便利ですよね。「とても良いので、がんがん使っていきましょう!」というお話では、今日はないんです。

実際、こんな感じの案件をいくつか横目に見ていましたが、こういったプロジェクトって例外なしに事故が起こるんです。本当に、がんがん事故が起こるんですよ。もちろん(規模の)大小はあって、軽傷で済むこともありますが。

なぜ事故が起こるのか?

じゃあ、何で事故が起こるんでしょうか? 応答速度を上げたいとか、開発の難易度を下げたいとかいろんな理由で、安直に無邪気に共有データの整合性を犠牲にして設計するんです。

まず、整合性が崩れます。結果、アイテムが消えたり無限増殖したり、ログインできなくなったり、いろいろ起こり始めます。修正したくても、サービスインと同時に実データが積み上がっていって、難易度の非常に高い大手術が必要になるケースが多いです。(こうなると)サービスを完全に停止して、修正に何週間、下手すると何ヶ月かかるかもしれない。

実際、つい最近こういう案件がありました。うちじゃないです。よその会社でつい最近「あ、止まっちゃったんですね」ということがありました。もう大惨事です。きっと政治的な責任の擦り付け合いが発生したり、敏腕の開発者が徹夜でがんばったり、ひどいことになっているわけです。

ゲームって、とてもつらいことに性能要件が高いんですよ。だから、みんな性能要件ばっかり見て、がんばって性能上げなきゃって設計して、事故が起こるんですね。

実はゲームでは、性能要件の高さに加えてデータの整合性を担保する要件も非常に高いです。性能は高くなきゃだめだ、手触り悪いのはだめだ、ってすごく言われる。でも、当たり前ですがデータの整合性が壊れたら「早く直せ」って言われる。

ということで、今日は、マイクロサービスの設計やNoSQLにとどまらず、さまざまな共有データの整合性を中心に、フレームワークの選定、言語の選定、クラウドの選定、マイクロサービスどうするの? 設計どうする? という話をしていこうと思います。

まず、共有データの整合性を担保すれば、リリース後の運用が非常に楽になります、という話。それから、システムの応答速度も開発速度も妨げません、という話。

ただ、コンウェイの法則や組織的な話は、今日はしません。サービスとかツール、AWS、Elixirの話もしないです。これらについては私も話したいとは思っているので、質疑応答などで聞いてください。

ということで、今日の話題ですね。事前に告知があったとおり、マイクロサービスの話と、RDBMS+NoSQLの話、その他もろもろの話をしていきます。

マイクロサービス設計のコツ

まず、マイクロサービスの話です。

マイクロサービスの設計のコツを1つ挙げろと言われたら、繰り返しになりますが「共有データの整合性の担保」を念頭に置いて設計することです。

密結合な共有データの集合でサービスを決めていく、サービス化していくのが、トランザクション境界=サービス境界という話です。

これを実現するためには、実は、サービス内は強結合で守るほうが、設計をシンプルに保てることが多いです。いつでもそうとは限らないんですけど、少なくともゲームの本体はトランザクションで守ったほうがいいです。

逆に、サービス間の結合は、結果整合性で担保したほうが設計がシンプルに保てることが非常に多いです。結果整合性が有効に機能する場合のみサービスを分ける、という言い方もできます。サービス間を強結合で守るのは非常に難易度が高いので、やめたほうがいいです。

この路線でサービスを分割していくと、実は1つのサービス、主にゲームの本体は非常に大きなモノリスになってしまいます。でも、それで正しい設計なんですよ。

モノリスになるのが正しい理由

なぜ正しいかを見ていきます。成功例よりも失敗例からのほうが人は学ぶので、まず失敗例からいきます。

例えば、お財布サービスがあるとします。実は私、ゲームサービスの前は、クレジットカードの決済サービスを開発していたんです。なので、決済システムを例にします。

サービス境界として、クライアント、APIサーバ、お財布サービスの3つがあります。まず、ゲーム内通貨をリアルマネーで購入するので、プラットフォーム、つまりApp StoreやGoogle Play ストア、Amazon Appstoreからレシートをもらいます。プラットフォームからもらったレシートを送って、それが検証に成功したら、ゲーム内通貨を加算して結果を返します。

その後ショップでアイテムを購入すると、ゲーム内通貨が減算されてアイテムが付与されます。ここでのポイントは、この3つの場所で共有データが更新されているということです。

システム全体の状態を考えたときに、通貨の追加前と、通貨の減算後で2つの状態に分かれています。

ここで注目するべきは、ここです。

一貫性を保つべき1つの状態の中で複数のサービスに分断されているんです。データストアのロケーションが2つに分かれちゃっている。

何が問題かというと、例えばここで通信エラーが起きたとします。

すると、ゲーム内通貨だけが減算されるという残念な状態になります。まぁ、最悪ですよね。

社内から「先にアイテムを追加すれば、ユーザから苦情は来ないよね」という意見も出たんですが「それはアイテム無限増殖バグですよね」って打ち返しました。サービスを超えた分散型のトランザクションはすごく難易度が高いし、マイクロサービスを設計する上でサービスを超えて強整合性を取るのはアンチパターンなので、やめたほうがいい。そもそも分けた時点で、結果整合性を取らなければいけません。

さらに悪いことに、ゲームではアイテムの購入処理を繰り返し行うことがありますよね。ゲーム内通貨の減算って、いろんなところで起こるんです。例えば、ボス戦で負けたとき、ゲーム内通貨を使うとすぐ再戦できるとか。

いろんな方法でどんどん減算していくので、通信回数が無駄に増えて、ゲーム全体の応答速度がめっちゃ悪くなるんです。

応答速度もゲームの重要な要件の1つなので、これらを理解せずに、お財布サービスを提供してくる課金プラットフォームも世の中にあります。あと、大手ゲーム会社の共通基盤チームとかが「お財布サービスを作ったので使ってください!」って言ってくることもある。これ、怒られるので、ツイートとかしないでくださいね。

(会場笑)

社名は出しませんが、お付き合いするときはみなさん本当に気を付けてください。ポイントむき出しで、レシートを実装していない課金プラットフォームとはお付き合いしないほうがいいです。

正しく設計し直すとどうなるか

そういう残念な設計を正しく設計したらどうなるかというと、まず「お財布」というのはやめて「レシート検証」だけにします。ゲーム内通貨の管理はゲーム本体に任せたほうがいいです。

よって、ゲーム内通貨の加算はここで行われます。レシートの検証はサービスで行って、ゲーム内通貨の追加はゲームAPIで行うことが多いです。

アイテムを購入すると、基本ゲームサーバ上でのみ更新処理が行われます。

これの何がいいかというと、3ヵ所で更新処理が発生しているんですが、状態を分けたときに同一の状態の中でストアが1ヵ所に集約されている。

例えば「レシート検証」で通信エラーが起きたら、レシートを再送してしまえばいいんです。

レシートの検証はただのロジックなので、状態をもっていなくて副作用がないので、冪等性があります。ここで正しくゲーム通貨が加算されるので、結果的に整合性が保たれる。これが結果整合性というやつです。

では「ゲーム内通貨加算」で通信エラーが起きたらどうなるか。

ゲーム通貨は加算し終わっているけど、クライアントは古い状態です。これも先ほどと同じように、レシートをまた送ればいいんです。レシート検証は冪等性があるので、問題がないです。

ここで、データベースで処理済みのレシートを確認できるので、加算せずに「加算したよ」という嘘の結果をクライアントに返せます。これでクライアントは課金プラットフォームに「処理済みだよ」という通知を送れるので、結果整合性が保たれました。

整合性を保つ仕組み

正しい設計とは、こういうことをいいます。

ちなみにクレジットカードだと「枠の確保」という概念があります。締日が来るまで支払いが確定していないので、実は取り消しができる。締日が過ぎたら店舗にお金が支払われるので、返金というかたちになります。

ちなみに取り消しが発生すると、クレジットカード会社はお店にお金を一切払いません。なので、クレジットカードシステムだと、実はそういうところに整合性の担保のしわ寄せがいきます。

あとGoogle Play ストアだと、返金の発生したものをAPIでとれるんですね。他のプラットフォームはとれないんですけど。なので、Google さんはゲーム内通貨を「マイナスで表示してね」という言い方をしています。

日本のゲーム会社はあんまりやってないんですけど、マイナス表示にしておけば、返金が発生したあと、次に購入したときにゼロに戻る可能性がある。そうやって整合性を担保してね、とGoogle さんはゲーム開発会社を指導しています。

速度に関してもこんな感じで、無駄な通信が減っていますよね。ゲーム内通貨は、ゲーム内で管理したほうが絶対にいいです。

関東財務局の指導では、資金決済法の供託金もトランザクションに守られた一貫性のあるDBに保存したデータをもとに計算することになっているので、厳密には、ゲーム内通貨の操作ログもゲーム内のDBに入れる必要があります。もちろん、このログを改ざんされると非常にまずいので、ログを保存しているテーブルだけアクセス権を変えたり、いろいろ苦労していることもあります。

gumiのマイクロサービス

続いて、少しだけgumiのマイクロサービスの全体像を説明していきます。ソーシャルゲームのシステム構成は、おおむねこんな感じですね。

ゲームサーバは、HTTPプロトコルを話すWebサーバが一般的です。クライアントへ、共有データを操作するインターフェースを提供しています。

リアルタイムなインタラクションが必要な場合は、クライアントとサーバを常時接続している例が多いです。この場合、HTTPは利用しないで、オリジナルのプロトコルをTCPやUDPの上に構築しています。サービスを分割しているので、疎結合にするために共有データを持たないようにしています。

あとは、右側のサーバが共有データのインターフェースで、左側は常時接続用のサーバで、一時的なステータスしかもたない。基本オンメモリで、素早くユーザに情報を返すというふうに、役割分担して共有データを持たないようにしています。さらに、ゲームロジックとネットワーク接続管理を分けて別サービスにしています。

Webサービスの文脈でいうと、NginxとかApacheは接続を受け持っていて、裏側にいるアプリケーションサーバないしCGIがロジックを担当しているという役割分担に似ています。

接続管理したり、同じパケットを複数のクライアントに複製して配信したりするのは、「PubSubサーバ」という別の分離したサーバで行うことが多いです。「PubSubサーバ」に関しては、各自で調べていただいたほうがよいかなと思います。

こうすると、裏側でロジックサーバを頻繁に再起動しても、ユーザの接続に影響が出ないんですね。とはいえ、完全に影響を廃するには、オンメモリをやめて、速度の速いKVSなどにデータを移す必要がありますけど。ここまですると、ロジックの部分をガンガン再起動してもクライアントとPubSubサーバはコネクションがつながりっぱなしになっていて、ゲーム進行データも継続的に利用できる。

あとは、認証やゲームシステムに依存しないものは、複数のゲームで共通で使用するマイクロサービスとして存在します。課金も、レシートの検証だけであるため、複数ゲーム共通です。

さらに、APIサーバの手前にプロキシを置いていますが、これは後で説明します。これ、ゲーム以外に流用が効きます。

うちが技術提供している他社さんの事例なんですが、「VARK」と書いて「バーク」と読む、VTuberのライブ配信サービスがあります。「Oculus GO」をかぶるとVTuberさんが目の前にいて、リアルタイムでトークしてくれたり歌ってくれたり踊ったりしてくれるサービスです。「VRライブ」や「ガチ恋距離」とかで検索してもらうと、評判が出てくると思います。

実際に、これが配信中にロジックサーバが止まってしまったという事故があったんですね。そのとき、慌てて再起動したらすぐ繋がって配信再開できて、「Oculus GO」をかぶっていた一般のユーザさんたちは障害に気付かなかったということがありました。

認証サービスの実例

話を戻すと、gumiのゲームは、こういう仕組みで作られています。

プロキシはちょっと特殊なので追加で説明すると、こんな感じでサービス境界があります。ユーザの情報はゲームサーバとの整合性が不要なので、マイクロサービスとして分離しています。さらに、プラットフォーム間のデータにも関係性がないので、プラットフォームごとにマイクロサービスを分断しています。

まず、ユーザ登録の登録ですね。「ユーザ登録をしたい」ってプロキシにお願いすると、ゲストログインのマイクロサービスにいきます。

この時点で、ゲームサーバへの通信は一切存在していないんですよ。大量にユーザを作り捨てられてもいいように、ゲストログイン側のデータストアはDynamoDBを使っています。

ログインもさっきと同じですね。ユーザIDとアクセストークンを、Proxyでキャッシングしてあげて処理するんですけど、ログインも、基本的にゲームサーバと一切通信せずに終わっちゃう。

次に、通常の通信ですね。アクセストークンをプロキシに送ると、そのアクセストークンを使ってキャッシングされている情報からユーザIDを引きにいくので、そのユーザIDをゲームサーバに渡してあげる。

ゲームサーバは認証関係の処理を一切行う必要がなくて、HTTPのヘッダーに入っているプレイヤーIDを信じて処理を進めるだけで大丈夫なかたちになっています。

TwitterやFacebookのユーザに紐づける場合は、ゲームサーバと通信せずに、Twitterとのやりとりを仲介するマイクロサービスと通信すると、マイクロサービスがTwitterと通信をしてユーザの紐づけを行って、紐づけたIDを返して、というふうにやっています。

なので、ユーザがゲームに対して新しい端末の追加や機種変更の処理をするとき、ゲームサーバは何にも関係ないので意識しなくて大丈夫なんですね。内部的にはデバイスIDが増えているのでいろんなことをやっているんですけど、基本的にはゲームサーバの開発者は認証の仕組みを一切知らない状態でゲームを開発できます。

課金サービスの実例

次に、課金について。課金も実はプロキシを通しています。

レシートをプラットフォームからもらって、レシートをプロキシに送って、各プラットフォームごとのマイクロサービスでレシートを検証して、ゲームサーバにゲーム内通貨の追加指示を出すんですが、プラットフォームの違いを吸収してゲーム通貨の追加の指示を出しているので、ゲームサーバのコードは1つでいいんです。

これから先プラットフォームが増えても、ゲームサーバには一切修正が入らないんですね。例えばどんな形式のレシートが来ようと、とにかく「検証OK」が答えなんです。もちろんレシートのユニークIDとかは送っていますが、それが全部共通化できるので、共通化して一本のAPIに絞っています。なので、マイクロサービスをどんどん増やして、クライアントのSDKもどんどん増やしていけば、ゲームサーバを更新することなくプラットフォームが増やせるというのが、今の仕組みです。

時間の関係上、これ以上紹介ができないので、ざっとゲーム開発で使用しそうなマイクロサービスを挙げてみます。

どうですか? ゲーム本体にもたくさんの機能がありますけど、ゲームの外側にもめちゃくちゃたくさんマイクロサービス化の対象があるんですよ。gumi でも、この表の全てをマイクロサービスにしているわけではありません。会社組織やチームの状況に応じて、この中から選択して、マイクロサービス化を試みると良いです。

ということで、普通はこのあたりの設計がいきなりできるようにはならないので、このあたりの本から入門すると良いと思います。

ここに基本的な話が書いてあります。何かの製品にベッタリな話は書いてないです。のちのち応用できる話がけっこう多いので、読み込んでおくのがおすすめです。

マイクロサービスアーキテクチャ

プロダクションレディマイクロサービス ―運用に強い本番対応システムの実装と標準化

余談ですが、私はマイクロサービスというキーワードを知る前からずっと社内で共通基盤を作ってきたんですが、この『プロダクションレディマイクロサービス』に書かれている内容は、そこで苦労してきた内容が事例として非常に多く掲載されていて、読んでよかったと思います。