ECSのデプロイ方法を見直して任意のタイミングでコミットハッシュをタグに使ったイメージをデプロイできるようにした


こんにちは。
弁護士ドットコムでSREをやっている @t2ynkmr です。
この記事は [弁護士ドットコム Advent Calendar 2020](https://qiita.com/advent-calendar/2020/bengo4com) の9日目の記事です。

# TL;DR

- CodePipeline を使った ECS への自動デプロイをやめた
- CodePipeline のデプロイステージで行われている処理の一部をビルドステージで実行させた
- CodeDeploy にリビジョン登録する際にコミットハッシュをタグにしたイメージを紐付けることで latest タグ運用から卒業した
- いろいろやったけど1から作れるなら [ecspresso](https://github.com/kayac/ecspresso) でやるのがよさげ

# はじめに

みなさん、ECS で動かすコンテナは latest タグを使わずにに運用していますか？
去年の7月には [ECR でイミュータブルなコンテナタグがサポート](https://aws.amazon.com/jp/about-aws/whats-new/2019/07/amazon-ecr-now-supports-immutable-image-tags/)され、クラスメソッドさんのブロクでも [ECS デプロイのアンチパターン](https://dev.classmethod.jp/articles/ecs-deploy-all/#toc-7)として紹介されています。

私が主に担当している[クラウドサイン](https://www.cloudsign.jp)というサービスでもばっちり latest タグ運用をやってました…:cry:

これは ECS のデプロイ方法の見直しに合わせて latest タグ運用を脱却し、コミットハッシュをタグに使ったイメージを指定してデプロイできるようにした記録です。

# ECS デプロイの見直し

クラウドサインではサービスのコンテナ化と ECS への移行を進めています。
その際に terraform で作成した構成は以下のようになっています。

![ecs-auto-deploy-ecs-deploy-auto.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525737/d99be02c-9311-0931-cc9e-c8c1c5c3e579.png)

リポジトリへの操作をトリガーにして CloudWatchEvents で CodePipeline を動かして latest タグでイメージを更新し ECS へのデプロイまで自動で行うものでした。
これはこれで便利だったのですが ECS を運用していく中でエンジニアから要望が上がってきました

- 今動いているイメージがどの時点でビルドされたのか把握したい
- エラーが発生したらなるべく早く切り戻したいので過去のコミットハッシュを指定したデプロイを行いたい
- revert コミットでの対応ではエラー発生時間が伸びてしまう
- 任意のタイミングでデプロイをしたい

ごもっともです。みんなえらい。
ECS のデプロイ方法を見直してこれらの要望を実現できないか検討することにしました。

# やること/やらないこと

デプロイの見直しにあたりまずは状況を整理します。

## やること

要望にもあったとおり以下ですね。

1. デプロイされたイメージがいつの時点でビルドされたものか表示
1. 過去のコミットハッシュを指定したデプロイ
1. 任意のタイミングでデプロイ

## やらないこと

やらないことも整理しておきます。

1. コンテナオーケストレーションサービスの変更
  - k8s なら…等も頭をよぎりましたが、解決したい課題に対して影響範囲が大きいので ECS のままいい感じにできる方法を模索します
1. ECS のデプロイタイプの変更
  - ECS ではデプロイタイプとしてローリングデプロイではなく Blue/Green デプロイを利用しています。これはこれでツラミがあるのですがデプロイタイプの見直しも今回は行いません
1. 3rd Party のデプロイツールの利用
  - 既存の EC2 のデプロイにて CodeDeploy を利用しています。デプロイツールが乱立するのもツラいのでひとまずは既存の資産を活かせる形での変更を模索します

見直しの前に引き続き既存のデプロイでやっている処理を整理します。

# 既存のデプロイについて
## EC2

![ecs-auto-deploy-ec2-deploy.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525737/363efd70-5026-0805-405f-a411036ec588.png)

EC2 へのデプロイはリポジトリへの操作をトリガーにして CI で CodeDeploy にリビジョンを登録しています。
リビジョンの description に `git show HEAD --oneline | head -n1` を登録することで、リビジョンを一覧表示した際にコミットハッシュで絞り込めるようにしてあります。
デプロイをする際は slack から bot 経由で CodeDeploy を操作して登録されたリビジョンの確認やデプロイを実施しています。
リビジョンの表示によりデプロイされたアプリケーションがいつビルドされたのか確認可能です。

操作感的にはこれと同様な仕組みができればエンジニアが新しいデプロイ方法を学ぶことなくやりたいことが実現できそうです。

## ECS

![ecs-auto-deploy-ecs-deploy-auto.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525737/988c4058-af0d-640f-f089-4e39d6432931.png)


前述したとおり ECS への自動デプロイはリポジトリへの操作をトリガーにして CodePipeline 経由で実行されます。
ビルドステージで CodeBuild によるイメージのビルド、デプロイステージで CodeDeploy によるデプロイが行われています。

# デプロイの見直し方針

CodePipeline が実行しているデプロイステージを読み解いてやれば EC2 へのデプロイのように任意のタイミングでのデプロイが実現できそうです。
デプロイステージで行われる CodeDeploy を使った Blue/Green デプロイの処理をもう少し細かく見てみます。

## 調査

デプロイステージの設定としてアプリケーション名やデプロイメントグループだけでなく以下を設定しています。

- ECS のタスク定義
- CodeDeploy の AppSpec ファイル

CodeDeploy にこれらを渡して create-deployment で終わり＼(^o^)／
と思いきやそんなに簡単ではありませんでした…。

- [create-deployment](https://docs.aws.amazon.com/cli/latest/reference/deploy/create-deployment.html)

create-deployment のオプションではリビジョンが指定可能ですがタスク定義は指定できません。

ECS の場合 appspec.yml がリビジョンにあたることは記載されていますが、単純にこの2つを設定することは難しそうです。
デプロイステージで設定されるタスク定義と AppSpec で何が行われているのか確認してみます。

### デプロイステージに設定するタスク定義

[CodePipeline のチュートリアル](https://docs.aws.amazon.com/codepipeline/latest/userguide/tutorials-ecs-ecr-codedeploy.html)を確認するとプレースホルダーを含んだタスク定義を設定しています。

```json:taskdef.json
{
    "executionRoleArn": "arn:aws:iam::account_ID:role/ecsTaskExecutionRole",
    "containerDefinitions": [
        {
            "name": "sample-website",
            "image": "<IMAGE1_NAME>",
            "essential": true,
            "portMappings": [
                {
                    "hostPort": 80,
                    "protocol": "tcp",
                    "containerPort": 80
                }
            ]
        }
    ],
    "requiresCompatibilities": [
        "FARGATE"
    ],
    "networkMode": "awsvpc",
    "cpu": "256",
    "memory": "512",
    "family": "ecs-demo"
}
```

プレースホルダーを利用したタスク定義を設定することで、イメージ名を動的に更新したタスク定義を生成し、タスク定義を更新（新しいリビジョンを発行）しているようです。

ちなみにプレースホルダーは `imageDetail.json` というファイルに記載されたイメージ名が参照されます。
自動的に作成される場合もありますが、ビルドステージでイメージをビルドするような場合には作成してやる必要があります。 [^1]
[^1]: https://docs.aws.amazon.com/ja_jp/codepipeline/latest/userguide/file-reference.html#file-reference-ecs-bluegreen 


### デプロイステージに設定する AppSpec

同じく [CodePipeline のチュートリアル](https://docs.aws.amazon.com/codepipeline/latest/userguide/tutorials-ecs-ecr-codedeploy.html)を確認するとこちらもプレースホルダーを含んだ AppSpec ファイルを設定しています。

```yaml:appspec.yml
version: 0.0
Resources:
  - TargetService:
      Type: AWS::ECS::Service
      Properties:
        TaskDefinition: <TASK_DEFINITION>
        LoadBalancerInfo:
          ContainerName: "sample-website"
          ContainerPort: 80
```

CodeDeploy で ECS へデプロイする場合は AppSpec ファイルがリビジョンとして登録されます。
プレースホルダーを利用して前述の json を利用して発行されたリビジョンのタスク定義の ARN を動的に設定して、CodeDeploy が利用するリビジョンとして登録しているようです。

### デプロイステージでの処理

デプロイステージでは CodeDeploy の実行だけではなく CodePipeline により以下のような CodeDeploy 実行前段階の処理が行われていました。

- ビルドしたイメージを指定したタスク定義の更新
- タスク定義を反映した AppSpec の作成
- CodeDeploy へのリビジョン登録

そもそもこのデプロイステージでの設定を適切に実施してやることでコミットハッシュをタグにしたイメージをデプロイすることができます。
`デプロイされたイメージがいつの時点でビルドされたものか表示` という部分は解決しそうです。

## 見直し方針策定

デプロイステージの仕組みを準備してやることで CodeDeploy でのデプロイ前の状態を再現できることがわかりました。
次のような方針でやりたいことが実現できそうです。

- デプロイタイミングの制御のために CodePipeline でデプロイステージは設定しない
- デプロイステージで行われている CodeDeploy の前段階の処理はビルドステージで設定
- デプロイステージで行われている CodeDeploy 自体の処理は既存の EC2 のデプロイ処理を使いまわす

具体的には以下の様に設定します。

1. リポジトリへの操作をトリガーに CodePipeline のソースステージが実行される
1. CodePipeline のビルドステージで CodeBuild を実行する
  1. コミットハッシュをタグにしたイメージをビルドして ECR にプッシュ
  1. コミットハッシュをタグにしたイメージを参照するタスク定義を作成して ECS に登録
  1. ECS に登録したタスク定義を利用する appspec.yml をリビジョンとして CodeDeploy に登録
1. CodePipeline 完了後に slackbot から CodeDeploy の情報を参照してデプロイを行う

これで複数人がリポジトリを更新していても 1〜2 の処理が自動で行われ、最終的に任意のタイミングでデプロイできるようになりそうです。

# デプロイ見直し結果

見直し方針を踏まえて CodePipeline のステージ設定を変更し以下のような構成になりました。

![ecs-auto-deploy-ecs-deploy-manual.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525737/0db5cfd0-1301-349c-a4c4-854ac949b994.png)

ビルドステージまでで CodeBuild でのイメージビルド、CodeDeploy へのリビジョン登録のみ実施し、デプロイ自体は CodeDeploy に登録されたリビジョンの情報を利用して任意のタイミングで行います。

設定としては buildspec.yml を以下の様に更新してアプリケーションのリポジトリに登録しました。
(前提として ECS のクラスター、サービスが作成されていること（タスク定義が存在していること）が必要です)
  
```yaml:buiidspec.yml

version: 0.2

env:
  variables:
    AWS_DEFAULT_REGION: ap-northeast-1
    AWS_ECR_URL: 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com
    CONTAINER_NAME: app
    CONTAINER_PORT: 9999
    DOCKER_BUILDKIT: 1

phases:
  pre_build:
    commands:
      - COMMIT_SHORT_SHA=$(git rev-parse --short HEAD)
      - DESC_REVISION=$(git show HEAD --oneline | head -n1)
      - aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin 426005763013.dkr.ecr.ap-northeast-1.amazonaws.com
  build:
    commands:
      # コミットハッシュをタグにしたイメージをビルドして ECR にプッシュ
      - docker build -t ${AWS_ECR_URL}/${CONTAINER_NAME}:${COMMIT_SHORT_SHA} . 
      - docker push ${AWS_ECR_URL}/${CONTAINER_NAME}:${COMMIT_SHORT_SHA}
  post_build:
    commands:
      # コミットハッシュをタグにしたイメージを利用するタスク定義を作成して ECS に登録
      ## タスク定義を取得
      - |-
        aws ecs describe-task-definition \
          --task-definition ${CONTAINER_NAME} | \
          jq '.taskDefinition | { family: .family, taskRoleArn: .taskRoleArn, executionRoleArn: .executionRoleArn, networkMode: .networkMode, containerDefinitions: .containerDefinitions, volumes: .volumes, requiresCompatibilities: .requiresCompatibilities, cpu: .cpu, memory: .memory }' \
          > ./taskdef.json
      - sed -i -e "s#\"${AWS_ECR_URL}/${CONTAINER_NAME}:.*\"#\"${AWS_ECR_URL}/${CONTAINER_NAME}:${COMMIT_SHORT_SHA}\"#" ./taskdef.json
      ## タスク定義を更新
      - aws ecs register-task-definition --cli-input-json file://$(pwd)/taskdef.json
      # ECS に登録したタスク定義を利用する appspec.yml をリビジョンとして CodeDeploy に登録
      ## タスク定義の ARN を取得
      - |-
        TASK_DEFINITION_ARN=$(aws ecs describe-task-definition \
          --task-definition ${CONTAINER_NAME} | \
          jq -r '.taskDefinition.taskDefinitionArn')
      ## CodeDeploy のリビジョンを作成する
      - |-
        cat << EOF > revision.json
        {
          "applicationName": "${CONTAINER_NAME}",
          "description": "${DESC_REVISION}",
          "revision": {
              "revisionType": "AppSpecContent",
              "appSpecContent": {
                  "content": "{\"version\": 1,\"Resources\": [{\"TargetService\": {\"Type\": \"AWS::ECS::Service\",\"Properties\": {\"TaskDefinition\": \"${TASK_DEFINITION_ARN}\",\"LoadBalancerInfo\": {\"ContainerName\": \"${CONTAINER_NAME}\",\"ContainerPort\": ${CONTAINER_PORT}},\"PlatformVersion\": \"1.4.0\"}}}]}"
              }
          }
        }
        EOF
      - aws deploy register-application-revision --cli-input-json file://$(pwd)/revision.json
```


これでリポジトリへの操作をトリガーに CodeDeploy にコミットハッシュ単位でリビジョンが登録されます。
あとは slack から呼び出す bot の処理に ECS のアプリケーション情報を追加してやれば既存のデプロイ処理と同様にリビジョンを呼び出してデプロイできそうです。

bot 自体の説明は割愛しますがこのような感じで動いています。

- デプロイできるリビジョンの確認
![スクリーンショット 2020-12-08 9.35.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525737/a56f22d5-ea47-08a1-f998-8c2348421533.png)
  CodeDeploy の [list-application-revisions](https://docs.aws.amazon.com/cli/latest/reference/deploy/list-application-revisions.html) と [get-application-revision](https://docs.aws.amazon.com/cli/latest/reference/deploy/get-application-revision.html) で取得した情報を `registerTime` でソートして整形して表示しています。

- デプロイしたリビジョンの確認
![スクリーンショット 2020-12-08 9.36.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525737/168e9471-002e-3e26-647c-60611007eafb.png)
  CodeDeploy の `list-application-revisions` と `get-application-revision` で取得した情報を `lastUsedTime` でソートして整形して表示しています。
 
- リビジョンを指定したデプロイ
![スクリーンショット 2020-12-08 9.52.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525737/5eb4a06a-b89b-454d-3c8f-03be3c8e741f.png)
リビジョンの `lastUsedTime` が更新されました
![スクリーンショット 2020-12-08 9.52.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525737/d6e834bf-b1c8-32bb-3846-e5a78f311e4d.png)


いい感じです。これでやりたかったことが実現できました。
開発者もデプロイへの抵抗を感じることがなく、作業できそうです。


# おわりに

回りくどいことをしたような気もしていますが、既存のデプロイと使用感をあわせて ECS にもデプロイできるようにできたので開発者フレンドリーな方法を採用できたのではないでしょうか。

また既存のデプロイへの影響を考慮し 3rdParty ツールを使わないという決意の元で実施したのですが、調べた限りでは [ecspresso](https://github.com/kayac/ecspresso) を利用すれば同じことがもっとシンプルにできそうでした。
1からデプロイの仕組みをつくるならこっちかな…。

ともあれAWS上のサービスを利用してデプロイしたい状況とコンテナのタグ運用の見直しの参考になれば幸いです。


明日は弁護士ドットコムの技術顧問である @koriym さんです。お楽しみに :tada: