Terraformでfirelensを使ったECSアプリログのデリバリー
こんにちはbunと申します!
以前某アドテク事業者様のサービスを開発していることがありまして、基盤としてAWSのECSで実行環境はFARGATEを利用しておりました。
アドテクサービスの性質上とんでもない数のリクエストがとんでくるため、システムから出力されるログも月間で数TB以上となっており、そのままCloudWatchlogsに出力すると結構な金額になっておりました。
そこで、firelensを利用してエラー系のログはCloudWatchLogsに配信し、エラーも含む全てのログはS3に配信することで料金を抑えつつ、大事な情報はSlackに通知することができるようになりました。
そこで今回は超シンプルなサンプルアプリをFARGATE実行環境で動作させつつ、そのログをfirelensを使って収集・配信できるようにTerraformで実装してみました。
- firelensの嬉しいところ
- ログを複数のサービス(今回で言えばCloudWatchlogsとFirehose)に配信できる
- エラーログだけをCloudWatchLogs、全てのログはFireshoseなどというfilterも可能
- など
他、今回参考にした記事
前提知識
以下の様な前提知識が必要なので、FireLens is 何の方はご参照ください
- FireLensとは何か、どういうことができるのか
- Firelensについての公式の記事
概要
以下が構成図のイメージとなっております。
ただし構成図には lambdaによりslack通知などのログが記載されておりますが、今回はログをCloudWatchlogsやFirehose(を通してS3)に配信するところまでのみ実装してみます。
やったこと
- ECSでアプリケーションを稼働させる
- タスクにはシンプルなWebアプリのコンテナとそのログを出力するためのFireLens(fluentbit)のコンテナをサイドカーとして配置する
- アプリの全てのログはS3に出力させる(長期保存用)
- ただしエラーログに関しては、CloudWathLogsにも飛ばす
やらなかったこと
- Codeシリーズを使ったCI/CDパイプライン
- CloudWatchLogsのエラーログをSlackやEmailに通知させること
【2022/1/28追記】
エラーログのSlackとEmail通知に関しては別記事でしてみました
実装内容
今回は以下のリポジトリの超シンプルなアプリケーションを利用します。
環境変数を変えれば画面の色が変わるという本当にシンプルな機能です。
またTerraformでの構築に関しては、こちらにソースコードを配置しております。
コード全文は記載せず、ポイントとなりそうな箇所だけ記載していきます。
ECRリポジトリの用意
今回はアプリ用のECRリポジトリと、Firelensのイメージ用のECRリポジトリが必要になります。
また、今回はCI/CDは用意していないため、Terraformでイメージをプッシュしております。
# app repository
resource "aws_ecr_repository" "app" {
name = "app_color"
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}
locals {
firelens_repo_name = "color-firelens"
firelens_tag = "v1"
}
# Firelensカスタムイメージ
resource "aws_ecr_repository" "firelens" {
name = local.firelens_repo_name
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}
# NOTE: アプリ側のリポジトリにimageをpushするためのスクリプトが用意されている
resource "null_resource" "app" {
triggers = {
ecr_repo = aws_ecr_repository.app.name
}
provisioner "local-exec" {
# FIXME: アプリ側のリポジトリのファイルを指定しているため不恰好
command = "cd ${path.root}/../../webapp-color && sh push_image.sh"
// スクリプト専用の環境変数
environment = {
AWS_REGION = var.region
REPO_URI = aws_ecr_repository.app.repository_url
TAG = "v1"
CONTAINER_NAME = "color"
}
}
}
# terraform apply時にFluent Bitのコンテナイメージプッシュ
resource "null_resource" "fluentbit" {
triggers = {
ecr_repo_create = aws_ecr_repository.firelens.arn
}
## 認証トークンを取得し、レジストリに対して Docker クライアントを認証
provisioner "local-exec" {
command = "aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin ${aws_ecr_repository.firelens.repository_url}"
}
## Dockerイメージ作成
provisioner "local-exec" {
working_dir = "${path.module}/fluentbit"
command = "docker build --platform linux/amd64 -f Dockerfile -t ${local.firelens_repo_name}:${local.firelens_tag} ."
}
## ECRリポジトリにイメージをプッシュできるように、イメージにタグ付け
provisioner "local-exec" {
command = "docker tag ${local.firelens_repo_name}:${local.firelens_tag} ${aws_ecr_repository.firelens.repository_url}:${local.firelens_tag}"
}
## ECRリポジトリにイメージをプッシュ
provisioner "local-exec" {
command = "docker push ${aws_ecr_repository.firelens.repository_url}:${local.firelens_tag}"
}
}
ここで firelensは今回S3とCloudwatchにログを出力したかったため、カスタムイメージをプッシュします。
以下の様に Dockerfileと fluentbit用の設定ファイルを用意します。
FROM amazon/aws-for-fluent-bit:latest
COPY extra.conf /fluent-bit/etc/extra.conf
[SERVICE]
Flush 1
Grace 30
# ELBヘルスチェックログ除外
[FILTER]
Name grep
Match *-firelens-*
Exclude log ^(?=.*ELB-HealthChecker\/2\.0).*$
# エラーログにタグ付け
[FILTER]
Name rewrite_tag
Match *-firelens-*
# ERRORの文字列があるか、ステータスコードが4系か5系
Rule $log (ERROR|\s4\d{2}\s|\s5\d{2}\s) error-$container_id false
# errorタグの一部キーを削除
[FILTER]
Name record_modifier
Match error-*
Remove_key container_id
Remove_key container_name
Remove_key ecs_cluster
Remove_key ecs_task_arn
Remove_key source
# errorタグをCloudWatch Logsへ
[OUTPUT]
Name cloudwatch_logs
Match error-*
region ap-northeast-1
log_group_name /aws/ecs/color-app-error-logs
log_stream_prefix fluentbit-
# Terraform上で管理するため自動作成はしない
auto_create_group false
log_retention_days 30
# ELBのヘルスチェック以外の全ログはFirehose経由S3へ
[OUTPUT]
Name kinesis_firehose
Match *
region ap-northeast-1
delivery_stream color-app-deliverystream
なお、この設定ファイルについては以下ブログ記事を参考にさせていただきました。
詳しく知りたい方は、公式にそれぞれのセクションの詳細が記載されておりますので、ご参照ください。
ECSクラスター・サービス・タスク定義
次にECSのFARGATE実行環境でコンテナを動作させるため、以下リソースを作成します。
ポイントは task定義のリソースでログドライバーとしてfirelens を指定することです。
resource "aws_ecs_cluster" "web" {
name = "${var.project}-cluster"
setting {
name = "containerInsights"
value = "enabled"
}
capacity_providers = [
"FARGATE",
"FARGATE_SPOT"
]
tags = var.tags
depends_on = [
aws_lb.app
]
}
resource "aws_ecs_task_definition" "app" {
family = "${var.project}-task-def"
task_role_arn = aws_iam_role.ecs_task.arn
network_mode = "awsvpc"
requires_compatibilities = [
"FARGATE"
]
execution_role_arn = aws_iam_role.ecs_task_execution.arn
memory = "512"
cpu = "256"
container_definitions = jsonencode([
{
name = "color"
image = "${aws_ecr_repository.app.repository_url}:v1"
essential = true
portMappings = [
{
containerPort = 8080
hostPort = 8080
}
],
environment = [
{
"name" : "APP_COLOR",
"value" : "blue"
}
],
# アプリのログはfirelensで出力
logConfiguration = {
logDriver = "awsfirelens"
}
},
{
essential = true,
name = "log_router",
image = "${aws_ecr_repository.firelens.repository_url}:v1",
memoryReservation = 50,
# fruentbit自体のログはcloudwatchへ出力
logConfiguration = {
logDriver = "awslogs",
options = {
awslogs-group = aws_cloudwatch_log_group.firehose.name,
awslogs-region = var.region,
awslogs-stream-prefix = "app-sidecar"
}
},
firelensConfiguration = {
type = "fluentbit",
options = {
config-file-type = "file",
config-file-value = "/fluent-bit/etc/extra.conf"
}
}
}
])
volume {
name = "app-storage"
}
depends_on = [
aws_lb.app,
null_resource.fluentbit,
null_resource.app
]
tags = var.tags
}
resource "aws_ecs_service" "app" {
name = "${var.project}-service"
cluster = aws_ecs_cluster.web.id
capacity_provider_strategy {
capacity_provider = "FARGATE"
base = 1
weight = 1
}
platform_version = "1.4.0"
task_definition = aws_ecs_task_definition.app.arn
desired_count = 1
deployment_minimum_healthy_percent = 100
deployment_maximum_percent = 200
load_balancer {
target_group_arn = aws_lb_target_group.app_blue.arn
container_name = "color"
container_port = 8080
}
deployment_controller {
# TODO: CodeDeployによるデプロイの場合CODE_DEPLOYにする
# 今回はそこはスコープではないため省略する
type = "ECS"
}
health_check_grace_period_seconds = 60
network_configuration {
assign_public_ip = false
security_groups = [
aws_security_group.ecs_service.id
]
subnets = var.private_subnets
}
# ECS Exec用
enable_execute_command = true
tags = var.tags
lifecycle {
ignore_changes = [
load_balancer,
desired_count,
]
}
}
なお、セキュリティグループなども作成しておりますが、タスク用のIAMロールとタスク実行ロール用のIAMロールの作成が必要です。
タスクロールとタスク実行ロールの違いは以下ブログがわかりやすかったです!
イメージを掴んだら公式の記事も読むとバッチリですね!
#####################
# ECSタスク実行ロール
#####################
resource "aws_iam_role" "ecs_task_execution" {
name = "${var.project}-ecs-task-execution"
assume_role_policy = jsonencode(
{
"Version" : "2012-10-17",
"Statement" : [
{
"Effect" : "Allow",
"Principal" : {
"Service" : "ecs-tasks.amazonaws.com"
},
"Action" : "sts:AssumeRole"
}
]
}
)
tags = var.tags
}
# タスク実行ロールは管理ポリシーを利用
data "aws_iam_policy" "ecs_task" {
arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
# CloudWatchLogの権限もタスク実行ロールに与える
data "aws_iam_policy" "cloudwatch" {
arn = "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy"
}
resource "aws_iam_role_policy_attachment" "ecs_task_exec" {
role = aws_iam_role.ecs_task_execution.name
policy_arn = data.aws_iam_policy.ecs_task.arn
}
resource "aws_iam_role_policy_attachment" "ecs_task_exec_logs" {
role = aws_iam_role.ecs_task_execution.name
policy_arn = data.aws_iam_policy.cloudwatch.arn
}
#####################
# ECSタスクロール
#####################
# ECS Exec用にタスクロールを作成する(コンテナへデバッグできるように)
resource "aws_iam_role" "ecs_task" {
name = "${var.project}-ecs-task"
assume_role_policy = file("${path.module}/files/ecs_task_assume_policy.json")
tags = var.tags
}
resource "aws_iam_role_policy" "ecs_exec" {
name = "${var.project}-ecs-task"
role = aws_iam_role.ecs_task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel",
]
Effect = "Allow"
Resource = "*"
},
]
})
}
#####################
# Firelensコンテナ用ポリシー
#####################
resource "aws_iam_role_policy" "firelens_task" {
name = "${var.project}-firelens-task"
role = aws_iam_role.ecs_task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [
"firehose:PutRecordBatch",
"logs:CreateLogStream",
"logs:CreateLogGroup",
"logs:DescribeLogStreams",
"logs:PutLogEvents",
"logs:PutRetentionPolicy"
]
Effect = "Allow"
Resource = "*"
},
]
})
}
#####################
# FirehoseStreamにアタッチするロール
#####################
resource "aws_iam_role" "firehose_role" {
name = "${var.project}-firehose-role"
assume_role_policy = <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Action": "sts:AssumeRole",
"Principal": {
"Service": "firehose.amazonaws.com"
},
"Effect": "Allow",
"Sid": ""
}
]
}
EOF
}
resource "aws_iam_role_policy" "firehose_role_policy" {
name = "${var.project}-firehose-role-policy"
role = aws_iam_role.firehose_role.id
policy = <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Action": [
"s3:AbortMultipartUpload",
"s3:GetBucketLocation",
"s3:GetObject",
"s3:ListBucket",
"s3:ListBucketMultipartUploads",
"s3:PutObject"
],
"Resource": [
"${aws_s3_bucket.app_logs.arn}",
"${aws_s3_bucket.app_logs.arn}/*"
]
},
{
"Sid": "",
"Effect": "Allow",
"Action": [
"logs:PutLogEvents"
],
"Resource": [
"*"
]
}
]
}
EOF
}
CloudWatchLogsグループやFirehoseの準備
次にログを格納するためのS3バケットや、Firehose配信ストリームなどを作成します。
# エラーログ配信用
resource "aws_cloudwatch_log_group" "app" {
name = "/aws/ecs/${var.project}-error-logs"
tags = var.tags
retention_in_days = 90
}
# FireHose自体のロギング用
resource "aws_cloudwatch_log_group" "firehose" {
name = "/aws/kinesisfirehose/${var.project}-firehose-logs"
tags = var.tags
retention_in_days = 30
}
# デリバリーログ配信用(fluentbitからfirehoseに配信され、そこからs3にストリーム配信)
resource "aws_kinesis_firehose_delivery_stream" "firelens" {
name = "${var.project}-deliverystream"
destination = "s3"
s3_configuration {
role_arn = aws_iam_role.firehose_role.arn
bucket_arn = aws_s3_bucket.app_logs.arn
cloudwatch_logging_options {
enabled = "true"
log_group_name = aws_cloudwatch_log_group.firehose.id
log_stream_name = "firehose_error"
}
}
}
# デリバリーログ長期保存用
resource "aws_s3_bucket" "app_logs" {
bucket = "${var.project}-deliverylog"
acl = "private"
server_side_encryption_configuration {
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
}
結果
まず以下の用にアプリがデプロイされています。
このアプリは /
にアクセすると以下の様な画面に
/error
にアクセスすると以下の様な画面になり、標準出力へエラーログを出力します
エラーログは以下の様な形です
ERROR in app: It's intentional error.
まずアプリにアクセスする前のアプリケーションエラーログ配信用のCloudWatchLogsのロググループの様子ですが、以下の様に空です。
同様にALBのヘルスチェック以外の全てのアプリケーションログ配信用のS3バケットの中も空っぽです
ところが、 アプリケーションの /
にアクセスすると・・・
fluentbit -> firehose -> S3バケット と配信されます
さらにあえて /error
にアクセスしてエラーログを発生させてみると・・・
アプリケーションエラーログ配信用のCloudWatchlogsロググループにエラーログだけが出力されています!(/
へのアクセスログはなし)
なんとか意図する動作とできました!
まとめ
- ECS(FARGATE実行環境)でログを複数のサービスに配信したい場合はFirelens(fluentbit)を使用すると良いと思います
- CloudWatchlogsに出力して、そのあとS3に出力することもできるのですが、ログの量が多くなるとCloudWatchLogsの利用料金だけで結構な金額になるので要注意です
なお、こちらの書籍にログの仕組み構築に関する話も載せてくれていますので、とてもおすすめです!
Discussion