CodePipeline×CodeBuildでTerraform CI/CD — マルチ環境・承認・OIDC

目次

AWS CodePipeline×CodeBuildで構築するTerraform CI/CD — エンタープライズ運用パターン

AWS×Terraform 複数人開発シリーズ

関連シリーズ(前提知識):

Git/GitHub × Terraform 実践シリーズ(全5弾)も合わせてどうぞ:
Git入門 /
GitHub入門 /
ブランチ戦略 /
セキュリティ /
Terraform実践

1. この記事について

1-1. 本シリーズの位置付け

本シリーズ「AWS×Terraform 複数人開発運用編」は、チームでTerraformを安全・効率的に運用するための3つの柱を段階的に構築してきた。

テーマ主な内容対象
第1弾(公開済み)state管理・lock・drift対策S3+DynamoDBバックエンド・ロック体験・drift検知インフラ・DevOpsエンジニア全般
第2弾(公開済み)PR駆動CI/CD(GitHub Actions)GitHub Actions+OIDC・Plan/Apply Role分離・PRコメント自動投稿GitHub中心のチーム
第3弾(本記事)AWSネイティブCI/CD(CodePipeline)CodePipeline×CodeBuild・マニュアル承認・マルチ環境・監査証跡エンタープライズ・AWSネイティブ派

第2弾ではGitHub Actionsを使ったCI/CDを構築した。これはGitHubを中心に開発しているチームにとって導入ハードルが低く、柔軟性も高い。一方で、企業規模が大きくなるほど「すべての操作をAWS上で管理・監査したい」「GitHub以外のソース管理ツールを使っている」「マルチアカウントで厳密な権限制御が必要」というニーズが生まれてくる。

本記事では AWS CodePipeline × CodeBuild というAWSネイティブな組み合わせでTerraform CI/CDを構築する。CodePipelineはAWSのマネージドCI/CDサービスであり、CloudTrailとの深い連携、マニュアル承認ステージ、マルチアカウント対応など、エンタープライズ要件に応えやすい特性を持つ。

なぜCodePipelineを選ぶのか、3つの理由を示す。

  1. AWSネイティブな監査証跡: すべてのパイプライン操作がCloudTrailに記録され、「誰が・いつ・何をApproveしたか」をAWS上で一元管理できる
  2. マニュアル承認の標準機能: Manual Approval ステージが組み込み機能として提供されており、applyの前に人間の承認を挟む設計が容易
  3. AWS IAMによる細粒度の権限制御: CodePipelineのIAMロール・リソースポリシーで、各ステージの実行権限をAWS標準の方法で管理できる

1-2. GitHub Actions方式 vs CodePipeline方式 — 選択基準

どちらの方式を選ぶべきかは、チームの規模・技術スタック・セキュリティ要件によって異なる。以下の比較表と判断フローチャートを参考に選択してほしい。

比較項目GitHub Actions方式(第2弾)CodePipeline方式(本記事)
管理場所GitHub(ワークフローYAML)AWS(コンソール / Terraform)
ソース管理ツールGitHub専用GitHub / CodeCommit / S3 / Bitbucket等
セットアップ難度低(YAMLファイルを追加するだけ)やや高(CodePipeline/CodeBuild/IAMの設定が必要)
マニュアル承認GitHub Environments(設定が必要)標準機能として組み込み済み
監査証跡GitHub Actions履歴 + CloudTrail(部分的)CloudTrailで全操作を一元記録
IAM制御の細かさGitHub Actionsの権限 + AWS IAM RoleAWS IAMのみで完結(一元管理)
コストGitHub Actionsの実行時間料金CodePipeline実行回数 + CodeBuild実行時間
マルチアカウント対応可能(Cross-account Role設定が必要)可能(Cross-account Action標準サポート)
マルチ環境構成ワークフローの分岐で対応ステージ追加で対応(GUI/Terraform両方)
Slack/SNS通知GitHub Actions + Slack ActionSNS + Lambda / EventBridge標準連携
障害時の再実行ワークフローの「Re-run」ボタンパイプラインの「Retry」ボタン
GitHub依存強い(GitHubがダウンするとCI/CDも停止)弱い(ソース取得のみGitHub依存)
学習コストGitHubユーザーなら低いAWSサービス理解が必要

どちらを選ぶべきか — 判断フローチャート

スタートここから
 │
 ▼
Q1: ソース管理はGitHubのみを使うか?
 │
  NO ──┴── YES
  │ │
  ▼ ▼
CodePipeline  Q2: チーム規模は10人以上か、または
  セキュリティ監査要件があるか?
│
NO ──┴── YES
│  │
▼  ▼
 Q3: AWS IAMで CodePipeline
 全権限を一元管理  (エンタープライズ向け)
 したいか?
│
NO ──┴── YES
│  │
▼  ▼
 GitHub Actions  CodePipeline
 (シンプル・  (AWSネイティブ
  スピード重視)権限管理)

推奨パターンまとめ

GitHub Actions を選ぶ場合:
  ✅ スタートアップ・小規模チーム(〜10人)
  ✅ GitHub中心の開発フロー
  ✅ 素早く導入したい
  ✅ Terraform以外のCI/CDも同じ基盤で管理したい

CodePipeline を選ぶ場合:
  ✅ エンタープライズ・大規模組織(10人〜)
  ✅ AWSセキュリティ監査・コンプライアンス要件がある
  ✅ マルチアカウント構成で運用している
  ✅ GitHubに依存したくない
  ✅ マニュアル承認を組み込みたい
  ✅ すべての操作をCloudTrailで追跡したい

1-3. 対象読者・前提知識

対象読者

  • エンタープライズ向けTerraform CI/CDを構築したいインフラ・DevOpsエンジニア
  • AWSのマネージドサービスでCI/CDを完結させたいチーム
  • 第2弾のGitHub Actions方式を使っているが、よりAWSネイティブな方式に移行を検討しているエンジニア
  • セキュリティ・監査要件からAWS上での権限管理を一元化したい組織

前提知識

本記事は以下の知識を前提としている。未習得の場合は先にリンク先を参照してほしい。

分野必要な知識参照先
Terraforminit / plan / apply の実行経験・リモートstate・moduleTerraform実践
state管理S3+DynamoDBバックエンド・ロックの仕組み第1弾
OIDC基礎IAM Role・信頼ポリシー・OIDC認証フロー第2弾
AWS CodePipelineパイプライン・ステージ・アクションの概念AWS公式ドキュメント
AWS CodeBuildbuildspec.yml・ビルドプロジェクトの概念AWS公式ドキュメント
AWS IAMRole・Policy・信頼ポリシー・最小権限AWS公式ドキュメント

1-4. この記事で学べること

本記事を通じて、以下のスキルと知識を習得できる。

  • CodePipeline基本構成: Source → Plan → Manual Approval → Apply の4ステージパイプラインを構築する
  • CodeBuildでのTerraform実行: buildspec.ymlでTerraformをインストール・実行する方法
  • マニュアル承認の組み込み: apply 前に人間の承認を必須にするManual Approvalステージの設定
  • マルチ環境構成(dev/stg/prod): 環境ごとにパイプラインを分離し、段階的なデプロイを実現する
  • マルチアカウント対応: Cross-account RoleによるマルチAWSアカウントへのTerraform apply
  • OIDC role assume chain: CodeBuildからAssumeRoleでターゲットアカウントのRoleを引き受ける設計
  • 監査ログ: CodePipelineの全操作をCloudTrailで記録し、承認者・実行者を追跡する
  • SNS通知: パイプラインの状態変化をSNSでSlack/Emailに通知する設定

1-5. 必要なもの

ハンズオンを進めるにあたり、以下の環境を用意しておくこと。

AWSアカウント

  • AWSアカウント(以下のサービスの作成・管理権限を持つユーザー)
  • CodePipeline / CodeBuild / CodeStar Connections
  • IAM Role / Policy / OIDC Provider
  • S3 / DynamoDB / SNS
  • アカウントID: 本記事では 123456789012 を使用(実際のアカウントIDに読み替えること)

ローカル環境

# バージョン確認
terraform version
# → Terraform v1.5.0 以上を推奨

aws --version
# → aws-cli/2.x 以上

GitHubアカウントとリポジトリ

  • GitHubアカウントとリポジトリ(本記事では myorg/terraform-repo を使用)
  • mainブランチへのpushがパイプラインのトリガーになる

Terraform backend(S3+DynamoDB)

第1弾で構築したバックエンドをそのまま使用できる。

S3バケット(state): myorg-terraform-state
DynamoDB テーブル:terraform-state-lock
S3バケット(artifact): myorg-pipeline-artifacts  ← 本記事で新規作成

1-6. ハンズオン全体アーキテクチャ

本記事で構築するCI/CDパイプラインの全体像を以下に示す。

┌──────────────────────────────────────────────────────────────────────┐
│GitHub Repository  │
│(myorg/terraform-repo)│
│ │
│  feature*"
  # アーティファクトのディスカード前にキャッシュする(オプション)
  discard-paths: no

cache:
  # .terraform ディレクトリをキャッシュしてプロバイダのダウンロードを省略
  paths:
 - ".terraform*"
 - "/root/.terraform.d/plugin-cache*"

reports:
  # テストレポートが存在する場合(terraform test等)
  TerraformTestReport:
 files:
- "test-results*.xml"
 base-directory: "."
 discard-paths: no

各フェーズの詳細説明

フェーズ実行内容失敗時の動作
installTerraform本体・TFLintをインストールインストール失敗 → ビルド失敗
pre_buildinit・fmt check・validateフォーマット違反・構文エラー → ビルド失敗
buildterraform plan実行・JSON保存planエラー(exit=1) → ビルド失敗。変更あり(exit=2)は正常継続
post_buildplan結果のサマリー表示失敗しても実行される

-detailed-exitcode の使い方: terraform plan -detailed-exitcode は終了コード 2 を「変更あり(正常)」として返す。シェルでは $?2 でも失敗と見なさないよう PIPESTATUS で明示的に制御する必要がある。exit 1(エラー)のみ失敗として扱い、exit 2(変更あり)は正常終了させる点がポイントだ。

4-2. Plan用CodeBuildプロジェクトの作成

AWSコンソール手順

  1. CodeBuild → 「ビルドプロジェクトを作成する」
  2. プロジェクト名: terraform-plan-project
  3. 説明: 「Terraform plan ステージ用 CodeBuild プロジェクト」
  4. ソース
  5. ソースプロバイダー: 「AWS CodePipeline」(CodePipelineから呼ばれる場合はこれを選択)
  6. 環境
  7. 環境イメージ: 「マネージドイメージ」
  8. オペレーティングシステム: 「Amazon Linux」
  9. ランタイム: 「Standard」
  10. イメージ: 「aws/codebuild/amazonlinux2-x86_64-standard:5.0」(最新を選択)
  11. イメージのバージョン: 「このランタイムバージョンの最新イメージを常に使用する」
  12. 環境タイプ: 「Linux EC2」
  13. 特権モード: オフ(Dockerビルドが不要な場合)
  14. サービスロール: 「既存のサービスロール」→ codebuild-plan-role を選択
  15. Buildspec
  16. 「buildspecファイルを使用する」を選択
  17. Buildspec名: buildspec-plan.yml
  18. アーティファクト
  19. タイプ: 「Amazon S3」
  20. バケット名: myorg-pipeline-artifacts
  21. 名前: plan-artifacts
  22. パスの削除: オフ
  23. ログ
  24. CloudWatch Logs: 有効
  25. グループ名: /aws/codebuild/terraform-plan-project

Terraform コード(完全版)

