FargateのログをFireLens(カスタマイズしたfluent-bit)を使ってCloudWatchとFirehoseの両方に送る

## やりたいこと
ECS Fargateで動いているRailsのアプリケーションログをFirelens(カスタマイズしたfluent-bitのイメージ)を使って**CloudWatchと2つのFirehose**にそれぞれ送りたい。

①ECS Fargate -> Firehose -> S3 (foo_log) -> Glue -> Athena
②ECS Fargate -> Firehose -> S3 (bar_log) -> Glue -> Athena
③ECS Fargate -> CloudWatch (全てのログ)

にログを送りたいという想定。
(fooとbarはある特定の条件のログのみを抽出したいという意)

## 構成図
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533070/e229cac8-8e6d-6bc4-b8b5-5402ee04bf75.png)
今回は青い部分の作業手順をまとめます。

## 作業手順
1. リソース作成
2. CloudWatchと2つのFirehoseに送るようにfluent-bitイメージをカスタマイズする
3. log-routerコンテナを定義
4. カスタマイズしたfluent-bitイメージでlog-routerコンテナを動かしてログを収集する

## 1. リソース作成
- firehose(今回は各firehoseごとにIAMロールを作成する想定)
    - foo_firehose
        - foo_firehose_role
    - bar_firehose
        - bar_firehose_role
- cloudwatch
- S3
    - foo_bucket
    - bar_bucket

- ECRリポジトリ(画像にはないがカスタマイズしたfluent-bitイメージを管理する)
https://docs.aws.amazon.com/ja_jp/AmazonECR/latest/userguide/repository-create.html
を参考に、カスタマイズしたfluent-bitのイメージを置くためのECRリポジトリを作成。

また各ロールに必要なポリシーをアタッチします。
### ecs_task_role
- firehoseにアクセスするためのPolicyをattach

```ecs_task_firehose_policy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "firehose:PutRecordBatch",
            "Resource": [
                "arn:aws:firehose:{{region}}:{{account-id}}:deliverystream/foo-log",
                "arn:aws:firehose:{{region}}:{{account-id}}:deliverystream/bar-log"
            ]
        }
    ]
}
```
- CloudWatchにアクセスするためのPolicyをattach
`arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy`

### foo_firehose_role

foo_firehoseがfoo_bucketにアクセスできるように、foo_firehose_roleにPolicyをattachする

```foo_firehose_role_policy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",      
            "Action": [
                "s3:AbortMultipartUpload",
                "s3:GetBucketLocation",
                "s3:GetObject",
                "s3:ListBucket",
                "s3:ListBucketMultipartUploads",
                "s3:PutObject",
                "s3:PutObjectAcl"
            ],
            "Resource": [        
                "arn:aws:s3:::foo-bucket",
                "arn:aws:s3:::foo-bucket/*"		    
            ]
        }
    ]
}
```

### bar_firehose_role

bar_firehoseがbar_bucketにアクセスできるようbar_frehose_roleにPolicyをattachする

```bar_firehose_role_policy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",      
            "Action": [
                "s3:AbortMultipartUpload",
                "s3:GetBucketLocation",
                "s3:GetObject",
                "s3:ListBucket",
                "s3:ListBucketMultipartUploads",
                "s3:PutObject",
                "s3:PutObjectAcl"
            ],
            "Resource": [        
                "arn:aws:s3:::bar-bucket",
                "arn:aws:s3:::bar-bucket/*"		    
            ]
        }
    ]
}
```


## 2. CloudWatchと2つのFirehoseに送るようにfluent-bitイメージをカスタマイズする
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/userguide/using_firelens.html
ここを読むと、firelensではAWSが提供しているAWS for Fluent Bitを使うこともできます。
しかしこれではCloudWatchとFirehoseの両方にログを送ることができないので、今回はfluent-bit.confをカスタマイズして加えたDockerイメージを使うようにします。

カスタマイズしたconfが以下の2つです。

