ECSタスクの終了ステータスを見てLambdaを発火させる

LIFULLでSETとしてテスト自動化やCI/CDの改善をやってます。
弊チームの自動テストはECSタスクで実行しています。
その自動テストの運用改善の一環で調べたことを書き綴ります。

# 自動システムテストのアーキテクチャ

下記の流れでテスト実行・テスト終了通知を行っていました。

Scheduled task(定時実行)
↓
ECS Run task(自動テスト実行)
↓
Cloudwatch Rules(ECS Taskの完了を検知)
↓
Lambda(チャットツールへ通知)

# 要求・要件
従来のままだとテストがすべてPassしても通知が来てしまいました。
ある自動テストプロジェクトの際に「テストがFailしたときだけチャットに通知させたい」という要求が生まれました。
よく考えれば当たり前の要求です。（たまたま私達の運用ではいらなかった）

要件に落とし込むと
「ECS Run taskで実行したテスト実行コンテナの終了ステータスが異常値の場合だけLambdaを発火させたい」
となりました。

# やったこと①：ECSイベントを調査

ECSタスクはある状態から別の状態に切り替わるときに**イベント**が発生します。

イベントには２つあります。
1. [コンテナインスタンス状態変更イベント](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/ecs_cwe_events.html#ecs_container_instance_events)
2. [タスク状態変更イベント](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/ecs_cwe_events.html#ecs_task_events)

今回はタスクの状態を利用したいので、2番のイベントタイプでした。

### イベントを確認する
[aws cli](https://docs.aws.amazon.com/cli/latest/reference/ecs/describe-tasks.html)を使って送信されるイベントを表示させて以下を確認しました。

- Fail時のrun-test(テスト実行)コンテナの終了ステータスを確認
- 後述のEventパターンで使えそうな条件がないか確認

```bash:
# 実際のTask実行後のTask-IDを使います
$ aws ecs describe-tasks --cluster run_test_cluster --tasks arn:aws:ecs:ap-northeast-1:0000000000000:task/run_test_cluster/0000000-11aa-22bb-33cc-444444444444 

# 以下の階層のJSONが取得できます
# 一部情報を空にしています

{
  "tasks":
  [
    {
      "taskArn": "value"
      "clusterArn": ""
      "taskDefinitionArn": ""
      "overrides": {},
      "lastStatus": "STOPPED",
      "desiredStatus": "STOPPED",
      "cpu": "4096",
      "memory": "8192",
      "containers": [
        {
          "containerArn": "",
          "taskArn": "",
          "name": "run-test",
          "lastStatus": "STOPPED",
          "exitCode": 1,
          "networkBindings": [],
          "networkInterfaces": [
              {
                  "attachmentId": "",
                  "privateIpv4Address": ""
              }
          ],
          "healthStatus": "UNKNOWN",
          "cpu": "0"
        },
        {
            "containerArn": "",
            "taskArn": "",
            "name": "selenium-node-chrome",
            "lastStatus": "STOPPED",
            "exitCode": 143,
            "networkBindings": [],
            "networkInterfaces": [
                {
                    "attachmentId": "",
                    "privateIpv4Address": ""
                }
            ],
            "healthStatus": "UNKNOWN",
            "cpu": "200",
            "memory": "400",
            "memoryReservation": "400"
        },
        {
            "containerArn": "",
            "taskArn": "",
            "name": "selenium-hub",
            "lastStatus": "STOPPED",
            "exitCode": 143,
            "networkBindings": [],
            "networkInterfaces": [
                {
                    "attachmentId": "",
                    "privateIpv4Address": ""
                }
            ],
            "healthStatus": "UNKNOWN",
            "cpu": "0",
            "memory": "200",
            "memoryReservation": "200"
        }
      ],
            "version": 1,
            "stoppedReason": "Essential container in task exited",
            "stopCode": "EssentialContainerExited",
            "connectivity": "CONNECTED",
            "connectivityAt": 123456789.123,
            "pullStartedAt": 123456789.123,
            "pullStoppedAt": 123456789.123,
            "executionStoppedAt": 123456789.123,
            "createdAt": 123456789.123,
            "startedAt": 123456789.123,
            "stoppingAt": 123456789.123,
            "stoppedAt": 123456789.123,
            "group": "family:run-test-task",
            "launchType": "FARGATE",
            "platformVersion": "1.3.0",
            "healthStatus": "UNKNOWN",
            "tags": []
        }
    ],
    "failures": []
}
```

`containers`の中にある`exitCode`と`name`が使えそうなことがわかりました。


#やったこと②：Cloudwatch Rule Eventパターンの調査

[Eventパターン](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/events/CloudWatchEventsandEventPatterns.html#CloudWatchEventsPatterns)を定義するとそのパターンに合致した場合にだけCloudwatch Ruleに紐付いた処理を行います。

## Before: Taskが終了したら毎回動くEventパターン
今回の改善前から使っていたものです。
対象のECS Clusterのタスクが終了したら後続処理が実行されます。
テストのPass/Fail関係なく動きます。

```json
{
  "source": [
    "aws.ecs"
  ],
  "detail-type": [
    "ECS Task State Change"
  ],
  "detail": {
    "clusterArn": [
      "arn:aws:ecs:ap-northeast-1:0000000000000:cluster/run_test_cluster"
    ],
    "lastStatus": [
      "STOPPED"
    ],
    "stoppedReason": [
      "Essential container in task exited"
    ]
  }
}
```

## After: 特定のコンテナが終了し、終了ステータスが１のときに動く
先程の`exitCode`と`name`を条件に追加したものです。

```json
{
  "source": [
    "aws.ecs"
  ],
  "detail-type": [
    "ECS Task State Change"
  ],
  "detail": {
    "clusterArn": [
      "arn:aws:ecs:ap-northeast-1:0000000000000:cluster/run_test_cluster"
    ],
    "lastStatus": [
      "STOPPED"
    ],
    "stoppedReason": [
      "Essential container in task exited"
    ],
    "containers": {
      "name": [
        "run-test"
      ],
      "exitCode": [
        1
      ]
    }
  }
}
```
これで要件が満たせた:v:

##と思いきや

上記のEventパターンだと以下の条件で動作する

- containers -> name -> “ldat” が存在する
  - かつ
- containers -> exitCode -> 1が存在すれば 動く

つまり以下のようなイベントでも実行されてしまう。。

```json:run-testコンテナは正常終了し、selenium-hubコンテナが異常終了（終了ステータス1）でも発火する
# 一部省略
"containers": [
  {
    "name": "run-test",
    "exitCode": 0,
  },
  {
    "name": "selenium-hub",
    "exitCode": 1,
  },
```

ジャーニーは続く・・・。
「こうすれば条件限定できるよ」みたいな情報をお待ちしております。

# Tips： EventオブジェクトがEventパターンにマッチするか検証する
ECSタスク実行して動作確認をしてましたが、確認にめちゃめちゃ時間がかかります。
途中で知ったのが`aws events test-event-pattern`で検証できるということでした。

[AWS CLI Command Reference - test-event-pattern](https://docs.aws.amazon.com/cli/latest/reference/events/test-event-pattern.html)

```bash:引数
  test-event-pattern
--event-pattern <value>
--event <value>
```

引数`--event-pattern`には先程のEventパターンを渡してあげます。
ただし、`"`はエスケープし、改行文字を削除する必要があります。
vimで`:%s/"/\\"/g`して、全選択`shift + j`すると割と楽です。

```bash:--event-patternの例
"{ \"source\": [ \"aws.ecs\" ], \"detail-type\": [ \"ECS Task State Change\" ], \"detail\": { \"clusterArn\": [ \"arn:aws:ecs:ap-northeast-1:0000000000000:cluster/run_test_cluster\" ], \"lastStatus\": [ \"STOPPED\" ], \"stoppedReason\": [ \"Essential container in task exited\" ], \"containers\": { \"name\": [ \"run-test\" ], \"exitCode\": [ 1 ] } } }"

```

引数`--event`はLambdaなどでEvent内容を取得する必要があります。

以下のようにログに吐き出すことができます。

```python
def lambda_handler(event, context):
    logger.info("Event: " + str(event))
```

```json:サンプル
{
  "version": "0",
  "id": "00000000-11aa-22bb-33cc-456789def",
  "detail-type": "ECS Task State Change",
  "source": "aws.ecs",
  "account": "0000000000000000",
  "time": "2019-12-06T00:00:00Z",
  "region": "us-east-1",
  "resources": [
    "arn:aws:ecs:ap-northeast-1:0000000000000:task/00000000-11aa-22bb-33cc-456789def"
  ],
  "detail": {
    "clusterArn": "arn:aws:ecs:ap-northeast-1:0000000000000:cluster/run_test_cluster",
    "containerInstanceArn": "arn:aws:ecs:ap-northeast-1:0000000000000:container-instance/00000000-11aa-22bb-33cc-456789def",
    "containers": [
      {
        "containerArn": "arn:aws:ecs:ap-northeast-11:0000000000000:container/00000000-11aa-22bb-33cc-456789def",
        "exitCode": 1,
        "lastStatus": "STOPPED",
        "name": "run-test",
        "taskArn": "arn:aws:ecs:ap-northeast-1:0000000000000:task/00000000-11aa-22bb-33cc-456789def"
      },
      {
        "containerArn": "arn:aws:ecs:ap-northeast-1:0000000000000:container/00000000-11aa-22bb-33cc-456789def",
        "exitCode": 142,
        "lastStatus": "STOPPED",
        "name": "selenium-node-chrome",
        "taskArn": "arn:aws:ecs:ap-northeast-1:0000000000000:task/00000000-11aa-22bb-33cc-456789def"
      }
    ],
    "stoppedReason": "Essential container in task exited",
    "createdAt": "2019-12-06T00:00:00.000Z",
    "desiredStatus": "STOPPED",
    "group": "family:run-test-task",
    "lastStatus": "STOPPED",
    "overrides": {},
    "startedAt": "2019-12-06T00:00:00.000Z",
    "startedBy": "ecs-svc/00000000000000",
    "updatedAt": "2019-12-06T00:00:00.000Z",
    "taskArn": "arn:aws:ecs:ap-northeast-1:0000000000000:task/00000000-11aa-22bb-33cc-456789def",
    "taskDefinitionArn": "arn:aws:ecs:ap-northeast-1:0000000000000:task-definition/run-test-task:2",
    "version": 4
  }
}

```
更に`"`はエスケープし、改行文字を削除する必要があります

```bash:
"{ \"version\": \"0\", \"id\": \"00000000-11aa-22bb-33cc-456789def\", \"detail-type\": \"ECS Task State Change\", \"source\": \"aws.ecs\", \"account\": \"0000000000000000\", \"time\": \"2019-12-06T00:00:00Z\", \"region\": \"us-east-1\", \"resources\": [ \"arn:aws:ecs:ap-northeast-1:0000000000000:task/00000000-11aa-22bb-33cc-456789def\" ], \"detail\": { \"clusterArn\": \"arn:aws:ecs:ap-northeast-1:0000000000000:cluster/run_test_cluster\", \"containerInstanceArn\": \"arn:aws:ecs:ap-northeast-1:0000000000000:container-instance/00000000-11aa-22bb-33cc-456789def\", \"containers\": [ { \"containerArn\": \"arn:aws:ecs:ap-northeast-11:0000000000000:container/00000000-11aa-22bb-33cc-456789def\", \"exitCode\": 1, \"lastStatus\": \"STOPPED\", \"name\": \"run-test\", \"taskArn\": \"arn:aws:ecs:ap-northeast-1:0000000000000:task/00000000-11aa-22bb-33cc-456789def\" }, { \"containerArn\": \"arn:aws:ecs:ap-northeast-1:0000000000000:container/00000000-11aa-22bb-33cc-456789def\", \"exitCode\": 142, \"lastStatus\": \"STOPPED\", \"name\": \"selenium-node-chrome\", \"taskArn\": \"arn:aws:ecs:ap-northeast-1:0000000000000:task/00000000-11aa-22bb-33cc-456789def\" } ], \"stoppedReason\": \"Essential container in task exited\", \"createdAt\": \"2019-12-06T00:00:00.000Z\", \"desiredStatus\": \"STOPPED\", \"group\": \"family:run-test-task\", \"lastStatus\": \"STOPPED\", \"overrides\": {}, \"startedAt\": \"2019-12-06T00:00:00.000Z\", \"startedBy\": \"ecs-svc/00000000000000\", \"updatedAt\": \"2019-12-06T00:00:00.000Z\", \"taskArn\": \"arn:aws:ecs:ap-northeast-1:0000000000000:task/00000000-11aa-22bb-33cc-456789def\", \"taskDefinitionArn\": \"arn:aws:ecs:ap-northeast-1:0000000000000:task-definition/run-test-task:2\", \"version\": 4 } }"
```

組み合わせると以下のようなコマンドになります。

```bash
$ aws events test-event-pattern \
 --region ap-northeast-1 \
 --event-pattern "{ \"source\": [ \"aws.ecs\" ], \"detail-type\": [ \"ECS Task State Change\" ], \"detail\": { \"clusterArn\": [ \"arn:aws:ecs:ap-northeast-1:0000000000000:cluster/run_test_cluster\" ], \"lastStatus\": [ \"STOPPED\" ], \"stoppedReason\": [ \"Essential container in task exited\" ], \"containers\": { \"name\": [ \"run-test\" ], \"exitCode\": [ 1 ] } } }" \
 --event "{ \"version\": \"0\", \"id\": \"00000000-11aa-22bb-33cc-456789def\", \"detail-type\": \"ECS Task State Change\", \"source\": \"aws.ecs\", \"account\": \"0000000000000000\", \"time\": \"2019-12-06T00:00:00Z\", \"region\": \"us-east-1\", \"resources\": [ \"arn:aws:ecs:ap-northeast-1:0000000000000:task/00000000-11aa-22bb-33cc-456789def\" ], \"detail\": { \"clusterArn\": \"arn:aws:ecs:ap-northeast-1:0000000000000:cluster/run_test_cluster\", \"containerInstanceArn\": \"arn:aws:ecs:ap-northeast-1:0000000000000:container-instance/00000000-11aa-22bb-33cc-456789def\", \"containers\": [ { \"containerArn\": \"arn:aws:ecs:ap-northeast-11:0000000000000:container/00000000-11aa-22bb-33cc-456789def\", \"exitCode\": 1, \"lastStatus\": \"STOPPED\", \"name\": \"run-test\", \"taskArn\": \"arn:aws:ecs:ap-northeast-1:0000000000000:task/00000000-11aa-22bb-33cc-456789def\" }, { \"containerArn\": \"arn:aws:ecs:ap-northeast-1:0000000000000:container/00000000-11aa-22bb-33cc-456789def\", \"exitCode\": 142, \"lastStatus\": \"STOPPED\", \"name\": \"selenium-node-chrome\", \"taskArn\": \"arn:aws:ecs:ap-northeast-1:0000000000000:task/00000000-11aa-22bb-33cc-456789def\" } ], \"stoppedReason\": \"Essential container in task exited\", \"createdAt\": \"2019-12-06T00:00:00.000Z\", \"desiredStatus\": \"STOPPED\", \"group\": \"family:run-test-task\", \"lastStatus\": \"STOPPED\", \"overrides\": {}, \"startedAt\": \"2019-12-06T00:00:00.000Z\", \"startedBy\": \"ecs-svc/00000000000000\", \"updatedAt\": \"2019-12-06T00:00:00.000Z\", \"taskArn\": \"arn:aws:ecs:ap-northeast-1:0000000000000:task/00000000-11aa-22bb-33cc-456789def\", \"taskDefinitionArn\": \"arn:aws:ecs:ap-northeast-1:0000000000000:task-definition/run-test-task:2\", \"version\": 4 } }"

{
    "Result": true
}
```