CodeBuild/CodeDeploy/CodePipeline で ECS にデプロイする素振り

CodeBuild/CodeDeploy/CodePipeline を使って ECS にデプロイを素振りしました。

残骸はこちら

デプロイの流れ

デプロイの基本的な流れは次のとおりです。

  1. VCS からソースコードを取得
  2. CodeBuild でイメージをビルドして ECR にプッシュ
    • このときイメージのURIを含むファイルをアーティファクトとして後段ステージに渡す
    • imagedefinitions.json とか imageDetail.json とか
  3. 新しいイメージの ECS タスク定義を作成し、ECS サービスを更新してデプロイ

ローリングアップデートであれば ECS 単体でできるので CodeDeploy は必要ありません。

ECS アクションプロバイダへは入力アーティファクトとして次のようなファイルを imagedefinitions.json というファイル名で提供する必要があります。この内容を元に既存のタスク定義のイメージの部分を書き換えたタスク定義が新たなリビジョンに登録され、そのタスク定義で ECS サービスが更新され、ローリングアップデートが実行されます。

// imagedefinitions.json
[{"name":"コンテナ名","imageUri":"イメージのURI"}]

CodeDeploy を絡めれば ELB のターゲットグループを複数使った Blue-Green Deployment が可能です。CodePipeline のアクションプロバイダに CodeDeploy というのがありますが、それではなく CodeDeployToECS というアクションプロバイダを使います。

CodeDeployToECS には appspec.yml taskdef.json imageDetail.json の3つのファイルを提供する必要があります。

  • imageDetail.json
    • イメージのURIが記述されたファイル
    • CodeBuild でイメージをビルド時にアーティファクトとして出力します
    • taskdef.json のプレースホルダがこのファイルで指定されたURIに書き換えられます
  • taskdef.json
    • ECS のタスク定義の内容、マネジメントコンソールでタスク定義の JSON をコピペできます
    • あらかじめリポジトリに含めておきます
    • <IMAGE1_NAME>imageDetail.json を元に置換されます
  • appspec.yml
    • CodeDeploy によるデプロイの設定
    • あらかじめリポジトリに含めておきます
    • <TASK_DEFINITION>taskdef.json を元に作成されたタスク定義に置換されます

taskdef.json をリポジトリに入れておく必要があるのがすごく微妙です。タスク定義はあらかじめ Terraform で作っておくので、これだと Terraform と taskdef.json とでタスク定義が2重に存在することになってしまいます。

taskdef.json はリポジトリには含めず、CodeBuild で既存のタスク定義からイメージの部分だけ <IMAGE1_NAME> に書き換えて生成しても良いかも。

aws ecs describe-task-definition --task-definition $ECS_TASK_DEFINITION_ARN --query taskDefinition |
  jq '.containerDefinitions[0].image="<IMAGE1_NAME>"' > taskdef.json

これならプレースホルダ使わずに、このタイミングで実際のイメージのURIに書き換えたものを作成するほうが手っ取り早い気もする・・・

CodeDeploy を使わない ECS アクションプロバイダはこれに相当することをやっていると思うんですけど(taskdef.json が必要ないので)、CodeDeployToECS アクションプロバイダだとなぜ taskdef.json が必要なんでしょうかね、謎。

CodePipeline のサービスロールの IAM ポリシー

CodePipeline のデプロイのアクションでサービスロールのポリシーが足りなくて失敗しても次のようなエラーメッセージしかダッシュボードに表示されず、なにが足りないのかさっぱりわかりません。

The provided role does not have sufficient permissions to access ECS

ユーザーガイドを見てもよくわからない。。。マネジメントコンソールでぽちぽちやる分には自動でサービスロールが作られたり必要なポリシーが更新されていくようですけど、Terraform であらかじめ作っておくとなると難しい。。。

CloudTrail のログを漁った感じ ECS 関係だと次のポリシーが必要なようです。