```docker/fluent-bit/fluent-bit.conf
[SERVICE]
    Flush 1
    Parsers_File fluent-bit-parsers.conf

# logというkeyの中身をデコードする
[FILTER]
    Name parser
    Match *
    Key_Name log
    Parser rails
    # logの中身以外はいらないので消去する(container_idとか)
    Reserve_Data false

# ログにtagをつける
[FILTER]
    Name    rewrite_tag
    Match   *
    # nameがfooのもののタグをfoo_logにする
    Rule    $name ^(foo)$ foo_log true

[FILTER]
    Name    rewrite_tag
    Match   *
    # nameがbarのものはタグをbar_logにする
    Rule    $name ^(bar)$ bar_log true

# logの送信
# 全てのログ(rails-firelens-*というtagがついている)をCloudWatchに送る
[OUTPUT]
   Name                cloudwatch
   Match               rails-firelens-*
   region              {CloudWatchロググループのregion}
   log_group_name      {CloudWatchロググループの名前}
   # つけたいprefixを指定。ここではlatest/としている
   log_stream_prefix   latest/

# tagがfoo_logのものをfirehoseに送る
[OUTPUT]
   Name firehose
   Match foo_log
   region {foo_firehoseのregion}
   delivery_stream foo_firehose

# tagがbar_logのものをfirehoseに送る
[OUTPUT]
   Name firehose
   Match bar_log
   region {bar_firehoseのregion}
   delivery_stream bar_firehose
```

```docker/fluent-bit/fluent-bit-parsers.conf
# ログがエンコードされてlogというkeyの中に入っているのでデコードするためのparser
# https://docs.fluentbit.io/manual/pipeline/parsers/decoders
[PARSER]
    Name         rails
    Format       json
    Time_Key     time
    Time_Format  %Y-%m-%dT%H:%M:%S %z
    # Command       |  Decoder  | Field | Optional Action   |
    # ==============|===========|=======|===================|
    Decode_Field_As    escaped     log
```

fluent-bitの公式ドキュメント
https://docs.fluentbit.io/manual/

基本的に公式ドキュメントに沿ってconfをカスタマイズしていきました。
ただドキュメントが全部英語なので、今回書いた部分だけ解説を残します。


### 解説
上から説明していきます。

```
[SERVICE]
    Flush 1
    Parsers_File fluent-bit-parsers.conf
```

`[SERVICE]`はログ全体の設定を定義するところです(多分)。

Flush 1ではドキュメントにあるようにログの出力間隔を指定しています。
https://docs.fluentbit.io/manual/administration/configuring-fluent-bit/configuration-file

> Flush
Set the flush time in seconds.nanoseconds. The engine loop uses a Flush timeout to define when is required to flush the records ingested by input plugins through the defined output plugins.

デフォルトだと5秒で、5秒だと例えばlog-routerコンテナが起動に失敗した時に、ログが出力される前にタスクがストップしてしまう、とかいうことがあったのでここでは1にしています。

```
Parsers_File fluent-bit-parsers.conf
```

ここではParserが書いてあるファイルパスを指定します。
独自でParserを作成した場合、このように別ファイルから読み込むようにするのがルール見たいです。Parserの解説は次にします。

### ログの整形
**Parser**
https://docs.fluentbit.io/manual/pipeline/parsers/decoders を参考にして作成したParserのファイルです。
firelensは渡ってきたログに勝手に情報を付け加えるので、Parserに不要なログは切り落としてアプリケーションのログだけを収集するという設定をします。(実行はfluent-bit.confの[FILTER]で定義する)

fluent-bitは特にカスタマイズしないとこんな感じでログを送ります。

```json
{
  "container_id": "3e638f00-c1a7-4794-b796-1d916dfa8cbc-1555792190",
  "container_name": "rails",
  "ecs_cluster": "{{cluster-name}}",
  "ecs_task_arn": "arn:aws:ecs:{{region}}:{{account-Id}}:task/{{task_id}}",
  "ecs_task_definition": "{{task-definition}}",
  "log": "{\"host\":\"xxxxxxxxx\",\"application\":\"xxxxxxxxxx\",\"environment\":\"xxxxxxx\",\"timestamp\":\"2020-01-01T00:00:00.00000Z\",\"level\":\"xxxx\",\"level_index\":x,\"pid\":xx,\"thread\":\"xxxxxx\",\"name\":\"xxxx\",\"message\":\"xxxxxx\"}
```

