📤

ゲーム運営を支えるログ配信基盤について

2022/12/15に公開

はじめに

Happy Elements株式会社カカリアスタジオ(以下、HEKK)で、インフラグループに所属している K.H. です。
今回は、ゲームのログデータ配信基盤において発生した課題と、それをふまえて今年改善活動を行ってきた、ログ配信基盤についての話を書きたいと思います。

所属グループでの主な業務内容

グループリーダー兼インフラエンジニアを担当しています。
HEKKではクラウドにAWS/GCPを採用しており、 データベースからKubernetes、はたまた今回のログ配信基盤のように、ゲームエンジニアがゲームを作るためにクラウドを使う場面を全社横断で全力サポートしています。

ログ配信基盤とは

HEKKの主要タイトルにおいては、ゲームサーバーが各種ログデータを所定のフォーマットで出力し、これを分析することが可能になっています。
ゲームサーバーはAWS上で動作していますが、ログ収集/分析の基盤にはGCPのBigQueryを採用しているため、このログデータをどうにかしてBigQueryに取り込む必要があります。
この、ゲームサーバーが出力したログをBigQueryに取り込むまでの処理を担当しているのがログ配信基盤と呼ばれているものです。

既存のログ配信基盤について

既存のログ配信基盤の構成は以下の通りとなっています。

このログ配信基盤は、APIサーバー(EC2もしくはECS)はCloudWatch logsに所定のフォーマットで構造化ログを出力した後、まずはRouting層と呼ばれているブロックが以下のような流れで処理を行っていました。

  • CloudWatch logsのサブスクリプションフィルタを利用し、Kinesis Data Firehoseにログを転送します。
  • Kinesis Data Firehoseのデータ変換機能を利用し、ログの一次加工を行い、結果をKinesisがS3バケットに配送します。
  • S3のイベント通知がLambda関数を呼び出し、ログデータを最終的にBigQueryに取り込む際のテーブルごとにファイル分割したうえでS3バケットに書き込みます。
    • ただし、S3のイベント関数がまれに発火しないことがあるため、cronを用いて定期的に未処理のログデータがある場合はこれを回収します。

続いて、Aggregate層と呼ばれているブロックが、テーブルごとに分割されたファイルを、ある一定の時間間隔ごとに集約しています。
これは、Kinesisが配送するログデータのバッファ間隔と、BigQueryにログをロードするためのバッファ間隔が異なり、またBigQueryのロード時はテーブルごとに1つのファイルになっていることが望ましいため、これを実現するためのレイヤーとなっています。

  • cronにて、テーブルごとのファイルを1ファイルにまとめるためのLambda関数を定期実行します。
    • ただし、Lambda関数1つ1つの処理性能はそこまで高くないため、かなりの数を起動します。
    • また、同じファイルを複数のLambda関数が処理してしまわないようにするため、Lambda関数ごとに処理するテーブルが予め静的に決められています。
  • ただし、Lambda関数で取り扱えないサイズにログが成長することがわかっている既知のテーブルはLambdaではなくEC2サーバー上で集約処理を行います。

最後に、Loading層と呼ばれているブロックが、Aggregate層が作成したファイルをBigQueryに転送しています。

  • Aggregate層がS3にファイルをアップロードしたことをS3イベント通知にて検知し、Loading層のLambda関数が実行されます。
  • Lambda関数は該当のファイルをBigQueryに転送します。
    • ただし、Lambda関数で取り扱えないサイズにログが成長することがわかっている既知のテーブルはLambdaではなくEC2サーバー上でこれを行います。

既存のログ配信基盤の抱えている課題

先ほどの構成図を見て分かる通り、このログ配信基盤は多数のコンポーネントが組み合わされたパイプラインになっており、

  • 新規メンバーが全体像をすぐに把握することが困難。
  • Lambda関数の処理性能限界のため、EC2に一部処理をオフロードする必要がある。
  • Lambda関数の処理性能限界のため、Lambda関数を複数準備し、これを静的にマッピングするような事を行っている。
    • つまりはLambda関数(≠インスタンス)の水平分割を手動で行っている。
  • ログ配信基盤の更新が容易ではない。
    • 常にパイプラインはどこかが動作しており、パイプライン内部で互換性がない更新を行うためには、新旧両方の処理をうまく行えるようにした上で適切にLambda関数を更新していく必要がある。
    • EC2で動作しているものについても同様。
    • 当然ロールバックも容易ではない。

といった課題を抱えていました。

既存のログ配信基盤で実際に起きた問題

  • とあるゲームタイトルにおいて、お正月にログデータが通常時の数十倍に一気に膨れ上がり、あらゆるポイントのLambdaがこれを処理できなくなりパイプライン全体がショートするという問題が発生しました。
  • 各コンポーネントはリトライによって処理を継続しようとしますが、それが結果としてDoS攻撃のようにLambdaを凄い勢いで起動させていき、結果としてLambda関数の起動がスロットリングされるという事態に陥りました。
    • そもそも同時実行数の上限は緩和していましたが、それでもまだ足りないという状況になりました。

