モバイルゲームのサーバーサイドを高速化する

長井昭裕氏(以下、長井):それでは『Ruby on Rails on AWSでの最適化事例 ~200ms → 100msへの歩み~』と題しまして、アカツキの長井が発表させていただきます。

まず簡単に自己紹介させていただきます。私は2016年にアカツキに入社して、モバイルゲームのインフラ構築と運用、そしてRuby on Railsのサーバーサイドアプリケーション開発、その他開発環境を整備したり、分析環境を構築したり。その他もろもろ担当しています。

すごくどうでもいいんですが趣味はカレーで、年間300食くらい食べるのでカレー情報もお待ちしております(笑)。

ではさっそく本題に入っていきます。この図は平均レスポンスタイムの推移を表したグラフです。

とある日のバージョンアップを境目に平均レスポンスタイムが200ミリ秒から100ミリ秒に大幅に改善することができました。まさしく断崖絶壁です。

今回このモバイルゲームのサーバーサイドにおいて、いかにしてこのような高速化を実現して、その結果どのようなメリットがあったかということについてご紹介いたします。

本日のアジェンダです。

まずは高速化。我々はなぜ高速化するのかという意義について説明して、モバイルゲームにおけるサーバサイドの役割。そしてそのインフラ構成と高速化事例をお伝えして、最後にまとめます。

我々はなぜシステムを高速化するのか?

まず高速化の意義です。我々はなぜシステムを高速化するのでしょうか? これはもう自明です。レスポンスタイムの高速化はいかなる場合も正義です。ただこれだとなんの説明にもなっていないので、具体的なメリットを挙げます。

まず、ユーザーサイドからするとUXの向上が挙げられます。ユーザーにとってストレスをなくすことができますし、これはひいては定着率の改善にもつながります。

そして我々サービスプロバイダ側としては、インフラの高集約化が可能になります。キャパシティが増加するのでより多くのユーザーを受け入れることもできますし、同じユーザーをさばく場合はインフラを縮小して運用することもできるので低コスト化することもできます。

つまり高速化というのはユーザーにとってもプロバイダ側にとっても双方にメリットがあるものです。やらない理由はありません。

さらに今回ターゲットとしているモバイルゲームの特徴として、アクセススパイクが頻繁に起こるというような事情もあります。

これは分間のリクエスト数をグラフ化したものなんですけれども、ここで新しいキャンペーンやイベントが開始していて、一気に跳ねています。

ちなみに補足しておくと、真ん中のところはへこんでいるんですがシステム障害が起きているわけじゃなくて、こういうキャンペーンの特徴です。

このように直前のリクエスト数の2倍とかそういうのが本当に頻繁に起こります。ですので高速化によるキャパシティ担保っていうのは我々にとっては使命なわけです。

そして今回の高速化のターゲットについて簡単に触れておきます。まず今回高速化するのは運用開始して数年経つモバイルゲームのサーバーサイドアプリケーションです。

WebフレームワークとしてRuby on Railsを使っていて AWS上で動作しています。そして運用開始して数年経っているので、一般的な高速化はやり尽くされています。

例えば、N+1クエリの撲滅だとか、DBから大量のレコードを取得するのを回避するだとか、indexをちゃんと貼るだとか。そうしたことはもうだいたいやってあります。

今回はこのほかにどんな高速が残されているのか? ということについて述べていきます。

そしてその前にRuby on Railsについて簡単に触れておきたいと思います。Ruby on Rails、おそらく知らない人はいないであろうぐらい非常に有名なWebフレームワークで高い生産性と柔軟性を持つという特徴があるので、スモールスタートができてスタートアップでの採用が多い。そんなフレームワークです。

一般的に「大規模環境向けではない」とか「高速ではない」と言われています。ですが今回のプレゼンでは、このRuby on Railsを用いたシステムがAWS上で大規模にスケールアウトして、かつ高速に動作するということをお伝えしようかと思います。

モバイルゲームにおけるサーバーサイドの役割

次にモバイルゲームにおけるサーバーサイドの役割について簡単に触れておきたいと思います。モバイルゲームにおけるサーバーサイドは基本的にはAPIサーバーとして動作します。

