Terraform で Laravel の artisan コマンドを起動する ESC タスクスケジューリングを作る

ECS タスクスケジューリングを Terraform を使って構築してみます。
Laravel の artisan コマンドを定期的に実行させてみます。
ECS タスクスケジューリングは、 AWS コンソールの以下の画面から作成できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/55d47caf-5cc7-e772-3598-57137eb51c2b.png)

ECS タスクスケジューリング = CloudWatch Events
========
[AWS ドキュメント » Amazon ECS » AWS Fargate 用ユーザーガイド » Amazon ECS タスクのスケジューリング » タスクのスケジューリング (cron)](scheduled_tasks)

CloudWatch Events で ECS ターゲットを起動することを、 ECS タスクスケジューリングと呼んでいるようです。
コンソールから作成すると画面のところどころに **CloudWatch Events** という言葉が見られます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/00c3fa10-f7df-9da6-6690-eb1fd07640ce.png)

実際、 ECS コンソールからタスクスケジューリングを作成すると、 CloudWatch Events にも同じものが出てきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/6cfcfc9a-2bed-2be5-8c80-af844fe71134.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/fe943656-087d-6bb0-ee12-d78cafa8057b.png)

Terraform からは aws_cloudwatch_event_rule/aws_cloudwatch_event_target で構築
========

Terraform で ECS タスクスケジューリングを構築する際は CloudWatch Event Rule, Event Target で ECS タスクスケジューリングを作ります。

[Resource: aws_cloudwatch_event_rule](aws_cloudwatch_event_rule)
[Resource: aws_cloudwatch_event_target](aws_cloudwatch_event_target)

Terraform の AWS 系リソースのドキュメントで ECS 配下を探してもタスクスケジューリングはありませんでした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/63088edb-1c27-be64-3275-40833b48aaf6.png)

サンプルコード
========

ECS タスクスケジューリングを動かせるサンプル一式を用意しました。

[ikasam/terraform-ecs-task-scheduler-with-laravel](https://github.com/ikasam/terraform-ecs-task-scheduler-with-laravel)

Terraform v0.12.15 で動作確認。

```bash
$ terraform version
Terraform v0.12.15
+ provider.aws v2.40.0
+ provider.template v2.1.2
```

VPC や ECR, ECS タスク定義など、必要なものも一式作成してるので、まっさらな環境でも動くと思います。[^laravel]
既存環境で試す場合は VPC の CIDR 被りやリソース名被りがあった箇所を変更したり、あるいは既存のリソースを利用するよう、置き換えてください。
あるいは、この中から必要なものを抜き取って、それぞれのプロジェクトに適用してみてください。

以下では、 ECS タスクスケジューリングに関わるところだけを紹介します。

## aws_cloudwatch_event_rule
```terraform:ecs_task_scheduler.tf
resource "aws_cloudwatch_event_rule" "inspire_every_minutes" {
  description         = "run php artisan inspire every minutes"
  is_enabled          = true
  name                = "inspire_every_minutes"
  schedule_expression = "cron(* * * * ? *)"
}
```

`aws_cloudwatch_event_rule` では、タスクを起動する条件を書きます。
ここでは、 cron 式でのスケジューリングを定義しています。
GMT として解釈されるので、日本時間との時差に注意してください。
たとえば、日本時間で平日の AM 8:00 に起動する場合は以下の式です。

```cron
cron(0 23 ? * 1-5 *)
```
 
詳しくは [AWS のドキュメント](cron) を参照ください。

## aws_cloudwatch_event_target
```terraform:ecs_task_scheduler.tf
data "template_file" "php_artisan_inspire" {
  template = file("ecs_container_overrides.json")

  vars = {
    container_name = "inspire"
    command        = "inspire"
  }
}

resource "aws_cloudwatch_event_target" "inspire" {
  rule      = aws_cloudwatch_event_rule.inspire_every_minutes.name
  arn       = aws_ecs_cluster.inspire.arn
  target_id = "inspire"
  role_arn  = aws_iam_role.ecs_events_run_task.arn
  input     = data.template_file.php_artisan_inspire.rendered
  ecs_target {
    launch_type         = "FARGATE"
    task_count          = 1
    task_definition_arn = replace(aws_ecs_task_definition.inspire.arn, "/:[0-9]+$/", "")
    network_configuration {
      assign_public_ip = true
      security_groups  = [aws_vpc.inspire.default_security_group_id]
      subnets          = [aws_subnet.inspire.id]
    }
  }
}
```

Event Rule で起動する ECS タスクを定義しています。
`ecs_container_overrides.json` の中身は以下のようになっていて、 Docker コンテナの `CMD` を上書きしています。

```json:ecs_container_overrides.json
{
  "containerOverrides": [
    {
      "name": "${container_name}",
      "command": [ "php", "artisan", "${command}" ]
    }
  ]
}
```

`template_file` の引数として `command = inspire` を渡しているので、
最終的に `php artisan inspire` が ECS タスクで実行されます。
今回は試していませんが、うまくやればログの出力先を変えたりといったこともできそうです。

### 最新のタスク定義の指定の仕方

```terraform
task_definition_arn = replace(aws_ecs_task_definition.inspire.arn, "/:[0-9]+$/", "")
```

このように書くことで、タスク定義の ARN を `arn:aws:ecs:us-east-2:123456789012:task-definition/inspire` と指定します。この指定方法で、タスクスケジューリングで起動するタスク定義の **最新** リビジョンを利用してくれます。バッチの更新によるデプロイでタスク定義が更新された場合も、タスクスケジューリングを更新しなくて良いので楽です。

### ECS コンソールからタスクスケジューリングを作成するとリビジョン固定となる

つまり、タスク定義の新しいリビジョンが作成されてもタスクスケジューリングは追従してくれません。
バッチだけ古いソースで動き続けるということです。怖い。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/a7060ce2-34ba-37b4-6691-5cbb8634543b.png)
タスク定義のリビジョン指定を強いられる。最新は選べない。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/1c501df9-44c0-917b-1a36-54005ae2474b.png)
リビジョン固定のタスクスケジューリングになってしまった。