今でこそ振り返れますが、発生当時はあらゆるシステムメトリクスが異常を示し、アラートが発火しすぎてもはやどこが最初に問題となったのかもすぐには分からないというかなりの地獄が広がっていました。

ログを最初に受け止める、Kinesis Data Firehoseは配信スループットの上限に90度の角度で張り付き、上限を緩和しても更にそれに瞬時に張り付いています。
※赤線が上限で薄い線が実測値です

Lambdaの同時実行数もスロットリング対象となるまで一気に駆け上がりました。

結果としてLambda関数は需要に対してまったく供給が追いつかず、ほぼ全てのログ配信基盤のLambda関数がアラート状態になりました。

そして、主に上限緩和を軸にこれらのパイプライン全体の帯域を太くしていった結果、最終的にLambda関数から読み書きされるS3のSlowDownが発生し、本アーキテクチャの限界が見えることになりました。

新しいログ配信基盤について

上記のような問題が発生したことから、新しいログ配信基盤を構築する必要が生まれました。
今回は以下のようなポイントを重視して、新基盤を設計構築することにしました。

  • 様々なログデータのサイズを処理できること。
    • ファイル点数 / ファイルサイズの両方の観点が必要。
  • コンポーネントは少数で構築すること。
    • 所謂、ピタゴラスイッチにはしない。
  • 開発/更新が容易であること
    • 問題発生時、新しいログ配信基盤を素早くデプロイできること。
    • ロールバックについても同様。

検討の結果、最終的にBigQueryにデータを転送するということもあり、ログ配信基盤はCloud Dataflowを利用することにしました。

Cloud Dataflowとは

Cloud Dataflowは、一言で言えばApache Beamのマネージド実行基盤です。
パイプライン処理全体をJava / Python / Goのいずれかで記述することができ、SDKの流儀に従ってコードを記述すれば、適切に実行するためのインフラについてはDataflowが面倒を見てくれます。

公式のサンプルを抜粋させていただくと、以下のような記述を行うことで、入力されたデータを変換し任意の出力を得ることができます。

public class MinimalWordCount {  
  
  public static void main(String[] args) {  
  
    PipelineOptions options = PipelineOptionsFactory.create();  
    Pipeline p = Pipeline.create(options);  
  
    p.apply(TextIO.read().from("gs://apache-beam-samples/shakespeare/kinglear.txt"))  
        .apply(  
            FlatMapElements.into(TypeDescriptors.strings())  
                .via((String line) -> Arrays.asList(line.split("[^\\p{L}]+"))))  
        .apply(Filter.by((String word) -> !word.isEmpty()))  
        .apply(Count.perElement())  
        .apply(  
            MapElements.into(TypeDescriptors.strings())  
                .via(  
                    (KV<String, Long> wordCount) ->  
                        wordCount.getKey() + ": " + wordCount.getValue()))  
        .apply(TextIO.write().to("wordcounts"));  
  
    p.run().waitUntilFinish();  
  }  
}  

また、これらのロジックは主にapply()呼び出しの単位でDataflowによって自動で水平分割され、利用者は水平分割されることをほぼ意識することなくコードを記述することで自動でパイプライン全体をスケールアウトさせることができるようになっています。

新しいログ配信基盤の構成について

このCloud Dataflowを軸に置くことで、新しいログ配信基盤は以下のような構成となりました。

この構成を取ることで、先程の課題をすべてクリアすることができるようになりました。

  • 様々なログデータのサイズを処理できること。
    • Dataflowの実行基盤にはVMが自動で利用され、Lambdaの場合に問題になった、実行時間が15分程度でタイムアウトとなってしまうといった問題が発生しません。
    • Lambdaの場合に比べてメモリも潤沢に利用することができ、またBeam SDKの流儀に従うことで巨大なメモリを一気に要求されるシーンも削減できるようになりました。
  • コンポーネントは少数で構築すること。
    • 今までは大量のS3バケットとLambdaによって実現していたパイプライン全体をすべてDataflow上に移行することで、こちらで面倒を見るコンポーネント点数を激減させることが出来ました。
  • 開発/更新が容易であること。
    • Dataflowは最終的に純粋なJavaプログラム1つに落とし込まれますので、開発者が手元でシンプルにIDEとデバッガを利用して開発できるようになりました。
    • Kinesisのデータ変換機能も取り外し、該当部分の処理もDataflow上で実行するようにしたことで、Dataflow上の処理のみに注力すれば良く、実はここで変換されていて〜のような問題を排除することができるようになりました。
    • デプロイにはコンテナを1つ準備すれば良くなり、今回のDataflow実行時にはどのコンテナが利用されたのか?という1点のみでパイプライン全体のバージョン管理が可能となりました。
    • 処理のトリガーはS3のイベント通知からcronによる実行とすることで、ストリーミング型ではなくバッチ型とし、結果として失敗時に処理を再実行しやすく、担当者がハンドリングが容易なデザインとしました。

おわりに

今回は、ログ配信基盤の抱えていた問題とその改善という比較的ニッチな話題についてお話しましたが、この記事が何かの助けになれば幸いです。

GitHubで編集を提案
Happy Elements

Discussion