{
  "Action": [
    "ecs:DescribeServices",
    "ecs:DescribeTaskDefinition",
    "ecs:RegisterTaskDefinition",
    "ecs:UpdateService"
  ],
  "Effect": "Allow",
  "Resource": "*"
},
{
  "Action": "iam:PassRole",
  "Effect": "Allow",
  "Resource": <ECSExecutionRole>,
  "Condition": {
    "StringEqualsIfExists": {
      "iam:PassedToService": [
        "ecs-tasks.amazonaws.com"
      ]
    }
  }
}

CloudTrail でAPIを書き込みだけログるようにしていたために ecs:DescribeServices などが必要なことに気づかず結構な時間を浪費しました。

データベースマイグレーション

デプロイの前にデータベースのマイグレーションを入れたいときはどうするのが良いのかな?

CodeDeploy によって作られたタスク定義を使って CodeBuild で ECS Run Task を実行する? いやいやそれだとデプロイが終わった後になるので遅すぎますね。

デプロイステージの前に CodeBuild をもう一段追加して、新たに作成されたイメージで docker run するのが簡単そう。

イメージをビルドする CodeBuild の buildspec.yml でイメージのURIをエクスポートします。

# buildspec.yml
version: 0.2
env:
  exported-variables:
    - IMAGE_URI
phases:
  pre_build:
    commands:
      - IMAGE_TAG=$CODEBUILD_RESOLVED_SOURCE_VERSION
      - IMAGE_URI=$REPOSITORY_URI:$IMAGE_TAG

  # ...snip... #

マイグレーションの buildspec.yml は別名 migration.buildspec.yml で作成します。

# migration.buildspec.yml
version: 0.2
phases:
  pre_build:
    commands:
      - $(aws ecr get-login --no-include-email)
  build:
    commands:
      - docker run --rm $IMAGE_URI <マイグレーションコマンド>

CodeBuild の定義で migration.buildspec.yml を参照させます。

resource "aws_codebuild_project" "migration" {
  // ...snip... //

  source {
    type      = "CODEPIPELINE"
    buildspec = "migration.buildspec.yml"
  }

  // ...snip... //
}

CodePipeline で環境変数をビルド間で受け渡すように指定します。

resource "aws_codepipeline" "pipeline" {
  // ...snip... //

  stage {
    name = "Build"

    action {
      name      = "Build"
      namespace = "BuildExport" // エクスポートする変数の名前空間
      category  = "Build"
      owner     = "AWS"
      provider  = "CodeBuild"
      version   = "1"

      input_artifacts  = ["SourceArtifact"]
      output_artifacts = ["BuildArtifact"]

      configuration = {
        ProjectName = aws_codebuild_project.build.name
      }
    }
  }

  stage {
    name = "Migration"

    action {
      name     = "Migration"
      category = "Build"
      owner    = "AWS"
      provider = "CodeBuild"
      version  = "1"

      input_artifacts = ["SourceArtifact"]

      configuration = {
        ProjectName = aws_codebuild_project.migration.name
        EnvironmentVariables = jsonencode([
          {
            name  = "IMAGE_URI"
            value = "#{BuildExport.IMAGE_URI}" // エクスポートされた変数を参照
          }
        ])
      }
    }
  }

  // ...snip... //
}

ただこの方法、CodeBuild からデータベースに接続するために VPC 内で実行する必要があります。VPC 内の CodeBuild には PublicIP が付与できないので、プライベートサブネットから NAT Gateway なり PrivateLink なりで CodeCommit やら ECR やらにアクセスできるようにする必要があります。

ECS サービスをプライベートサブネットに置くなら CodeCommit はともかく ECR へは同じようにアクセスする必要があるので、CodeBuild を ECS サービスと同じサブネットで実行すれば問題はないですね。

定期バッチ

いわゆる定期バッチのために Cloudwatch Event で ECS Task を定期実行していたとして、デプロイ時は Cloudwatch Event を新しいイメージのタスク定義で実行するように更新する必要がありますが、これはどう更新するのが良いのかな?

