ECSのCI/CDパイプラインを、CodePipeline + CodeBuildで組む構成は定番パターンです。GitHubと連携し、Dockerイメージをビルドしてから ECS サービスへデプロイする、という一連の流れをTerraformでコード化していきます。
この記事では、CodePipelineモジュールを構築する際の考え方を実際に手を動かす順序に沿って解説します。
全体像:何を作るのか
今回構築するCodePipelineモジュールは、大きく分けて次の要素で構成されます。
- パイプラインアーティファクト保存用のS3バケット
- CodePipeline実行ロール・CodeBuild実行ロール(IAM)
- GitHub連携用のCodeStar Connections
- CodeBuildプロジェクト(APIビルド用・バッチビルド用)
- CodePipeline本体(APIデプロイ用・バッチデプロイ用の2系統)
前編ではこのうち1〜3を扱い、後編で4〜5を扱います。
アーティファクト用S3バケットの設計
CodePipelineは、Source・Build・Deployといった各ステージ間で生成物(アーティファクト)を受け渡ししながら実行されます。この受け渡し先として、まず生成物の配置に使うS3バケットを用意します。
resource "aws_s3_bucket" "artifacts" {
bucket = "${var.project}-${var.env}-pipeline-artifacts"
force_destroy = true
tags = {
Project = var.project
Env = var.env
}
}
バージョニングと暗号化
CodePipelineの実行のたびにアーティファクトが上書きされていくため、バージョニングを有効化しておくと、特定の実行で使われたアーティファクトを後から追跡しやすくなります。
resource "aws_s3_bucket_versioning" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
アーティファクトにはソースコードのzipなど、内容そのものが含まれます。「誰が復号できるか」まで踏み込んで制御したい場合は、AWS管理鍵ではなくカスタマー管理のKMSキーに切り替える余地もあります。
特にクロスアカウント構成(別アカウントのECSへデプロイする等)を見据えるなら、KMSキーポリシー側でアクセス制御した方が柔軟です。
パブリックアクセスの遮断
アーティファクトにはビルドコンテキスト(ソースコードのzip等)が含まれるため、パブリックアクセスは一切許可しません。ここも他モジュールと同じく4項目を全てtrueにします。
resource "aws_s3_bucket_public_access_block" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
ライフサイクルルール
CodePipelineはデプロイのたびにアーティファクトを新規生成します。放置すると無期限に蓄積してストレージコストが膨らむため、一定期間(今回は30日)経過後に自動削除するライフサイクルルールを設定します。ALB/WAFのログと異なり、アーティファクトは「過去のデプロイ内容を長期間振り返る」用途ではなく「直近の実行を追跡できれば十分」なため、比較的短い保持期間で問題ありません。
resource "aws_s3_bucket_lifecycle_configuration" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
id = "expire-pipeline-artifacts"
status = "Enabled"
filter {}
expiration {
days = 30
}
noncurrent_version_expiration {
noncurrent_days = 30
}
abort_incomplete_multipart_upload {
days_after_initiation = 7
}
}
}
あわせて、S3バケットのハードニングとして定番の「非TLSアクセスの拒否」もバケットポリシーとして加えておきます。ALBのログバケットで扱ったのと同じ考え方で、CodePipeline/CodeBuildからのアクセスは通常HTTPS経由のため実害は出にくいですが、モジュール間で設定を統一しておくと一貫性が保てます。
resource "aws_s3_bucket_policy" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyInsecureTransport"
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = [
aws_s3_bucket.artifacts.arn,
"${aws_s3_bucket.artifacts.arn}/*"
]
Condition = {
Bool = { "aws:SecureTransport" = "false" }
}
}
]
})
}
IAMロール設計:最小権限を2つのロールに分ける
CodePipelineとCodeBuildは、それぞれ異なるAWSサービスとして動作するため、担う責務も必要な権限も異なります。1つの万能ロールにまとめるのではなく、サービスごとに実行ロールを分離するのが基本方針です。
CodePipeline実行ロール
CodePipelineの実行ロールは、codepipeline.amazonaws.com がAssumeRoleする信頼ポリシーを持ちます。
resource "aws_iam_role" "pipeline" {
name = "${var.project}-${var.env}-codepipeline-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "codepipeline.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
このロールに付与する権限ポリシーは、パイプラインが各ステージを進行させるために必要なアクションに絞り込みます。
resource "aws_iam_role_policy" "pipeline" {
name = "pipeline-policy"
role = aws_iam_role.pipeline.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject", "s3:GetBucketVersioning", "s3:ListBucket"]
Resource = ["${aws_s3_bucket.artifacts.arn}", "${aws_s3_bucket.artifacts.arn}/*"]
},
{
Effect = "Allow"
Action = ["codebuild:BatchGetBuilds", "codebuild:StartBuild"]
Resource = "*"
},
{
Effect = "Allow"
Action = ["ecs:DescribeServices", "ecs:DescribeTaskDefinition", "ecs:DescribeTasks", "ecs:ListTasks", "ecs:RegisterTaskDefinition", "ecs:UpdateService", "ecs:TagResource"]
Resource = "*"
},
{
Effect = "Allow"
Action = ["iam:PassRole"]
Resource = [
var.task_execution_role_arn,
var.task_role_arn
]
},
{
Effect = "Allow"
Action = ["codestar-connections:UseConnection", "codestar-connections:PassConnection"]
Resource = aws_codestarconnections_connection.github.arn
},
{
Effect = "Allow"
Action = ["ecs:RunTask"]
Resource = "*"
}
]
})
}
ここで注目したいのが iam:PassRole の扱いです。CodePipelineがECSデプロイを実行する際、ECSタスクに task_execution_role_arn(タスク実行ロール)と task_role_arn(タスクロール)を渡す必要があります。iam:PassRole は「このロールに、指定したロールを他のサービスへ引き渡す権限」を与えるアクションですが、Resource を * にしてしまうと、パイプラインが任意のIAMロールを他サービスに渡せてしまうという強力すぎる権限になります。今回のように、渡す可能性のあるロールのARNを配列で明示的に列挙しておくことで、権限昇格のリスクを抑えています。
ecs:RunTask の Resource を * にしているのは、後述するFlywayマイグレーション用の一回限りのECSタスク実行のためです。特定のタスク定義ARNに絞り込みたいところですが、タスク定義はデプロイのたびに新しいリビジョンが登録されるため、リビジョン番号を含む固定ARNでは事前に指定できません。このように、動的に変化するリソースに対する権限は、ワイルドカードを許容せざるを得ない場合があることも実践上のポイントです。
一方で、codebuild:StartBuild/BatchGetBuilds のように、対象が動的に増減しないリソースについては、Resource = "*" のままにせず対象のCodeBuildプロジェクトARNへ絞り込む余地があります。
{
Effect = "Allow"
Action = ["codebuild:BatchGetBuilds", "codebuild:StartBuild"]
Resource = [aws_codebuild_project.build.arn, aws_codebuild_project.batch_build.arn]
}
「動的に変化するためワイルドカードが必要なもの」と「絞り込めるのに絞り込んでいないもの」を分けて考える習慣をつけておくと、IAMポリシーのレビューがしやすくなります。
CodeBuild実行ロール
CodeBuildの実行ロールも同様に、codebuild.amazonaws.com を信頼する専用ロールとして分離します。
resource "aws_iam_role" "codebuild" {
name = "${var.project}-${var.env}-codebuild-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "codebuild.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
権限ポリシーは、CodeBuild内で実行される処理(ログ出力、アーティファクトの読み書き、ECRへのDockerイメージプッシュ、ECSタスク定義の登録・実行)に対応する内容になります。
resource "aws_iam_role_policy" "codebuild" {
name = "codebuild-policy"
role = aws_iam_role.codebuild.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
Resource = "*"
},
{
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject", "s3:GetBucketVersioning"]
Resource = ["${aws_s3_bucket.artifacts.arn}", "${aws_s3_bucket.artifacts.arn}/*"]
},
{
Effect = "Allow"
Action = ["ecr:GetAuthorizationToken", "ecr:BatchCheckLayerAvailability", "ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage", "ecr:InitiateLayerUpload", "ecr:UploadLayerPart",
"ecr:CompleteLayerUpload", "ecr:PutImage"]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"ecs:RunTask",
"ecs:DescribeTasks",
"ecs:DescribeTaskDefinition",
"ecs:RegisterTaskDefinition"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"iam:PassRole"
]
Resource = [
var.task_execution_role_arn,
var.task_role_arn
]
}
]
})
}
CodeBuild側にも ecs:RunTask と iam:PassRole が含まれているのは、ビルドプロセスの中でFlywayマイグレーション用のECSタスクを都度実行するためです。CodePipeline側の権限と重複しているように見えますが、これは「パイプラインの進行を管理するCodePipeline」と「実際にビルド・マイグレーションを実行するCodeBuild」がそれぞれ独立してECSと対話する必要があるためで、責務が異なるロールに同種の権限が付与されること自体は自然な設計です。
GitHub連携:CodeStar Connections
最後に、GitHubリポジトリと連携するための接続を作成します。
resource "aws_codestarconnections_connection" "github" {
name = "${var.project}-${var.env}-github"
provider_type = "GitHub"
}
GitHub側の認可が必要です。 aws_codestarconnections_connection リソースは、Terraform apply だけでは接続が完了しません。作成直後の接続ステータスは PENDING のままで、AWSマネジメントコンソール上でGitHub側の認可(OAuth承認)を手動で行って初めて AVAILABLE になります。この手動承認ステップを踏まないまま後続のCodePipelineをapplyしても、パイプラインはGitHubからソースを取得できません。