はじめに
はじめまして、2024年3月下旬から1ヶ月間、JOB生としてインターンをしていた小池雄大です。株式会社アプリボットでゲームのバックエンド開発をGoで行っておりました。普段は、個人開発や長期インターンシップでWebアプリケーションの開発を行っております。本記事では、インターンを通じて学んだことや気づきを紹介できればと思います。
参加動機
私がサイバーエージェントのゲーム事業に興味を持った理由は主に2つあります。
Webとゲームのバックエンド開発の違いを知りたかった
2023年8月に参加した「Go College」というサイバーエージェント主催の育成型インターンに参加したことがきっかけです。当時の私のメンターがゲーム事業部のバックエンドエンジニアであり、普段の仕事内容を聞く中でゲーム開発に興味を抱くようになりました。Web開発との特性の違いを知りたいと思い、今回のインターンに参加しました。
高トラフィックな環境に興味があった
普段から開発を行う機会は多いのですが、大量のユーザーを想定して設計および実装を行う経験があまりありませんでした。そのため、高トラフィックな環境を通じて非機能要件を強く意識できるようになりたいと思い、ゲーム事業を志望しました。
ゲームならではの特性
インターンをして気づいたゲームのバックエンドならではの様々な特性がありました。
ユーザー/マスターデータの取り扱い方
私がこれまで扱ってきたアプリケーションとの大きな違いは、データの扱い方にあると思います。ゲーム開発では膨大な量のデータを扱うため、特別な工夫が必要となります。扱うデータには、ユーザーデータとマスターデータの2種類が存在します。
マスターデータ
マスターデータとは、ゲームの基盤となる固定的なデータであり、ゲームのルールや設定、アイテム情報などを含みます。このデータはゲームの開発者によって定義され、イベントなど追加コンテンツに応じて更新されます。
ユーザーデータ
ユーザーデータとは、各プレイヤーがゲームをプレイする過程で生成・管理されるデータです。このデータはプレイヤーごとに異なり、動的に変化します。
特にマスターデータの扱い方が特殊でした。マスターデータはGit上でCSVファイルとして管理され、スプレッドシートで編集を行う仕組みになっていました。Gitとスプレッドシートの中継役としてデータベースが使われており、稼働しているサーバーはデータベースからマスターデータを直接取得するのではなく、インメモリにキャッシュされたデータを参照する仕組みになっています。さらに、サーバーへのマスターデータの同期はGitから特定のコミットをデプロイすることで行われていました。
このフローを初めて聞いたときは、少し混乱したのを覚えています。
DBへの負荷軽減
ゲームのAPIサーバーの特徴の一つとして、大量のリクエストが送られてくるため、いかにしてデータベースへの負荷を減らすかを考える必要があります。そのため、以下のような取り組みを行っていました。
インメモリキャッシュ
インメモリキャッシュは、データをメインメモリに一時的に保存する仕組みです。これにより、データへのアクセス速度が向上し、ディスクI/Oによる遅延を避けることができます。
シャーディング
シャーディングは、大規模なデータベースやストレージシステムを複数の分割されたデータセット(シャード)に分割して管理する方法です。各シャードは異なるデータを保持し、全体として一つのデータベースを構成します。
ゲームサーバーは1秒間に1回、RedisにキャッシュされているGitのハッシュ値を元にマスターデータが書き換わっていないか確認し、更新されていたらS3上に保存されているCSVファイルをサーバーに取り込んでいます。そのため、すべてのAPIでマスターデータを参照する際データベースにアクセスする必要がないため、データベースへの負荷を軽減しています。
また、ゲームサーバーの特徴として、書き込みが多くなる(例えば、ガチャや新クエスト公開時)ため、ユーザーデータはシャーディングされてデータベースに振り分けられています。Cloud SpannerやTiDBなど、フルマネージドなNew SQLも存在しますが、私の部署では独自にMySQLのシャーディングを行っていました。
ユーザーの伸び方
ユーザーの動向が異なるのも特徴的だと思います。昨今のゲーム事業(特に大きなタイトル)の場合、リリース直後が一番ユーザーの注目が集まります。反対に、Web開発では運用を続ける中で徐々にユーザーへの認知を増やしていく性質があります。そのため、ゲームにおける開発ではMVP(Minimum Viable Product)のような開発スタイルではなく、最初から100パーセントのコンテンツを提供する必要があります。
また、初期に獲得したユーザーをいかにして減らさないかを考え、新しい施策を打っていく必要があります。これにより、徐々にユーザーを増やしていく開発スタイルとは異なる特性を持っています。
ビジネスロジックの多さ
ビジネスロジックが多くなるのは、ゲームのドメインの特徴かもしれません。特に、ゲームサーバー側で行う必要のあるバリデーションやキャッシュの処理が多いため、サービス層(ビジネスロジックを扱う層)が非常に大きくなります。そのため、私のチームではサービス層のメソッドをプライベートな構造体に詰め替え、単体テストを行いやすくしていました。
また、多くのコード(モデルやprotobuf)が自動生成されており、ビジネスロジックの記述に集中できる環境が整っていました。
自作ORM
DBへのアクセスには、自作のORM(オブジェクトリレーショナルマッピング)を使用していました。自作している理由は以下の2点です。
- 独自のキャッシュ機能に対応するため
- シャーディングに対応したORMが必要だったため
ユーザーデータは負荷分散のためにシャードごとに振り分けられており、ゲームサーバーの用途に合ったORMを基盤として提供しています。特に、ゲームサーバーでは独自のキャッシュ機能を取り入れています。トランザクションの管理はミドルウェア層で行っており、insertやupdateのクエリはその場で発行されず、更新の状態とテーブル情報をキャッシュします。ミドルウェアの処理が終わるタイミングでDBへコミットするという仕組みです。そのため、キャッシュをDBへ流す機能を持つ、ゲームサーバーに適したライブラリが作られました。
やったこと
インターンを通じて以下のタスクを行いました。
- Datadogで同一エラー内容として検出されるように、ログの順番をソートさせる
- 管理画面で模倣時間を常に確認できるようにする
- Redisを使ったランキングのAPI実装
- ランキングの復元バッチ
- 基盤ライブラリの改良
特に最後のタスクの難易度が特に高かったため、詳細を述べます。
背景
このタスクに取り掛かる前、RedisのSorted Setを使ったランキングのAPI実装を行っていました。ランキング機能を実装する中で、スコアの同一順位の考慮や排他制御、シャーディングといった複雑な要素を、基盤ライブラリ側がうまく吸収していることに気づきました。そのため、よりゲームの特性を考慮した開発をしたいと思い、基盤側のコードに興味を持ちました。
内容
改良内容としては、トランザクションが親子間でネストして発行された場合の、子から親へのキャッシュの伝搬です。前述した通り、私の開発チームでは独自のキャッシュ機能を取り入れてDBへの更新を行っています。書き込みのパフォーマンスを向上させるために、その場で更新クエリを発行せず、Goのcontextに更新内容をキャッシュし、ミドルウェアの処理が終わるタイミングで、DBへ複数のクエリをコミットしていました。
しかし、現状の問題点として、より狭い範囲でロックした状態でトランザクションを開始したいときにトランザクションをネストさせる必要がありましたが、その場合、子のトランザクションで更新した内容が親のトランザクションのキャッシュに反映されないという問題がありました。従って、子から親へのキャッシュの伝搬を可能にすることが、このタスクの主な内容となります。
また、開発を進めるにあたって以下のような制約がありました。
- 影響範囲が大きい
- 他のチームも使用している
基盤側のコードのため、既存のAPIサーバーの多くの箇所で使用されていました。さらに、私の所属するチーム以外でも使われている状況でした。そのため、改良にあたって影響範囲が大きく、他チームとの整合性を考慮する必要があります。従って、ライブラリのインターフェースをなるべく変えず、内部挙動の変更のみで完結させる必要がありました。
解決策
解決策として、更新されたキャッシュを保存するスタック構造をGoのcontextに取り入れました。親のトランザクションが開始される前にスタック構造を初期化し、子のトランザクションがコミットされるタイミングで更新されたキャッシュをプッシュします。そして、親のトランザクションが更新クエリを流すタイミングで、子のトランザクションでキャッシュされた内容をポップする処理を行い、子から親への伝搬を実現しました。インターフェースを変更せずに内部構造のみの変更を行うことで、ゲームサーバー側への影響範囲をなるべく小さく抑えました。
インターンを通じての学び
インターンを通じての学びを書きたいと思います。
高トラフィックな環境での取り組み
高トラフィックな環境ならではの学びがたくさんありました。前述したシャーディングやキャッシュだけでなく、他にも様々な取り組みが行われていました。特に印象に残っているのがサーバーの負荷分散についてです。リクエスト数が増えたときにオートスケーリングでサーバーの台数を増やすだけでは、十分にリクエストを捌き切れないため、事前にサーバーの台数を増やし、ロードバランシングを行っていました。
インターン期間中、メンテナンスのタイミングがあったのですが、アップデート後にユーザーのリクエスト数が急激に増加する様子をDatadogで見ていて面白かったです。また、リリースにあたり負荷試験を行う必要があるのですが、担当の属人化を防ぐために負荷試験用の指南書を作成していました。このように、ドキュメンテーション文化の必要性についても学ぶことができました。
コードリーディング
基盤コードの改良タスクを通じて、複雑なコードの読み方を習得することができました。私はオープンソースソフトウェア(OSS)のコードを読むのが苦手でしたが、その原因が前提知識の不足にあると気づきました。タスクを進めるにあたり、過去の勉強会動画を参照して必要な知識を得ることで、コードが何をしているのか理解できるようになりました。
また、複雑な要素をシーケンス図などに書き起こして整理することも重要でした。すべてのコードを読むのではなく、インターフェースのメソッド名から処理内容を理解し、読み進めることで理解しやすくなります。そのため、変数や関数名の命名の重要性を改めて実感しました。
最適な人に聞く
1ヶ月間という短い期間の中で、多くのタスクをこなすには、質問してからフィードバックが来るまでの応答スピードをいかに早くするかが重要だと感じました。その中で重要なのは、最適な人に聞くことです。ゲームの開発には多くの人が関わっており、自分の所属するバックエンドチームだけでなく、クライアントやプランナーの方々とも連携が必要です。
自分の所属するチームにとどまらず、回答の質とスピードを上げるためには、適切な人に質問し、時には直接ヒアリングすることが重要であると学びました。
インターンの雰囲気
学生のやりたいことを基にタスクを選定していただけたと思います。学生が何に困っていて何をやりたいかを日々の1on1で丁寧にメンタリングしていただきました。インターンでの学生の成長を一番に考えていることがとても伝わりました。
インターン期間中は様々な方々とランチに行かせていただきました。技術的なことやキャリアのことまでお話しでき、ランチ中でも様々な学びがあったと思います。また、ランチだけでなく気になる人がいれば個別で面談をする機会もいただけました。
インターンを通じて感じたサイバーエージェントの特徴は、「仕事に対する熱量の高さ」と「褒める文化」だと思います。私は3月から4月の間にインターンをしていたこともあり、全社集会や子会社での目標設定会を見学する機会をいただけました。見学して思ったことは、集会の表彰や日々の業務の称揚が、活躍したいと思わせる組織文化を形成しており、それが仕事に対する高い熱量に繋がっているということです。仕事に対するモチベーションを上げるための工夫が、組織的に行われていると感じました。
おわりに
普段とはまた違った開発を行うことができ、とても貴重な経験でした。中でもゲーム開発の特徴として、一つの大きな作品を多くの人で作り上げていく熱量の高い文化に触れることができ、とても良い刺激を受けました。1ヶ月という短い期間でしたが、アプリボットのバックエンドチームの皆様、ありがとうございました。