CodeDeploy を使うなら appspec.yml の AfterAllowTraffic で Lambda を呼び出して Cloudwatch Event を更新する、とかでできるでしょうか。

あるいは定期バッチは常に最新のタスク定義または Docker イメージを使うように構成しておいて、Cloudwatch Event を更新せずに済ませるとか。ロールバックしたときに困りそうだし、どのイメージで動いてるかわかりにくくなるので微妙ですね。

そもそも定期バッチのタスク定義はアプリケーションサーバのタスク定義と同じものは使わなだろうので、デプロイステージでアプリケーションサーバをデプロイするアクションとは別に、定期バッチのためのタスク定義を作成&Cloudwatch Event を更新する処理を CodeBuild で実行すればいいですね。

CodeBuild の buildspec.yml は次のような感じ。ECS_TASK_DEFINITION_ARNCWE_RULR_NAME はあらかじめ CodeBuild の環境変数で定義しておきます。IMAGE_URI は前述のマイグレーションの場合と同様に CodePipeline 間で受け渡す必要があります。

version: 0.2
phases:
  build:
    commands:
      - |
        aws ecs describe-task-definition \
          --task-definition "$ECS_TASK_DEFINITION_ARN" |
          jq --arg IMAGE_URI "$IMAGE_URI" '.taskDefinition | {
            family: .family,
            executionRoleArn: .executionRoleArn,
            networkMode: .networkMode,
            containerDefinitions: .containerDefinitions,
            requiresCompatibilities: .requiresCompatibilities,
            cpu: .cpu,
            memory: .memory
          } | .containerDefinitions[].image = $IMAGE_URI' > taskdef.json
      - aws ecs register-task-definition
          --cli-input-json file://taskdef.json
          --query taskDefinition > registered.json
      - |
        aws events list-targets-by-rule --rule "$CWE_RULR_NAME" |
          jq --slurpfile task registered.json '
            .Targets |
              .[].EcsParameters.TaskDefinitionArn = $task[0].taskDefinitionArn
          ' > targets.json
      - aws events put-targets
          --rule "$CWE_RULR_NAME"
          --targets file://targets.json

うーんこれは・・AWS CLI ではなく Terraform でデプロイすればいいんじゃないかという気がしてきます。

なお、aws ecs register-task-definitionaws events put-targets のために、CodeBuild のサービスロールには ECS タスク定義の Execution Role や CloudWatch Event Rule の IAM Role に対する iam:PassRole が必要です。

{
  "Action": "iam:PassRole"
  "Effect": "Allow"
  "Resource": [
    aws_iam_role.ecs_execution.arn,
    aws_iam_role.schedule.arn
  ]
}

さいごに

軽く触ってみた感じ、CodePipeline の ECS プロバイダや CodeDeployToECS プロバイダを使えば Rolling Update や Blue-Green Deployment が簡単にできるのは便利だと思う反面、単にビルドしたイメージを使うタスク定義を新たに登録して ECS や CodeDeploy を呼んでるだけなので、それならデプロイも CodeBuild で Terraform や AWS CLI で実行するのでよいかも・・という気もしました。

また、CI/CD のパイプラインの定義が、CodeBuild/CodeDeploy/CodePipeline そのものを作成するための Terraform のテンプレートと、CodePipeline の実行中に利用される buildspec.yml appspec.yml とに分かれるため、どっちに何があるかわかりにくく感じます(この環境変数は Terraform テンプレート? いや buildspec.yml だったかな? みたいな)。

また、Terraform のテンプレートを修正したときは CI/CD のパイプラインとは別に terraform apply が必要になるため、ちょっと修正してプッシュしたらすぐ実行、みたいな手軽さもありません。 terraform apply するための CodePipeline も作って CodePipeline を 2 段重ねにすればできるかもしれないですけど・・複雑。

普段 Gitlab CI を使っているので、ECS のデプロイも Gitlab CI で docker build -> docker pubh -> terrafrm apply みたいなフローでも良いかも。