内容としては、まずクライアントへのゲームデータの返却があります。これを具体的に言うと、例えばあるプレイヤーが現在チャレンジ可能なイベント一覧とか、「このイベントに出よう」と思ったときにそのイベントの内容みたいなのをクライアントサイドに返します。

次にゲームロジックの計算というのもサーバーサイドで行います。ここに書いてあるようにログインボーナスやミッションの達成条件判定ですね。

ログインボーナスっていうのはゲームにログインしたときにアイテムがもらえるんですけど、そのユーザーが受け取れるのかどうかとか。今日はどのアイテムが受け取れるのかとか。そんなような判定をしたり。

ミッションというのは特定の条件でゲームプレイしたときに報酬がもらえるんですけども、その特定の条件を達成したのかどうかとか、そういうような条件判定というのをサーバーサイドで行ったりします。

そしてもう1つ大事なのがユーザーデータの管理です。

例えばあるプレーヤーがそのイベントクリアしたのかどうかとか、そういう達成状況を管理したりとか。ユーザーが持っているアイテムを管理したりとか、もろもろほかにもいろいろあるんですが、そんなことをモバイルゲームのサーバーサイドはやっています。

何が重要かと言うと、ユーザーがゲームでなにかアクションをするたびにサーバーサイドとの通信が走ります。そのためレスポンスタイムの長短はUXに大きく関わってきます。

そんなモバイルゲームのサーバーサイドなのですが、高速化するにはいくつかの技術的な課題があります。まずユーザーデータが大量にあるということですね。

ユーザーが持っているキャラクターは数百体にのぼりますし、キャラクターの育成状態もまちまちで、キャラクターの育成パラメーターというのも非常に膨大だったりします。

また、今回は運用して数年経っているゲームですし、かつて開催したイベントというのはもう数百個を超えている状態だったりとかします。それぞれ扱わなければならない点が難しいですね。

そしてもう1つ、ゲームロジックが複雑です。例えば「このイベントに挑戦するには別のイベントA、Bをクリアしていないとダメだよ」とか。あと「このミッションは特定のキャラクターを編成して、かつ特定のアイテムを使用した場合のみ達成するよ」とか。

こんなふうにイベントに挑戦するにしてもキャラクターの情報を取得したりとかアイテムの情報を取得したりとか、そういうさまざまなデータを参照してゲームロジックができています。そのように大量のデータを扱って、かつ複雑であるという特徴があります。

そしてもう1つ、インフラ的にはWrite intensiveな特性というのが課題としてあります。「ユーザーがアイテムを取得しました」とか「経験値を獲得しました」とか、そのたびに書き込みが発生する。

一般的なWebサイトですとHTTPで言うGETメソッドが多く呼ばれると思うんですが、モバイルゲームではPOSTやPUTみたいなメソッドが大量に呼ばれます。なのでキャッシュを並べてスケールアウトという戦略が取りづらくて。

何が言いたいかと言うと、高速化というのはなかなか一筋縄ではいかないということです。

インフラ構成について

このような特性のシステムなんですが、次はインフラ構成について述べておきたいと思います。今回は高速化ターゲットにしているので、高速化に関連するインフラだけを抜き出して図にしております。

実際ゲームを運用するには、もちろんCDN、CloudFrontを使ったりとか、ログの分析とか収集基盤とかあるんですけど。今回この図からは割愛させていただいております。

動作を簡単に説明しますと、上から、ユーザーからのリクエストがLoad Balancerを通って、ここのEC2ですね。ここでRailsが動いていて、ここで処理されます。

ここのアプリケーションロジックに従って、それぞれのデータストアにアクセスして計算して、レスポンスを再度ユーザーに返してあげるというようなことをくり返します。

データストアたくさんあるんですが、それぞれ役割に応じてわかれています。まず左から説明しますと、このMaster DBと呼ばれるものですね。これはイベント情報などのゲームデータを格納しています。

次にUser DBと呼ばれるものですね。こちらはユーザーが所持しているアイテムとか、そういうものを格納しています。

このシステムではキャッシュを2種類使っていて、memcachedではセッションとかレスポンスキャッシュとか、揮発して問題ないデータをキャッシュしておくのに使っています。

データストアのスケールアウト戦略

