定期バッチをECS Scheduled Tasksで動かす

# はじめに
　皆さんの環境には定期バッチのために起動させっぱなし、又は都度手動で起動/停止させているEC2はありますか？
　私は作ろうとしていました。「起動させっぱなしでcron実行はコストがかかる。」「都度起動はSREの工数を奪い、自分自身も常に意識していなければならない。」と悩んでいたところ、職場の先輩に「ECS Scheduled Tasksを使えば？」とお勧めされました。
　使ってみるとかなり手軽に構築でき、コスト面含めメリットも多かったので、今回はECS Schedule Tasksを構築する際に使用したTerraformを共有したいと思います。

# ECS Scheduled Tasksとは？
　EventBridgeを使ってECSタスク(≒コンテナ)をスケジュール実行できるシステムのことです。cronでの定期実行だけでなく、GitHubと連携させてPRがマージされた時やCloudWatchがアラートを検知した時など様々なタイミングで実行することができます。
　Lambdaでも同様のことができますが、Lambdaの最大実行時間15分を超える場合はECSが選択肢となります。

# 構成
![スクリーンショット 2023-12-22 18.24.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264055/01d09a69-c8eb-8913-268d-6fe8b19e79d5.png)



# サンプルコード
2023/12/22現在、`terraform v1.5.7`、`aws provider v5.31.0`の環境ではほぼコピペで動きますので、気になる方は試してみてください！
※サブネットとセキュリティグループは別に用意してください

## ECS
```
resource "aws_ecs_cluster" "sample" {
  name = "cluster-sample"
}

resource "aws_ecs_task_definition" "sample" {
  family                   = "sample"
  execution_role_arn       = aws_iam_role.task_execution_role.arn
  task_role_arn            = aws_iam_role.task_role.arn
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = 512
  memory                   = 1024

  container_definitions = jsonencode([
    {
      name      = "sample"
      image     = "${data.aws_caller_identity.current.account_id}.dkr.ecr.ap-northeast-1.amazonaws.com/sample"
      cpu       = 512
      memory    = 1024
      essential = true
      "logConfiguration" : {
        "logDriver" : "awslogs",
        "options" : {
          "awslogs-region" : "ap-northeast-1",
          "awslogs-group" : "/ecs/scheduled-task/sample"
          "awslogs-stream-prefix" : "ecs",
        }
      },
      "environment" : [
        {
          "name" : "S3_BUCKET_NAME",
          "value" : "bucket"
         }
      ],
      "secrets" : [
        {
          "name" : "DB_HOST",
          "valueFrom" : data.aws_ssm_parameter.db_host.arn
        }
      ]
    }
  ])
}

// シークレットの管理にはSSM パラメータストアを使っています
data "aws_ssm_parameter" "db_host" {
  name = "/ecs/sample/db-host"
}

data "aws_caller_identity" "current" {}
```
コンテナでシークレットを使う場合は下記コマンドを使うなどして事前にAWS上に置いておく必要があります。
```bash
aws ssm put-parameter \
--name "/ecs/sample/db-host" \
--value "db" \
--type SecureString
```

## EventBridge
```
resource "aws_cloudwatch_event_rule" "sample" {
  name                = "sample"
  schedule_expression = "cron(*/2 * * * ? *)" // 2分おき
}

resource "aws_cloudwatch_event_target" "sample" {
  target_id = "sample"
  rule      = aws_cloudwatch_event_rule.sample.name
  arn       = aws_ecs_cluster.sample.arn
  role_arn  = aws_iam_role.scheduled_task_execute.arn

  ecs_target {
    task_definition_arn    = aws_ecs_task_definition.sample.arn
    task_count             = 1
    launch_type            = "FARGATE"
    platform_version       = "1.4.0"
    enable_execute_command = true

    network_configuration {
      assign_public_ip = false
      // セキュリティグループ・サブネットは通信する外部システムや他サービスに合わせてください。
      security_groups  = [ "sg-xxx" ] 
      subnets          = [ "subnet-xxxx" ] 
    }
  }
}
```
cronの記述ルールがlinuxのものと微妙に違ってハマりました。[cron 式のリファレンス](https://docs.aws.amazon.com/ja_jp/eventbridge/latest/userguide/eb-cron-expressions.html)
例) 毎週月曜16時(UTC)
- aws -> `0 16 ? * 1 *`
- linux -> `0 16 * * 1 *`