## コンソールでは、 CloudWatch Events から最新のタスク定義を設定できる

コンソールからタスクスケジューリングの ECS タスク定義に最新を設定する場合は CloudWatch Events から設定しましょう。ただし、 CloudWatch Events からはコンテナコマンドの上書きはできません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/efbbe63f-c18f-47a7-0090-1c70bca77764.png)

## CloudWatch Events IAM ロール

```terraform:ecs_task_scheduler.tf
data "aws_iam_policy_document" "events_assume_role" {
  statement {
    sid     = "CloudWatchEvents"
    effect  = "Allow"
    actions = ["sts:AssumeRole"]
    principals {
      identifiers = ["events.amazonaws.com"]
      type        = "Service"
    }
  }
}

resource "aws_iam_role" "ecs_events_run_task" {
  name               = "ECSEventsRunTask"
  assume_role_policy = data.aws_iam_policy_document.events_assume_role.json
}

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

CloudWatch Events から ECS タスクを起動するためのロールです。
ECS タスクスケジューリングをコンソールからポチポチ作成する際、ロールを新規作成すると上記と同等のものができます。
`aws_cloudwatch_event_target` リソースの `role_arn` に、ここで作成したロールの ARN が入ります。
このロールの権限を適切に設定しないと、 Event Rule が起動しても ECS タスクを起動できずに終了してしまいます。

動作確認
========

サンプルでは、毎分ごとに `php artisan inspire` を実行する ECS タスクスケジューリングを構築しました。
`php artisan inspire` は、[実行すると 1 行の格言を表示するだけのコマンド](inspire)です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/352750b1-5512-3626-c187-c5c10cd09ca8.png)

約 1 分ごとに実行されていることがわかりますね。[^time]

終わったら後片付けを忘れずに。ECS タスクが起動しているとクラスターの削除に失敗して、下手するとデッドロックがかかるので、**必ずタスクスケジューリングを `DISABLE` にしてから `destroy` しましょう**。

```bash
$ terraform destroy
```

まとめ
========
* Terraform で ECS タスクスケジューリングを構築する場合は、 CloudWatch Events として構築すること
* タスクスケジューリングでタスク定義の最新を利用したい場合はリビジョン番号を指定しない
    * コンソールから作成する際は、 CloudWatch Events の画面から作成する
* IAM ロールの権限大事
* 1 日 1 回、感謝の `php artisan inspire`

参考
========
* [AWS ドキュメント » Amazon ECS » AWS Fargate 用ユーザーガイド » Amazon ECS タスクのスケジューリング » タスクのスケジューリング (cron)](scheduled_tasks)
* [AWS Documentation » Amazon CloudWatch » User Guide » Schedule Expressions for Rules](cron)
* [Terraform: Resource: aws_cloudwatch_event_rule](aws_cloudwatch_event_rule)
* [Terraform: Resource: aws_cloudwatch_event_target](aws_cloudwatch_event_target)

[^laravel]: Laravel の Web アプリケーションとしては動きません。 Web サーバーも DB もないし。
[^time]: 時間にばらつきがあるのは、 ECS タスクが起動開始してから実際にコマンドが走るまでの時間にばらつきがあるためです

[scheduled_tasks]: https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/userguide/scheduled_tasks.html
[cron]: https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/events/ScheduledEvents.html
[aws_cloudwatch_event_rule]: https://www.terraform.io/docs/providers/aws/r/cloudwatch_event_rule.html
[aws_cloudwatch_event_target]: https://www.terraform.io/docs/providers/aws/r/cloudwatch_event_target.html
[inspire]: https://github.com/laravel/framework/blob/6.x/src/Illuminate/Foundation/Inspiring.php