FluentD + Dockerでアプリのログサーバーを構築する

　はじめまして。
本記事では表題の通り、Unityで開発したアプリのログをFluentD & DockerとCoudwatchを用いてアプリのログを集計する方法を紹介いたします。

## はじめに
　私はモバイルゲーム会社でクライアントエンジニアを経験し、カバー株式会社に入社してからはスタジオで使うスタジオアプリの開発に携わっています。
　スタジオアプリでいうと、モーションデータをリアルタイムで受信し、モデルデータに紐づけたうえで、ライブ毎に使うリソース・演出(花火、照明など)を操作するアプリです。その結果物がYoutubeなどのストリーミングサイトで見れる動画になっております。

　世の中の誰かが使うアプリはエラーがあってはいけないです。いくら「バグがないのがバグ」という笑えない冗談があるとはいえ、私が携わっているスタジオアプリはリアルタイム配信などで使われるアプリですので、アプリを操作している間は、せめてストリーミングに支障が発生するなどのエラーが発生してはいけないです。

　しかし、リリースしたアプリを実際使う際、意図してない挙動、意図してない値での誤作動などがたまに発生するのがあります。そのような不具合や誤作動が発生した場合、それが防げなかったのは仕方なかったとはいえ、せめて再発しないように原因把握と修正するのは当然でしょう。

　不具合の原因把握について、事後不具合報告があったとしても不具合が発生した際の条件、操作手順、その時の値が把握できていないと再現と不具合の修正に時間がかかるでしょう。

　また、どのリソースがどれくらい使われたのかなどの集計が出来ると今後のストリーミング企画、リソース管理などで有用に活用できるでしょう。

　その理由で、弊社のスタジオの方からのリクエストとチーム内での相談によって、ログサーバーを構築し、アプリ側のログを受信してログの集計が出来るようにログサーバーを構築しました。
 
　今回はEC2、CloudWatchなどのAWSが提供している機能とFluentDというログ受信のプラグインをDockerで起動する手順を紹介いたします。


## 環境
### AWS EC2
　今回はAWSで無料枠のEC2とEBSを使うことにしました。
- 理由
    - データ量が多くなく、また同時接続ユーザー数も限られている小規模のサーバーになりますので、直近何か月間はEC2の無料枠でも対応できるだろうと判断しました。
    - シングルインスタンスですが、直接EC2へアクセスするのを防ぐため、またアプリケーションへのリクエストの負荷分散を念に入れてALBは使うことにしました。

### AWS CloudWatch
　FluentDで受信したログを可視化するため、AWSが提供しているCloudWatchを使いました。
ログサーバーとの管理の一元化の理由もありますが、Cloudwatch Logs Insightsの使いやすさと必要なデータだけ絞ってJSONやCSVとしてExportが出来るメリットが強かったです。

### Docker
　FluentDの設定と起動はDockerイメージとして運用することにしました。
- 理由
    - インフラやサーバーには素人の者として複雑な設定などにあまり自身がないので、設定と起動を含めて運営の標準化を担保したい。

### FluentD
　FluentDはログデータ収集ツールです。開発言語はCとRubyですので、どのOSでも汎用的に互換出来るメリットがあります。
　FluentDに送信されたデータは基本的に tag, time, record(JSON) で構成されています。こちらのログデータはイベントとして扱われて、求める形に加工してログを最終的に扱うサービス(Elasticsearch, S3, HDFS...)に渡します。以下はFluentDがログを扱うフローです。
<img src="https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Ft1.daumcdn.net%2Fcfile%2Ftistory%2F2252EF3C575EBE5B24" width="75%" height="40%" style="display: block; margin: 0" />
<br>


#### FluentDを使う利点
##### 1. データ漏れを気にする必要がない
　FluentDに入ってくるInput dataについてのBufferとQueueが存在して、別度の Messaging Queue が不要になりますので、エンジニア側ではログデータの漏れについて気にする必要がなくなります。<br>
<div align="center">
<img src="https://thumbnews.nateimg.co.kr/view610///news.nateimg.co.kr/orgImg/na/2022/11/07/5669927_high.jpg" width="40%" height="40%" style="display: block; margin: 0" />
</div>

##### 2. ログ処理のサイクルの調整可能
　自分で指定したサイクル、もしくは指定したBufferがフルになると自動的にログデータを処理しますので、ログ処理についての負荷分散なども気にする必要がありません。
<img src="https://m.hickieskorea.co.kr/web/product/medium/202203/d602bc8ea9251decf155a51dc624dcdb.jpg" width="40%" height="30%" style="display: block; margin: 0" /><br>