サブネットはFargateを使う場合VPCエンドポイントを設定しないといけないので注意が必要です。詳しい解説はリンクの記事にお任せします。
https://dev.classmethod.jp/articles/fargate_pv14_vpc_endpoint/

## IAM
```
resource "aws_iam_role" "scheduled_task_execute" {
  name = "scheduled-task-execute"
  assume_role_policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Sid" : "",
        "Effect" : "Allow",
        "Principal" : {
          "Service" : "events.amazonaws.com"
        },
        "Action" : "sts:AssumeRole"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "scheduled_task_execute" {
  role       = aws_iam_role.scheduled_task_execute.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceEventsRole"
}

// タスク実行ロール
resource "aws_iam_role" "task_execution_role" {
  name = "task-execution-role"

  assume_role_policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Sid" : ""
        "Effect" : "Allow",
        "Principal" : {
          "Service" : "ecs-tasks.amazonaws.com"
        },
        "Action" : "sts:AssumeRole",
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "task_execution_role" {
  role       = aws_iam_role.task_execution_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

// シークレットをssmで管理している場合は必要になります
resource "aws_iam_policy" "task_execution_role_ssm_get_parameter" {
  name = "task-execution-role-ssm-get-parameter"

  policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Sid" : "",
        "Effect" : "Allow",
        "Action" : "ssm:GetParameters",
        "Resource" : "arn:aws:ssm:ap-northeast-1:${data.aws_caller_identity.current.account_id}:parameter/ecs/sample/*",
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "task_execution_role_ssm_get_parameter" {
  role       = aws_iam_role.task_execution_role.name
  policy_arn = aws_iam_policy.task_execution_role_ssm_get_parameter.arn
}

// タスクロール
resource "aws_iam_role" "task_role" {
  name = "task-role"

  assume_role_policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Sid" : ""
        "Effect" : "Allow",
        "Principal" : {
          "Service" : "ecs-tasks.amazonaws.com"
        },
        "Action" : "sts:AssumeRole",
      }
    ]
  })
}

resource "aws_iam_policy" "task_role" {
  name = "task-role"

  // アプリでs3からファイルを取得している場合は下記権限を与えています。環境に合わせて設定してください。
  policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Effect" : "Allow",
        "Resource" : [
          "arn:aws:s3:::sample/*",
        ],
        "Action" : [
          "s3:GetObject",
        ]
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "task_role" {
  role       = aws_iam_role.task_role.name
  policy_arn = aws_iam_policy.task_role.arn
}

// ECSのログをCloudWatch Logsに送信する場合に必要になります。
resource "aws_iam_policy" "task_role_cwl" {
  name = "task-role-put-logs"

  policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Effect" : "Allow",
        "Resource" : [
          "arn:aws:logs:ap-northeast-1:${data.aws_caller_identity.current.account_id}:log-group:/ecs/scheduled-task/sample/*",
        ],
        "Action" : [
          "logs:DescribeLogGroups",
          "logs:CreateLogStream",
          "logs:PutLogEvents",
        ]
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "task_role_cwl" {
  role       = aws_iam_role.task_role.name
  policy_arn = aws_iam_policy.task_role_cwl.arn
}
```
EventBridge Targetsに渡すIAMに`AmazonEC2ContainerServiceEventsRole`ポリシーをアタッチする必要があります。[Amazon ECS CloudWatch Events IAM ロール](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/CWE_IAM_role.html)

## ECR
```
resource "aws_ecr_repository" "sample" {
  name = "sample"
}
```

