ECRへのレプリケーションをトリガーにECSのサービス更新を実行する

## はじめに
バックエンドのBuild成果物（docker image）がDeploy環境にレプリケートされた事をトリガーに、ECSのサービス更新を行うCDの構築を行ったので、それに関するTipsのようなものを書き残しておこうと思う。
（前回はCloud Frontのキャッシュ削除についての記事だったが、この記事はECSのサービス更新についての記事になる。）

## 構成イメージ
何らかのトリガーでCode BuildがStartすると、ECRにBuild成果物であるdocker imageがpushされ、それをトリガーにレプリケートが走る。
レプリケーション先の環境でレプリケート後に自動でDeploy（ECSのサービス更新）を実行させる。

図で示すと以下のようになる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/9d8558f1-8ecf-23cb-2743-61ee82825473.png)

## ECSのサービス更新
今回のDeployは、Blue/Green Deployではなく、単純にECSのサービス更新を行う方式で行った（検証環境という事でダウンタイム発生が許容されていたので）。
Deploy自体は、レプリケーションでECRに新規でimageがpushされるのでそれをCloud Watch Eventsで検知してそれをトリガーにLambda関数を実行し、以下のAWS CLIコマンドで行っている事と同じ事をAWS SDK（今回はJavaScript）で実行させる、という感じで行う。

```bash
aws ecs update-service --cluster {cluster名} --service {service名}
```

※ _--cluster_ は省略可能だがその場合デフォルトのクラスターになるので、UpdateServiceのAPIを実行するARNが _arn:aws:ecs:ap-northeast-1:xxxxxxxxxxxx:service/default/{service名}_ になるので注意。

※Cloud Watch Eventに[Amazon Elastic Container Registry イベント](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/events/EventTypes.html#ecr-event-types)があるので、設定する際はこれに思えるが、レプリケーションでECRにimageがpushされる場合は、 *ReplicateImage* というCloud Trailに記録されるイベントになるので、これをCloud Watch Eventの *AWS API Call via CloudTrail* で捕捉させてDeployを実行する必要がある。

・参考：[aws.ecs.update-service](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ecs/update-service.html)

## IAM
Lambda関数を作成するために必要なIAM（ _lambda:_ や _iam:_ ）については、ここでは省略する（[S3へのレプリケーションをトリガーにCloud Frontのキャッシュ削除を実行する](#)の方を参照）。
ここではCloud Watch Eventのルールを作成するためのIAMについて見ていく。

今回構築してみて分かった事だが、Management Consoleから設定する場合と、AWS CLIで設定する場合とでは、必要になる権限に大きな違いがあったのでそれぞれの設定方法で必要な権限についてみていく。

※Cloud Watch Eventsは _cloudwatch:_ ではなく _events:_ （EventBridge）なので注意。

### Management Consoleからの設定
Management consoleからのルール作成の場合、参考に示した[CloudWatch Events API オペレーションおよびアクションに必要な許可](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/permissions-reference-cw.html#cwe-permissions-table)の権限全てがないと、以下のように「今すぐ始める」ボタンが非活性で作成できない。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/5da36c93-40e9-5fa3-e9fa-47ee1f870ac7.png)

したがって以下のような権限（インラインポリシー）が必要になる。

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "events:PutEvents",
                "events:DeleteRule",
                "events:PutTargets",
                "events:DescribeRule",
                "events:EnableRule",
                "events:PutRule",
                "events:RemoveTargets",
                "events:ListTargetsByRule",
                "events:DisableRule"
            ],
            "Resource": [
                "arn:aws:events:ap-northeast-1:xxxxxxxxxxxx:rule/replication-deploy-by-console",
                "arn:aws:events:ap-northeast-1:xxxxxxxxxxxx:event-bus/*"
            ]
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": [
                "events:ListRuleNamesByTarget",
                "events:ListRules",
                "events:TestEventPattern"
            ],
            "Resource": "*"
        }
    ]
}
```

・参考：[CloudWatch Events API オペレーションおよびアクションに必要な許可](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/permissions-reference-cw.html#cwe-permissions-table)

### AWS CLIコマンドからの設定
AWS CLIコマンドでルールを作成する場合には、以下のIAMのみでOKで、Management Consoleから設定する時よりも最小権限で済む。

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "events:PutTargets",
                "events:PutRule"
            ],
            "Resource": "arn:aws:events:ap-northeast-1:xxxxxxxxxxxx:rule/replication-deploy-by-cli"
        }
    ]
}
```

・参考：[aws.events.put-rule](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/events/put-rule.html)

