🔥

Terraformでfirelensを使ったECSアプリログのデリバリー

2022/01/26に公開

こんにちはbunと申します!

以前某アドテク事業者様のサービスを開発していることがありまして、基盤としてAWSのECSで実行環境はFARGATEを利用しておりました。

アドテクサービスの性質上とんでもない数のリクエストがとんでくるため、システムから出力されるログも月間で数TB以上となっており、そのままCloudWatchlogsに出力すると結構な金額になっておりました。

そこで、firelensを利用してエラー系のログはCloudWatchLogsに配信し、エラーも含む全てのログはS3に配信することで料金を抑えつつ、大事な情報はSlackに通知することができるようになりました。

そこで今回は超シンプルなサンプルアプリをFARGATE実行環境で動作させつつ、そのログをfirelensを使って収集・配信できるようにTerraformで実装してみました。

  • firelensの嬉しいところ
    • ログを複数のサービス(今回で言えばCloudWatchlogsとFirehose)に配信できる
    • エラーログだけをCloudWatchLogs、全てのログはFireshoseなどというfilterも可能
    • など

https://dev.classmethod.jp/articles/fargate-fiirelens-fluentbit/

他、今回参考にした記事

https://dev.classmethod.jp/articles/terraform-ecs-fargate-firelens-log-output/

前提知識

以下の様な前提知識が必要なので、FireLens is 何の方はご参照ください

  • FireLensとは何か、どういうことができるのか

https://dev.classmethod.jp/articles/ecs-firelens/

  • Firelensについての公式の記事

https://aws.amazon.com/jp/blogs/news/under-the-hood-firelens-for-amazon-ecs-tasks/

概要

以下が構成図のイメージとなっております。

構成図

ただし構成図には lambdaによりslack通知などのログが記載されておりますが、今回はログをCloudWatchlogsやFirehose(を通してS3)に配信するところまでのみ実装してみます。

やったこと

  • ECSでアプリケーションを稼働させる
  • タスクにはシンプルなWebアプリのコンテナとそのログを出力するためのFireLens(fluentbit)のコンテナをサイドカーとして配置する
  • アプリの全てのログはS3に出力させる(長期保存用)
  • ただしエラーログに関しては、CloudWathLogsにも飛ばす

やらなかったこと

  • Codeシリーズを使ったCI/CDパイプライン
  • CloudWatchLogsのエラーログをSlackやEmailに通知させること

【2022/1/28追記】

エラーログのSlackとEmail通知に関しては別記事でしてみました

https://zenn.dev/bun913/articles/7c6c3a1ed53087

実装内容

今回は以下のリポジトリの超シンプルなアプリケーションを利用します。

https://github.com/bun913/sample_app

環境変数を変えれば画面の色が変わるという本当にシンプルな機能です。

またTerraformでの構築に関しては、こちらにソースコードを配置しております。

https://github.com/bun913/aws_network_practice/tree/main/ecs_cloudwatch_logs

コード全文は記載せず、ポイントとなりそうな箇所だけ記載していきます。

ECRリポジトリの用意

今回はアプリ用のECRリポジトリと、Firelensのイメージ用のECRリポジトリが必要になります。

また、今回はCI/CDは用意していないため、Terraformでイメージをプッシュしております。

ecr.tf
# 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用の設定ファイルを用意します。

Dockerfile
FROM amazon/aws-for-fluent-bit:latest
COPY extra.conf /fluent-bit/etc/extra.conf
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

なお、この設定ファイルについては以下ブログ記事を参考にさせていただきました。

https://dev.classmethod.jp/articles/terraform-ecs-fargate-firelens-log-output/

詳しく知りたい方は、公式にそれぞれのセクションの詳細が記載されておりますので、ご参照ください。

https://docs.fluentbit.io/manual/administration/configuring-fluent-bit/configuration-file

ECSクラスター・サービス・タスク定義

次にECSのFARGATE実行環境でコンテナを動作させるため、以下リソースを作成します。

ポイントは task定義のリソースでログドライバーとしてfirelens を指定することです。

ecs.tf
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ロールの作成が必要です。

タスクロールとタスク実行ロールの違いは以下ブログがわかりやすかったです!

https://www.karakaram.com/difference-between-ecs-task-role-and-task-execution-role/

イメージを掴んだら公式の記事も読むとバッチリですね!

https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task_execution_IAM_role.html

https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task-iam-roles.html

iam.tf
#####################
# 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配信ストリームなどを作成します。

log.tf
# エラーログ配信用
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の利用料金だけで結構な金額になるので要注意です

なお、こちらの書籍にログの仕組み構築に関する話も載せてくれていますので、とてもおすすめです!

https://www.amazon.co.jp/dp/B09DKZC1ZH/ref=dp-kindle-redirect?_encoding=UTF8&btkr=1

Discussion