今回は動作確認に公式の[hello world](https://hub.docker.com/_/hello-world)イメージを使用します。
```
// ログイン
aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin <アカウントID>.dkr.ecr.ap-northeast-1.amazonaws.com

// hello-worldイメージのpull
docker pull hello-world

// タグ付け
docker tag sample:latest <アカウントID>.dkr.ecr.ap-northeast-1.amazonaws.com/sample:latest

// ECRへpush
docker push <アカウントID>.dkr.ecr.ap-northeast-1.amazonaws.com/sample:latest
```

## CloudWatch Logs
```
resource "aws_cloudwatch_log_group" "sample" {
  name              = "/ecs/scheduled-task/sample"
  retention_in_days = 5 // ログの保持期間
}
```

## その他
全くterraform環境が無い状態からコピペで動かす際は下記のようなterraformの初期設定も必要になります。
```
provider "aws" {
  region = "ap-northeast-1"
}

terraform {
  backend "local" {
    path = "terraform.tfstate"
  }
}
```

# ECS Scheduled Taskを採用するメリット
- 手動から自動へ。時間の節約も小さな積み重ねが大事ですね！
- バッチ実行時のみEC2を起動する場合と比較しても大差ないコストになります。
    - 例) バッチ実行時間が30時間だった場合、
        - ECS：$0.932
            - = (0.05056 / 2 + 0.00553) * 30 +  (0.76 + 0.033) * 0.01
        - EC2(都度起動)：$0.816	
            - = 0.0272 * 30
        - 参考: EC2(起動したまま)：$19.584 
            - = 0.0272 * 24 * 30
    - ※EC2はt3.small
    - ※ECSはt3.smallのベースラインパフォーマンスを考慮した際、同等程度のパフォーマンスになる0.5vCPU/memory 500MB
    - ※ログ転送量は10MB
    - ※S3からファイル取得している場合など別途追加でコストがかかります
- 手動実行の場合、工数も取られるため月一実行としていましたが、自動になったことで短いスパンで実行でき、データの鮮度を高められました。
    - 前回実行時から現在時刻までにS3にアップロードされた画像の解像度を取得するバッチであったため実行間隔の短縮がデータ鮮度の向上につながりました。
- 実行ログを残すのが簡単でした。 EC2で動かす場合はマシン内に入ってFTPするか、[agent](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/UseCloudWatchUnifiedAgent.html)を仕込む必要があります。

 # 最後に
 　いかがでしょうか？EC2を操作する手間やコストを考えると、このくらいのコードを書く手間は安いものではないでしょうか？
　 dockerイメージ化が難しかったり、DBや外部サービスとのネットワークが複雑であったりする場合はそう簡単でもないかもしれませんが一向の余地はあるかもしれません。
 
 # あとがき
 　記事を書きながら調べ直していたら、2023年現在はEventBridge Schedule RuleではなくEventBridge Schedulerを推奨していることに気づきました。。今後Scheduled Tasksを作成される方は[こちら](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/userguide/scheduled_tasks-eventbridge-scheduler.html)をご利用ください。
 
 # 文献
 - [Schduled Tasks](https://docs.aws.amazon.com/AmazonECS/latest/userguide/scheduled_tasks.html)
 - [Scheduled tasks using EventBridge rules](https://docs.aws.amazon.com/AmazonECS/latest/userguide/scheduled_tasks-event-bridge.html)
 - [AWS Black Belt Online Seminar](https://pages.awscloud.com/rs/112-TZM-766/images/AWS-Black-Belt_2023_Amazon-EventBridge-Scheduler_0930_v1.pdf)
 - [ECS Fargate料金](https://aws.amazon.com/jp/fargate/pricing/?nc=sn&loc=2)
 - [CloudWatch Logs料金](https://aws.amazon.com/jp/cloudwatch/pricing/)
 - [EC2料金](https://aws.amazon.com/jp/ec2/pricing/on-demand/)