もう1つ、Redisに関してはランキングとか永続化したいキャッシュとか、そういうのでデータストアを使い分けています。

これらのデータストアなんですが、今回のタイトルにあるようにもちろん大規模環境ですのでスケールアウトしています。それぞれのスケールアウト戦略について簡単に述べておきます。

Master DBはゲームデータを格納しているので、ほとんどがRead。Read intensiveな特性になっています。ですのでこれのスケールアウトはRead Replicaを追加するだけでオッケーです。そのためにRDSのAurora MySQLを使っていて、簡単にRead Replicaを増やすことができるというようなかたちです。

次にUser DBですね。

こちらは水平分割。いろいろなやり方があると思いますが、水平分割を採用しています。ユーザーのアイテムとか経験値とかそういうものを管理しているので、非常にWrite intensiveな特性です。

先ほどのMaster DBのようにRead Replicaを追加することでスケールアウトっていう戦略は取れないので。これは水平分割というようなことをしています。

また アプリケーションの都合、生産性によるところなんですけど、異なる種類のユーザー情報ですね。アイテムの情報、キャラクターの情報みたいな。そういう情報をJoinしたいとか、そういう要求があって1人のユーザーの情報というのは1つのshard内、1つのDBインスタンス内に収めるような戦略をとっています。

キャッシュについては、これは垂直と水平分割を両方やっています。

垂直分割に関しては基本的には用途別のキャッシュクラスタを構築してやっているんですが。1つのインスタンスですとやっぱりキャパシティ的に足りないので、そこはコンシステントハッシュ法とかでkey分散して、水平分割してキャパシティを担保しています。

このようにスケールアウトしているんですが、それぞれがどれぐらいの規模感かということもざっくりとお伝えしておきます。ちょっと詳細な数字は出せないんですけど、だいたいこれぐらいです。

ユーザーからのリクエスト数はだいたい分間数十万から百数十万リクエスト来ていて。EC2はコンピュート最適化のc4.8xlargeですね。36個あるものが数十台いて、Master DBはr4.8xlargeが数台。

User DBに関してはr4.4xlargeが数十台。そしてキャッシュについては用途に応じて混在してるんですが、m4.2xlargeとかr3.xlargeとか。ここらへんが数十台ある。そんなような規模感です。今回はこういうシステムを高速化していきます。

実行した高速化施策

では、高速化の実践に入っていくんですが、おさらいとして、一般的な高速化はやり尽くされているということです。今回はそうではないものについてご紹介したいと思います。

まず1つ目、ActiveRecordの実行時間短縮です。ActiveRecordっていうのはRuby on Railsが採用している、Ruby製のORMapperですね。このレスポンスタイムのところで言うと、このところにあたります。だいたい30ミリ秒から15ミリ秒に改善しているというような感じですね。

先にネタばらしをしておくと、これはAZ間の通信オーバヘッドでしたというかたちです。先ほどのインフラの図に戻ると、このあたりですね。Master DBとEC2のところです。

ここをちょっとクローズアップして別の図に書き起こすとだいたいこんな感じです。

AZが2つあって、それぞれにEC2とRead Replicaがこういうふうに分散して配置されています。

もちろんこれはAWSのデザインパターンとして対障害性担保のためにこういうふうに複数のAZにインスタンスを配置するということが推奨されています。そうすることで片方のAZで障害が起きて丸ごと使えなくなっても処理を継続できるためですね。

ですが異なるAZ間のラウンドトリップタイムというのが同一AZ間のものよりも全然大きいという問題もあります。今回は東京リージョンを使っていて、これはap-northeast-1aと1cですね。実際に測定した値だと、1aと1cの間はだいたいラウンドトリップタイムが2.5ミリ秒。そして同一AZ内だと0.2ミリ秒あります。

これが1回だけならいいんですが、ActiveRecordを使っていると、どうしても1トランザクション内で複数のクエリを発行することになるので、この差が積もり積もって無視できないものになってしまいます。

そこで今回は、可能な限り同一AZ内にあるDBへクエリ発行を行うことでレスポンスタイムの高速化を実現しています。

これだけならいいんですが、実は縮退時のことも考えなければなりません。大規模環境ですし、データベースのインスタンスはたくさんあるわけです。そして長い間運用しているとしばしばFailoverとかDBのインスタンスの障害に見舞われることがあります。