firelens側でcontainer_id とか container_name とか勝手につけてくれます。
そしてlogに実際にRailsアプリケーションが出したjsonのログがstringにエンコードされて入っています。
(今回はRailsアプリケーション側でもログはjsonで吐き出すように設定している)
これをjsonに戻すための設定を定義しているのが、このfluent-bit-parsers.confになります。

```docker/fluent-bit/fluent-bit-parsers.conf
# ログがエンコードされてlogというkeyの中に入っているのでデコードするためのparser
# https://docs.fluentbit.io/manual/pipeline/parsers/decoders
[PARSER]
    Name         rails
    Format       json
    Time_Key     time
    Time_Format  %Y-%m-%dT%H:%M:%S %z
    # Command       |  Decoder  | Field | Optional Action   |
    # ==============|===========|=======|===================|
    Decode_Field_As    escaped     log
```

`Name rails` でこのParserに名前をつけています(自由です)。
一応命名規則としては、ログを吐き出したものの名前をつけるみたいで、今回扱うのはRailsが吐き出したログなのでrailsにしています。(ドキュメントでは`docker`になっている)

fluent-bit.confに戻ります。

```docker/fluent-bit/fluent-bit.conf
# logというkeyの中身をデコードする
[FILTER]
    Name parser
    Match *
    Key_Name log
    Parser rails
    # logの中身以外はいらないので消去する(container_idとか)
    Reserve_Data false
```

ここで先ほど定義したParserを使って実行してね、という処理を書きます。
参考: https://docs.fluentbit.io/manual/pipeline/filters/parser

- `[FILTER]`
いろんな用途で使われるようで、用途ごとに`Name`を変えていきます。
今回はParserを使いたいから`Name parser`です。

- `Match *`
このFILTERに引っ掛けるログのタグを指定します。
このタグについてはあとで解説します。
`*`では全部のログがこのFILTERを通るという意味になります。

- `Key_Name log`
これがFILTERの対象にしたいログの中のKeyを指定するところですね。
今回は `log` です。

- `Parser rails`
ここでどんなParserを使いたいか指定します。先ほど作成した`rails`を指定。

- `Reserve_Data false` 
これは Key_Name で指定した以外のKeyのデータはどうする？という意味になります。
ここをfalseにすると指定した以外のKey、今回でいうと`log`というKey以外は削除されます。
よってログに実際のアプリケーションが出したログのみを出力することができます。
もし、firelensが付与してくれた`container_id`や`container_name`とかも出力させたい！ということでしたらここをtrueにすれば出力されます。

ここまでがログをいい感じに整形する部分です。
次からはログを分割したい時の各設定です。
そこで必要な知識がさっき言ったログのタグです。

### fluent-bitにおけるログのタグ
fluent-bit側で、ログの種類を分けるためにログにタグをつけることができます。
ただfluent-bitによって吐き出されたログがjsonの場合はそのタグの情報は見えません。
jsonでなければログは下のように出力されてタグも見えます。

```zsh
[0] foo_log: [1598502349.876671400, {"host"=>"xxxxxxxxx", "timestamp"=>"2020-01-01T00:00:00.000000Z", "level"=>"xxxx", "level_index"=>0, "pid"=>00, "thread"=>"xxxxxxxxx", "name"=>"foo", "message"=>"xxxxxx"}}]
```

この`foo_log`の部分がタグです。

また今回とっても大事なのが、firelensに入ってきたログにはデフォルトで
`{container_name}-firelens-{task_id}` というタグがつけられているということです。
今回だと`rails-firelens-89caedfc-07fd-41f3-8239-5313a7d10ca7`のようなタグが最初に全てのログにつけられています。
これをうまく使ってログの分割を行なっていきます。

### ログのタグ付け

```docker/fluent-bit/fluent-bit.conf
[FILTER]
    Name    rewrite_tag
    Match   *
    # nameがfooのもののタグをfoo_logにする
    Rule    $name ^(foo)$ foo_log true

[FILTER]
    Name    rewrite_tag
    Match   *
    # nameがbarのものはタグをbar_logにする
    Rule    $name ^(bar)$ bar_log true
```

