2020 September 17th, Thursday

ECS FargateのログをFireLensを使ってCloudWatchとFirehoseの両方に送る

AWSFluentdfluentbitFargateFireLens

やりたいこと

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 今回は青い部分の作業手順をまとめます。

作業手順

  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 は特にカスタマイズしないとこんな感じでログを送ります。

{
  "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_idcontainer_nameとかも出力させたい!ということでしたらここを true にすれば出力されます。

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

fluent-bit におけるログのタグ

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

[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 にfoobarという文字が入っているという想定です。

  • $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.conffluent-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_loglatest/bar_log というログファイルも作られます。