## Lambda関数のトリガーを設定する
ここでもManagement Consoleから設定する場合と、AWS CLIコマンドで設定する場合の2パターンを見ていく。

### Management Consoleからの設定
Management Console上で設定すると以下のような感じ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/9241282e-38e9-38ca-60ad-5bb3ca2235d6.png)

```json
{
  "source": [
    "aws.ecr"
  ],
  "detail-type": [
    "AWS API Call via CloudTrail"
  ],
  "detail": {
    "eventSource": [
      "ecr.amazonaws.com"
    ],
    "eventName": [
      "ReplicateImage"
    ]
  }
}
```

設定が完了するとLambdaのトリガーに以下のように表示される。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/28a0e0cb-73e5-bb52-9ec8-527071018a03.png)

### AWS CLIコマンドからの設定
以下のコマンドを順番に実行していけば設定できる。

```bash
aws events put-rule \
--name "replication-deploy-by-cli" \
--event-pattern file://event-json.json \
--description "AWS CLIから作成したreplication-deployのルール"

aws lambda add-permission \
--function-name replication-deploy-lambda \
--statement-id replication-deploy-by-cli \
--action 'lambda:InvokeFunction' \
--principal events.amazonaws.com --source-arn arn:aws:events:ap-northeast-1:xxxxxxxxxxxx:rule/replication-deploy-by-cli

aws events put-targets \
--rule replication-deploy-by-cli \
--targets "Id"="1","Arn"="arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:replication-deploy-lambda"
```

コマンドについて少し補足をすると、