ここも`[FILTER]`ですが`Name rewrite_tag`となっています。
その名の通りここでタグの上書きを行います。



- `Rule    $name ^(foo)$ foo_log true`
参考: https://docs.fluentbit.io/manual/pipeline/filters/rewrite-tag#rules
ドキュメントの通りRuleでは`$KEY  REGEX  NEW_TAG  KEEP`を記述しています。
`[FIRTER]`は記述した順番に処理されるらしく、ここに来るログはすでに`log`というkeyの中身(applicationが吐き出したログそのもの)がやってきます。
今回は `name`というkeyに`foo`か`bar`という文字が入っているという想定です。

- `$name ^(foo)$ foo_log true`
これはnameというKeyの中身が`foo`なら`foo_log`というタグをつける、という意味になります。 
(`^(foo)$`は正規表現です)

- `true`
はタグをつけた後、タグをつける前のログをとっておくかどうか、を表します。
具体的にいうと、もともとは `rails-firelens-*` というタグがついていたけれど、`foo_log` というタグを上書きしたので、元の `rails-firelens-*` のタグがついている方は取っておくか？という意味です。
今回はこの後の用途にて必要になるので`true`にして取っておくようにしています。
`false` にすれば元のログは削除され `foo_log` とついたもののみ、残ります。

ここまでで、ログをいい感じに整形して、タグをつけるところまできました。
あとはタグを使って出力先を指定するだけです。

### ログの出力

```docker/fluent-bit/fluent-bit.conf
# logの送信
# 全てのログ(*-firelens-*というtagがついている)をCloudWatchに送る
[OUTPUT]
   Name                cloudwatch
   Match               rails-firelens-*
   region              {CloudWatchロググループのregion}
   log_group_name      {CloudWatchロググループの名前}
   # つけたいprefixを指定。ここではlatest/としている
   log_stream_prefix   latest/

# tagがfoo_logのものをfirehoseに送る
[OUTPUT]
   Name firehose
   Match foo_log
   region {foo_firehoseのregion}
   delivery_stream foo_firehose

# tagがbar_logのものをfirehoseに送る
[OUTPUT]
   Name firehose
   Match bar_log
   region {bar_firehoseのregion}
   delivery_stream bar_firehose
```

このように[OUTPUT]を複数かくと、複数の場所に出力されます。

- CloudWatch

```docker/fluent-bit/fluent-bit.conf
[OUTPUT]
    Name                cloudwatch
    Match               rails-firelens-*
```

今回はCloudWatchに「applicationが吐き出した全てのログ」を送りたいと考えています。
タグがfoo_log、bar_log、そうでないもの全てです。
ここで、さっき`rails-firelens-*`というタグのログを残しておいたのが生きてきます。
foo_logやbar_logというタグとは別に`rails-firelens-*`というタグがついた状態で全てのログが残っているので、このタグのログを送ってあげれば全てのログが送られることになります。ですので`Match rails-firelens-*`と書きます。

- Firehose

```docker/fluent-bit/fluent-bit.conf
# tagがfoo_logのものをfirehoseに送る
[OUTPUT]
   Name firehose
   Match foo_log
   region {foo_firehoseのregion}
   delivery_stream foo_firehose

# tagがbar_logのものをfirehoseに送る
[OUTPUT]
   Name firehose
   Match bar_log
   region {bar_firehoseのregion}
   delivery_stream bar_firehose
```

ここも難しくはなく、Matchで送りたいログのタグを指定して、`delivery_stream`に送るfirehose名をきます。

### カスタマイズしたconfを配置するDockerfileを書く。
作成した、`fluent-bit.conf`、`fluent-bit-parsers.conf` をコンテナのルートに配置するようにDockerfileを書きます。(ルートでなくても問題ない)

```docker/fluent-bit/Dockerfile.
FROM amazon/aws-for-fluent-bit:latest
COPY ./docker/fluent-bit/fluent-bit.conf /fluent-bit.conf
COPY ./docker/fluent-bit/fluent-bit-parsers.conf /fluent-bit-parsers.conf
```