##### 3. フィルター機能
　Filter機能を使って、条件に合致しているログデータだけ選択して処理を行いますので、本当に必要なデータだけの選別が出来ます。もちろん、こちらの実装も直感的で簡単に指定できます。 <br>
<img src="https://t1.daumcdn.net/cfile/tistory/225F823A5339B4DF18" width="40%" height="30%" style="display: block; margin: 0" />
　イメージしやすく説明すると、搭乗ゲートの前でチケットに書いているゲートの番号や時間、便名が一致していない人はゲートに入れないことと同じです。


## 実装
　EC2での環境構築は出来ている前提でスキップします。
　今回、クライアントサイドで定義したログデータ一式は以下のようなデータクラスをJSONとしてパーシングしたものがログデータになります。

### アプリ側のログデータクラス例
```csharp
public class ClientInfo : IClientInfo
{
    public string user_id { get; private set; }
    public string server_id { get; private set; }
    public string room_id { get; private set; }
}
public class DeviceInfo : IDeviceInfo
{
    public string unique_id { get; private set; }
    public string device_name { get; private set; }
    public string device_model { get; private set; }
    public string app_version { get; private set; }
}
public class LogData : ILogData
{
    public string _appName => "app\.name";
    public float _runTime { get; private set; }
    public string _logType { get; private set; }
    public string _methodName { get; private set; }
    public string _logMessage { get; private set; }
    public string _stackTrace { get; private set; }
    public string _errorMessage { get; private set; }
    public IDeviceInfo _deviceInfo { get; private set; }
    public IClientInfo _clientInfo { get; private set; }
}
```

### Dockerfile 構成
```shell
# 最新バージョンののFluentdを使うDebianのイメージに基づいて起動
FROM fluent/fluentd:edge-debian

# ユーザーをRootユーザーに切り替え
USER root

# fluent.confファイルをContainer内部の/fluentd/etc/にコピー
COPY fluent.conf /fluentd/etc/

# 必要なプラグインをインストール
RUN gem install fluent-plugin-record-reformer 
RUN gem install fluent-plugin-filter-object-flatten
RUN gem install fluent-plugin-cloudwatch-logs
RUN gem install fluent-plugin-s3

# 9880番ポートを開放
EXPOSE 9880

# ログディレクトリーの権限設定
RUN mkdir -p /var/log/fluent && chown -R fluent:fluent /var/log/fluent

# fluent ユーザーへの切り替え＆コマンド設定
USER fluent
CMD exec fluentd -c /fluentd/etc/fluent.conf
```

### fluent.conf 構成
```ruby
<source>
  @type http
  port  9880
  bind  0.0.0.0
</source>

<filter **>
  @type grep
  <pattern>
    _appName app\.name
  </pattern>
</filter>

<filter **>
  @type record_transformer
  renew_record true
  enable_ruby true
  <record>
    _logType ${record["_logType"]}
    _runTime ${record["_runTime"]}
    _methodName ${record["_methodName"]}
    _logMessage ${record["_logMessage"]}
    _stackTrace ${record["_stackTrace"]}
    _errorMessage ${record["_errorMessage"]}
    _deviceInfo.device_name ${record.dig("_deviceInfo", "device_name") || 'N/A'}
    _deviceInfo.device_model ${record.dig("_deviceInfo", "device_model") || 'N/A'}
    _deviceInfo.unique_id ${record.dig("_deviceInfo", "unique_id") || 'N/A'}
    _deviceInfo.app_version ${record.dig("_deviceInfo", "app_version") || 'N/A'}
    _clientInfo.user_id ${record.dig("_clientInfo", "user_id") || 'N/A'}
    _clientInfo.server_id ${record.dig("_clientInfo", "server_id") || 'N/A'}
    _clientInfo.room_id ${record.dig("_clientInfo", "room_id") || 'N/A'}
  </record>
</filter>

<match **>
  @type s3
  aws_key_id {AWS SECRET ID}
  aws_sec_key {AWS SECRET KEY}
  s3_bucket {AWS S3 BUCKET NAME}
  s3_region "ap-northeast-1"
  s3_object_key_format FluentD_%Y_%m_%d_%H_%M_{index}.log
  timezone Asia/Tokyo
  format json
  <buffer tag,time>
    @type file
    path /var/log/fluent/s3
    timekey 86400
    timekey_wait 1h
    flush_interval 60s
    flush_mode interval
    chunk_limit_size 256m
    timekey_use_utc false
  </buffer>
</match>

<match **>
  @type cloudwatch_logs
  log_group_name XXX
  log_stream_name XXX
  region ap-northeast-1
  <buffer>
    flush_interval 300s
  </buffer>
  @log_level debug
</match>
```

