CodePipelineからLambdaを使ってECSタスク定義を更新する

# 概要
AWS CodePipelineを使うとECSに自動デプロイができるが、対応しているのはECSサービスに紐づいたタスク定義だけとなっている。
そこで、EventBridgeを使った定時バッチ用に都度コンテナを起動するタスク定義の更新をCodePipelineから行う方法を試した。

# CodePipelineの流れ
今回作成するパイプラインは以下の通り。

1. Sourceステージ：GitHubから指定したブランチのコードをpullする
1. Buildステージ：buildspec.ymlの手順でビルド、イメージをECR登録
1. Deployステージ：ビルドしたイメージを設定したタスク定義をECSにデプロイ

DeployステージのアクションプロバイダーをAmazon ECSにすると、Buildステージのアクションでアーティファクトに出力したイメージ定義ファイルを元に、ビルドしたイメージを使ってデプロイできる。
この方法はサービスに紐づいていないタスク定義ではできないので、今回はタスク定義の更新にLambda関数を使う。

# Lambda関数でタスク定義を更新する流れ

1. Buildステージでビルドしたイメージのタグを取得する
1. DeployステージのLambda関数でイメージのタグを取得する
1. 対象のタスク定義を更新する（前リビジョンの数値を使って新規作成）
1. EventBridgeなど対象のタスク定義を使うサービスのターゲットを変更する

# Buildステージでビルドしたイメージのタグを取得する

方法は2通りある。今回は最初の方法を使う。

- Buildステージでイメージのタグを変数として出力する
- アーティファクトに保存したイメージ定義ファイルからタグを取得する
    - 既にECSへの自動デプロイを実装しているならbuildspec.ymlを変更する必要がない

## Buildステージでイメージのタグを変数として出力する

`exported-variables` を設定することで別のステージで該当変数を扱うことができる。
https://docs.aws.amazon.com/codebuild/latest/userguide/build-spec-ref.html#build-spec.env.exported-variables

buildspec.yml
```
env:
    exported-variables:
        - IMAGE_TAG_HASH
phases:
    # buildspec内で指定したイメージのタグをexportする
    export IMAGE_TAG_HASH="HOGEFUGA"
```

Buildステージのアクション「変数の名前空間」で `BuildVariables` と指定すれば、
以降のステージで `#{BuildVariables.IMAGE_TAG_HASH}}` と参照できる。


# DeployステージのLambda関数でイメージのタグを取得する

## CodePipelineの設定

「アクションプロバイダー」はAWS Lambda、「関数名」は作成済みのLambda関数の名前を選択する。

デフォルトでは先ほどの `IMAGE_TAG_HASH` はLambda関数に渡されないので、
Lambda用アクションの「ユーザーパラメーター」で `#{BuildVariables.IMAGE_TAG_HASH}}` を指定する。

## Lambda関数の設定

`lambda_handler` の引数 `event` にCodePipelineから渡されたデータが格納されている。

このコードは以下の処理を実施する。

3. 対象のタスク定義を更新する（前リビジョンの数値を使って新規作成）
4. EventBridgeなど対象のタスク定義を使うサービスのターゲットを変更する

