この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
こんにちは!コンサル部のinomaso(@inomasosan)です。
ECS FargateでFireLensからCloudWatch LogsやKinesis Data Firehoseへのログ出力を、Terraformでコード化したので紹介します。
検証にあたり、弊社の以下ブログを参考にしました。
そもそもFireLensって何?
FireLensは、複数のAWSサービスやAWSパートナーネットワーク(Datadog等)にログ出力することができます。 ECSタスク定義でサイドカーとして起動し、他のコンテナからログドライバーとして使用します。
コンテナイメージにはFluentdとFluent Bitを選択可能です。
今回の検証では、リソース使用率が低く、ログルータに推奨されているFluent Bitを使用します。
2021/11/6時点でFluent Bitでは、以下のAWSサービスにログを出力することができます。
- Amazon CloudWatch
- Amazon Kinesis Data Firehose
- Amazon Kinesis Data Streams
- Amazon S3
FireLens(Fluent Bit)の新旧プラグインに注意
FireLens(Fluent Bit)のプラグインにはC言語のプラグインと、GO言語のプラグインがあります。
新プラグインはC言語のプラグインの方なので、S3以外の対応するAWSサービスはプラグイン指定に注意が必要です。
プラグイン指定はECSタスク定義か、Fluentd又はFluent Bitの設定ファイルのName
に設定します。
新旧プラグインの指定方法の比較表は以下の通りです。
サービス | 新プラグイン | 旧プラグイン |
---|---|---|
Amazon CloudWatch | cloudwatch_logs | cloudwatch |
Amazon Kinesis Data Firehose | kinesis_firehose | firehose |
Amazon Kinesis Data Streams | kinesis_streams | kinesis |
Fluent BitのオフィシャルマニュアルにあるAmazon CloudWatchには、以下のような記載があるので、新規構築する場合は基本的に新プラグインを選択して頂くのが良いかと思います。
The Golang plugin was named cloudwatch; this new high performance CloudWatch plugin is called cloudwatch_logs to prevent conflicts/confusion. Check the amazon repo for the Golang plugin for details on the deprecation/migration plan for the original plugin.
構成図
今回はECS Fargateで起動したApache httpdコンテナで検証してみました。
Terraformで構築する全体構成図は以下の通りです。
ログ出力の詳細
ログ出力の詳細な図となります。
各ログ毎の補足事項を、以下にまとめました。
httpd Log
FireLens経由で、CloudWatch LogsやKinesis Data Firehoseへログ出力します。
FireLensのFluent BitアプリにIAM権限が必要なため、ECSタスクロールに権限が必要です。
Fluent Bit Log
FireLensのFluent Bitコンテナ自身のログを、ログ配信の障害切り分けのために取得します。
awslogs
ログドライバーでCloudWatch Logsへログを送信するため、ECSタスク実行ロールに権限が必要です。
ちなみに今回の検証とは別に、awslogs
ログドライバーのみ使用する場合は、FireLensは不要となります。
Delivery Error log
Kinesis Data FirehoseからS3へ配信する際のエラーログとなります。
障害切り分けのために、Kinesis Data Firehoseのエラーログ記録を有効化します。
検証環境
今回実行した環境は以下の通りです。
Terraform関連
項目 | バージョン |
---|---|
macOS BigSur | 11.6 |
Terraform | 1.0.7 |
AWSプロバイダー | 3.63.0 |
コンテナ
項目 | バージョン |
---|---|
httpd | latest(2.4.51) |
amazon/aws-for-fluent-bit | latest(2.21.1) |
Fluent Bit | 1.8.9 |
Docker Desktop | 4.1.1 |
ざっくり設計方針
今回コードを書くにあたって、特に意識した方針は以下の通りです。
- 検証環境のコンテナのイメージタグはlatestタグとする。
- ※本番環境ではlatestタグの運用はトラブルの元なので避けましょう。ECRでイメージタグの上書き禁止も合わせて設定するのが良いです。
- FireLens用のイメージ作成とECRへプッシュも、Terraformの
null_resource
で実施する。 - httpd Log用のCloudWatch Logsのログストリーム作成は、Fluent Bitのカスタム設定ファイルで定義し、Fluent Bitから実施できるようにする。
- IAMロールにアタッチするIAMポリシーはFull権限を避け、必要最低現にする。
- ただし、Fluent Bitのカスタム設定ファイルによるログストリーム作成については、
Resources
は*
で定義し、イメージデプロイ担当者に裁量を持たせる。
- ただし、Fluent Bitのカスタム設定ファイルによるログストリーム作成については、
フォルダ構成
% tree
.
├── aws_alb.tf
├── aws_cloudwatch.tf
├── aws_ecr.tf
├── aws_ecs.tf
├── aws_iam_ecs.tf
├── aws_iam_kinesis.tf
├── aws_kinesis.tf
├── aws_s3_alblog.tf
├── aws_s3_firelens.tf
├── aws_sg_alb.tf
├── aws_sg_ecs.tf
├── aws_vpc.tf
├── fluentbit
│ ├── Dockerfile
│ └── extra.conf ##Fluent Bitのカスタム設定ファイル
├── httpd
│ └── container_definitions.json ##ECSコンテナ定義
├── outputs.tf
├── provider.tf
└── version.tf
Terrraformコード
各コードは折りたたんで記載してあります。
プロバイダー設定
provider.tf
provider "aws" {
##東京リージョン
region = "ap-northeast-1"
}
バージョン設定
version.tf
terraform {
## バージョンを固定
required_version = "1.0.7"
}
VPC
aws_vpc.tf
# Terraform Registry
# AWS VPC Terraform module
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "3.10.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
azs = ["ap-northeast-1a", "ap-northeast-1c"]
public_subnets = ["10.0.11.0/24", "10.0.12.0/24"]
#デフォルトセキュリティグループのルール削除
manage_default_security_group = true
default_security_group_ingress = []
default_security_group_egress = []
}
ALB
aws_alb.tf
resource "aws_lb" "alb" {
name = "inomaso-dev-alb"
load_balancer_type = "application"
internal = false
idle_timeout = 60
# ALB削除保護無効
# 本番はtrue推奨
enable_deletion_protection = false
security_groups = [aws_security_group.alb.id]
subnets = module.vpc.public_subnets
}
resource "aws_alb_listener" "alb_http" {
load_balancer_arn = aws_lb.alb.arn
port = "80"
protocol = "HTTP"
default_action {
target_group_arn = aws_lb_target_group.alb_tg.arn
type = "forward"
}
}
resource "aws_lb_target_group" "alb_tg" {
name = "inomaso-dev-alb-tg"
port = 80
protocol = "HTTP"
vpc_id = module.vpc.vpc_id
target_type = "ip"
health_check {
path = "/"
port = "traffic-port"
protocol = "HTTP"
timeout = 5
interval = 10
healthy_threshold = 2
unhealthy_threshold = 2
matcher = 200
}
}
aws_sg_alb.tf
resource "aws_security_group" "alb" {
name = "inomaso-dev-alb-sg"
description = "inomaso-dev-alb-sg"
vpc_id = module.vpc.vpc_id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "inomaso-dev-alb-sg"
}
}
outputs.tf
output "alb_dns_name" {
description = "ALB DNS Name"
value = aws_lb.alb.dns_name
}
ECR関連
fluentbit/Dockerfile
FROM amazon/aws-for-fluent-bit:latest
COPY fluentbit/extra.conf /fluent-bit/etc/extra.conf
fluentbit/extra.conf
一点注意なのが、この方法でロググループを作成した場合は、Terraformのtfstateファイルの管理対象外となるため、terraform destroyしてもリソースは残り続けます。
[OUTPUT]
Name cloudwatch_logs
Match *
region ap-northeast-1
log_group_name /ecs/firelens/httpd
log_stream_prefix from-
auto_create_group true
log_retention_days 90
[OUTPUT]
Name kinesis_firehose
Match *
region ap-northeast-1
delivery_stream inomaso-dev-kinesis-firehose
aws_ecr.tf
resource "aws_ecr_repository" "fluentbit" {
name = "inomaso-dev-ecr-fluentbit"
## 同じタグを使用した後続イメージのプッシュによるイメージタグの上書き許可
image_tag_mutability = "MUTABLE"
## プッシュ時のイメージスキャン
image_scanning_configuration {
scan_on_push = true
}
}
# AWSアカウント情報取得
data "aws_caller_identity" "my" {}
# terraform apply時にFluent Bitのコンテナイメージプッシュ
resource "null_resource" "fluentbit" {
## 認証トークンを取得し、レジストリに対して Docker クライアントを認証
provisioner "local-exec" {
command = "aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin ${data.aws_caller_identity.my.account_id}.dkr.ecr.ap-northeast-1.amazonaws.com"
}
## Dockerイメージ作成
provisioner "local-exec" {
command = "docker build -f fluentbit/Dockerfile -t inomaso-dev-fluentbit ."
}
## ECRリポジトリにイメージをプッシュできるように、イメージにタグ付け
provisioner "local-exec" {
command = "docker tag inomaso-dev-fluentbit:latest ${aws_ecr_repository.fluentbit.repository_url}:latest"
}
## ECRリポジトリにイメージをプッシュ
provisioner "local-exec" {
command = "docker push ${aws_ecr_repository.fluentbit.repository_url}:latest"
}
}
ECS
aws_ecs.tf
# タスク定義
resource "aws_ecs_task_definition" "task" {
depends_on = [null_resource.fluentbit]
family = "httpd-task"
task_role_arn = aws_iam_role.ecs_task.arn
execution_role_arn = aws_iam_role.ecs_task_exec.arn
#0.25vCPU
cpu = "256"
#0.5GB
memory = "512"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
## templatefile関数でFluent BitのイメージがプッシュされたECRリポジトリURLを、imageurl変数で引き渡し
container_definitions = templatefile("httpd/container_definitions.json", {
imageurl = "${aws_ecr_repository.fluentbit.repository_url}:latest"
})
}
# クラスター
resource "aws_ecs_cluster" "cluster" {
name = "httpd-cluster"
}
# サービス
resource "aws_ecs_service" "service" {
#depends_on = [aws_cloudwatch_log_group.firelens]
name = "httpd-service"
cluster = aws_ecs_cluster.cluster.arn
task_definition = aws_ecs_task_definition.task.arn
desired_count = 2
launch_type = "FARGATE"
platform_version = "1.4.0"
health_check_grace_period_seconds = 60
network_configuration {
assign_public_ip = true
security_groups = [aws_security_group.ecs.id]
subnets = module.vpc.public_subnets
}
## ALBのターゲットグループに登録する、コンテナ定義のnameとportMappings.containerPortを指定
load_balancer {
target_group_arn = aws_lb_target_group.alb_tg.arn
container_name = "httpd"
container_port = 80
}
## デプロイ毎にタスク定義が更新されるため、リソース初回作成時を除き変更を無視
lifecycle {
ignore_changes = [task_definition]
}
}
httpd/container_definitions.json
[
{
"essential": true,
"name": "httpd",
"image": "httpd:latest",
"portMappings": [
{
"protocol": "tcp",
"containerPort": 80
}
],
"memoryReservation": 100,
"logConfiguration": {
"logDriver": "awsfirelens"
}
},
{
"essential": true,
"name": "log_router",
"image": "${imageurl}",
"memoryReservation": 50,
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/firelens",
"awslogs-region": "ap-northeast-1",
"awslogs-stream-prefix": "httpd-sidecar"
}
},
"firelensConfiguration": {
"type": "fluentbit",
"options": {
"config-file-type": "file",
"config-file-value": "/fluent-bit/etc/extra.conf"
}
}
}
]
aws_sg_ecs.tf
resource "aws_security_group" "ecs" {
name = "inomaso-dev-ecs-sg"
description = "inomaso-dev-ecs-sg"
vpc_id = module.vpc.vpc_id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "inomaso-dev-ecs-sg"
}
}
aws_iam_ecs.tf
# ECSタスクロール作成
resource "aws_iam_role" "ecs_task" {
name = "inomaso-dev-ecs-task-role"
assume_role_policy = data.aws_iam_policy_document.ecs_task_assume.json
}
# ECSタスクロールを引き受けるための信頼関係を設定
data "aws_iam_policy_document" "ecs_task_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
# ECSタスクロール用のIAMポリシーを作成し、IAMロールにアタッチ
resource "aws_iam_role_policy" "ecs_task" {
name = "inomaso-dev-ecs-task-role-policy"
role = aws_iam_role.ecs_task.id
policy = data.aws_iam_policy_document.ecs_task_access.json
}
# ECSタスクロール用のIAMポリシーJSON
data "aws_iam_policy_document" "ecs_task_access" {
version = "2012-10-17"
statement {
sid = "CloudWatchLogsAccess"
effect = "Allow"
actions = [
"logs:CreateLogStream",
"logs:CreateLogGroup",
"logs:DescribeLogStreams",
"logs:PutLogEvents",
"logs:PutRetentionPolicy"
]
resources = [
"*"
]
}
statement {
sid = "FirehoseAccess"
effect = "Allow"
actions = [
"firehose:PutRecordBatch"
]
resources = [
"${aws_kinesis_firehose_delivery_stream.firelens.arn}"
]
}
}
# ECSタスク実行ロール作成
resource "aws_iam_role" "ecs_task_exec" {
name = "inomaso-dev-ecs-task-exec-role"
assume_role_policy = data.aws_iam_policy_document.ecs_task_exec_assume.json
}
# ECSタスク実行ロールを引き受けるための信頼関係を設定
data "aws_iam_policy_document" "ecs_task_exec_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
# ECSタスク実行ロールに既存IAMポリシーをアタッチ
resource "aws_iam_role_policy_attachment" "ecs_task_exec" {
role = aws_iam_role.ecs_task_exec.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
Kinesis Data Firehose
aws_kinesis.tf
resource "aws_kinesis_firehose_delivery_stream" "firelens" {
name = "inomaso-dev-kinesis-firehose"
destination = "s3"
s3_configuration {
role_arn = aws_iam_role.firehose.arn
bucket_arn = aws_s3_bucket.firelens.arn
buffer_size = 1
buffer_interval = 60
compression_format = "GZIP"
cloudwatch_logging_options {
enabled = "true"
log_group_name = aws_cloudwatch_log_group.firelens.name
log_stream_name = "kinesis_error"
}
}
}
aws_iam_kinesis.tf
# firehose用IAMロール作成
resource "aws_iam_role" "firehose" {
name = "inomaso-dev-firehose-role"
assume_role_policy = data.aws_iam_policy_document.firehose_assume.json
}
# firehose用IAMロールを引き受けるための信頼関係を設定
data "aws_iam_policy_document" "firehose_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["firehose.amazonaws.com"]
}
}
}
# firehose用IAMロール用のIAMポリシーを作成し、IAMロールにアタッチ
resource "aws_iam_role_policy" "firehose" {
name = "inomaso-dev-firehose-role-policy"
role = aws_iam_role.firehose.id
policy = data.aws_iam_policy_document.firehose_access.json
}
# firehose用IAMロールのIAMポリシーJSON
data "aws_iam_policy_document" "firehose_access" {
version = "2012-10-17"
statement {
sid = "S3Access"
effect = "Allow"
actions = [
"s3:AbortMultipartUpload",
"s3:GetBucketLocation",
"s3:GetObject",
"s3:ListBucket",
"s3:ListBucketMultipartUploads",
"s3:PutObject"
]
resources = [
"arn:aws:s3:::${aws_s3_bucket.firelens.bucket}",
"arn:aws:s3:::${aws_s3_bucket.firelens.bucket}/*",
]
}
statement {
sid = "CloudWatchLogsDeliveryErrorLogging"
effect = "Allow"
actions = [
"logs:PutLogEvents"
]
resources = [
"${aws_cloudwatch_log_group.firelens.arn}:log-stream:*"
]
}
}
CloudWatch Logs
aws_cloudwatch.tf
resource "aws_cloudwatch_log_group" "firelens" {
name = "/ecs/firelens"
#retention_in_days = 90
}
resource "aws_cloudwatch_log_stream" "kinesis" {
name = "kinesis_error"
log_group_name = aws_cloudwatch_log_group.firelens.name
}
S3
aws_s3_firelens.tf
resource "aws_s3_bucket" "firelens" {
## bucketを指定しないと[terraform-xxxx]というバケット名になる
bucket = "inomaso-dev-firelens"
acl = "private"
## S3バケットにオブジェクトがあっても削除
## 本番はfalse推奨
force_destroy = true
## SSE-S3で暗号化
server_side_encryption_configuration {
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
lifecycle_rule {
id = "Delete-After-90days"
enabled = true
expiration {
days = 90
}
}
}
resource "aws_s3_bucket_public_access_block" "firelens" {
bucket = aws_s3_bucket.firelens.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
ログ出力の確認
httpd Log
ECSで実行中のタスク数だけ、ログストリームが作成されてることを確認できました。
またALBからのヘルスチェックのリクエストログを確認できました。
{
"source": "stdout",
"log": "10.0.12.144 - - [07/Nov/2021:09:14:02 +0000] \"GET / HTTP/1.1\" 200 45",
"container_id": "5c5c639cc38042649f84d55c5df013dd-3386804179",
"container_name": "httpd",
"ecs_cluster": "httpd-cluster",
"ecs_task_arn": "arn:aws:ecs:ap-northeast-1:[AccountID]:task/httpd-cluster/5c5c639cc38042649f84d55c5df013dd",
"ecs_task_definition": "httpd-task:72"
}
S3にもログが出力されていることを確認できました。
S3 Selectでログの内容をクエリーしてみました。
{
"log": "10.0.12.144 - - [07/Nov/2021:09:13:52 +0000] \"GET / HTTP/1.1\" 200 45",
"container_id": "5c5c639cc38042649f84d55c5df013dd-3386804179",
"container_name": "httpd",
"source": "stdout",
"ecs_cluster": "httpd-cluster",
"ecs_task_arn": "arn:aws:ecs:ap-northeast-1:[AccountID]:task/httpd-cluster/5c5c639cc38042649f84d55c5df013dd",
"ecs_task_definition": "httpd-task:72"
}
Fluent Bit Log
httpdのサイドカーとして起動しているので、ECSで実行中のタスク数だけ、ログストリームが作成されてることを確認できました。
ログの内容も確認できました。
info
レベルのログのみなので、特に問題がないことがわかります。
Delivery Error log
FireLensとは直接関係ないですが、Kinesis Data Firehoseのエラーログも見ていきます。 ロググループとログストリームは事前に作成した通りに作成されていることを確認できました。
特に問題なければエラーログは作成されないので、試しにIAMポリシーのresources
を存在しないS3バケットに変更してみます。
aws_iam_kinesis.tf
data "aws_iam_policy_document" "firehose_access" {
version = "2012-10-17"
statement {
sid = "S3Access"
effect = "Allow"
actions = [
"s3:AbortMultipartUpload",
"s3:GetBucketLocation",
"s3:GetObject",
"s3:ListBucket",
"s3:ListBucketMultipartUploads",
"s3:PutObject"
]
resources = [
"arn:aws:s3:::${aws_s3_bucket.firelens.bucket}:errortest",
"arn:aws:s3:::${aws_s3_bucket.firelens.bucket}:errortest/*",
]
}
statement {
sid = "CloudWatchLogsDeliveryErrorLogging"
effect = "Allow"
actions = [
"logs:PutLogEvents"
]
resources = [
"${aws_cloudwatch_log_group.firelens.arn}:log-stream:*"
]
}
}
少し時間をまってからエラーログを確認したところ、以下のログが増えていました。
{
"deliveryStreamARN": "arn:aws:firehose:ap-northeast-1:[AccountID]:deliverystream/inomaso-dev-kinesis-firehose",
"destination": "arn:aws:s3:::inomaso-dev-firelens",
"deliveryStreamVersionId": 1,
"message": "Access was denied. Ensure that the trust policy for the provided IAM role allows Firehose to assume the role, and the access policy allows access to the S3 bucket.",
"errorCode": "S3.AccessDenied"
}
検証中のFAQ
1. ECSでのログ保存にFireLensは必須なのか?
ECSからのログ出力先がCloudWatch Logsのみで問題なければ、FireLensを無理に使う必要はありません。 ログ出力先にCloudWatch Logs以外も選択したい場合や、ログのフィルタリングをしたい場合にFireLensを検討するのが良いかと思います。
2. FireLens経由でS3バケットへログ保存するのにKinesis Data Firehoseは必要なの?
FireLensのFluent Bitコンテナが予期せぬ停止をしてしまうと、送信頻度によってはログを消失する可能性があります。 Kinesis Data Firehoseを間に挟むことでログのバッファリングが可能なので、高頻度の送信によるニアリアルタイムでログを保管することができます。
3. FireLensとKinesis Data Firehoseの障害切り分け用のロググループも自動作成できなかったの?
awslogs
の設定で、LogConfiguration
のawslogs-create-group: true
を設定すれば可能です。
ただし、タスク実行ロールにlogs:CreateLogGroup
等の権限が追加で必要となります。
タスク実行ロールのデフォルトIAMポリシーであるAmazonECSTaskExecutionRolePolicy
には、上記権限は含まれていません。
今回はタスク実行ロールに余計な権限を与えたくなかったので、Terraformによりロググループを作成することにしました。
4. FireLensの新プラグインだとJSONに順序性がなくなる
プラグインのGithubのissueにもありましたが、JSONはキーの順序付けの概念をサポートしていないようです。 また、Qiitaの記事にも以下のような記載がありました。
JSON に限らずキー、バリューで表現されるデータ形式は順番を保証しないと考えるのがベターようです。
ログの確認はCloudWatch LogsのフィルターやAthenaを使用することになりますが、キーバリューによる検索なので、特に問題はないかと思います。
5. Kinesis Data Firehoseにてエラーログの記録を有効にしたけどログストリームが作成されない
Terraformでエラーログ保存用のロググループまで作成したのですが、ログストリームが自動作成されませんでした。 調べてみたところ弊社のブログに以下の記載があることを見つけました。
Kinesis Data Firehoseにてエラーログの記録を有効にする場合かつ、マネジメントコンソール以外で配信ストリームを作成する場合は、ロググループ、ログストリームを事前に作成する必要があります。
特にログストリームも自動で作成されないのは注意が必要ですね。
まとめ
FireLensの検証はタスク定義方法や、ECSのタスクロールやタスク実行ロールの権限で大分時間がかかってしまいました。 ただ苦労した分、ECSのログ出力についてだいぶ理解することができたので、案件対応にも活かすことができそうです。
この記事が、どなたかのお役に立てば幸いです。それでは!