resource "aws_codebuild_project" "terraform_plan" {
  name = "terraform-plan-project"
  description= "Terraform plan ステージ用 CodeBuild プロジェクト"
  build_timeout = 60  # 分

  service_role = aws_iam_role.codebuild_plan_role.arn

  artifacts {
 type = "CODEPIPELINE"
  }

  cache {
 type  = "LOCAL"
 modes = ["LOCAL_CUSTOM_CACHE", "LOCAL_SOURCE_CACHE"]
  }

  environment {
 compute_type = "BUILD_GENERAL1_SMALL"
 image  = "aws/codebuild/amazonlinux2-x86_64-standard:5.0"
 type= "LINUX_CONTAINER"
 image_pull_credentials_type = "CODEBUILD"
 privileged_mode = false

 environment_variable {
name  = "TF_VERSION"
value = "1.9.0"
 }

 environment_variable {
name  = "TF_BACKEND_BUCKET"
value = "myorg-terraform-state"
 }

 environment_variable {
name  = "TF_BACKEND_KEY"
value = "terraform.tfstate"
 }

 environment_variable {
name  = "TF_BACKEND_REGION"
value = "ap-northeast-1"
 }

 environment_variable {
name  = "TF_BACKEND_DYNAMODB_TABLE"
value = "terraform-state-lock"
 }

 # シークレット値はParameter Store経由で渡す
 # environment_variable {
 #name  = "TF_VAR_db_password"
 #value = "/terraform/prod/db_password"
 #type  = "PARAMETER_STORE"
 # }
  }

  source {
 type= "CODEPIPELINE"
 buildspec = "buildspec-plan.yml"
  }

  logs_config {
 cloudwatch_logs {
group_name  = "/aws/codebuild/terraform-plan-project"
stream_name = "build-log"
 }
  }

  tags = {
 ManagedBy = "terraform"
 Purpose= "terraform-plan"
  }
}

compute_type の選択基準: BUILD_GENERAL1_SMALL(3GB RAM, 2vCPU)でほとんどのTerraform planは十分。大規模なモジュール(リソース数200以上)では BUILD_GENERAL1_MEDIUM(7GB RAM, 4vCPU)への変更を検討する。

4-3. CodePipelineの作成(SourceステージとPlanステージ)

AWSコンソール手順

  1. CodePipeline → 「パイプラインを作成する」
  2. パイプライン設定
  3. パイプライン名: terraform-cicd-pipeline
  4. パイプラインタイプ: V2(変数・条件・フィルタリングをサポート)
  5. 実行モード: 「キュー」(前の実行を待ってから次を開始)
  6. サービスロール: 「既存のサービスロール」→ codepipeline-role を選択
  7. アドバンスト設定
  8. アーティファクトストア: 「カスタムロケーション」→ myorg-pipeline-artifacts
  9. 暗号化キー: 「デフォルトAWSマネージドキー」
  10. Sourceステージ
  11. ソースプロバイダー: 「GitHub(GitHub App)」※CodeStar Connectionsを使用
  12. 接続: 先ほど作成した github-myorg-connection
  13. リポジトリ名: myorg/terraform-repo
  14. ブランチ名: main
  15. パイプライントリガー: 「特定のブランチへのプッシュ」(デフォルト)
  16. 出力アーティファクト形式: 「CodePipeline形式」
  17. ビルドステージ(Plan)
  18. ビルドプロバイダー: 「AWS CodeBuild」
  19. リージョン: ap-northeast-1
  20. プロジェクト名: terraform-plan-project
  21. ビルドタイプ: 「単一ビルド」
  22. 入力アーティファクト: SourceArtifact
  23. 出力アーティファクト: PlanArtifact
  24. 「パイプラインを作成する」をクリック

パイプラインタイプ V2 vs V1: V2は2023年末に導入された新しいパイプライン仕様で、変数・条件付きステージ・GitTagトリガーなどをサポートする。新規作成は必ずV2を選ぶこと。V1(旧仕様)は機能拡張が停止している。

Terraform コード(SourceステージとPlanステージ含む完全版)

