はじめに
この記事では、
GitHub に push → GitHub Actions がイメージを作る → ECS が Blue/Green で切り替える
という流れを Terraform で組み、実際に切り替わる様子を確認してみました。
簡易構成図
ECS ネイティブの Blue/Green
ECS ネイティブ方式では、ALB のリスナールールに 2 つのターゲットグループ(TG)を 重み 100 / 0 でぶら下げておき、ECS がこの重みを書き換えて切り替えます。
| CodeDeploy 方式 | ECS ネイティブ方式 | |
|---|---|---|
| 切り替えるのは | CodeDeploy | ECS 自身 |
| 追加で必要なもの | CodeDeploy アプリ / デプロイグループ / AppSpec | なし(サービスの設定だけ) |
| デプロイの呼び出し | CodeDeploy のデプロイ作成 | 通常と同じ UpdateService |
AWS からも CodeDeploy 方式からの移行ガイド が出ているので、新しく組むならこちらです。
切り替えは次の順で進みます。
本番より先に、テスト用のリスナー(今回は :8080)に新しい版が出る のがポイントです。
構成
コードは GitHub に置いています。
Blue/Green の本体は ECS サービスの次の部分だけ です。
deployment_configuration {
strategy = "BLUE_GREEN"
bake_time_in_minutes = var.bake_time_in_minutes # 切り替え後に blue を残す時間
}
load_balancer {
target_group_arn = aws_lb_target_group.this["blue"].arn
container_name = "app"
container_port = var.container_port
advanced_configuration {
alternate_target_group_arn = aws_lb_target_group.this["green"].arn
production_listener_rule = aws_lb_listener_rule.this["prod"].arn
test_listener_rule = aws_lb_listener_rule.this["test"].arn
role_arn = aws_iam_role.ecs_infra_lb.arn
}
}
GitHub Actions 側は、普通のデプロイと同じ です。
イメージを ECR に push し、タスク定義のイメージを差し替えて amazon-ecs-deploy-task-definition で UpdateService を呼ぶだけで、あとはサービスの設定に従って ECS が Blue/Green で切り替えます。
①〜④ と ⑥ が GitHub Actions のステップ(.github/workflows/deploy.yml)で、⑤ は ECS が自分で進めます。ワークフローは ⑤ が完了するまで待ちます。
それ以外の部分は、AWS のベストプラクティスに沿って次のようにしています。
| 対象 | 設定 | 対応するコントロール |
|---|---|---|
| ネットワーク | タスクにパブリック IP を付けない | ECS.2 |
| ネットワーク | タスクはプライベートサブネットに置き、ECR / Logs へは VPC エンドポイント経由 | - |
| コンテナ | 非 root(UID 101) | ECS.20 |
| コンテナ | 読み取り専用ルートファイルシステム | ECS.5 |
| ECS | Container Insights(enhanced) | ECS.12 |
| ECR | プッシュ時スキャン / タグ不変 / ライフサイクルポリシー | ECR.1〜3 |
| ALB | アクセスログ / 不正ヘッダー破棄 / 削除保護 | ELB.4〜6 |
| VPC | フローログ / デフォルト SG のルールを空に | EC2.6 / EC2.2 |
| GitHub Actions | OIDC で AssumeRole(アクセスキーなし)、アクションはコミット SHA で固定 | - |
HTTPS は独自ドメインが必要なので、今回は HTTP のままにしています。そのため ELB.1(HTTP→HTTPS リダイレクト)と ELB.18(暗号化されたリスナー)は満たしていません。
動作確認
手順 0:自分のリポジトリを作る
GitHub Actions の OIDC は「どのリポジトリから来たか」で AssumeRole を許可する ので、自分のリポジトリとして作り直します。
git clone https://github.com/leomaro7/ecs-bluegreen.git
cd ecs-bluegreen
git remote remove origin
gh repo create ecs-bluegreen --private --source . --remote origin
アカウントに GitHub 用の OIDC プロバイダーがなければ作ります(GitHub の OIDC を AWS で使う手順)。1 アカウントに 1 つなので、Terraform の管理外にしています。
aws iam list-open-id-connect-providers --output text | grep -q token.actions.githubusercontent.com || aws iam create-open-id-connect-provider --url https://token.actions.githubusercontent.com --client-id-list sts.amazonaws.com
手順 1:Terraform state 用のバケットを作る
state は S3 に置き、use_lockfile = true で S3 のロックファイルを使います(S3 backend)。
export AWS_REGION=ap-northeast-1
echo $AWS_REGION
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
echo $ACCOUNT_ID
STATE_BUCKET=ecs-bluegreen-tfstate-${ACCOUNT_ID}-${AWS_REGION}
echo $STATE_BUCKET
aws s3api create-bucket --bucket $STATE_BUCKET --create-bucket-configuration LocationConstraint=$AWS_REGION
aws s3api put-bucket-versioning --bucket $STATE_BUCKET --versioning-configuration Status=Enabled
手順 2:Terraform でインフラを作る
リポジトリの owner 名と ID を terraform.tfvars に書いてから apply します。
2026 年 7 月 15 日より後に作った(または名前変更・移管した)リポジトリは、OIDC トークンの sub が ID 付きの形式になります(GitHub の changelog)。
sub はどのリポジトリ・ブランチから来たかを表す値で、IAM ロールの信頼ポリシーはこの値で呼び出し元を絞ります。旧形式(repo:<owner>/<repo>:ref:refs/heads/main)だけを許可していると AssumeRole に失敗するので、このリポジトリでは新形式(repo:<owner>@<owner_id>/<repo>@<repo_id>:ref:refs/heads/main)も受けられるよう ID も渡しています。
cd terraform
OWNER=$(gh api user --jq .login)
echo $OWNER
cat > terraform.tfvars <<EOF
github_owner = "$OWNER"
github_repo = "ecs-bluegreen"
github_owner_id = "$(gh api repos/$OWNER/ecs-bluegreen --jq .owner.id)"
github_repo_id = "$(gh api repos/$OWNER/ecs-bluegreen --jq .id)"
test_listener_cidr = "$(curl -s https://checkip.amazonaws.com)/32"
EOF
terraform init -backend-config="bucket=$STATE_BUCKET"
terraform apply
ECS サービスはタスク数 0 で作られるので、この時点ではまだアプリは動いていません。
手順 3:GitHub Actions でアプリを初めてデプロイする
手順 2 で作ったロールの ARN をリポジトリ変数に入れて push すると、GitHub Actions がイメージを作って ECS にデプロイします。
gh variable set AWS_ROLE_ARN --body "$(terraform output -raw github_actions_role_arn)"
git push -u origin main
初回は Blue/Green の切り替えが終わってから、ワークフローがタスク数を 0 → 2 に上げて、自前のイメージで起動します。
ワークフローが終わったら、本番の URL を開きます。
curl $(terraform output -raw prod_url)
<h1>ecs-bluegreen</h1><p>version: 8e8a6c5...</p>
version には push したコミットの SHA が入るので、環境ごとに異なります。
手順 4:2 回目のアプリのデプロイで切り替わりを見る
ページのタイトルを変えて push し、本番(:80)とテスト(:8080)の応答を 10 秒おきに並べて 見ます。
cd ..
sed -i '' 's|<h1>ecs-bluegreen</h1>|<h1>ecs-bluegreen v2</h1>|' app/Dockerfile
git commit -am "v2" && git push
URL=$(terraform -chdir=terraform output -raw prod_url)
echo $URL
while true; do
echo "$(date +%T) :80 $(curl -s $URL/ | grep -o '<h1>[^<]*') :8080 $(curl -s ${URL}:8080/ | grep -o '<h1>[^<]*')"
sleep 10
done
手元の検証では、リスナールールの重みと :80 / :8080 の応答を 10 秒おきに記録して、次のように切り替わりました。
新しい版がテストリスナーだけに出ていたのは約 25 秒 でした。フックなしだとすぐ本番に進むので、確認の時間を取りたいならライフサイクルフックを入れる必要がありそうです。
ハマりどころ
| 起きたこと | 対処 |
|---|---|
非 root のコンテナが /tmp に書けず起動失敗(バインドマウントは root 所有) |
Dockerfile に VOLUME ["/tmp"]
|
| そのため公開イメージのままでは初回起動できない | サービスはタスク数 0 で作り、初回 push で起動 |
| 初回に新しいタスク定義と台数を同時に渡すと、旧リビジョン(起動できない初回の枠)まで起動しようとして pull 失敗が続く | 0 台のまま切り替えてから台数を上げる |
| デプロイのたびに ECS が TG の役割と重みを入れ替える | Terraform 側は ignore_changes で管理から外す |
後片付け
ALB の削除保護を外してから destroy し、Terraform の管理外のもの(GitHub Actions が登録したタスク定義は登録解除、Container Insights が自動で作ったロググループ、state バケット)を消します。
cd terraform
STATE_BUCKET=ecs-bluegreen-tfstate-$(aws sts get-caller-identity --query Account --output text)-ap-northeast-1
echo $STATE_BUCKET
terraform apply -var enable_deletion_protection=false
terraform destroy -var enable_deletion_protection=false
for td in $(aws ecs list-task-definitions --family-prefix ecs-bluegreen --query 'taskDefinitionArns[]' --output text); do aws ecs deregister-task-definition --task-definition $td --query 'taskDefinition.revision' --output text; done
aws logs delete-log-group --log-group-name /aws/ecs/containerinsights/ecs-bluegreen/performance
aws s3api delete-objects --bucket $STATE_BUCKET --delete "$(aws s3api list-object-versions --bucket $STATE_BUCKET --query '{Objects: Versions[].{Key: Key, VersionId: VersionId}}' --output json)"
aws s3api delete-objects --bucket $STATE_BUCKET --delete "$(aws s3api list-object-versions --bucket $STATE_BUCKET --query '{Objects: DeleteMarkers[].{Key: Key, VersionId: VersionId}}' --output json)"
aws s3api delete-bucket --bucket $STATE_BUCKET


