目指すは“毎秒20万リクエスト”対応、「FF7エバークライシス」におけるパフォーマンス向上策の数々
ゲームを中心としたコンピューターエンターテインメント開発者向けカンファレンス「CEDEC2024」が開催。本記事では、「『FINAL FANTASY VII EVER CRISIS』全世界同時リリースを支えた大規模負荷に耐えるハイパフォーマンスなゲームサーバーの仕組み」と題した、セッションの様子をお届けする。 【もっと写真を見る】
2024年8月21日から23日にかけて、ゲームを中心としたコンピューターエンターテインメント開発者向けのカンファレンス「CEDEC2024」が開催された。本記事では、「『FINAL FANTASY VII EVER CRISIS(以下FF7エバークライシス)』全世界同時リリースを支えた大規模負荷に耐えるハイパフォーマンスなゲームサーバーの仕組み」と題した、セッションの様子をお届けする。 FF7エバークライシスのゲームサーバーに求められた要件は、「“250万DAU(デイリー・アクティブ・ユーザー)”に耐えられる構成」かつ、「全世界同時リリース」の実現だ。開発を担当したのはサイバーエージェントのゲーム・エンターテイメント事業部(SGE)に所属する子会社のひとつ、アプリボットである。 同ゲームにおけるバックエンドのリーダーを担当した永田員広氏から、独自開発のORM(Object Relational Mapper)を用いたデータベースアクセス削減を始めとする、パフォーマンス向上のための数々のアプローチ、そして、多言語対応の取り組みなどが語られた。 目標は負荷試験による「20万rps」達成、全世界同時リリースに向けた多言語対応 FF7エバークライシスは、2023年9月7日にスマートフォン向けにリリースされたFINAL FANTASY VIIシリーズのコンピレーションタイトルのひとつ(2023年12月にはSteam版もリリース)。原作シリーズから完全新作オリジナルストーリーまでを楽しめ、戦闘はアクティブターンコマンドバトル制となり、マルチプレイにも対応する。 前述の通り、同ゲームのサーバーに求められた要件は、250万DAUに耐えられること、そして全世界同時リリースだった。アプリボットではそれぞれの要件に対して「負荷試験による20万rps(Request Per Second)の達成」、「多言語対応」という目標を立てた。 まずは、クライアントとのメインの通信を行うゲームサーバーにおけるパフォーマンス向上の取り組みが紹介された。 ゲームサーバーは、AWSをクラウドサービスとして利用して、言語は「Go」を、フレームワークは「Echo」を用いて実装している。データベースは「Aurora MySQL」を利用して、ユーザー別の水平分割でシャーディング対応。キャッシュサーバーは「ElastiCache For Redis」を利用して、ワンタイムトークンによるセッション管理を行う。 なお詳細は触れられなかったが、オンラインでのリアルタイムバトルを担う「GameLift」を利用したC#で書かれたマルチバトル用サーバーや、「Octo」と呼ぶ社内のアセット配信基盤も、同一のAWS上に構築されている。 データベースアクセスの削減:リクエスト単位でキャッシュする独自開発のORMを活用 20万rpsの達成に向けて、アプリボットは「データベースアクセスの削減」「CPU負荷の軽減」「メモリ使用量の最適化」の3つの施策に取り組むことを基本方針として、APIを実装した。 最も注力したのは「データベースアクセスの削減」だという。これには、Go言語で社内開発した「ContextCashedORM」ライブラリによるキャッシュ管理が大きく寄与したという。 ContextCashedORMは、ゲームに必要な処理に特化したORMであり、APIリクエスト単位でcontext中のインメモリにキャッシュを保持する。キャッシュの有効期間はリクエスト中のみで、リクエストごとに破棄される仕組みだ。 キャッシュ管理では「読み込みキャッシュ」「書き込みキャッシュ」「バルク処理」の3つの機能を用意している。 まずは、データベースから取得したデータを、テーブルキャッシュとしてメモリ上に保持する「読み込みキャッシュ」の仕組みと工夫から解説された。 テーブルキャッシュ上に何もない状態で、特定ユーザーのアイテムを全取得する検索クエリが発行された場合には、データベースから取得したデータをテーブルキャッシュに保持する。これにより、データベースへのアクセス数を減らせる。 ただし、テーブルキャッシュだけを管理するのであれば、存在しないアイテム(item_id)を取得する際に“キャッシュに存在しない”のか“データベース自体に存在しない”のかが判断できない。そこで、テーブルキャッシュだけではなく、検索時のクエリ条件を保持するクエリキャッシュも管理する仕組みにした。 このクエリキャッシュは、SELECT文のWHERE句以下の条件をクエリ条件としている。AND条件において、部分一致するクエリ条件の検索結果は、特定のクエリ条件の検索結果を含んでいるという関係性を活かして、新規で発行するクエリ条件に対して、クエリキャッシュに部分一致するクエリ条件が存在する場合には、検索対象がキャッシュ済みであると判断することできる。 キャッシュしていないと判断した場合は、データベースからデータを取得してテーブルキャッシュに追加する流れとなり、「検索済みの場合は、データベースアクセスを一切発生させない仕組みができた」と永田氏。 続いては、データベースの更新・追加・削除などの処理をキャッシュ上で管理する「書き込みキャッシュ」の仕組みだ。これらの更新処理は、テーブルキャッシュ上の“レコード状態”を管理することで実現した。 レコード状態は、データベースから取得した未変更のデータであれば“SELECT”、更新する必要があるデータであれば“UPDATE”、新規追加する必要があるデータであれば“INSERT”、削除する必要があるデータであれば“DELETE”と定義。この各レコードの状態を、書き込み処理の際に適切に設定する。 例えば更新処理の場合は、更新したいテーブルキャッシュのデータのステータスが“SELECT”から“UPDATE”に変わる。「ただし更新処理をした場合に常にステータスが変わるわけではない」(永田氏)といい、更新対象の状態がデータベースに追加予定の“INSERT”の場合は“INSERT”のままで更新され、“DELETE”の状態やキャッシュ自体に存在しない場合は、エラーを出力する。 最後に、書き込みキャッシュに保持する複数の処理を最低限のクエリ数でデータベースに書き込む「バルク処理」の仕組みだ。テーブルキャッシュ上のすべてのデータをレコード状態単位で集計して、更新・追加・削除に分けてクエリを発行、それぞれまとめてバルク処理で書き込む。「クエリの発行数をひとつのテーブルで最大3件に収められ、書き込みクエリ数を大幅に削減できる」と永田氏。 バルク処理は、すべての検証が終わったAPIの最後の処理として行われるため、エラーによるロールバックが発生しないのも特徴だ。 ContextCashedORMでレコード状態を把握することで、「ユーザーデータの差分同期」も可能になった。本ゲームでは、ログイン時にユーザーの全データをクライアントに返却しているが、すべての通信の共通レスポンスとして、更新・追加・削除したレコードを返すことで差分同期を実現した。不要なデータベースアクセスや通信コストの削減につながり、基盤で対応していることから、サーバー・クライアント間のユーザーデータの整合性も向上している。 事前ロードでの検索クエリの最適化も実現した。これは、ContextCashedORMの読み込みキャッシュ機能を用いて、事前にまとめてデータベースの情報をテーブルキャッシュにロードしておく仕組みによるものだ。報酬付与・消費や条件チェックなどのゲームでよく利用される共通処理基盤に適用することで、処理を変更することなく、検索クエリを削減することができた。 CPU負荷の軽減:逆引きテーブルなどの自動生成マスターの活用、カスタムマスターによる計算済みデータのキャッシュ化 続いては、「CPU負荷の軽減」のための施策だ。 ひとつ目の取り組みが、他のマスターデータからロジックで生成可能な「自動生成マスター」のサポートだ。 本ゲームでは、マスターデータもデータベースアクセスを必要とせず、メモリ上で管理している。マスターは、Gitのブランチ単位でcsv形式で管理され、編集は管理用データベースを経由しスプレッドシートで行なう。このGitHubのGitブランチをデプロイして、ゲームサーバーやクライアントで利用できるようAmazon S3に配置している。 自動生成マスターは、スプレッドシートからGitを取り込む際に、ロジック用のマスターをGitブランチにcsvとして出力できる機能だ。これにより、事前に全探索をした逆引きテーブルを用意しておくことで、計算コストを削減することができる。特定のアイテムがどこから入手できるか、バトルの有利属性はなにかといった、マスターの全検索が必要な際に有効な機能になる。 また、カスタムマスターの利用によって計算済みデータをキャッシュ化して、計算コストを最適化している。 自動生成マスターはGit上でcsv管理しているが、カスタムマスターはゲームサーバー上でメモリに展開されるGoのstruct(構造体)として管理される。特定の処理をしたデータをカスタムマスターキャッシュとして保持することができ、初回後の計算コストを削減できる。Sort済みの配列の用意や、Map化による検索コスト削減などに用いられる。 メモリ使用量の最適化:Steamの活用とsync.Poolでのメモリの再利用 パフォーマンス最適化の最後の取り組みは、「メモリ使用量の最適化」だ。 まずは、Streamを出来るだけ引き回すことで、メモリの使用を削減している。特にリクエストやレスポンスなどは、クライアントとのやり取りの関係上、大規模なバイト配列の確保が必要となる。リクエストの処理をio.Reader、レスポンスの処理をio.Writerとして、bytes.buffer Streamを引き回すことで、各処理のbytes.bufferがひとつずつで済み、メモリの確保量が削減できる。 また、Go標準のメモリを再利用できる仕組みである「sync.Pool」を用いて、再利用可能なメモリプールとデータをやり取りすることで、一度確保したメモリを解放せずに再利用できる。「sync.Poolは、リクエスト・レスポンスなどの頻繁かつ大規模にメモリを確保する処理に適している」(永田氏)といい、ログ出力やredisのデータ通信にも利用されている。 負荷試験では“”ミニマム“での最適化で、ボトルネックをひとつずつ解消 こうして様々なアプローチでパフォーマンスの最適化を図った後、負荷試験でさらに徹底的なチューニングを行なった。 負荷試験は、1時間程度の瞬間的な負荷が高まるスパイクにおいて「20万rps」、定常時の最低限の負荷は「5万rps」という目標を立てた。シナリオは、社内試遊会のプレイデータからAPI比率を算出した“通常プレイシナリオ”と、ユーザーの新規作成からガチャを引くまでを繰り返す“リセマラシナリオ”の2つを用意した。 また、最初はゲームサーバー1台に対して最適化する“ミニマム試験”から入り、チューニング後に台数を増やしてスケールをチェックするという流れをとった。永田氏は、「1台のチューニングから始めることで、負荷試験にかかるコストが減り、シンプルな構成でボトルネックも可視化しやすい。ミニマム試験で徹底的にチューニングすることをおすすめする」と語る。 事前準備として、全APIを叩くシナリオを用意し、ログ出力やマスターの状態なども本番相当の環境を整えた。また、ボトルネックが可視化できるようpprofなどのプロファイラーやtracerなども導入した。 ミニマム試験では、pprofやtracerでボトルネックを調査して、調査結果をもとにひとつずつ対応していく。「複数の対応を進めると影響度が分からなくなるため、丁寧にボトルネックを潰していくのが重要」と永田氏。パフォーマンスチューニングを繰り返すと徐々にCPU負荷も減少するため、CPU負荷が適切にかかるようクライアントを随時調整していくのも注意点だ。 特に影響の大きかった改善項目は「ライブラリの変更・更新」だったという。例えば圧縮用のライブラリである「Lz4」をバージョンアップ(v2からv4へ)するだけで、CPU負荷は22%低下した。また、ログエンコードライブラリをpbjsonから標準のencoding/jsonに変更することで、CPU負荷は33%低下したという。 Go自体の設定変更もパフォーマンス改善の効果をもたらした。GC(garbage collector)の頻度を減らすためにGOGCをオフにして、適切なGOMEMLIMITを設定することでCPU負荷は9%低下。さらに、ビルド時にバイナリを最適化する「PGO」を利用することでCPU負荷は7%低下した。その他にも、EC2のインスタンスタイプをARM系に変更することで、10~20%のCPU負荷低下につながっている。 APIの調整も効果が得られた。APIログで共通レスポンスなど不要なフィールドを削除することでCPU負荷は34%低下。同一テーブルに複数クエリを発行しているロジックも調整した。 このような細かな改善策を重ねていった結果、初回は1台あたり180rps(1vcpu:90rps)であったのが、最終的には1500rps(1vcpu:380rps)にまで達した。大きくロジックを変更することなく、1vcpuあたり420%もパフォーマンスを向上させたことになる。 運用に耐える海外環境の構築:マスターデータ・リソースの多言語化、国内・海外の差分管理 もうひとつの目標である全世界同時リリースのための多言語対応の取り組みも紹介した。 海外対応における多言語化には、マスターの文字列および文字列が埋め込まれた画像を日本語・英語表記にしなければならない。その一方で、日本語版と英語版のマスターデータはリアルタイムに同期する必要があり、そのためには設定やストア情報など、国内と海外で差分が出るマスターを取り込む仕組みが求められた。 まず、マスターデータの多言語対応には、翻訳が必要な文字列をすべて言語テーブルで管理することで解決した。この言語テーブルは通常のマスターとは別で管理を行い、言語別で配信することでクライアントが必要とする言語情報のみが取得できる。これにより、通常のマスターデータからほとんどの文字列情報を排除して容量を削減し、それを言語テーブルに集中させることで、翻訳などの管理コストも低減できた。 リソースの多言語対応も必要となる。前提として、画像などのリソースには極力文字情報を含めない方針をとった。ただし、ヘルプなどどうしても言語の出し分けが必要な場面では、クライアント側で対応した。日本語、英語で階層を用意して、同じIDで常にそれぞれのリソースを作成、言語に応じて参照してもらう仕組みを採用した。 最後に、全世界同時リリースのための、マスター差分管理機能だ。まず、国内・海外で専用のブランチを作成して管理する形をとった。マスターの差分管理では、ブランチにどこまで取り込んだかの情報を持たせ、最後に取り込んだブランチからの追加差分を取り込む、“gitのリベース”のような挙動を管理画面のロジックとして採用。国内・海外マスターの追従が容易にできるようにしている。この仕組みは、マスターのバージョン管理や個人ブランチのデータ反映にも用いているという。 文● 福澤陽介/TECH.ASCII.jp