そんなときにこちらに障害が起きたとします。再接続しなければならないんですが、同じように同一AZのDBに再接続すると負荷の偏りが発生して連鎖的に障害を起こす可能性があります。

なのでどんどん端っこからデータベースが死んでいくみたいな、そんなリスクがあるわけです。

ですのでこの縮退時を検出して、そのときだけは同一AZ内の優先的な接続っていうのをやめてランダムでインスタンス選ぶということで負荷分散するというような処置が必要です。つまり高速化だけではなくて、システムの信頼性も維持することも忘れてはならないということです。

このようなことを行った結果、平均レスポンスタイムは15ミリ秒に短縮して、信頼性も犠牲にせずできました。

高速化の効果

では次に移っていきます。次はこのミドルウェアの部分ですね。もう一見してなんか怪しいっていうのがわかる感じですね(笑)。

このレスポンスの詳細を比較して、ミドルウェアがどうも時間かかりすぎていると。これは怪しいと思って調べてみたところ、やはり改善できる余地がありました。これも原因を先にお伝えしておくと、DBのコネクションの死活監視が原因でした。

まずRuby on RailsにおけるDBのコネクション管理について簡単に説明しておきます。Ruby on Railsでは接続済みのDBのコネクションをプーリングしています。そして必要に応じてこのプールからコネクションが返却されます。

必要に応じてというのは、このようにユーザーからのリクエストがあって、アプリケーションロジックがデータベースのアクセスが必要になったというときにこのコネクションプールからチェックアウト。

そしてクエリを出して、レスポンスを返すにあたってもう必要がなくなったらチェックインするというようなサイクルをリクエストごとにくり返すわけです。

ただこのプールからは生きたコネクションをアプリケーションロジックに返したい。返さなければならないので、事前に疎通確認を行っています。MySQLの場合はpingを送ることで疎通確認をしているんですが、これがリクエストごとに発生するわけです。

ただリクエストごとに発生するにしてはコネクションが切れる要因はけっこう少なくて。例えばデータベースがFailoverしたりとか、WaitTimeoutというのは、例えばコネクションプールに入りっぱなしでぜんぜんクエリを送ってなくて何秒も経ってMySQLサーバ側から切られるというような。そういうようなときには起きます。

これって運用しているとどれもレアケースで、毎回ユーザーからのリクエストのたびに行う必要はないよねということで。今回はこのWaitTimeoutの値に気をつけながら、今回採用した値は30秒ですね。最後の疎通確認から30秒pingを抑止するというような施策を取りました。

これだけだと「まあ、数ミリ秒しか改善しないんじゃないの?」っていうふうに思うかもしれませんが、今回は大規模環境なわけです。データベースは数十台あります。

ただRuby on Railsというのは残念ながらデフォルトでは複数のデータベースを扱うことができないので、このシステムではOctopusというライブラリを使っています。

OctopusはRuby on Railsのロジックを書き換えるかたちで実装していて、その関係上このコネクションプールからのチェックアウトのときに、すべてデータベースにpingを送ってしまうという問題があることが今回の調査でわかりました。

こうなってしまうとデータベース数十台で、そしてping1回送るのに数ミリ秒かかるわけで、これが積もり重なると莫大なオーバヘッドになってしまうことがわかってこれを改善しました。そういうことでこの削減に成功しました。

もう1回図に戻ると、ここのところですね。

ここで平均レスポンスタイムが50ミリ秒改善しました。ちなみになのですが、この問題。プールからコネクションチェックアウトするたびにpingを送ってしまう問題っていうのは、Railsのアップストリームでも議論されていて。

具体的にはSpotifyやGrouponとかそういう会社でも困っているようです。今のところMySQLとか、そこらへんが正しくコネクションの状況を返してくれないという事情があって、なかなか根が深い問題のようです。

一旦ここまでの高速化の効果をまとめます。

同一AZへの優先的なクエリ発行でだいたい平均15ミリ秒改善。DBへの過剰な死活監視を抑止して、50ミリ秒。

これで65ミリ秒改善したんですが、100ミリ秒までの残りは、実は地道なアプリケーションの最適化を行なっています。

