ECS FargateのログをFireLensを使って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 はある特定の条件のログのみを抽出したいという意)
構成図
今回は青い部分の作業手順をまとめます。
作業手順
- リソース作成
- CloudWatch と 2 つの Firehose に送るように fluent-bit イメージをカスタマイズする
- log-router コンテナを定義
- カスタマイズした fluent-bit イメージで log-router コンテナを動かしてログを収集する
1. リソース作成
-
firehose(今回は各 firehose ごとに IAM ロールを作成する想定)
- foo_firehose
- foo_firehose_role
- bar_firehose
- bar_firehose_role
- foo_firehose
-
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_id
やcontainer_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 に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
というログファイルも作られます。