resource "aws_codepipeline" "terraform_cicd" {
  name  = "terraform-cicd-pipeline"
  pipeline_type  = "V2"
  role_arn = aws_iam_role.codepipeline_role.arn
  execution_mode = "QUEUED"

  artifact_store {
 location = aws_s3_bucket.pipeline_artifacts.bucket
 type  = "S3"
  }

  # ===== Sourceステージ =====
  stage {
 name = "Source"

 action {
name = "GitHubSource"
category= "Source"
owner= "AWS"
provider= "CodeStarSourceConnection"
version = "1"
output_artifacts = ["SourceArtifact"]

configuration = {
  ConnectionArn  = aws_codestarconnections_connection.github.arn
  FullRepositoryId  = "myorg/terraform-repo"
  BranchName  = "main"
  OutputArtifactFormat = "CODE_ZIP"
  # mainブランチへのPRマージのみトリガー(直pushは除外したい場合)
  # DetectChanges  = "true"
}
 }
  }

  # ===== Planステージ =====
  stage {
 name = "Plan"

 action {
name = "TerraformPlan"
category= "Build"
owner= "AWS"
provider= "CodeBuild"
version = "1"
input_artifacts  = ["SourceArtifact"]
output_artifacts = ["PlanArtifact"]

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

  # ===== Manual Approvalステージ(Section 5で詳述)=====
  stage {
 name = "ManualApproval"

 action {
name  = "ApproveTerraformApply"
category = "Approval"
owner = "AWS"
provider = "Manual"
version  = "1"

configuration = {
  NotificationArn = aws_sns_topic.pipeline_notifications.arn
  CustomData= "terraform planの結果を確認し、Applyを承認してください。"
  ExternalEntityLink = "https://ap-northeast-1.console.aws.amazon.com/codesuite/codebuild/projects/terraform-plan-project/history"
}
 }
  }

  # ===== Applyステージ(Section 6で詳述)=====
  stage {
 name = "Apply"

 action {
name= "TerraformApply"
category  = "Build"
owner  = "AWS"
provider  = "CodeBuild"
version= "1"
input_artifacts = ["PlanArtifact"]

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

  tags = {
 ManagedBy = "terraform"
  }
}

execution_mode = "QUEUED" の重要性: デフォルトの SUPERSEDED だと、新しいコミットがpushされると実行中のパイプラインが上書きキャンセルされる。インフラ変更では途中キャンセルが危険なため、QUEUED(前の実行が終わるまで待機)が安全だ。

4-4. Planステージの動作確認

Planステージが正常に動作することを確認するための手順と、よくあるエラーの対処法を示す。

動作確認の流れ

# 1. テスト用の変更をGitHubのmainブランチにpush
git checkout main
git pull origin main

# 空コミット(テスト用)
git commit --allow-empty -m "test: trigger CodePipeline"
git push origin main
2. AWSコンソールでパイプラインの実行を確認
→ CodePipeline → terraform-cicd-pipeline
→ 「実行の詳細」で各ステージのステータスを確認

3. Sourceステージ
→ ステータス: 成功 ✅ → GitHubからコードの取得に成功

4. Planステージ
→ ステータス: 進行中 → CodeBuildが起動してterraform planを実行中
→ ステータス: 成功 ✅ → plan完了、PlanArtifactが生成された

5. ManualApprovalステージ
→ ステータス: 待機中 → 承認を待っている状態で一時停止

CodeBuildのビルドログ確認

CodeBuild → terraform-plan-project → ビルド履歴
→ 最新のビルド → ビルドログを確認

期待される出力:
  === Installing Terraform 1.9.0 ===
  Terraform v1.9.0 on linux_amd64
  === Pre-build: Init, Format Check, Validate ===
  Initializing the backend...
  Successfully configured the backend "s3"!
  Success! The configuration is valid.
  === Build: terraform plan ===
  Plan: 3 to add, 0 to change, 0 to destroy.
  Plan exit code: 2  ← 変更あり(正常)
  === Post-build: Plan Complete ===

よくあるエラーと対処法

エラー原因対処法
Error: error configuring S3 Backend: no valid credential sourcesCodeBuildのIAMロールにS3アクセス権限がないcodebuild-plan-role にstateバケットへのGetObject/ListBucket権限を追加
Error: Failed to install providerプロバイダーのダウンロードに失敗NAT Gatewayまたはインターネットアクセスを確認(プライベートサブネットで実行している場合)
The action failed because no branch named main existsSourceステージでブランチ名が不一致GitHubのデフォルトブランチ名(main vs master)を確認
Error: Connection 'github-myorg-connection' is not AVAILABLECodeStarConnectionsのOAuth認証が未完了AWSコンソールのConnections画面でOAuth認証を完了させる
╷ Error: Reference to undeclared resourceTerraformコードの構文エラーterraform validate をローカルで実行して事前確認
Process completed with exit code 1 (fmt check)terraform fmt -check でフォーマット違反を検出terraform fmt -recursive をローカルで実行してからpush
AccessDenied: User: arn:aws:sts::...codebuild-plan-role is not authorizedIAMロールの権限が不足不足しているアクション・リソースをポリシーに追加

パイプラインの手動再実行

# AWS CLIでパイプラインを手動起動
aws codepipeline start-pipeline-execution \
  --name terraform-cicd-pipeline \
  --region ap-northeast-1

# パイプラインの現在の状態を確認
aws codepipeline get-pipeline-state \
  --name terraform-cicd-pipeline \
  --region ap-northeast-1 \
  --query 'stageStates[*].{Stage:stageName,Status:latestExecution.status}' \
  --output table

Section 4 まとめ

設定内容リソース/ファイル確認ポイント
buildspec-plan.ymlGitリポジトリinit・fmt・validate・plan が順に実行される
terraform-plan-projectCodeBuildプロジェクトIAMロールがcodebuild-plan-roleに設定されている
terraform-cicd-pipelineCodePipelineV2・QUEUED・アーティファクトストアが正しい
SourceステージCodeStarConnections接続ステータスがAVAILABLEである
PlanステージCodeBuildplan出力がPlanArtifactとして保存される

SourceステージとPlanステージの確認が取れたら、次はManualApprovalとApplyステージの構築に進む。Section 5ではManualApproval(人間によるterraform plan確認・承認)を、Section 6ではApplyステージ(terraform apply実行)を構築する。


5. マニュアル承認ステージ

5-1. マニュアル承認の設計思想

CodePipelineのManual Approvalアクションは、terraform planの結果を人間が確認してからapplyを実行するための安全弁です。

なぜapply前に人間の確認が必要か

Terraformのplanは「これから行う変更の差分」を出力しますが、以下のリスクが残ります:

  • 破壊的変更の見落とし: destroyを含むリソース差分
  • 意図しない依存関係の連鎖: あるリソース変更が他リソースのrecreateを引き起こす
  • 本番データへの影響: RDS/S3などのステートフルリソースへの変更
  • コスト急増: 高額インスタンスタイプへの意図しない変更

GitHubのPRレビューとの違い

確認タイミング確認内容確認者
GitHubのPRレビューTerraformのコード変更(.tf ファイル)チームメンバー全員
ManualApprovalterraform planの実行結果(実際の変更差分)承認権限を持つメンバー

PRレビューはコードの意図を確認しますが、ManualApprovalは実行環境での実際の変更を確認します。環境ドリフト(手動変更やリソース追加)が発生している場合、planで想定外の変更が表示されることがあり、それを承認前にキャッチできます。

承認者の選定と権限設計

推奨構成:
  PRレビュー  → チーム全員(コードレビュー)
  ManualApproval → TechLead / SREチーム(インフラ変更最終確認)
  prod apply  → 特定のIAMユーザーのみApproveアクション可能

承認権限はIAMポリシーで制御します:

{
  "Version": "2012-10-17",
  "Statement": [
 {
"Effect": "Allow",
"Action": [
  "codepipeline:GetPipeline",
  "codepipeline:GetPipelineState",
  "codepipeline:GetPipelineExecution",
  "codepipeline:PutApprovalResult"
],
"Resource": "arn:aws:codepipeline:ap-northeast-1:123456789012:terraform-cicd-pipeline"
 }
  ]
}

5-2. マニュアル承認アクションの設定

AWSコンソール手順

  1. CodePipeline → terraform-cicd-pipeline を選択
  2. 「パイプラインを編集」をクリック
  3. Planステージの後ろの「+」ボタンをクリック → 「ステージを追加」
  4. ステージ名: Approve
  5. 「アクショングループを追加」をクリック

アクション設定:
| 項目 | 値 |
|—|—|
| アクション名 | ManualApproval |
| アクションプロバイダー | Manual |
| SNS トピック ARN | arn:aws:sns:ap-northeast-1:123456789012:terraform-pipeline-notifications |
| URL(任意) | CodePipelineコンソールURL |
| コメント(任意) | terraform plan結果を確認してApproveしてください |

  1. 「完了」→「保存」をクリック

Terraform コード(ManualApprovalステージ追加版)

resource "aws_codepipeline" "terraform_cicd" {
  name  = "terraform-cicd-pipeline"
  role_arn = aws_iam_role.codepipeline_role.arn

  artifact_store {
 location = aws_s3_bucket.pipeline_artifacts.id
 type  = "S3"
  }

  stage {
 name = "Source"
 action {
name = "Source"
category= "Source"
owner= "AWS"
provider= "CodeStarSourceConnection"
version = "1"
output_artifacts = ["source_output"]
configuration = {
  ConnectionArn = aws_codestarconnections_connection.github.arn
  FullRepositoryId = "myorg/terraform-repo"
  BranchName = "main"
}
 }
  }

  stage {
 name = "Plan"
 action {
name = "TerraformPlan"
category= "Build"
owner= "AWS"
provider= "CodeBuild"
version = "1"
input_artifacts  = ["source_output"]
output_artifacts = ["plan_output"]
configuration = {
  ProjectName = aws_codebuild_project.terraform_plan.name
}
 }
  }

  stage {
 name = "Approve"
 action {
name  = "ManualApproval"
category = "Approval"
owner = "AWS"
provider = "Manual"
version  = "1"
configuration = {
  NotificationArn = aws_sns_topic.notifications.arn
  CustomData= "Terraform plan完了。CodePipelineコンソールでplan結果を確認し、承認または拒否してください。"
  ExternalEntityLink = "https://console.aws.amazon.com/codesuite/codepipeline/pipelines/terraform-cicd-pipeline/view"
}
 }
  }

  stage {
 name = "Apply"
 action {
name= "TerraformApply"
category  = "Build"
owner  = "AWS"
provider  = "CodeBuild"
version= "1"
input_artifacts = ["plan_output"]
configuration = {
  ProjectName = aws_codebuild_project.terraform_apply.name
}
 }
  }
}

SNSトピックのTerraformコード:

resource "aws_sns_topic" "notifications" {
  name = "terraform-pipeline-notifications"
}

resource "aws_sns_topic_subscription" "email" {
  topic_arn = aws_sns_topic.notifications.arn
  protocol  = "email"
  endpoint  = "infra-team@example.com"
}

5-3. plan結果の確認方法(承認者向け)

承認メールが届いたら、以下の手順でplan結果を確認します。

方法1: CodePipelineコンソールで確認

  1. AWSコンソール → CodePipeline → terraform-cicd-pipeline
  2. Planステージの「詳細」をクリック
  3. CodeBuildの実行ログ → 「terraform plan」出力を確認
# コンソールで確認できるplan出力例
Plan: 2 to add, 1 to change, 0 to destroy.

+ aws_instance.web
+ aws_security_group.web
~ aws_iam_role.app_role

方法2: S3アーティファクトからplan.jsonをダウンロード

# PlanArtifactを含むS3バケットを確認
aws codepipeline get-pipeline \
  --name terraform-cicd-pipeline \
  --query "pipeline.artifactStore"

# 最新の実行IDを取得
EXECUTION_ID=$(aws codepipeline list-pipeline-executions \
  --pipeline-name terraform-cicd-pipeline \
  --query "pipelineExecutionSummaries[0].pipelineExecutionId" \
  --output text)

# S3からplan出力をダウンロード
aws s3 cp s3://myorg-pipeline-artifacts/terraform-cicd-pipeline/plan_outp/ ./plan-artifact/ \
  --recursive

# plan.jsonをterraform showで読みやすく表示
terraform show -json plan.json | jq '.resource_changes[] | select(.change.actions[] | . != "no-op")'

確認すべきポイント

確認項目アクションリスク
destroy が含まれる要注意 — 意図的か確認データ損失・サービス停止
RDS/S3への変更慎重に確認ステートフルリソース影響
forces replacementreplace = destroy + createダウンタイム発生
コスト増加リソースコスト試算と一致するか確認予算超過
IAMポリシー変更セキュリティレビュー権限昇格リスク

承認・拒否の操作手順

コンソールから承認:
1. CodePipeline → Approveステージ → 「レビュー」クリック
2. コメント入力(任意)
3. 「承認」または「拒否」をクリック

AWS CLIから承認:

# パイプラインの実行状態を確認
aws codepipeline get-pipeline-state \
  --name terraform-cicd-pipeline \
  --query "stageStates[?stageName=='Approve'].actionStates"

# 承認トークンを取得
TOKEN=$(aws codepipeline get-pipeline-state \
  --name terraform-cicd-pipeline \
  --query "stageStates[?stageName=='Approve'].actionStates[0].latestExecution.token" \
  --output text)

# 承認
aws codepipeline put-approval-result \
  --pipeline-name terraform-cicd-pipeline \
  --stage-name Approve \
  --action-name ManualApproval \
  --result "summary=Plan confirmed,status=Approved" \
  --token "$TOKEN"

# 拒否
aws codepipeline put-approval-result \
  --pipeline-name terraform-cicd-pipeline \
  --stage-name Approve \
  --action-name ManualApproval \
  --result "summary=Unexpected destroy detected,status=Rejected" \
  --token "$TOKEN"

5-4. 承認タイムアウトと期限切れの対処

デフォルトのタイムアウト

ManualApprovalアクションはデフォルトで7日間で自動的にタイムアウトします。タイムアウトするとパイプラインはFAILED状態になります。

タイムアウト変更はTerraformでは直接設定できないため、パイプラインを再起動します:

# タイムアウト後の再実行
aws codepipeline start-pipeline-execution \
  --name terraform-cicd-pipeline

定期的なリマインダー通知(EventBridgeで実現)

承認待ちが長引く場合に定期リマインダーを送る設計:

# 毎日朝9時にパイプライン状態を確認するLambda(設計パターン)
resource "aws_cloudwatch_event_rule" "approval_reminder" {
  name = "pipeline-approval-reminder"
  schedule_expression = "cron(0 0 * * ? *)"  # UTC 00:00 = JST 09:00
}

緊急時の承認スキップ(権限設計で制御)

緊急デプロイが必要な場合でも、ManualApprovalをコードでスキップすることはできません。代わりに以下の設計で対応します:

緊急対応パターン:
  1. 承認者をSlack/電話で直接連絡して即時承認
  2. 緊急用パイプライン(terraform-cicd-emergency)を用意(承認ステージなし)
  → 通常時は無効化、緊急時のみIAMで有効化
  3. 直接terraform apply(BreakGlass手順)を整備
  → 実行はCloudTrailに記録必須

5-5. マニュアル承認ハンズオン

以下の手順でManualApprovalの動作を確認します。

Step 1: GitHubにpushしてパイプラインを起動

# テスト用の変更をpush
git checkout -b feature/test-approval
echo "# test" >> README.md
git add README.md
git commit -m "test: trigger pipeline for approval test"
git push origin feature/test-approval

# PRをmainにマージ
# → mainへのpushでパイプライン自動起動

Step 2: Planステージ完了後、Approveステージで停止することを確認

# パイプラインの状態をポーリング
watch -n 10 aws codepipeline get-pipeline-state \
  --name terraform-cicd-pipeline \
  --query "stageStates[*].{Stage:stageName,Status:latestExecution.status}"

期待する出力:

StageStatus
Source  Succeeded
Plan Succeeded
Approve InProgress  ← ここで停止
Apply-

Step 3: SNSメール通知が届くことを確認

SNSサブスクリプションのメールアドレスに以下のようなメールが届きます:

Subject: APPROVAL NEEDED: AWS CodePipeline terraform-cicd-pipeline

Pipeline: terraform-cicd-pipeline
Stage: Approve
Action: ManualApproval

CustomData: Terraform plan完了。CodePipelineコンソールでplan結果を確認し...

To review and approve/reject, please visit:
https://console.aws.amazon.com/codesuite/codepipeline/...

Step 4: CodePipelineコンソールから承認操作

  1. コンソール → Approveステージ → 「レビュー」
  2. コメント: plan確認OK — EC2追加のみ、destroyなし
  3. 「承認」をクリック

Step 5: パイプラインが次ステージ(Apply)に進むことを確認

# 承認後のステータス確認
aws codepipeline get-pipeline-state \
  --name terraform-cicd-pipeline \
  --query "stageStates[*].{Stage:stageName,Status:latestExecution.status}"

期待する出力:

StageStatus
Source  Succeeded
Plan Succeeded
Approve Succeeded  ← 承認完了
ApplyInProgress ← apply実行中

Section 5 まとめ

設定内容確認ポイント
ManualApprovalアクションApproveステージに追加されているか
SNS通知承認者のメールアドレスに届くか
IAM権限承認権限をもつユーザーのみApprove可能か
タイムアウト7日で自動失敗(運用ルールで対処)

ManualApprovalが機能したら、次はApplyステージの構築に進みます。


6. Apply BuildステージとCodeBuild設定

6-1. Apply用 buildspec.yml の作成

Apply用のbuildspecは、Planステージで生成したtfplanバイナリを受け取り、terraform applyを実行します。

# buildspec-apply.yml
version: 0.2

env:
  variables:
 TF_VERSION: "1.7.0"
 TF_DIR: "."
  parameter-store:
 TF_BACKEND_BUCKET: "/terraform/backend/bucket"
 TF_BACKEND_KEY: "/terraform/backend/key"
 TF_BACKEND_REGION: "/terraform/backend/region"
 TF_BACKEND_LOCK_TABLE: "/terraform/backend/lock_table"

phases:
  install:
 runtime-versions:
python: 3.11
 commands:
- echo "=== Terraform Install ==="
- wget -q https://releases.hashicorp.com/terraform/${TF_VERSION}/terraform_${TF_VERSION}_linux_amd64.zip
- unzip -q terraform_${TF_VERSION}_linux_amd64.zip
- mv terraform /usr/local/bin/
- terraform version

  pre_build:
 commands:
- echo "=== Terraform Init ==="
- cd ${TF_DIR}
- |
  terraform init \
 -backend-config="bucket=${TF_BACKEND_BUCKET}" \
 -backend-config="key=${TF_BACKEND_KEY}" \
 -backend-config="region=${TF_BACKEND_REGION}" \
 -backend-config="dynamodb_table=${TF_BACKEND_LOCK_TABLE}" \
 -no-color
- echo "=== Extracting Plan Artifact ==="
- ls -la ${CODEBUILD_SRC_DIR_plan_output}/
- cp ${CODEBUILD_SRC_DIR_plan_output}/tfplan ./tfplan

  build:
 commands:
- echo "=== Terraform Apply ==="
- terraform apply -no-color -auto-approve tfplan
- echo "Apply exit code: $?"

  post_build:
 commands:
- echo "=== Terraform Output ==="
- terraform output -json > outputs.json || echo "No outputs defined"
- echo "Apply completed at $(date)"

artifacts:
  files:
 - outputs.json
  name: apply_output
  discard-paths: yes

cache:
  paths:
 - '.terraform/providers*'

buildspec-apply.yml のポイント

設定説明
CODEBUILD_SRC_DIR_plan_outputPlanステージのアーティファクトをApplyステージが受け取る環境変数
-auto-approve tfplanユーザー入力なしで保存済みplanを適用
terraform output -jsonapply後のリソース出力をアーティファクトとして保存
キャッシュ設定プロバイダーキャッシュでビルド時間を短縮

6-2. Apply用CodeBuildプロジェクトの作成

AWSコンソール手順

  1. CodeBuild → 「ビルドプロジェクトを作成」
設定項目
プロジェクト名terraform-apply-project
ソースNo source(CodePipelineから受け取る)
環境イメージマネージドイメージ / Amazon Linux 2023
ランタイムStandard
イメージaws/codebuild/standard:7.0
サービスロールcodebuild-apply-role
Buildspecbuildspecファイルを使用 → buildspec-apply.yml
ログCloudWatch Logs: /aws/codebuild/terraform-apply
  1. 「ビルドプロジェクトを作成」をクリック

Terraform コード(完全版)

resource "aws_codebuild_project" "terraform_apply" {
  name = "terraform-apply-project"
  description= "Terraform apply stage for CI/CD pipeline"
  build_timeout = 60
  service_role  = aws_iam_role.codebuild_apply_role.arn

  artifacts {
 type = "CODEPIPELINE"
  }

  cache {
 type  = "LOCAL"
 modes = ["LOCAL_DOCKER_LAYER_CACHE", "LOCAL_SOURCE_CACHE"]
  }

  environment {
 compute_type = "BUILD_GENERAL1_SMALL"
 image  = "aws/codebuild/standard:7.0"
 type= "LINUX_CONTAINER"
 image_pull_credentials_type = "CODEBUILD"

 environment_variable {
name  = "TF_VERSION"
value = "1.7.0"
 }

 environment_variable {
name  = "TF_DIR"
value = "."
 }
  }

  source {
 type= "CODEPIPELINE"
 buildspec = "buildspec-apply.yml"
  }

  logs_config {
 cloudwatch_logs {
group_name  = "/aws/codebuild/terraform-apply"
stream_name = ""
 }
  }

  tags = {
 Project = "terraform-cicd"
 Stage= "apply"
  }
}

Apply用IAMロールのポリシー(Plan用より権限が広い):

resource "aws_iam_role_policy" "codebuild_apply_policy" {
  name = "codebuild-apply-policy"
  role = aws_iam_role.codebuild_apply_role.id

  policy = jsonencode({
 Version = "2012-10-17"
 Statement = [
{
  Effect = "Allow"
  Action = [
 # S3 アーティファクト / state 操作
 "s3:GetObject", "s3:PutObject", "s3:DeleteObject",
 "s3:ListBucket", "s3:GetBucketLocation",
 # DynamoDB ロック
 "dynamodb:GetItem", "dynamodb:PutItem",
 "dynamodb:DeleteItem", "dynamodb:DescribeTable",
 # CodeBuild ログ
 "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents",
 # パラメータストア
 "ssm:GetParameter", "ssm:GetParameters",
 # KMS
 "kms:GenerateDataKey", "kms:Decrypt",
 # リソース操作(管理対象リソースに応じて追加)
 "ec2:*", "rds:*", "iam:PassRole"
  ]
  Resource = "*"
}
 ]
  })
}

6-3. CodePipelineにApplyステージを追加(完全版)

Section 5のManualApprovalと組み合わせた、4ステージ完全版のパイプラインコードです。

resource "aws_codepipeline" "terraform_cicd" {
  name = "terraform-cicd-pipeline"
  role_arn= aws_iam_role.codepipeline_role.arn
  pipeline_type = "V2"

  execution_mode = "QUEUED"

  artifact_store {
 location = aws_s3_bucket.pipeline_artifacts.id
 type  = "S3"

 encryption_key {
id= aws_kms_key.pipeline.arn
type = "KMS"
 }
  }

  # ステージ1: Source
  stage {
 name = "Source"
 action {
name = "Source"
category= "Source"
owner= "AWS"
provider= "CodeStarSourceConnection"
version = "1"
output_artifacts = ["source_output"]
run_order  = 1

configuration = {
  ConnectionArn = aws_codestarconnections_connection.github.arn
  FullRepositoryId = "myorg/terraform-repo"
  BranchName = "main"
  DetectChanges = "true"
}
 }
  }

  # ステージ2: Plan
  stage {
 name = "Plan"
 action {
name = "TerraformPlan"
category= "Build"
owner= "AWS"
provider= "CodeBuild"
version = "1"
input_artifacts  = ["source_output"]
output_artifacts = ["plan_output"]
run_order  = 1

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

  # ステージ3: Approve
  stage {
 name = "Approve"
 action {
name= "ManualApproval"
category  = "Approval"
owner  = "AWS"
provider  = "Manual"
version= "1"
run_order = 1

configuration = {
  NotificationArn = aws_sns_topic.notifications.arn
  CustomData= "terraform plan完了。plan結果を確認し、承認または拒否してください。"
  ExternalEntityLink = "https://console.aws.amazon.com/codesuite/codepipeline/pipelines/terraform-cicd-pipeline/view"
}
 }
  }

  # ステージ4: Apply
  stage {
 name = "Apply"
 action {
name= "TerraformApply"
category  = "Build"
owner  = "AWS"
provider  = "CodeBuild"
version= "1"
input_artifacts = ["plan_output"]
run_order = 1

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

6-4. apply失敗時のロールバック戦略

apply失敗の確認

# パイプラインの最新実行ステータスを確認
aws codepipeline list-pipeline-executions \
  --pipeline-name terraform-cicd-pipeline \
  --max-results 3

# 失敗したapplyのビルドログを確認
aws logs tail /aws/codebuild/terraform-apply --follow

部分的apply失敗時の対処

Terraformはapply途中で失敗すると、適用済みのリソースはstateに記録されます。

# 現在のstateを確認
terraform state list

# 問題のあるリソースをstateから除外(再インポートで対処)
terraform state rm aws_instance.web_server

# 手動修正後、再インポート
terraform import aws_instance.web_server i-1234567890abcdef0

# 修正コードで再apply
terraform apply

よくある失敗パターンと対処法

エラー原因対処
Error: access deniedIAMロールに権限不足codebuild-apply-roleのポリシーを確認
Error: resource already exists手動作成済みリソースとのコンフリクトterraform importでstateに取り込む
Error: timeoutリソース作成に時間がかかりすぎtimeoutsブロックで延長
Error: lock timeout別のterraform操作が進行中DynamoDBのlockアイテムを確認・削除
plan artifact期限切れplanとapplyの間が72時間以上パイプラインを再起動してplanからやり直し

リトライ方針

# 失敗したパイプラインを特定のステージから再起動(Apply失敗時、Planからやり直す)
aws codepipeline retry-stage-execution \
  --pipeline-name terraform-cicd-pipeline \
  --stage-name Apply \
  --pipeline-execution-id <execution-id> \
  --retry-mode ALL_ACTIONS

6-5. apply完了後の通知設定

EventBridge → SNS通知

# パイプライン成功/失敗通知ルール
resource "aws_cloudwatch_event_rule" "pipeline_state_change" {
  name  = "terraform-pipeline-state-change"
  description = "Notify on CodePipeline state changes"

  event_pattern = jsonencode({
 source= ["aws.codepipeline"]
 detail-type = ["CodePipeline Pipeline Execution State Change"]
 detail = {
pipeline = ["terraform-cicd-pipeline"]
state = ["SUCCEEDED", "FAILED"]
 }
  })
}

resource "aws_cloudwatch_event_target" "pipeline_sns" {
  rule= aws_cloudwatch_event_rule.pipeline_state_change.name
  target_id = "pipeline-notification"
  arn = aws_sns_topic.notifications.arn

  input_transformer {
 input_paths = {
pipeline  = "$.detail.pipeline"
state  = "$.detail.state"
execution = "$.detail.execution-id"
 }
 input_template = "\"Pipeline <pipeline> <state>. Execution ID: <execution>\""
  }
}

# EventBridgeがSNSにPublishする権限
resource "aws_sns_topic_policy" "pipeline_events" {
  arn = aws_sns_topic.notifications.arn

  policy = jsonencode({
 Version = "2012-10-17"
 Statement = [
{
  Effect = "Allow"
  Principal = { Service = "events.amazonaws.com" }
  Action = "SNS:Publish"
  Resource  = aws_sns_topic.notifications.arn
}
 ]
  })
}

通知フォーマット例

成功通知:

Subject: [SUCCEEDED] CodePipeline terraform-cicd-pipeline

Pipeline terraform-cicd-pipeline SUCCEEDED.
Execution ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890

失敗通知:

Subject: [FAILED] CodePipeline terraform-cicd-pipeline

Pipeline terraform-cicd-pipeline FAILED.
Execution ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890

確認先: https://console.aws.amazon.com/codesuite/codepipeline/pipelines/terraform-cicd-pipeline/view

6-6. エンドツーエンドハンズオン(全4ステージ)

実際に全4ステージが動作することを確認します。

Step 1: テスト用リソースを追加してpush

# main.tfに追加するテスト用EC2
resource "aws_instance" "web" {
  ami  = "ami-0d52744d6551d851e"  # Amazon Linux 2023
  instance_type = "t3.micro"

  tags = {
 Name = "cicd-test-web"
 Project = "terraform-cicd"
  }
}
git add main.tf
git commit -m "feat: add web EC2 instance for CI/CD test"
git push origin main

Step 2: Source → Plan と進みplan結果確認

# パイプラインの進行をモニタリング
watch -n 15 "aws codepipeline get-pipeline-state \
  --name terraform-cicd-pipeline \
  --query 'stageStates[*].{Stage:stageName,Status:latestExecution.status}' \
  --output table"

Planステージ完了後、CodeBuildのログで確認:

Plan: 1 to add, 0 to change, 0 to destroy.

Step 3: 承認操作

SNSメールを確認し、コンソールから承認:
1. Approveステージ → 「レビュー」
2. コメント: plan確認OK — EC2 1台追加のみ
3. 「承認」

Step 4: Apply実行 → AWSコンソールでEC2作成確認

# Apply完了を確認
aws codepipeline get-pipeline-state \
  --name terraform-cicd-pipeline \
  --query "stageStates[?stageName=='Apply'].latestExecution.status"

# EC2インスタンスが作成されたことを確認
aws ec2 describe-instances \
  --filters "Name=tag:Name,Values=cicd-test-web" \
  --query "Reservations[].Instances[].{ID:InstanceId,State:State.Name,IP:PublicIpAddress}"

Step 5: SNS通知でapply完了メール確認

Subject: [SUCCEEDED] CodePipeline terraform-cicd-pipeline

Pipeline terraform-cicd-pipeline SUCCEEDED.

Step 6: terraform destroyで後片付け

# main.tfからEC2リソースを削除(コメントアウトまたは削除)
# resource "aws_instance" "web" { ... }  ← 削除
git add main.tf
git commit -m "cleanup: remove test EC2 instance"
git push origin main
# → パイプラインが自動起動 → Plan: 0 to add, 0 to change, 1 to destroy
# → 承認後にdestroy実行

Section 6 まとめ

コンポーネントファイル/リソース確認ポイント
buildspec-apply.ymlGitリポジトリtfplanを受け取り、apply -auto-approve で実行
terraform-apply-projectCodeBuildプロジェクトcodebuild-apply-role が設定されている
Apply ステージCodePipelineplan_output を入力アーティファクトとして受け取る
EventBridge通知CloudWatch EventsSUCCEEDED/FAILED でSNS通知が届く

4ステージパイプラインの完成後は、Section 7でマルチ環境構成(dev/stg/prod)に拡張します。


7. マルチ環境構成(dev/stg/prod)

マルチ環境・マルチアカウント構成図

7-1. マルチ環境設計の考え方

単一のAWSアカウントで dev/stg/prod を管理する場合でも、Terraformの状態(state)と設定値は環境ごとに完全に分離する必要があります。それを怠ると、dev環境での実験的な変更がprod環境に誤って適用される「環境汚染」が発生します。

環境分離の2つのアプローチ

アプローチA: Terraform Workspace

terraform workspace new dev
terraform workspace new stg
terraform workspace new prod
terraform workspace select dev
terraform apply
メリット: コードの重複が少ない
デメリット:
  - workspace名の付け間違いで別環境に適用する事故が起きる
  - stateファイルのパスが env:/dev/terraform.tfstate と読みにくい
  - 環境ごとに設定を大きく変えたい場合に分岐が増えて複雑になる

アプローチB: ディレクトリ分離(本記事採用)

terraform-repo/
├── environments/
│├── dev/
││├── main.tf
││├── variables.tf
││├── terraform.tfvars
││└── backend.tf
│├── stg/
││├── main.tf
││├── variables.tf
││├── terraform.tfvars
││└── backend.tf
│└── prod/
│ ├── main.tf
│ ├── variables.tf
│ ├── terraform.tfvars
│ └── backend.tf
└── modules/
 ├── ec2/
 │├── main.tf
 │├── variables.tf
 │└── outputs.tf
 └── vpc/
  ├── main.tf
  ├── variables.tf
  └── outputs.tf
メリット:
  - 各環境が独立したTerraformルートモジュールとして扱われる
  - 誤って別環境に適用するリスクが低い(ディレクトリが物理的に分かれている)
  - 環境ごとのstateファイルパスが直感的
  - dev/prodで全く異なるリソース構成も扱いやすい
デメリット: environments/ 間でコードが重複しやすい → modules/ で共通化する

本記事でディレクトリ分離を採用する理由: CodePipelineのステージごとに異なるディレクトリを cd して実行するのが自然であり、環境の切り間違いリスクが低いため。

各環境の backend.tf 設定

# environments/dev/backend.tf
terraform {
  backend "s3" {
 bucket= "myorg-terraform-state"
 key= "dev/terraform.tfstate"
 region= "ap-northeast-1"
 dynamodb_table = "terraform-state-lock"
 encrypt  = true
  }
}
# environments/stg/backend.tf
terraform {
  backend "s3" {
 bucket= "myorg-terraform-state"
 key= "stg/terraform.tfstate"
 region= "ap-northeast-1"
 dynamodb_table = "terraform-state-lock"
 encrypt  = true
  }
}
# environments/prod/backend.tf
terraform {
  backend "s3" {
 bucket= "myorg-terraform-state"
 key= "prod/terraform.tfstate"
 region= "ap-northeast-1"
 dynamodb_table = "terraform-state-lock"
 encrypt  = true
  }
}

S3のバケット内は以下のように分離されます。

myorg-terraform-state/
├── dev/terraform.tfstate
├── stg/terraform.tfstate
└── prod/terraform.tfstate

7-2. 環境ごとのパイプライン設計

マルチ環境のCodePipelineには2つの設計パターンがあります。

選択肢A: 1パイプライン × 複数ステージ(本記事採用)

┌──────────────────────────────────────────────────────────────────┐
│  terraform-cicd-pipeline│
│  │
│  Source → Plan-Dev → Approve-Dev → Apply-Dev│
│  ↓ │
│Plan-Stg → Approve-Stg → Apply-Stg  │
│ ↓  │
│ Plan-Prod → Approve-Prod → Apply-Prod │
└──────────────────────────────────────────────────────────────────┘
メリット:
  - パイプラインの実行フローが1画面で確認できる
  - dev→stg→prodの昇格ルールをCodePipelineが強制する
  - Approve(手動承認)を組み込みやすい
デメリット:
  - パイプラインが長くなり管理が複雑になる
  - 1ステージが失敗すると後続の環境にも影響する

選択肢B: 環境ごとに独立したパイプライン

pipeline-dev:Source → Plan-Dev → Apply-Dev
pipeline-stg:Source → Plan-Stg → Approve-Stg → Apply-Stg
pipeline-prod:  Source → Plan-Prod → Approve-Prod → Apply-Prod
メリット:
  - 各環境のパイプラインを独立して実行・管理できる
  - devとprodのリリースサイクルを完全に分離できる
デメリット:
  - dev→stg→prodの順序を自動強制できない
  - パイプラインが3本になり、コード・コスト・管理コストが増える

本記事での選択: シンプルさを優先し、選択肢A(1パイプライン×複数ステージ)を採用します。


7-3. 環境別パイプラインの実装

以下は dev→stg→prod を順次実行する完全なCodePipeline Terraformコードです。

# pipeline.tf — マルチ環境対応 CodePipeline

resource "aws_codepipeline" "terraform_cicd" {
  name  = "terraform-cicd-pipeline"
  role_arn = aws_iam_role.codepipeline_role.arn

  artifact_store {
 location = aws_s3_bucket.pipeline_artifacts.bucket
 type  = "S3"
  }

  # ─── Stage 1: Source ─────────────────────────────────────────────
  stage {
 name = "Source"
 action {
name = "Source"
category= "Source"
owner= "ThirdParty"
provider= "GitHub"
version = "2"
output_artifacts = ["source_output"]

configuration = {
  Owner= "myorg"
  Repo = "terraform-repo"
  Branch  = "main"
  OAuthToken = var.github_oauth_token
}
 }
  }

  # ─── Stage 2: Plan-Dev ───────────────────────────────────────────
  stage {
 name = "Plan-Dev"
 action {
name = "TerraformPlan-Dev"
category= "Build"
owner= "AWS"
provider= "CodeBuild"
version = "1"
input_artifacts  = ["source_output"]
output_artifacts = ["plan_dev_output"]

configuration = {
  ProjectName = aws_codebuild_project.terraform_plan.name
  EnvironmentVariables = jsonencode([
 { name = "TF_ENV", value = "dev" },
 { name = "TF_DIR", value = "environments/dev" }
  ])
}
 }
  }

  # ─── Stage 3: Approve-Dev ────────────────────────────────────────
  stage {
 name = "Approve-Dev"
 action {
name  = "ManualApproval-Dev"
category = "Approval"
owner = "AWS"
provider = "Manual"
version  = "1"

configuration = {
  NotificationArn = aws_sns_topic.pipeline_approval.arn
  CustomData= "dev環境へのapplyを承認してください。planの結果を確認の上、承認・却下を選択してください。"
}
 }
  }

  # ─── Stage 4: Apply-Dev ──────────────────────────────────────────
  stage {
 name = "Apply-Dev"
 action {
name= "TerraformApply-Dev"
category  = "Build"
owner  = "AWS"
provider  = "CodeBuild"
version= "1"
input_artifacts = ["source_output"]

configuration = {
  ProjectName = aws_codebuild_project.terraform_apply.name
  EnvironmentVariables = jsonencode([
 { name = "TF_ENV", value = "dev" },
 { name = "TF_DIR", value = "environments/dev" }
  ])
}
 }
  }

  # ─── Stage 5: Plan-Stg ───────────────────────────────────────────
  stage {
 name = "Plan-Stg"
 action {
name = "TerraformPlan-Stg"
category= "Build"
owner= "AWS"
provider= "CodeBuild"
version = "1"
input_artifacts  = ["source_output"]
output_artifacts = ["plan_stg_output"]

configuration = {
  ProjectName = aws_codebuild_project.terraform_plan.name
  EnvironmentVariables = jsonencode([
 { name = "TF_ENV", value = "stg" },
 { name = "TF_DIR", value = "environments/stg" }
  ])
}
 }
  }

  # ─── Stage 6: Approve-Stg ────────────────────────────────────────
  stage {
 name = "Approve-Stg"
 action {
name  = "ManualApproval-Stg"
category = "Approval"
owner = "AWS"
provider = "Manual"
version  = "1"

configuration = {
  NotificationArn = aws_sns_topic.pipeline_approval.arn
  CustomData= "stg環境へのapplyを承認してください。"
}
 }
  }

  # ─── Stage 7: Apply-Stg ──────────────────────────────────────────
  stage {
 name = "Apply-Stg"
 action {
name= "TerraformApply-Stg"
category  = "Build"
owner  = "AWS"
provider  = "CodeBuild"
version= "1"
input_artifacts = ["source_output"]

configuration = {
  ProjectName = aws_codebuild_project.terraform_apply.name
  EnvironmentVariables = jsonencode([
 { name = "TF_ENV", value = "stg" },
 { name = "TF_DIR", value = "environments/stg" }
  ])
}
 }
  }

  # ─── Stage 8: Plan-Prod ──────────────────────────────────────────
  stage {
 name = "Plan-Prod"
 action {
name = "TerraformPlan-Prod"
category= "Build"
owner= "AWS"
provider= "CodeBuild"
version = "1"
input_artifacts  = ["source_output"]
output_artifacts = ["plan_prod_output"]

configuration = {
  ProjectName = aws_codebuild_project.terraform_plan.name
  EnvironmentVariables = jsonencode([
 { name = "TF_ENV", value = "prod" },
 { name = "TF_DIR", value = "environments/prod" }
  ])
}
 }
  }

  # ─── Stage 9: Approve-Prod ───────────────────────────────────────
  stage {
 name = "Approve-Prod"
 action {
name  = "ManualApproval-Prod"
category = "Approval"
owner = "AWS"
provider = "Manual"
version  = "1"

configuration = {
  NotificationArn = aws_sns_topic.pipeline_approval.arn
  CustomData= "【本番環境】prod環境へのapplyを承認してください。影響範囲を十分に確認した上で承認してください。"
}
 }
  }

  # ─── Stage 10: Apply-Prod ────────────────────────────────────────
  stage {
 name = "Apply-Prod"
 action {
name= "TerraformApply-Prod"
category  = "Build"
owner  = "AWS"
provider  = "CodeBuild"
version= "1"
input_artifacts = ["source_output"]

configuration = {
  ProjectName = aws_codebuild_project.terraform_apply.name
  EnvironmentVariables = jsonencode([
 { name = "TF_ENV", value = "prod" },
 { name = "TF_DIR", value = "environments/prod" }
  ])
}
 }
  }
}

ポイント: 全ステージで同一の CodeBuild プロジェクト(terraform_plan / terraform_apply)を使用しています。環境の切り替えは TF_ENVTF_DIR という環境変数で行います。これにより、buildspec.yml を1本書けば全環境に使い回せます


7-4. 環境別 buildspec.yml の分岐

CodeBuild の buildspec.yml で TF_ENVTF_DIR を参照し、環境ごとにbackend設定とtfvarsを切り替えます。

terraform-plan の buildspec.yml

# buildspec-plan.yml
version: 0.2

phases:
  install:
 commands:
- TF_VERSION=1.7.5
- curl -fsSL "https://releases.hashicorp.com/terraform/${TF_VERSION}/terraform_${TF_VERSION}_linux_amd64.zip" -o terraform.zip
- unzip terraform.zip -d /usr/local/bin/
- terraform version

  pre_build:
 commands:
- echo "=== Environment: $TF_ENV ==="
- cd $CODEBUILD_SRC_DIR/$TF_DIR
- |
  terraform init \
 -backend-config="bucket=myorg-terraform-state" \
 -backend-config="key=${TF_ENV}/terraform.tfstate" \
 -backend-config="region=ap-northeast-1" \
 -backend-config="dynamodb_table=terraform-state-lock"

  build:
 commands:
- cd $CODEBUILD_SRC_DIR/$TF_DIR
- echo "=== terraform plan ($TF_ENV) ==="
- |
  terraform plan \
 -var-file="terraform.tfvars" \
 -out=tfplan-${TF_ENV} \
 -no-color \
 2>&1 | tee /tmp/plan_output.txt
- |
  echo "## Terraform Plan - $TF_ENV" >> /tmp/summary.md
  echo '```' >> /tmp/summary.md
  tail -20 /tmp/plan_output.txt >> /tmp/summary.md
  echo '```' >> /tmp/summary.md

artifacts:
  files:
 - "**/*"
  base-directory: $TF_DIR

terraform-apply の buildspec.yml

# buildspec-apply.yml
version: 0.2

phases:
  install:
 commands:
- TF_VERSION=1.7.5
- curl -fsSL "https://releases.hashicorp.com/terraform/${TF_VERSION}/terraform_${TF_VERSION}_linux_amd64.zip" -o terraform.zip
- unzip terraform.zip -d /usr/local/bin/
- terraform version

  pre_build:
 commands:
- echo "=== Apply Environment: $TF_ENV ==="
- cd $CODEBUILD_SRC_DIR/$TF_DIR
- |
  terraform init \
 -backend-config="bucket=myorg-terraform-state" \
 -backend-config="key=${TF_ENV}/terraform.tfstate" \
 -backend-config="region=ap-northeast-1" \
 -backend-config="dynamodb_table=terraform-state-lock"

  build:
 commands:
- cd $CODEBUILD_SRC_DIR/$TF_DIR
- terraform plan -var-file="terraform.tfvars" -out=tfplan-apply -no-color
- terraform apply -auto-approve -no-color tfplan-apply

  post_build:
 commands:
- |
  if [ "$CODEBUILD_BUILD_SUCCEEDING" = "1" ]; then
 echo "Apply succeeded for $TF_ENV"
  else
 echo "Apply failed for $TF_ENV"
  fi

環境別 terraform.tfvars の例

# environments/dev/terraform.tfvars
environment = "dev"
instance_type  = "t3.micro"
desired_count  = 1
enable_deletion_protection = false
# environments/stg/terraform.tfvars
environment = "stg"
instance_type  = "t3.small"
desired_count  = 2
enable_deletion_protection = true
# environments/prod/terraform.tfvars
environment = "prod"
instance_type  = "t3.medium"
desired_count  = 4
enable_deletion_protection = true

7-5. マルチ環境ハンズオン

実際にマルチ環境パイプラインを動かす手順を step-by-step で確認します。

Step 1: feature ブランチで変更を作成して push

git checkout -b feature/add-s3-bucket
# environments/dev/main.tf に追記
resource "aws_s3_bucket" "app_data" {
  bucket = "myorg-app-data-${var.environment}"

  tags = {
 Environment = var.environment
 ManagedBy= "terraform"
  }
}
git add environments/dev/main.tf
git commit -m "feat: add app-data S3 bucket"
git push origin feature/add-s3-bucket

PR を作成して main にマージします。マージ後、CodePipeline が自動起動します。

Step 2: dev 環境に apply

AWS コンソールで CodePipeline > terraform-cicd-pipeline を開きます。

確認の流れ:
1. Source→ 成功(最新コミットを取得)
2. Plan-Dev → 成功(CodeBuildログで「1 to add」を確認)
3. Approve-Dev → SNS通知が届く
  → CodePipelineコンソールで「Review」→「Approve」を選択
4. Apply-Dev→ 成功
# dev 環境のリソースを確認
aws s3 ls | grep myorg-app-data-dev

Step 3: dev 承認後、stg に自動昇格

Apply-Dev が完了すると自動的に Plan-Stg が実行されます。

Plan-Stg の確認:
  - stg/terraform.tfstate を参照していること
  - myorg-app-data-stg が plan に表示されること
  - 「1 to add, 0 to destroy」であること

SNS 通知を受け取ったら Approve-Stg を承認します。

aws s3 ls | grep myorg-app-data-stg

Step 4: stg 承認後、prod 昇格

prod の承認ゲートで確認すること:
  ✅ plan で destroy が発生していないこと
  ✅ 変更対象のリソースが意図したものだけであること
  ✅ stg 環境での動作確認が完了していること
  ✅ 本番リリースの時間帯として適切か
aws s3 ls | grep myorg-app-data-prod

Step 5: 全環境で terraform destroy

cd environments/prod && terraform destroy -var-file="terraform.tfvars"
cd environments/stg  && terraform destroy -var-file="terraform.tfvars"
cd environments/dev  && terraform destroy -var-file="terraform.tfvars"

または Terraform コードからリソースブロックを削除して PR→マージ→パイプライン実行でも destroy できます(コードdriven destroy)。


8. マルチアカウント構成

8-1. なぜマルチアカウントか

多くのエンタープライズ企業では、AWSアカウントを環境ごとに分けています。

シングルアカウント構成(小規模チーム向け):
  AWS Account: 123456789012
  ├── dev リソース
  ├── stg リソース
  └── prod リソース
  問題: dev の実験的操作が prod を誤って変更するリスクがある

マルチアカウント構成(エンタープライズ向け):
  管理アカウント: 123456789012  ← CodePipeline/CodeBuild が存在
  ├── dev アカウント:  111111111111
  └── prod アカウント: 222222222222

マルチアカウントの3大メリット

メリット内容
セキュリティ分離prod アカウントへの IAM 権限を持つ人間・ロールを最小限にできる
請求分離環境ごとのコストをアカウントレベルで分離・可視化できる
リソース制限の独立dev の Service Quotas 上限超過が prod に影響しない

AWS Organizations の概要

Organizations 管理アカウント (123456789012)
├── OU: Infrastructure
│├── dev アカウント (111111111111)
│└── prod アカウント (222222222222)
└── OU: Management
 └── 管理ツール用アカウント(本記事のCodePipeline)

本記事では、管理アカウントにCodePipeline/CodeBuildを配置し、各環境アカウントへクロスアカウントでIAMロールをAssumeしてTerraformを実行する設計を採用します。


8-2. クロスアカウント IAM ロール設計

全体の認証フロー

CodeBuild (管理アカウント: 123456789012)
 │
 │ sts:AssumeRole
 ▼
TerraformApplyRole-Dev (dev アカウント: 111111111111)
 │
 │ terraform apply
 ▼
dev アカウントの AWS リソース

dev アカウント側の設定(信頼ポリシー)

dev アカウント(111111111111)に TerraformApplyRole-Dev を作成し、管理アカウントの CodeBuild ロールからのみ AssumeRole を許可します。

{
  "Version": "2012-10-17",
  "Statement": [
 {
"Effect": "Allow",
"Principal": {
  "AWS": "arn:aws:iam::123456789012:role/codebuild-apply-role"
},
"Action": "sts:AssumeRole",
"Condition": {
  "StringEquals": {
 "sts:ExternalId": "terraform-cicd-dev"
  }
}
 }
  ]
}

ExternalId を設定する理由: 管理アカウントの CodeBuild ロールが複数システムで共有される場合、ExternalId なしでは意図しない AssumeRole が発生するリスクがあります。ExternalId を設定することで「terraform-cicd パイプラインからの要求のみ受け付ける」という制約を追加できます。

prod アカウント(222222222222)の信頼ポリシー:

{
  "Version": "2012-10-17",
  "Statement": [
 {
"Effect": "Allow",
"Principal": {
  "AWS": "arn:aws:iam::123456789012:role/codebuild-apply-role"
},
"Action": "sts:AssumeRole",
"Condition": {
  "StringEquals": {
 "sts:ExternalId": "terraform-cicd-prod"
  }
}
 }
  ]
}

dev アカウントの TerraformApplyRole-Dev に付与するポリシー

{
  "Version": "2012-10-17",
  "Statement": [
 {
"Effect": "Allow",
"Action": ["ec2:*", "s3:*", "iam:*", "cloudwatch:*"],
"Resource": "*"
 },
 {
"Effect": "Allow",
"Action": [
  "s3:GetObject", "s3:PutObject", "s3:ListBucket", "s3:GetBucketVersioning"
],
"Resource": [
  "arn:aws:s3:::myorg-terraform-state",
  "arn:aws:s3:::myorg-terraform-state/*"
]
 },
 {
"Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem"],
"Resource": "arn:aws:dynamodb:ap-northeast-1:123456789012:table/terraform-state-lock"
 }
  ]
}

注意: DynamoDB のロックテーブルは管理アカウントにある前提です。dev/prod アカウントからクロスアカウントアクセスできるよう、管理アカウントの DynamoDB テーブルへのアクセスを許可します。

管理アカウントの CodeBuild ロールへの追加ポリシー

{
  "Version": "2012-10-17",
  "Statement": [
 {
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": [
  "arn:aws:iam::111111111111:role/TerraformApplyRole-Dev",
  "arn:aws:iam::222222222222:role/TerraformApplyRole-Prod"
]
 }
  ]
}

8-3. buildspec.yml でのクロスアカウント AssumeRole

CodeBuild の buildspec.yml 内で STS の AssumeRole を行い、dev/prod アカウントの認証情報を一時的に取得してから Terraform を実行します。

# buildspec-apply-crossaccount.yml
version: 0.2

phases:
  install:
 commands:
- TF_VERSION=1.7.5
- curl -fsSL "https://releases.hashicorp.com/terraform/${TF_VERSION}/terraform_${TF_VERSION}_linux_amd64.zip" -o terraform.zip
- unzip terraform.zip -d /usr/local/bin/
- terraform version
- apt-get install -y jq

  pre_build:
 commands:
- echo "=== Cross-Account Apply: $TF_ENV (Account: $TARGET_ACCOUNT_ID) ==="

# STS AssumeRole でターゲットアカウントの認証情報を取得
- |
  CREDS=$(aws sts assume-role \
 --role-arn "arn:aws:iam::${TARGET_ACCOUNT_ID}:role/TerraformApplyRole-${TF_ENV_CAPITALIZE}" \
 --role-session-name "terraform-apply-${TF_ENV}-${CODEBUILD_BUILD_ID}" \
 --external-id "terraform-cicd-${TF_ENV}" \
 --duration-seconds 3600 \
 --output json)

# 取得した認証情報を環境変数にセット
- export AWS_ACCESS_KEY_ID=$(echo $CREDS | jq -r '.Credentials.AccessKeyId')
- export AWS_SECRET_ACCESS_KEY=$(echo $CREDS | jq -r '.Credentials.SecretAccessKey')
- export AWS_SESSION_TOKEN=$(echo $CREDS | jq -r '.Credentials.SessionToken')

# 認証確認(ARNのみ表示)
- aws sts get-caller-identity --query 'Arn' --output text

- cd $CODEBUILD_SRC_DIR/$TF_DIR
- |
  terraform init \
 -backend-config="bucket=myorg-terraform-state-${TF_ENV}" \
 -backend-config="key=${TF_ENV}/terraform.tfstate" \
 -backend-config="region=ap-northeast-1" \
 -backend-config="dynamodb_table=terraform-state-lock" \
 -reconfigure

  build:
 commands:
- cd $CODEBUILD_SRC_DIR/$TF_DIR
- terraform plan -var-file="terraform.tfvars" -out=tfplan -no-color
- terraform apply -auto-approve -no-color tfplan

  post_build:
 commands:
- unset AWS_ACCESS_KEY_ID
- unset AWS_SECRET_ACCESS_KEY
- unset AWS_SESSION_TOKEN
- |
  if [ "$CODEBUILD_BUILD_SUCCEEDING" = "1" ]; then
 echo "Cross-account apply succeeded: $TF_ENV ($TARGET_ACCOUNT_ID)"
  else
 echo "Cross-account apply failed: $TF_ENV ($TARGET_ACCOUNT_ID)"
  fi

TF_ENV_CAPITALIZE について: devDevprodProd のように頭文字を大文字にした値です。IAM ロール名(TerraformApplyRole-Dev)との一致が必要なため、buildspec 内で変換します。

# buildspec 内での変換例
- TF_ENV_CAPITALIZE=$(echo $TF_ENV | python3 -c "import sys; print(sys.stdin.read().strip().capitalize())")

8-4. Terraform での OIDC role assume chain

provider "aws" ブロックで assume_role を使うと、Terraform 実行中に自動でクロスアカウント AssumeRole を行えます。buildspec での STS 操作が不要になるため、よりシンプルな構成です。

# environments/dev/providers.tf
provider "aws" {
  region = "ap-northeast-1"

  assume_role {
 role_arn  = "arn:aws:iam::111111111111:role/TerraformApplyRole-Dev"
 session_name = "terraform-dev-pipeline"
 external_id  = "terraform-cicd-dev"
  }
}
# environments/prod/providers.tf
provider "aws" {
  region = "ap-northeast-1"

  assume_role {
 role_arn  = "arn:aws:iam::222222222222:role/TerraformApplyRole-Prod"
 session_name = "terraform-prod-pipeline"
 external_id  = "terraform-cicd-prod"
  }
}

provider assume_role と buildspec AssumeRole の比較

方法メリットデメリット
provider assume_roleTerraformコードで宣言的に設定provider.tf が環境ごとに異なるコードになる
buildspec AssumeRolebuildspec を1本に集約できるjq インストールが必要・セッション管理が複雑

小規模なマルチアカウント構成では provider assume_role が推奨です。

role_arn を変数で切り替える設計

# environments/dev/providers.tf(変数方式)
variable "target_role_arn" {
  description = "Cross-account IAM role ARN for Terraform"
  type  = string
}

provider "aws" {
  region = "ap-northeast-1"

  assume_role {
 role_arn  = var.target_role_arn
 session_name = "terraform-${var.environment}"
 external_id  = "terraform-cicd-${var.environment}"
  }
}
# environments/dev/terraform.tfvars
target_role_arn = "arn:aws:iam::111111111111:role/TerraformApplyRole-Dev"
environment  = "dev"

8-5. マルチアカウント構成の Terraform コード(完全版)

管理アカウント側:CodeBuild ロールとクロスアカウント設定

# management/codebuild_role.tf

resource "aws_iam_role" "codebuild_apply" {
  name = "codebuild-apply-role"

  assume_role_policy = jsonencode({
 Version = "2012-10-17"
 Statement = [{
Effect = "Allow"
Principal = { Service = "codebuild.amazonaws.com" }
Action = "sts:AssumeRole"
 }]
  })
}

resource "aws_iam_role_policy" "codebuild_crossaccount" {
  name = "crossaccount-assume-role"
  role = aws_iam_role.codebuild_apply.id

  policy = jsonencode({
 Version = "2012-10-17"
 Statement = [
{
  Effect= "Allow"
  Action= "sts:AssumeRole"
  Resource = [
 "arn:aws:iam::${var.dev_account_id}:role/TerraformApplyRole-Dev",
 "arn:aws:iam::${var.prod_account_id}:role/TerraformApplyRole-Prod"
  ]
},
{
  Effect = "Allow"
  Action = [
 "logs:CreateLogGroup",
 "logs:CreateLogStream",
 "logs:PutLogEvents"
  ]
  Resource = "*"
},
{
  Effect = "Allow"
  Action = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"]
  Resource = [
 "arn:aws:s3:::${var.pipeline_artifact_bucket}",
 "arn:aws:s3:::${var.pipeline_artifact_bucket}/*"
  ]
}
 ]
  })
}

variable "dev_account_id" {
  default = "111111111111"
}

variable "prod_account_id" {
  default = "222222222222"
}

variable "pipeline_artifact_bucket" {
  default = "myorg-codepipeline-artifacts"
}

各環境アカウント側:信頼ポリシーと実行ポリシー

# dev-account/terraform_role.tf
# このコードは dev アカウント (111111111111) で terraform apply する

variable "management_account_id" {
  default = "123456789012"
}

resource "aws_iam_role" "terraform_apply_dev" {
  name = "TerraformApplyRole-Dev"

  assume_role_policy = jsonencode({
 Version = "2012-10-17"
 Statement = [{
Effect = "Allow"
Principal = {
  AWS = "arn:aws:iam::${var.management_account_id}:role/codebuild-apply-role"
}
Action = "sts:AssumeRole"
Condition = {
  StringEquals = {
 "sts:ExternalId" = "terraform-cicd-dev"
  }
}
 }]
  })
}

resource "aws_iam_role_policy" "terraform_apply_dev_policy" {
  name = "terraform-apply-dev-policy"
  role = aws_iam_role.terraform_apply_dev.id

  policy = jsonencode({
 Version = "2012-10-17"
 Statement = [
{
  Effect= "Allow"
  Action= ["ec2:*", "s3:*", "rds:*", "iam:*", "cloudwatch:*", "logs:*"]
  Resource = "*"
},
{
  Effect = "Allow"
  Action = [
 "s3:GetObject", "s3:PutObject", "s3:ListBucket",
 "s3:DeleteObject", "s3:GetBucketVersioning"
  ]
  Resource = [
 "arn:aws:s3:::myorg-terraform-state-dev",
 "arn:aws:s3:::myorg-terraform-state-dev/*"
  ]
},
{
  Effect = "Allow"
  Action = [
 "dynamodb:GetItem", "dynamodb:PutItem",
 "dynamodb:DeleteItem", "dynamodb:DescribeTable"
  ]
  Resource = "arn:aws:dynamodb:ap-northeast-1:111111111111:table/terraform-state-lock-dev"
}
 ]
  })
}
# prod-account/terraform_role.tf
# このコードは prod アカウント (222222222222) で terraform apply する

variable "management_account_id" {
  default = "123456789012"
}

resource "aws_iam_role" "terraform_apply_prod" {
  name = "TerraformApplyRole-Prod"

  assume_role_policy = jsonencode({
 Version = "2012-10-17"
 Statement = [{
Effect = "Allow"
Principal = {
  AWS = "arn:aws:iam::${var.management_account_id}:role/codebuild-apply-role"
}
Action = "sts:AssumeRole"
Condition = {
  StringEquals = {
 "sts:ExternalId" = "terraform-cicd-prod"
  }
}
 }]
  })
}

resource "aws_iam_role_policy" "terraform_apply_prod_policy" {
  name = "terraform-apply-prod-policy"
  role = aws_iam_role.terraform_apply_prod.id

  policy = jsonencode({
 Version = "2012-10-17"
 Statement = [
{
  Effect= "Allow"
  Action= ["ec2:*", "s3:*", "rds:*", "iam:*", "cloudwatch:*", "logs:*"]
  Resource = "*"
},
{
  Effect = "Allow"
  Action = [
 "s3:GetObject", "s3:PutObject", "s3:ListBucket",
 "s3:DeleteObject", "s3:GetBucketVersioning"
  ]
  Resource = [
 "arn:aws:s3:::myorg-terraform-state-prod",
 "arn:aws:s3:::myorg-terraform-state-prod/*"
  ]
},
{
  Effect = "Allow"
  Action = [
 "dynamodb:GetItem", "dynamodb:PutItem",
 "dynamodb:DeleteItem", "dynamodb:DescribeTable"
  ]
  Resource = "arn:aws:dynamodb:ap-northeast-1:222222222222:table/terraform-state-lock-prod"
}
 ]
  })
}

Section 8 まとめ

設定箇所アカウント内容
CodeBuild 実行ロール管理アカウント (123456789012)sts:AssumeRole を dev/prod ロールに許可
TerraformApplyRole-Devdev アカウント (111111111111)管理アカウントの CodeBuild からの AssumeRole を許可
TerraformApplyRole-Prodprod アカウント (222222222222)管理アカウントの CodeBuild からの AssumeRole を許可(ExternalId 必須)
buildspec.yml管理アカウントSTS AssumeRole → Terraform init/plan/apply を実行
provider.tf(代替案)各環境の Terraform コードassume_role ブロックで宣言的に設定

マルチアカウント構成を採用することで、prod アカウントへの直接アクセス権限を持つ人間をゼロに近づけ、全ての変更を CodePipeline 経由の自動化フローに集約できます。これがエンタープライズ環境でのTerraform CI/CDの到達点です。


9. 運用・モニタリング・セキュリティ強化

9-1. CloudTrailによる監査証跡

CodePipeline/CodeBuildの操作はすべてAWS CloudTrailに記録されます。インフラ変更の監査証跡として活用します。

CloudTrailで記録される主要イベント

EventNameサービス意味
StartPipelineExecutionCodePipelineパイプライン手動起動
PutApprovalResultCodePipelineManualApproval の承認/拒否
StopPipelineExecutionCodePipelineパイプラインの手動停止
UpdatePipelineCodePipelineパイプライン設定変更
StartBuildCodeBuildビルド起動
BatchDeleteBuildsCodeBuildビルド履歴削除

CloudTrail Lakeでの監査クエリ

-- 過去30日のManualApproval操作一覧
SELECT
  eventTime,
  userIdentity.arn AS approver,
  requestParameters.result.status AS action,
  requestParameters.result.summary AS comment
FROM
  aws_cloudtrail_logs
WHERE
  eventSource = 'codepipeline.amazonaws.com'
  AND eventName = 'PutApprovalResult'
  AND eventTime > DATE_ADD('day', -30, NOW())
ORDER BY
  eventTime DESC;

-- 過去7日でprod applyを実行したIAM Entity
SELECT
  eventTime,
  userIdentity.arn AS executor,
  requestParameters.projectName AS project
FROM
  aws_cloudtrail_logs
WHERE
  eventSource = 'codebuild.amazonaws.com'
  AND eventName = 'StartBuild'
  AND requestParameters.projectName LIKE '%apply%'
  AND eventTime > DATE_ADD('day', -7, NOW());

CloudWatch Logsへのエクスポート設定

resource "aws_cloudtrail" "pipeline_audit" {
  name  = "terraform-pipeline-audit"
  s3_bucket_name = aws_s3_bucket.cloudtrail_logs.id
  include_global_service_events = true
  is_multi_region_trail= true
  enable_log_file_validation = true

  event_selector {
 read_write_type  = "WriteOnly"
 include_management_events = true

 data_resource {
type= "AWS::CodePipeline::Pipeline"
values = ["arn:aws:codepipeline:ap-northeast-1:123456789012:terraform-cicd-pipeline"]
 }
  }
}

9-2. CodePipeline EventBridgeアラート

パイプライン失敗時の自動通知

# パイプライン失敗通知ルール
resource "aws_cloudwatch_event_rule" "pipeline_failed" {
  name  = "terraform-pipeline-failed"
  description = "Alert when CodePipeline execution fails"

  event_pattern = jsonencode({
 source= ["aws.codepipeline"]
 detail-type = ["CodePipeline Pipeline Execution State Change"]
 detail = {
pipeline = ["terraform-cicd-pipeline"]
state = ["FAILED"]
 }
  })
}

resource "aws_cloudwatch_event_target" "pipeline_failed_sns" {
  rule= aws_cloudwatch_event_rule.pipeline_failed.name
  target_id = "pipeline-failed-notification"
  arn = aws_sns_topic.notifications.arn

  input_transformer {
 input_paths = {
pipeline  = "$.detail.pipeline"
state  = "$.detail.state"
execution = "$.detail.execution-id"
time= "$.time"
 }
 input_template = "\"[ALERT] Pipeline <pipeline> <state> at <time>. Execution: <execution>. 確認: https://console.aws.amazon.com/codesuite/codepipeline/pipelines/terraform-cicd-pipeline/view\""
  }
}

# ステージレベルの失敗通知
resource "aws_cloudwatch_event_rule" "stage_failed" {
  name = "terraform-stage-failed"

  event_pattern = jsonencode({
 source= ["aws.codepipeline"]
 detail-type = ["CodePipeline Stage Execution State Change"]
 detail = {
pipeline = ["terraform-cicd-pipeline"]
state = ["FAILED"]
 }
  })
}

Slack連携(Lambda経由)の設計パターン

設計パターン:
  EventBridge → Lambda → Slack Webhook

Lambda関数(Python)の概要:
  1. EventBridgeイベントを受け取る
  2. パイプライン名・ステージ・ステータスを取得
  3. Slack Webhookにメッセージを送信

メッセージフォーマット:
  🚨 [FAILED] terraform-cicd-pipeline / Apply stage
  Execution ID: a1b2c3d4...
  確認: https://console.aws.amazon.com/codesuite/...
# EventBridge → Lambda(Slack通知)
resource "aws_cloudwatch_event_target" "pipeline_slack" {
  rule= aws_cloudwatch_event_rule.pipeline_failed.name
  target_id = "pipeline-slack-notification"
  arn = aws_lambda_function.slack_notifier.arn
}

resource "aws_lambda_permission" "eventbridge_invoke" {
  statement_id  = "AllowEventBridgeInvoke"
  action  = "lambda:InvokeFunction"
  function_name = aws_lambda_function.slack_notifier.function_name
  principal  = "events.amazonaws.com"
  source_arn = aws_cloudwatch_event_rule.pipeline_failed.arn
}

9-3. CodeBuildのログ管理

CloudWatch Logsへのbuildspec出力

resource "aws_cloudwatch_log_group" "codebuild_plan" {
  name  = "/aws/codebuild/terraform-plan"
  retention_in_days = 30

  tags = {
 Project = "terraform-cicd"
  }
}

resource "aws_cloudwatch_log_group" "codebuild_apply" {
  name  = "/aws/codebuild/terraform-apply"
  retention_in_days = 90  # apply履歴は長めに保持

  tags = {
 Project = "terraform-cicd"
  }
}

ログ検索クエリ(CloudWatch Logs Insights)

# apply失敗の原因を検索
fields @timestamp, @message
| filter @message like /Error:|error:|FAILED/
| sort @timestamp desc
| limit 50

# terraform planで破壊的変更を検出
fields @timestamp, @message
| filter @message like /destroy/
| sort @timestamp desc

# apply実行時間の統計
stats avg(@duration), max(@duration), min(@duration)
by bin(1d)

S3へのビルドレポート保存

resource "aws_codebuild_project" "terraform_apply" {
  # ... 既存設定 ...

  logs_config {
 cloudwatch_logs {
group_name  = "/aws/codebuild/terraform-apply"
stream_name = ""
 }

 s3_logs {
status= "ENABLED"
location = "${aws_s3_bucket.pipeline_artifacts.id}/build-reports/apply"
encryption_disabled = false
 }
  }
}

9-4. パイプライン実行コストの管理

CodePipelineの課金モデル

課金対象単価(2024年時点)備考
アクティブパイプライン$1.00/月/パイプラインV1: 最初の1本無料。V2は別料金体系
アクション実行V2: $0.002/アクションV2パイプライン使用時

CodeBuildの課金モデル

コンピューティングタイプ料金(ap-northeast-1)
BUILD_GENERAL1_SMALL (3.5GB/2 vCPU)$0.005/分
BUILD_GENERAL1_MEDIUM (7GB/4 vCPU)$0.01/分
BUILD_GENERAL1_LARGE (15GB/8 vCPU)$0.02/分

月次コスト試算

小規模チーム(1パイプライン、1日5回push):

CodePipeline: $1.00/月
CodeBuild Plan: 5回/日 × 5分 × 30日 = 750分 × $0.005 = $3.75
CodeBuild Apply: 5回/日 × 3分 × 20日(承認あり) = 300分 × $0.005 = $1.50
合計: 約 $6.25/月

エンタープライズ(5パイプライン、1日20回push):

CodePipeline: $5.00/月
CodeBuild Plan: 20回/日 × 5分 × 30日 × 5環境 = 15,000分 × $0.005 = $75.00
CodeBuild Apply: 10回/日 × 5分 × 20日 × 5環境 = 5,000分 × $0.005 = $25.00
合計: 約 $105/月

コスト削減のベストプラクティス

1. ビルド時間の短縮
- Terraformプロバイダーをキャッシュ(LOCAL_DOCKER_LAYER_CACHE)
- 変更のないモジュールはtarget指定でplan範囲を限定

2. 不要なパイプライン実行の防止
- BranchFilterでmainブランチのみトリガー
- ファイルパスフィルター(V2パイプライン機能)で.tfファイル変更時のみ実行

3. 環境別パイプラインの最適化
- dev環境はManualApprovalを省略してコスト削減
- stg/prodのみApprovalあり構成に

9-5. セキュリティ強化チェックリスト

以下の項目を定期的に確認し、エンタープライズグレードのセキュリティを維持します。

IAM・権限設計

  • [ ] CodePipelineロールに最小権限が付与されているか(ワイルドカード * の使用を最小化)
  • [ ] CodeBuildのPlan用とApply用ロールを分離しているか(Plan: ReadOnly、Apply: Write)
  • [ ] ManualApprovalのPutApprovalResult権限が承認者のみに付与されているか
  • [ ] クロスアカウント構成でExternalIdが設定されているか(混乱した代理問題対策)
  • [ ] S3バケットポリシーでCodePipeline/CodeBuildのサービスロールのみアクセス可か

ネットワーク・データ保護

  • [ ] S3アーティファクトバケットでSSE-KMS暗号化が有効か
  • [ ] S3バケットへのパブリックアクセスがブロックされているか
  • [ ] S3アクセスログが有効か(誰がplan結果を参照したか追跡)
  • [ ] CodeBuildをVPC内で実行しているか(VPCエンドポイント経由でS3/SSMにアクセス)
  • [ ] CloudTrailでの証跡収集が有効か(マルチリージョン)

パイプライン設計

  • [ ] Approvalなしでprod applyができない構成になっているか
  • [ ] 緊急時のBreakGlass手順が文書化されているか
  • [ ] パイプラインの変更履歴がCloudTrailで追跡可能か
  • [ ] S3のアーティファクトに保持期限(TTL)が設定されているか

定期監査

# IAMポリシーの過剰権限を確認(Access Advisor)
aws iam generate-service-last-accessed-details \
  --arn arn:aws:iam::123456789012:role/codebuild-apply-role

# S3バケットのパブリックアクセスブロック設定を確認
aws s3api get-public-access-block \
  --bucket myorg-pipeline-artifacts

# CloudTrailが有効か確認
aws cloudtrail describe-trails \
  --query "trailList[?HomeRegion=='ap-northeast-1'].{Name:Name,S3Bucket:S3BucketName,IsLogging:IsLogging}"

Section 9 まとめ

施策効果
CloudTrail監査証跡誰がいつapplyしたか完全記録
EventBridge通知失敗の即時検知・Slack連携
CloudWatch Logsへのビルドログapply失敗原因の迅速な特定
コスト管理不要なビルド実行の削減
セキュリティチェックリスト定期的な権限・設定の棚卸し

10. まとめとシリーズ完結

10-1. 本記事のまとめ

本記事では、AWS CodePipelineとCodeBuildを組み合わせてTerraformのCI/CDパイプラインを構築しました。単純な4ステージパイプラインから始まり、エンタープライズ運用に必要なすべての要素を実装しました。

構築したCodePipelineの全体サマリー

パイプライン構成(全4ステージ):

  GitHub (main push)
 ↓
  [Source] CodeStarConnections → S3アーティファクト
 ↓
  [Plan] CodeBuild (terraform plan) → PlanArtifact保存
 ↓
  [Approve] ManualApproval → SNS通知 → 人間が確認・承認
 ↓
  [Apply] CodeBuild (terraform apply) → SNS完了通知

マルチ環境・マルチアカウントでの拡張パターン

規模構成追加コスト
単一アカウント・単一環境本記事の基本構成
単一アカウント・dev/stg/prod環境別パイプライン × 3本~$3/月
マルチアカウント管理アカウント + 各環境アカウント+ クロスアカウントIAM設計
エンタープライズOrganizations + Control Tower+ ガードレール・SCP設計

GitHub Actions方式(第2弾)との使い分け最終まとめ

観点GitHub Actions (第2弾)CodePipeline (本記事)
セットアップコスト低(YAMLファイルのみ)高(AWSリソース一式)
AWSサービスとの統合OIDC経由(疎結合)ネイティブ統合(密結合)
ManualApprovalPR承認フローで代替専用UIとSNS通知
コストGitHub Actionsの無料枠CodeBuild従量課金
監査・コンプライアンスGitHubの監査ログCloudTrail完全統合
推奨シーンスタートアップ・スモールチームエンタープライズ・金融・官公庁

10-2. シリーズ全体の振り返り

本シリーズ全3弾を通じて、Terraform複数人開発の完全像を構築しました。

第1弾: state管理・lockで複数人開発の基盤を整えた

  • S3 + DynamoDBによるstate管理とロック機構を導入
  • state divergence(ドリフト)の検出と修復
  • 複数人がterraform applyを同時実行しても安全な環境

第2弾: GitHub Actions + OIDCでPR駆動CI/CDを実現した

  • OIDCによる認証情報不要のAWS連携
  • PRトリガーでterraform planを自動実行
  • Atlantis的なPRレビューとapplyの統合フロー

第3弾(本記事): CodePipeline + CodeBuildでエンタープライズグレードのCI/CDを構築した

  • AWS完全マネージドなCI/CDパイプライン
  • ManualApprovalによる人間の確認ゲート
  • マルチ環境・マルチアカウントへのスケールアウト
  • CloudTrailによる完全な監査証跡

3弾を通して得られたTerraform複数人開発の完全像

開発フロー(完成形):
  1. 開発者がfeatureブランチで変更
  2. PRを作成 → GitHub Actions で terraform plan 自動実行
  3. コードレビュー + plan結果レビュー → マージ
  4. mainマージ → CodePipeline起動
  5. Planステージ → ManualApproval → Applyステージ
  6. CloudTrailに全操作を記録
  7. SNS/Slackで完了通知

10-3. 次のステップ(読者へ)

本シリーズで構築した基盤をさらに発展させるための方向性を紹介します。

Atlantis(Terraform専用CI/CD)との比較

AtlantisはTerraform専用のCI/CDツールで、GitHubのPRコメントからplanとapplyを操作できます。

比較軸AtlantisCodePipeline
セットアップEC2/EKSへの自己ホストフルマネージド
PR連携PRコメントで操作(atlantis plan/apply)別途GitHub Actionsと組み合わせ
AWSネイティブなし完全統合
適合シーンTerraform専用チーム・OSS重視AWSオールイン環境

Terraform Cloud/HCP Terraformとの比較

HashiCorp公式のマネージドプラットフォームです。

比較軸HCP TerraformCodePipeline
料金有料(Free枠あり)AWSの従量課金
state管理組み込み別途S3+DynamoDB
policy-as-codeSentinel対応SCP/CloudFormationで代替
マルチクラウド対応(Azure/GCP)AWS専用

AWS Organizations + Control Towerの活用

本記事のマルチアカウント構成を組織規模にスケールさせる場合:

発展構成:
  Control Tower → ランディングゾーン自動セットアップ
  Organizations SCP → 各アカウントへのガードレール適用
  AWS Config → インフラのコンプライアンス継続評価
  Security Hub → セキュリティ所見の一元管理

Lambda/ECSへのCI/CD拡張

Terraform以外のリソースにも同じパイプラインパターンを適用できます:

Lambda CI/CD:
  GitHub → CodePipeline → CodeBuild (build + test) → ManualApproval → Lambda Deploy

ECS CI/CD:
  GitHub → CodePipeline → CodeBuild (Docker build) → ECR Push → ECS Blue/Green Deploy

10-4. シリーズリンクまとめ

AWS×Terraform 複数人開発シリーズ(全3弾)

第1弾から読む

本シリーズが、あなたのチームのTerraform CI/CD構築の参考になれば幸いです。AWS CodePipelineを使ったエンタープライズグレードのインフラ自動化で、安全・確実・監査可能なデプロイフローを実現してください。