## 詳細説明
　今回は大体の設定がfluent.confで行われていますので、こちらの説明をさせて頂きたいです。

### Port 開放
```ruby
<source>
  @type http
  port  9880
  bind  0.0.0.0
</source>
```
　こちらはEC2のPort9880から受信するデータを対象にする意味です。

### フィルター設定
```ruby
<filter **>
  @type grep
  <pattern>
    _appName app\.name
  </pattern>
</filter>
```
　こちらは`_appNameフィールド`が`app.name`の値であるログだけ扱うという、Filterの定義です。

### レコードを再定義
```ruby
<filter **>
  @type record_transformer
  renew_record true
  enable_ruby true
  <record>
    # 中略
  </record>
</filter>
```
`enable_ruby true`
　こちらはRubyを使って、レコードを再定義する意味です。

`renew_record true`
　今回は階層が1ではないログデータ(つまり、LogDataクラスの中に更にIDeviceInfo, IClientInfoなどのデータクラスがある)になっていて、こちらを再定義してLogDataの他のメンバーと同じコラムになるように再定義しました。
ちなみに、IClientInfoなどは起動時Nullになる場合(ex: まだアプリサーバーにログインしいてない)がありますので、Nullの場合はN/AというDefault値を代入されます。

　こちらのレコードの再定義は `Dockerfile` に定義した`fluent-plugin-record-reformer` プラグインで再定義を行い、階層があるログデータを平面化するのは `fluent-plugin-filter-object-flatten` プラグインで行われます。この2つのプラグインによって、
```json
 {
  "_appName": "app\.name",
  {中略}
  "_deviceInfo": {
    "unique_id": "XXX",
    {中略}
  },
  "_clientInfo": {
    "user_id": null,
    {中略}
  }
}
```
のようなデータを次のように平面化されます。
```json
 {
  "_appName": "app\.name",
  {中略}
  "_deviceInfo.unique_id": "XXX",
  {中略}
  "_clientInfo.user_id": null
}
```
### S3へのバックアップ
```ruby
<match **>
  @type s3
  aws_key_id {AWS SECRET ID}
  aws_sec_key {AWS SECRET KEY}
  s3_bucket {AWS S3 BUCKET NAME}
  s3_region "ap-northeast-1"
  s3_object_key_format FluentD_%Y_%m_%d_%H_%M_{index}.log
  timezone Asia/Tokyo
  format json
  <buffer tag,time>
    @type file
    path /var/log/fluent/s3
    timekey 86400
    timekey_wait 1h
    flush_interval 60s
    flush_mode interval
    chunk_limit_size 256m
    timekey_use_utc false
  </buffer>
</match>
```

　今回の設定は、定期的にS3にバックアップするように設定しています。
　恥ずかしい告白ですが、最初にログサーバーを構築した時はfilterの設定の不備などでログデータが肥大化されてEC2のインスタンスが落ちたことがありました。(...)
 　なので、そのような問題が再発しないように設定の見直しと共にEC2にたまったログデータを定期的にS3にバクアップされるように設定を行いました。
- `aws_key_id`と`aws_sec_key`
　こちらは認証情報です。こちらは `s3:PutObject` と `s3:ListBucket` の権限が付与されている AWS IAM が必要になります。
- `s3_object_key_format` 
　こちらはS3に登録されるログファイル名のフォーマットです。この設定によって、ログファイルは`{Bucket}` / ` FluentD_年4桁_月2桁_日2桁_時間2桁_分2桁_S3側で付与されるindex.log`  になります。
　ex) 2023年12月23日17時00分にバックアップされた場合、S3でのパスは`{Bucket名}/FluentD_2023_12_23_17_00_1.log` になります。
- `<buffer tag,time>`
こちらはS3にバックアップするため、ログメッセージを臨時的に保管するパスの指定、バックアップを行うサイクル、Bufferが対応する最大のサイズの指定を行います。
この設定によって、毎日ログのバックアップが行われるようになっています。

### CloudWatch Logs Output
```ruby
<match **>
  @type cloudwatch_logs
  log_group_name XXX
  log_stream_name XXX
  region ap-northeast-1
  <buffer>
    flush_interval 300s
  </buffer>
  @log_level debug
</match>
```

　こちらはCloudWatchへのログ登録の設定を行います。

　この設定によって指定されたサイクルや条件でログをCloudWatchへ登録しますので事前にこのログサーバーに
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
の権限が付与されているIAMをEC2の環境変数として登録するか、EC2に付与する必要があります。

　以下はパラメータの説明です。