```
import os
import traceback
import boto3

TASK_DEFINITION_NAME = os.environ['taskDefinitionName']
ENV_NAME = os.environ['EnvName']

rule_list = [
    'appname-%s-batch-01' % (ENV_NAME),
    'appname-%s-batch-02' % (ENV_NAME),
]

code_pipeline = boto3.client('codepipeline')


def put_job_success(job, message):
    print('Putting job success')
    print(message)
    code_pipeline.put_job_success_result(jobId=job)


def put_job_failure(job, message):
    print('Putting job failure')
    print(message)
    code_pipeline.put_job_failure_result(jobId=job, failureDetails={'message': message, 'type': 'JobFailed'})


def update_scedule(job_id, rule_name, task_definition_arn):
    eb = boto3.client('events')

    response = eb.list_targets_by_rule(Rule=rule_name)

    target_contents = response['Targets']
    target_contents[0]['EcsParameters']['TaskDefinitionArn'] = task_definition_arn

    ret = eb.put_targets(Rule=rule_name, Targets=target_contents)

    if ret['FailedEntryCount'] > 0:
        put_job_failure(job_id, 'Update Failure.')

    print('Update Success: ' + rule_name)


def create_task_definition(image_tag_hash, account_id):
    ecs = boto3.client('ecs')

    response = ecs.list_task_definitions(
        familyPrefix=TASK_DEFINITION_NAME,
        status='ACTIVE',
        sort='DESC',
        maxResults=1
    )
    arn = response['taskDefinitionArns'][0]

    response = ecs.describe_task_definition(taskDefinition=arn, include=['TAGS'])

    td = response['taskDefinition']
    cd = td['containerDefinitions'][0]
    cd['image'] = '%s.dkr.ecr.ap-northeast-1.amazonaws.com/app:%s' % (account_id, image_tag_hash)

    response = ecs.register_task_definition(
        family=TASK_DEFINITION_NAME,
        taskRoleArn=td['taskRoleArn'],
        executionRoleArn=td['executionRoleArn'],
        networkMode='awsvpc',
        containerDefinitions=[cd],
        requiresCompatibilities=td['requiresCompatibilities'],
        cpu=td['cpu'],
        memory=td['memory'],
        tags=response['tags']
    )

    return response['taskDefinition']['taskDefinitionArn']


def lambda_handler(event, context):
    rule_name = ''

    try:
        job_id = event['CodePipeline.job']['id']
        account_id = event['CodePipeline.job']['accountId']
        image_tag_hash = event['CodePipeline.job']['data']['actionConfiguration']['configuration']['UserParameters']

        task_definition_arn = create_task_definition(image_tag_hash, account_id)
        print('Create Success: ' + task_definition_arn)

        for rule_name in rule_list:
            update_scedule(job_id, rule_name, task_definition_arn)
    except Exception as e:
        print('Function failed due to exception. ' + rule_name)
        print(e)
        traceback.print_exc()
        put_job_failure(job_id, 'Function exception: ' + str(e))

    put_job_success(job_id, 'All Update Success.')
    print('Function complete.')
    return "Complete."
```

タスク定義は更新のAPIがないので、最新リビジョンの詳細を取得し、そのデータを以って新規リビジョンを作成する必要がある。

この関数はCodePipelineから呼び出すので、単純に結果をreturnするのではなく、put_job_success()とput_job_failure()を使ってパイプラインに結果を返す必要がある。
これを実施しないとパイプラインが動き続けてしまう。

### 参考
- https://docs.aws.amazon.com/codepipeline/latest/userguide/actions-invoke-lambda-function.html
- https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ecs.html

## Lambda関数のロール

デフォルトのロールだとLambda関数からCodePipelineとECSにアクセスできない。
そこで以下の様なポリシーを付与する。

```
        {
            "Sid": "ECS",
            "Effect": "Allow",
            "Action": [
                "iam:PassRole",
                "ecs:ListTaskDefinitions",
                "ecs:DescribeTaskDefinition",
                "ecs:RegisterTaskDefinition"
            ],
            "Resource": "*"
        },
        {
            "Sid": "EventBridge",
            "Effect": "Allow",
            "Action": [
                "events:ListRules",
                "events:PutTargets",
                "events:ListTargetsByRule"
            ],
            "Resource": "arn:aws:events:ap-northeast-1:000000000000:rule/batch-*"
        },
        {
            "Sid": "CodePipeline",
            "Effect": "Allow",
            "Action": [
                "codepipeline:PutJobFailureResult",
                "codepipeline:PutJobSuccessResult"
            ],
            "Resource": "*"
        },
```

各アクションに対して必要最低限のリソースを設定するのがベストだが、
アクションによってはリソースが `*` 必須のものが多かった。

### 参考
- https://docs.aws.amazon.com/ja_jp/codepipeline/latest/userguide/permissions-reference.html
- https://docs.aws.amazon.com/ja_jp/service-authorization/latest/reference/list_amazonelasticcontainerservice.html

## テスト

毎回パイプラインを実行しなくともテストの「イベント JSON」に以下のような設定をすれば動作確認ができる。

```
{
  "CodePipeline.job": {
    "id": "00000000-0000-0000-0000-000000000000",
    "accountId": "000000000000",
    "data": {
      "actionConfiguration": {
        "configuration": {
          "FunctionName": "app-deploy-batch",
          "UserParameters": "0000000000000000000000000000000000000000"
        }
      }
    }
  }
}
```

# 余談
ちなみにイメージ定義ファイルからタグを取得する場合は、
Lambda関数のコードで以下の様にアクセスできる `inputArtifacts` のアーティファクトの情報を使う。
このデータからS3クライアントを使ってイメージ定義ファイルを読み込みjsonをパースしてイメージのタグを取得できる。

```
# Extract the Job Data
job_data = event['CodePipeline.job']['data']

# Get the list of artifacts passed to the function
artifacts = job_data['inputArtifacts']
artifacts["location"]["s3Location"]["bucketName"]
artifacts["location"]["s3Location"]["objectKey"]
```