この内訳の中で言うと、Rubyの部分でだいたい100ミリ秒かかっていたのが65ミリ秒に縮んでいます。インフラの図で言うと、戦略としてはここです。

キャッシュを積極的に活用することでレスポンスタイムを縮めていきます。

フレンド機能の高速化のためにやったこと

ここでは紹介しきれないくらいたくさんの施策を行ったんですが、1つピックアップしたいのがフレンド機能ですね。

まずフレンド機能というのは何かと言うと、ほかのユーザーの一部キャラクターを自分のキャラクターのようにイベントに連れ出せる機能です。モバイルゲームでは非常によく実装されている機能です。

ただ、これを高速化するにあたってはもちろん技術的な課題があります。キャラクターデータを保持しているUserDBが水平分割されていることです。つまりほかのユーザーのデータにアクセスするためには自分以外のシャードにアクセスしなくちゃいけないのでなかなか大変です。

あとは先ほども述べたように、キャラクター1体あたりのデータ量が多い。そしてこのスクリーンショットを見てわかるように候補としてリストアップするフレンドがすごく多いです。

これスクロールもできていて、数十人のキャラクターを出さなくちゃいけないです。これをそのまま実装すると数十台に分割したUserDBにすべてアクセスしなければいけないので非常に時間かかってしまいます。そこでキャッシュを利用するということを考えるわけです。

インフラの図に戻りますと、ユーザーのデータはこの数十台に分割されたこのUserDBに置かれています。

それぞれにアクセスしなくちゃいけないんですが、逐一アクセスするというのは非常に時間がかかります。

そこで例えば「非同期で数十台にアクセスすればいいんじゃないの?」っていう話もあるんですが、残念ながらRailsでそれをやるとコネクションプールの扱いがちょっと難しくてやりづらいっていうような課題があります。

こういうようなインフラ上の課題を解決するために、まずフレンドとして必要になる情報のみMemcachedに置いてしまうと。そしてフレンド機能として必要になるデータはここから読み込む。

ただ既存の機能を壊さないためには、キャラのレベルアップなどのフレンド機能として出すべき情報、常にアップデートしなくちゃいけない情報というのはこういうふうにキャッシュにも書き込むというようなことをしました。

こういうようなことで、もともとこのAPIすごく重たくて900ミリ秒かかっていたんですけど、それが300ミリ秒に高速化するというようなかたちです。このようなちょっとインフラで苦手なところを別の仕組みでカバーしてあげるというようなこういう施策を実はたくさんやっております。

このようなアプリケーションの高速化を重ねていった結果、平均レスポンスタイムは35ミリ秒削減することができて。これで平均レスポンスタイムは100ミリ秒を達成しました。

UXの改善と同時にコストも削減

そしてこれに付随してすごくよかったことがありました。実は必要なEC2の台数が約3分の2になりました。この規模を出すと年間数千万円になるので非常に大きい削減となりました。

そして実はリージョン間の通信費ですね。同一AZへの優先的なクエリ発行をすることによって、リージョン間の通信も減って。これも年間数百万円削減しました。

これはつまりユーザーにとってはよりよいUXを提供して、我々にとってもコスト削減できて嬉しいというような双方にハッピーな効果が得られました。これこそが高速化の効果です。

ちなみにちょっと補足しておきますと、このリージョン間の通信費はAWSのエンタープライズサポートに詳細に出していただいているコストレポートのおかげです(笑)。これに気づいてから現場でもコスト意識が高まったので非常に良い効果が生まれたなと感じております。

まとめます。今回はAWS上で大規模にスケールアウトしたRuby on Railsアプリケーションの高速化事例についてご紹介しました。

具体的には対障害性を犠牲にせず同一AZ内で通信を優先的に行う。そしてコネクション監視といったミドルウェアレベルで解析して挙動最適化しました。そしてインフラの特性を活かしたアプリケーションの最適化っていうのを行なっていきました。

このような最適化を継続していることでRuby on Railsの高い生産性や柔軟性を活かしながらも、こういうふうに大規模でさらに高品質なサービスをコストを抑えて提供することができます。

ちなみに今回のターゲットで使ったソフトウェアです。

資料はあとで公開されるのでご覧になってください。以上となります。ご清聴ありがとうございました。