- `log_group_name` と `log_stream_name`
こちらはFluentDの設定によって加工されたログの登録先であるCloudWatchのロググループとログストリーム名を指定します。
※ ロググループとログストリームは事前にCloudWatchで登録する必要があります。
- `<buffer>` ブロック
ログデータを最終的な登録するサービス(今回はCloudWatch)へ登録するサイクルです。こちらでは5分単位でログ登録を行います。


## CloudWatch Logs Insights
　FluentDのログを最終的に登録するサービスとしてCloudWatchを採用した理由は、前述のとおり、CloudWatch Logs Insightsという機能のメリットもあります。
 CloudWatchはSQLのクエリと同じ感覚でログデータの検索が出来ます。今回はCloudWatchの使い方も少し紹介致します。

### 検索の例
　今回は [「アプリ側のログデータクラス例 」](#アプリ側のログデータクラス例)に定義されている全ての変数をフィールドにして表示させる想定でサンプルを作成し、メソッド名に`Load`が含まれているログを確認します。

#### クエリ実施によって表示させるフィールドを定義する
```sql
# Column Define Start
fields @timestamp, _deviceInfo.app_version, _logType
| fields _clientInfo.server_id, _clientInfo.user_id, _clientInfo.room_id
| fields _methodName, _logMessage, _deviceInfo.device_name
| fields _errorMessage, _stackTrace
| filter _appName like "app.name"
# Column Define End
| sort @timestamp desc
```
　これで表示するフィールドと、ログイベントが発生した時間を基準として降順で検索結果が表示されるように大枠を作りました。
　上の `fluent.conf` で指定した `Log Group` と `Log Stream` での `CloudWatch Logs Insights` での操作ですので `filter _appName like "app.name"` は不要ですが、条件指定の例として追記しました。
　`like "値"`のように `""` で囲まれている場合、`値と一致する`データを検索する意味です。

#### 検索条件を指定する(=メソッド名にLoadが含まれている) 
```sql
# Column Define Start
fields @timestamp, _deviceInfo.app_version, _logType
# {中略}
| filter _appName like "app.name"
# Column Define End
# Search By Value
| filter _methodName like /Load/
| sort @timestamp desc
```
`# Search By Value`コメントの下の行が検索条件になります。今回は `""` ではなく`//`で囲まれています。こちらは`値と一致する`ではなく、`値が含まれている`の意味です。
#### 実行結果
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3657583/0f0184f6-45d2-5ba0-bb7a-add355b93696.png)

### 集計の例
　今回は、各ルームで特定リソースがどれくらいロードされたのか確認するため、 _logMessage に特定リソース名が含まれていて、メソッド名に`Load`が含まれているログを確認します。

#### クエリ実施によって表示させるフィールドを定義する
```sql
# Column Define Start
fields @timestamp, _deviceInfo.app_version, _logType
| fields _clientInfo.server_id, _clientInfo.user_id, _clientInfo.room_id
| fields _methodName, _logMessage, _deviceInfo.device_name
| fields _errorMessage, _stackTrace
| filter _appName like "app.name"
# Column Define End
| sort @timestamp desc
```

#### 検索条件を指定する 
```sql
# Column Define Start
fields @timestamp, _deviceInfo.app_version, _logType
# {中略}
| filter _appName like "app.name"
# Column Define End
# Search By Value
| filter _logMessage like /XXX/
| filter _methodName like /Load/
| stats count(*) as cnt by _clientInfo.room_id
| sort @timestamp desc
```
　今回はstats count(*)で、`_logMessage`にリソース名`XXX`が含まれているデータを数えた結果を変数`cntに保存`して、`_clientInfo.room_idでグルーピング`します。

#### 実行結果
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3657583/b05ed46d-af9e-08c8-66cb-feb6ef5e27e1.png)


## 今後の課題
　今回の実装と改修でCloudWatchでのログ確認は勿論、ログの肥大化も解決されましたが、EC2のストレージが逼迫される時の通知などの予防策の実装はまだ解決されておりません。
　また、S3にバックアップされるログデータは、平面化されてCloudWatchに登録したログデータがそのままバックアップされていて、CloudWatchのタイムスタンプの値が抜けています。なので、CloudWatch Logs Insightsではなくバックアップされたログだけでは該当ログイベントが発生した時間の特定が出来ない問題があります。
　今後こちらの問題も改善しようとしております。

## 参考記事
- https://bcho.tistory.com/1115 (韓国の記事)
- https://docs.fluentd.org/
- https://qiita.com/enerick/items/89adf05d076a533a89b4
- https://qiita.com/danishi/items/ad2a5812bba5f8da3a30