CodeBuild/CodeDeploy/CodePipeline を使って ECS にデプロイを素振りしました。
残骸はこちら。
デプロイの流れ
デプロイの基本的な流れは次のとおりです。
- VCS からソースコードを取得
- CodeBuild でイメージをビルドして ECR にプッシュ
- このときイメージのURIを含むファイルをアーティファクトとして後段ステージに渡す
imagedefinitions.json
とかimageDetail.json
とか
- 新しいイメージの 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_ARN
や CWE_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-definition
や aws 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
みたいなフローでも良いかも。