1, _aws events put-rule_ を実行すると、ターゲットのないイベントルールが作成される
2, _aws lambda add-permission_ を実行すると、以下の画像のようにEventBridgeからのアクセスを信頼しLambdaのトリガーにEventBridge（Cloud Watch Event）を設定できるようになる（ルールのターゲットが未設定なので赤字のエラーが出ている）![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/cc6ddefc-6f37-7475-9346-e1d45f9b78c8.png)
3, _aws events put-targets_ を実行すると、ルールにターゲットが設定されトリガーとしての設定が完了する（設定完了後のLambdaのトリガーの状況は上記の[Management consoleから](#management-consoleから)の画像を参照）

※_event-json.json_ の中身は[Management Consoleからの設定](#management-consoleからの設定-1)のJSONと同じ。

※add permissionコマンドで *events:*（EventBridge）からのLambdaへのアクセス・設定を信頼するのがポイントで、これをしないとちゃんと設定できない。

・参考：[ステップ 2: ルールを作成する](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/events/RunLambdaSchedule.html)

## Lambda関数の実装
イベントトリガーの場合にはLambda関数にEventオブジェクトが渡ってくるが、まずはECRへのレプリケーションでimageがpushされる時にCloud Trailに記録され、Cloud Watch Eventで検知するイベントのEventオブジェクトがどのようなものか？を見てみる。
その後、そのEventオブジェクトの中身からDeploy（ECS UpdateService）を行うのに必要な情報を抜き出し実際にそれを実行するコードを考える。

### Lambdaに渡ってくるEventオブジェクトの中身
まず、Cloud Trailに記録されるオブジェクトだが、以下のようなオブジェクトが記録される（関係のある部分のみに省略して書いている）。

```json
{
    "eventVersion": "1.04",
    "eventSource": "ecr.amazonaws.com",
    "eventName": "ReplicateImage",
    "awsRegion": "us-east-2",
    "requestParameters": {
        ...
    },
    "responseElements": {
        ...
    },
    "requestID": "cb8c167e-EXAMPLE",
    "eventID": "e3c6f4ce-EXAMPLE",
    "resources": [
        {
            "ARN": "arn:aws:ecr:us-east-2:123456789012:repository/testrepo",
            "accountId": "123456789012"
        }
    ],
    "eventType": "AwsApiCall",
}
```

そしてこのCloud Trailに記録されるイベントをCloud Watch Eventsの _AWS API Call via CloudTrail_ で捕捉するので、Lambda関数に渡ってくるEventオブジェクトの中身としては以下のようなJSONになる（一部省略している）。
ポイントは、 Cloud Trailに記録されるイベントをCloud Watch Eventsで捕捉する場合、[CloudWatch イベントのイベントパターン](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/events/CloudWatchEventsandEventPatterns.html)で書かれているJSONの _detail-type_ が _AWS API Call via CloudTrail_ になり、_detail_ がCloud Trailに記録されるイベントのオブジェクト（上記）になるという事。

```json
{
  "version": "0",
  "id": "6a7e8feb-b491-4cf7-a9f1-bf3703467718",
  "detail-type": "AWS API Call via CloudTrail",
  "source": "aws.ecr",
  "region": "us-east-2",
  "resources": [],
  "detail": {
    "eventVersion": "1.04",
    "eventSource": "ecr.amazonaws.com",
    "eventName": "ReplicateImage",
    "awsRegion": "us-east-2",
    "requestParameters": {
        ...
    },
    "responseElements": {
        ...
    },
    "requestID": "cb8c167e-EXAMPLE",
    "eventID": "e3c6f4ce-EXAMPLE",
    "resources": [
        {
            "ARN": "arn:aws:ecr:us-east-2:123456789012:repository/testrepo",
            "accountId": "123456789012"
        }
    ],
    "eventType": "AwsApiCall",
  }
}
```

・参考：[Amazon ECR ログファイルエントリの概要](https://docs.aws.amazon.com/ja_jp/AmazonECR/latest/userguide/logging-using-cloudtrail.html)
・参考：[CloudTrail ログイベントリファレンス](https://docs.aws.amazon.com/ja_jp/awscloudtrail/latest/userguide/cloudtrail-event-reference.html)
・参考：[CloudWatch イベントのイベントパターン](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/events/CloudWatchEventsandEventPatterns.html)

### Lambda関数（index.handler）の実装
今回はNode.jsのLambda関数でDeployを実行させるので、実装としては以下のようした。

```js
const { ECSClient, UpdateServiceCommand } = require("@aws-sdk/client-ecs");
const client = new ECSClient({ region: process.env.REGION });

const serviceRegex = new RegExp(process.env.ECR_SERVICE_REGEX);
const clustreRegex = new RegExp(process.env.ECR_CLUSTER_REGEX);

exports.handler = async (event) => {
    try {
        const service = event.detail.resources[0].ARN.match(serviceRegex)
            ? event.detail.resources[0].ARN.match(serviceRegex)[1]
            : undefined;
        const cluster = event.detail.resources[0].ARN.match(clustreRegex)
            ? event.detail.resources[0].ARN.match(clustreRegex)[1]
            : undefined;

        if (!service && !cluster) {
            console.log("not match regex", event.detail.resources[0].ARN)
            return "";
        }

        const input = {
            service, // service: service の ES6省略形
            cluster, // cluster: cluster の ES6省略形
            forceNewDeployment: true
        }
        console.log("input", input);

        const command = new UpdateServiceCommand(input);
        const response = await client.send(command);

        console.log("status", response.$metadata.httpStatusCode);
        console.log("serviceArn", response.service.serviceArn);

        return "";
    } catch (error) {
        return errorHandler(error);
    }
}

const errorHandler = (error) => {
    const obj = {};
    obj["status"] = 500;
    obj["message"] = error.message;
    obj["stack"] = error.stack;
    obj["result"] = "ng";
    if (error.$metadata) {
        obj["status"] = error.$metadata.httpStatusCode;
    }

    console.log("errorHandler", obj);
    return "";
}
```

※実装について何点か補足すると、

・_if (!service && !cluster)_
　三項演算子で _undefined_ にせず、if()の中に _event.detail.resources[0].ARN.match(serviceRegex)_ を書く事もできるが長いのであえて上記のような実装にした

・_console.log()_
　今回はCloud Watch Events（EventBridge）がLambda関数のトリガーであり、returnされた結果を人が見る事ができないので、何が起きているか分かるように敢えてconsole.log()でlogに出力させるようにした

・_return "";_
　これも今回S3がトリガーでreturnされた結果を人が見れないので""（空文字）を返すようにした（何かJSONを返しても意味ないと判断）。

・参考：[ECS Client - AWS SDK for JavaScript v3](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-ecs/index.html)
・参考：[Class UpdateServiceCommand](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-ecs/classes/updateservicecommand.html)

## Lambda関数の実行ロールの設定
Lambda関数が他のAWSサービスに対して何らかの操作を行うには、実行ロールでその権限を設定する必要がある。
今回はECSのUpdateServiceが実行できればいいので以下のようなインラインポリシーを実行ロールに追加でアタッチするればOK。

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": "ecs:UpdateService",
            "Resource": "arn:aws:ecs:ap-northeast-1:xxxxxxxxxxxx:service/{cluster名}/{service名}"
        }
    ]
}
```

## まとめとして
前回はCloud Frontのキャッシュ削除について扱ったが、ECSについても同じような考え方でレプリケーション後にそれをトリガーにしてDeployを実行させる事ができた。