## 3.log-routerコンテナを定義
ecs-task-definition.jsonはこのように書きます。

```ecs-task-definition.json
{
  "containerDefinitions": [
    # railsコンテナを定義
    {
      "command": [
        "bundle",
        "exec",
        "unicorn",
        "-p",
        "3000",
        "-c",
        "/rails/config/unicorn_ecs.rb"
      ],
      "cpu": 0,
      "dnsSearchDomains": [],
      "dnsServers": [],
      "dockerSecurityOptions": [],
      "entryPoint": [],
      "environment": [],
      "essential": true,
      "image": "Railsサービスを動かすイメージを指定する"
      "links": [],
      # ここでrailsコンテナのログドライバーをfirelensにする
      "logConfiguration": {
        "logDriver": "awsfirelens"
      },
      "mountPoints": [],
      "name": "rails",
      "portMappings": [
        {
          "containerPort": 3000,
          "hostPort": 3000,
          "protocol": "tcp"
        }
      ],
      "volumesFrom": []
    },
    # log-routerコンテナを定義
    {
      "essential": true,
      "image": "カスタマイズしてbuild,pushしたECRにあるfluent-bitのイメージを指定",
      "name": "log-router",
      "environment": [],
      # カスタマイズしたconfをパス指定
      "firelensConfiguration": {
        "type": "fluentbit",
        "options": {
          "config-file-type": "file",
          "config-file-value": "/fluent-bit.conf"
        }
      },
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "log-router",
          "awslogs-region": "ap-northeast-1",
          "awslogs-stream-prefix": "latest"
        }
      },
      "memoryReservation": 50
    }
  ],
  "cpu": "2048",
  "taskRoleArn": "taskRoleArnを書く",
  "executionRoleArn": "taskExecutionRoleArnを書く",
  "family": "hoge",
  "memory": "4096",
  "networkMode": "awsvpc",
  "placementConstraints": [],
  "requiresCompatibilities": ["FARGATE"],
  "volumes": []
}
```

ここで使いたい `fluent-bit.conf` のパスを指定しています。

```ecs-task-definition.json
# カスタマイズしたconfをパス指定
      "firelensConfiguration": {
        "type": "fluentbit",
        "options": {
          "config-file-type": "file",
          "config-file-value": "/fluent-bit.conf"
        }
```
今回はルートに配置したので`"config-file-value"`は`/fluent-bit.conf` です。
(ルート以外に配置した場合はそのパスを指定)
ここを指定しない場合はAWS側で `/fluent-bit/etc/fluent-bit.conf` にあるデフォルトのconfを見にいくようになっています。
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/userguide/using_firelens.html#firelens-example-firehose
>重要
カスタム設定ファイルを使用する場合は、FireLens が使用するパスとは異なるパスを指定する必要があります。Amazon ECS では /fluent-bit/etc/fluent-bit.conf (Fluent Bit) と /fluentd/etc/fluent.conf (Fluentd) のファイルパスは予約されています。

## 4.カスタマイズしたfluent-bitイメージでlog-routerコンテナを動かしてログを収集する
後はfluent-bitのイメージをbuildし、ECRリポジトリへpushした後、ECS Fargateでコンテナを立ち上げます。
railsコンテナとlog-routerコンテナが正しく起動したのを確認し、

- CloudWatch
- S3
    - foo_bucket
    - bar_bucket

を見て、ログが正しく送られているかを確認します。

### 補足
特にCloudWatchでログのファイル名をよく見てみると、`latest/rails-firelens-${ランダムな数字}`となっているかと思います。
`${ランダムな数字}`は`{task_id}`になっているはずです。
つまり、`fluent-bit.conf`のCloudWatchの`[OUTPUT]`で`log_stream_prefix   latest/`と指定したprefixが使われ、`{prefix}/{ログのタグ名}`というファイル名になっているのです。
仮に `Match rails-firelens-*`ではなく、`Match *` にすると、全てのタグのログがCloudWatchに送られることになるので、このファイルの他に `latest/foo_log` と`latest/bar_log` というログファイルも作られます。