Terraform 実践 — モジュール化・tfstate管理・GitHub Actions + OIDC CI/CDハンズオン

目次

Terraform 実践 — モジュール化・tfstate管理・GitHub Actions + OIDC CI/CDハンズオン

📚 関連記事 — AWS ハンズオンシリーズ前作: Terraform 基礎 — IaC入門ハンズオン

公開日: 2026-04-15 / 難易度: 中級 / 所要時間: 約180分


目次


Section 1: この記事について

1-1. 前作との関係

前作「Terraform 基礎 — IaC入門ハンズオン」(WP ID:1120)では、Terraformの根幹となる概念——tfstate(State)providerresourceplan/apply/destroy サイクル——を習得しました。S3バケットをコードで作成し、EC2インスタンスを変数化して管理するところまでを実践しています。

本記事はその直接の続編です。前作で「動くコードを書ける」段階に達した読者が、次の壁——チーム開発・環境分離・自動化——を突破するための実践編として設計されています。

前作との位置づけ

スキルレベル状態担当記事
入門Terraformが何かを理解し、S3・EC2を1人でコード化できる前作(Terraform基礎)
中級チームでTerraformを運用し、CIで自動デプロイできるこの記事(Terraform実践)
上級Terragrunt・Atlantis・Terraform Cloudで大規模管理できる次回以降(シリーズ予定)

前作を読まずにこの記事を始める場合は、少なくとも以下の知識を事前に確認してください: terraform init / plan / apply / destroy の意味、resource ブロックの書き方、variableoutput の基本、tfstateが何を管理するか。

用語統一宣言

前作と本記事で揺れが生じやすい用語を以下のように統一します。

表記ゆれ本記事での統一表記意味
State / state file / terraform.tfstatetfstateTerraformが管理する状態ファイル
remote backend / S3 backendS3バックエンドtfstateをS3に保存する設定
workspace / envworkspaceTerraform公式コマンド名に統一
CI/CD pipeline / workflowworkflowGitHub Actionsの文脈ではworkflow
Secret / Access KeyアクセスキーAWS認証情報(OIDCで不要になるもの)

この宣言以降は上記の統一表記を使用します。前作を参照する際も同様の読み替えをお願いします。

1-2. この記事で学ぶこと

本記事は3本柱で構成されています。

🏛️ 3本柱の概要モジュール化(Section 2-3): module構文でVPC/EC2設定を再利用可能なコンポーネントにまとめる。DRY原則とチーム標準化を実現。

  • tfstate管理(Section 4-5): S3+DynamoDBでtfstateをチーム共有・暗号化・ロック管理する。terraform workspaceで開発/ステージング/本番を分離。
  • OIDC CI/CD(Section 6-7): GitHub ActionsからAWSへアクセスキー不要(secretless)でデプロイ。IAM OIDC ProviderとAssumeRoleWithWebIdentityを活用。

各テーマは独立して学習可能ですが、Section 8で3つを統合した完全IaC構成に仕上げます。まずはSection 1-3(モジュール化)を一通り実践し、動くコードを手元に持ってから、残りのテーマに進むことを推奨します。

各テーマが解決する「現場の痛み」

テーマ解決前の状態解決後の状態
モジュール化VPCのコードをdev/stg/prodで3コピー管理。変更時に漏れが発生するmodule "vpc" に変数を渡すだけ。変更は1箇所で全環境に反映
tfstate管理terraform.tfstate がローカルにしかなく、他のメンバーが apply できないS3にtfstateを保存。誰でも同じ状態からapply可能
OIDC CI/CDAWS_ACCESS_KEY_ID をGitHub Secretsに保存。キー漏洩・ローテーション管理が必要OIDCで一時クレデンシャルを自動取得。アクセスキーがリポジトリに存在しない

この3つを組み合わせることで、「ローカルPCで1人がterraform applyする運用」から「チームでPR-based、CI/CDによる自動デプロイ」へと進化します。

1-3. 前提条件

本記事を実践するには以下が必要です。

必須

  • 前作「Terraform 基礎」の内容を習得済み(tfstate・resource・plan/applyを理解している)
  • AWSアカウント(IAMユーザーに AdministratorAccess ポリシーが付与済み)
  • AWSアクセスキーの設定完了(~/.aws/credentials または環境変数)
  • GitHubアカウント + 新規リポジトリの作成権限
  • Terraform 1.9系インストール済み(バージョン確認: terraform version
  • Git の基礎操作(clone / add / commit / push)

任意(Section 6-7 を実践する場合)

  • GitHub Actionsを使ったことがある(.github/workflows/ の構造を知っている)
  • IAMロール・信頼ポリシーの概念を理解している

バージョン情報(2026年4月時点)

本記事で使用する主要コンポーネントのバージョンは以下の通りです。

コンポーネントバージョン備考
Terraform1.9.xterraform version で確認
AWS Provider (hashicorp/aws)~> 5.0terraform-aws-modules との互換性
terraform-aws-modules/vpc/aws~> 5.0公式VPCモジュール
terraform-aws-modules/ec2-instance/aws~> 5.0公式EC2モジュール
aws-actions/configure-aws-credentialsv4GitHub Actions用

1-4. 構成全体図

本記事で構築する最終的なCI/CDパイプラインの全体像を以下の表で示します。

フェーズ操作主体実行内容AWSへの影響
ローカル開発開発者コード編集・terraform validateなし
PR作成GitHub Actionsterraform plan(ReadOnlyロール使用)読み取りのみ
PRコメントGitHub Actionsplanの差分をPRにコメント投稿なし
mainへのmergeGitHub Actionsterraform apply(applyロール使用)リソース変更
デプロイ確認開発者AWSコンソールでリソース確認なし

構成図について: Section 6-2 に OIDC認証フロー図(テキスト表形式)を掲載しています。全体のデータフローはそちらを参照してください。

1-5. 想定所要時間とコスト見積

所要時間(目安)

セクション内容所要時間
Section 1-3モジュール化 概念 + ハンズオン約60分
Section 4-5tfstate管理 + workspace約45分
Section 6-7OIDC + GitHub Actions CI/CD約60分
Section 8完全IaC統合約15分
合計約180分

コスト見積(月額)

リソースコスト備考
S3バケット(tfstate保存)≈ $0.02状態ファイルは数KB程度
DynamoDBテーブル(ロック用)≈ $0.00PAY_PER_REQUEST、リクエスト数極小
EC2 t3.micro(ハンズオン用)≈ $0.00ハンズオン後に即destroy
合計≈ $0.5/月以内放置しなければほぼ無料

重要: EC2インスタンスは起動したままにすると料金が発生します。各ハンズオン終了後は必ず terraform destroy を実行してください。Section 10-3 にコスト節約チェックリストを掲載しています。


Section 2: Terraformモジュール化 — 概念編

2-1. なぜモジュール化するのか

Terraformを使い始めると、最初はすべてのリソースをルートの main.tf に書きたくなります。VPC、サブネット、EC2、セキュリティグループ——それらが一つのファイルに混在すると、開発環境と本番環境で「ほぼ同じコード」が複製されていくことに気づきます。

モジュール化は、この問題を3つの観点で解決します。

① DRY原則(Don’t Repeat Yourself)

同じVPC構成を開発・ステージング・本番それぞれに書くと、修正が発生したときに3箇所を変更しなければなりません。モジュールにまとめれば1箇所の変更で済みます。

② 環境差分の吸収

モジュールを「VPCの設計図」として定義し、cidr_blockinstance_type変数(variable)として外部から渡せるようにすると、同じモジュールで開発(t3.micro)と本番(t3.large)を使い分けられます。

③ チーム標準化

「VPCを作るときはこのモジュールを使う」というルールを設けることで、チーム全体が同じセキュリティグループルール・タグ規則・サブネット設計を自動的に踏襲できます。新メンバーがゼロからVPCを書くリスクを排除できます。

モジュール化の前後比較

モジュール化によって何が変わるかを具体的な数字で見てみましょう。

比較項目モジュール化前モジュール化後
VPCリソース定義の箇所数dev/stg/prod の3箇所に各100行 = 300行modules/vpc/main.tf の1箇所に100行
CIDRを変更したい場合の修正箇所3箇所(漏れのリスクあり)呼び出し側で1変数を変更するだけ
新メンバーがVPCを追加する難易度main.tf 全体を理解する必要があるmodule "vpc" ブロックに変数を渡すだけ
セキュリティグループルールの標準化個人の裁量に依存モジュールに組み込まれ自動適用
コードの再利用性ゼロ(コピペのみ)別プロジェクトにも source で参照可能

この表が示すように、モジュール化は「コードを短くするテクニック」ではなく、インフラのソフトウェアエンジニアリング化です。関数やクラスでロジックをカプセル化するのと同じ考え方を、インフラ定義に適用しています。

2-2. module構文の基礎

Terraform の module ブロックには4つの必須・推奨要素があります。

# ローカルモジュールを呼び出す例
module "vpc" {
  source = "./modules/vpc"  # ローカルパス(versionは不要)

  # 変数(モジュールのvariables.tfで定義された入力)
  cidr_block = "10.0.0.0/16"
  availability_zones  = ["ap-northeast-1a", "ap-northeast-1c"]
  environment= var.environment
}

# Terraform Registry のリモートモジュールを呼び出す例
module "ec2" {
  source  = "terraform-aws-modules/ec2-instance/aws"
  version = "~> 5.0"  # リモートモジュールはversionの明示が必須

  name = "my-instance"
  instance_type = "t3.micro"
  subnet_id  = module.vpc.public_subnet_ids[0]  # モジュール出力を参照
}


各要素の役割

要素必須/推奨説明
source必須モジュールの場所。ローカルパス (./modules/vpc) またはRegistryパス (org/module/provider)
version必須(リモート)Registry・Githubモジュールは必ず指定。~> 5.05.x の最新を意味する
変数(入力値)任意モジュールの variables.tf で定義された変数に値を渡す
providers任意特定のproviderエイリアスをモジュールに渡す場合に使用(マルチリージョン構成等)

モジュールの出力を参照する方法

上記の例で module.vpc.public_subnet_ids[0] と書いているように、モジュールの出力値は module.<MODULE_NAME>.<OUTPUT_NAME> の形式で参照できます。これにより、VPCモジュールが作成したサブネットのIDをEC2モジュールに渡すことが自然なコードで表現できます。

2-3. ディレクトリ構成パターン

Terraform プロジェクトの標準的なディレクトリ構成を示します。

terraform-advanced/ # プロジェクトルート(root module)
├── main.tf# リソース定義・moduleブロック
├── variables.tf # 入力変数定義
├── outputs.tf# 出力値定義
├── providers.tf # required_providers・provider設定
├── terraform.tfvars# 変数の実際の値(git管理可)
├── .terraform.lock.hcl# providerバージョンロック(git管理必須)
├── .gitignore# .terraform/ と *.tfstate を除外
└── modules/  # ローカルモジュール群
 ├── vpc/
 │├── main.tf # モジュール内のリソース定義
 │├── variables.tf  # モジュールの入力変数
 │└── outputs.tf # モジュールの出力値
 └── security_group/# 例:別モジュール
  ├── main.tf
  ├── variables.tf
  └── outputs.tf


重要な規則

  • modules/ ディレクトリ内の各モジュールは必ず main.tfvariables.tfoutputs.tf の3ファイルを持つ
  • terraform.tfstate.terraform/ ディレクトリは .gitignore に追加する(後述のS3バックエンド設定後はローカルに残らない)
  • .terraform.lock.hcl必ずgit管理する(チーム全員が同じproviderバージョンを使うための鍵)

2-4. ローカルモジュール vs リモートモジュール

モジュールの source には3種類の指定方法があります。

# パターン1: ローカルモジュール(./modules/ 配下)
module "vpc" {
  source = "./modules/vpc"
  # versionは指定しない(ローカルパスに対しては無効)
}

# パターン2: Terraform Registry(公式モジュール)
module "vpc_public" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"
}

# パターン3: GitHubリポジトリ(プライベートモジュールのチーム共有に)
module "internal_vpc" {
  source = "git::https://github.com/your-org/terraform-modules.git//vpc?ref=v1.2.0"
  # versionブロックは使えない。ref= でタグを固定すること
}


使い分けの指針

方式向いているケース注意点
ローカルモジュール自プロジェクト専用の設定。外部共有不要バージョン管理は git タグで行う
Terraform Registry汎用的なAWSリソース(VPC・EC2・RDS等)。品質保証済みversion必須。~> で安全に固定
GitHub組織内共有モジュール。非公開にしたい場合git認証設定が必要。ref= でタグ固定必須

2-5. 公式AWSモジュール紹介

Terraform Registryterraform-aws-modules 組織は、AWSリソースの公式モジュール群を提供しています。これらのモジュールはHashiCorpが品質を審査した「Verified Module」であり、数千のプロジェクトで実戦テスト済みです。代表的なモジュールを以下に示します。

terraform-aws-modules/vpc/aws v5.x

VPC・パブリックサブネット・プライベートサブネット・IGW・NATゲートウェイ・ルートテーブルを一括構築します。マルチAZ構成・サブネットの分割・NATゲートウェイの有無などをすべて変数で制御でき、数十行のコードで本番グレードのVPCを構築できます。

本記事のSection 3ハンズオンでは「ローカルモジュールを自作する体験」を重視するため、VPCはローカルモジュールで実装します。本番環境では terraform-aws-modules/vpc/aws の使用を強く推奨します。

機能ローカルVPCモジュール(本記事ハンズオン用)公式VPCモジュール(本番推奨)
コード量約70行(学習に適したシンプルな実装)約2000行(フル機能)
NATゲートウェイ非対応対応(台数・高可用性を設定可)
VPCフローログ非対応対応
IPv6非対応対応
コミュニティサポートなし活発(GitHub Issues・PR多数)

terraform-aws-modules/ec2-instance/aws v5.x

EC2インスタンスの作成に必要なAMI検索・セキュリティグループ関連付け・EBSボリューム設定・IAMインスタンスプロファイル連携を簡潔に記述できます。ami_ssm_parameter を指定するだけで最新のAmazon Linux 2023 AMIを自動選択できるため、AMI IDのハードコーディング(リージョン依存・定期更新が必要)を回避できます。Section 3のハンズオンではこのモジュールを実際に使用します。

terraform-aws-modules/security-group/aws v5.x

よく使われるセキュリティグループルール(http-80・https-443・ssh・mysql等)をプリセットとして提供します。ingress_with_cidr_blocksegress_rules で直感的に記述でき、ルール名のタイポによるミスを防げます。terraform-aws-modules/ec2-instance/aws との組み合わせが一般的です。

2-6. バージョン管理戦略

リモートモジュールのバージョン指定には ~> 構文(ペシミスティック制約)を使います。

# ~> 5.0 は 5.0 以上 6.0 未満 を意味する
# パッチバージョン(5.0 → 5.1 → 5.9)は自動更新されるが
# メジャーバージョン(5.x → 6.0)の破壊的変更はブロックされる
module "ec2" {
  source  = "terraform-aws-modules/ec2-instance/aws"
  version = "~> 5.0"
}

# providerも同様に固定する
terraform {
  required_providers {
 aws = {
source  = "hashicorp/aws"
version = "~> 5.0"
 }
  }
  required_version = ">= 1.9.0"
}


.terraform.lock.hcl の役割

terraform init を実行すると、実際にダウンロードされたproviderのバージョンとハッシュが .terraform.lock.hcl に記録されます。このファイルをgitにコミットしておくことで、version = "~> 5.0" の範囲内でもチーム全員が同一バージョンを使うことが保証されます。

Dependabotとの連携(推奨)

.github/dependabot.ymlpackage-ecosystem: terraform を追加すると、モジュールやproviderの新バージョンが出たときにDependabotがPRを自動作成します。これにより、バージョンアップの見落としを防げます。

バージョン表記の意味まとめ

Terraform の version 制約演算子を理解しておくことで、チームのバージョン管理方針を明確に表現できます。

記法意味推奨シーン
= 5.1.0ちょうど v5.1.0 のみ完全固定(セキュリティ審査済み環境)
>= 5.0v5.0 以上(上限なし)非推奨(破壊的変更をブロックできない)
~> 5.0v5.0 以上 v6.0 未満推奨(メジャー変更をブロック、マイナーは許容)
~> 5.1v5.1 以上 v5.2 未満より厳密に固定したい場合
>= 5.0, < 6.0v5.0 以上 v6.0 未満~> 5.0 と同義(より明示的な書き方)

一般的な推奨は ~> 5.0 です。.terraform.lock.hcl とセットで管理することで、制約の範囲内であっても実際の使用バージョンを固定できます。

2-7. アンチパターン

canonical_specが指摘する3つのアンチパターンを解説します。これらは「知らないとハマる」系のミスで、本番環境での実害(予期しないリソース削除、セキュリティホール、再現できないビルド)に直結します。

アンチパターン1: countfor_each の混在

同一モジュール内(またはその呼び出し元)で countfor_each を混在させると、tfstateのアドレッシングが破綻します。

# NG: count で作ったリソースのアドレス
module.servers[0].aws_instance.this

# OK: for_each で作ったリソースのアドレス
module.servers["web"].aws_instance.this


count を使うと要素の削除・挿入でインデックスがずれ、意図しないリソースのdestroyが発生します。ループが必要な場合は常に for_each を使用してください。

アンチパターン2: リモートモジュールのversion未指定

# NG: version を指定しない
module "vpc" {
  source = "terraform-aws-modules/vpc/aws"
  # versionを省略すると最新版が使われる
}


このまま半年後に terraform init を実行すると、メジャーバージョンアップされた破壊的変更が含まれる版がダウンロードされ、apply時に予期しないエラーや設定変更が発生します。リモートモジュールには必ず version を指定してください。

アンチパターン3: module間の循環依存

moduleAの出力をmoduleBが参照し、moduleBの出力をmoduleAが参照するとTerraformはグラフ解決に失敗します。循環依存が発生している場合は、data ソースを使って既存リソースから値を取得する設計に切り替えてください。

# NG: module_a の出力を module_b が参照し、module_b の出力を module_a が参照する
# → Terraform は依存グラフを解決できずエラーになる

# OK: data source で既存VPCを参照(循環を断ち切る)
data "aws_vpc" "existing" {
  tags = {
 Name = "production-vpc"
  }
}

module "security_group" {
  source = "./modules/security_group"

  # module.vpc.vpc_id の代わりに data source から取得することで循環を回避
  vpc_id = data.aws_vpc.existing.id
}


data ソースはTerraformが管理していない既存リソース(他チームが手動作成したVPC、別のTerraformプロジェクトが管理するリソース等)を参照する際にも有効です。循環依存の解消だけでなく、クロスプロジェクト参照の標準パターンとして覚えておいてください。


Section 3: モジュール化ハンズオン — ローカルVPCモジュール + 公式EC2モジュール

このハンズオンでは以下を構築します。

  • ローカルVPCモジュール./modules/vpc/): パブリックサブネット2つを持つVPCを自作モジュールで定義
  • 公式EC2モジュールterraform-aws-modules/ec2-instance/aws ~> 5.0): VPCモジュールの出力(subnet_id)を受け取りEC2を起動

二つのモジュールを組み合わせることで「モジュール間の出力渡し」を体験するのがこのハンズオンの核心です。

このハンズオンで作成されるAWSリソース一覧

リソースTerraform リソース名作成元用途
VPCaws_vpc.thisローカルVPCモジュールEC2・サブネットを収容するネットワーク
インターネットゲートウェイaws_internet_gateway.thisローカルVPCモジュールVPCからインターネットへの出口
パブリックサブネット × 2aws_subnet.public["ap-northeast-1a"]ローカルVPCモジュールEC2を配置するサブネット(各AZ1つ)
ルートテーブルaws_route_table.publicローカルVPCモジュールIGWへのデフォルトルートを設定
ルートテーブル関連付け × 2aws_route_table_association.publicローカルVPCモジュールサブネットとルートテーブルの紐付け
EC2インスタンスaws_instance (内部)公式EC2モジュールAmazon Linux 2023、t3.micro

合計 8リソースterraform apply 1回で作成されます。terraform plan 実行時に Plan: 8 to add と表示されることを確認してください。

3-1. プロジェクト初期化

# プロジェクトディレクトリの作成
mkdir terraform-advanced && cd terraform-advanced
git init

# モジュール用ディレクトリの作成
mkdir -p modules/vpc

# .gitignore の作成
cat > .gitignore << 'EOF'
.terraform/
*.tfstate
*.tfstate.backup
*.tfplan
.terraform.lock.hcl.bak
EOF


補足: .terraform.lock.hcl.gitignore含めません。このファイルはgitで管理することでチームのバージョン整合性を保ちます。

3-2. AWSコンソール確認: VPC手動作成ウィザード(対比用){#3-2}

Terraformの価値を体感するために、まずAWSコンソールでVPCを手動作成する手順を確認します(後でTerraformで自動再現します)。

コンソール操作手順

  1. AWSマネジメントコンソール → VPC → 左メニュー「VPC」→「VPCを作成」
  2. 設定内容:
  3. 作成するリソース: VPCなど(VPC+サブネット+IGW+ルートテーブルを一括作成)
  4. 名前タグの自動生成: handson
  5. IPv4 CIDR ブロック: 10.0.0.0/16
  6. アベイラビリティゾーン数: 2
  7. パブリックサブネット数: 2
  8. プライベートサブネット数: 0(今回は不要)
  9. NATゲートウェイ: なし(コスト節約)
  10. 「VPCを作成」をクリック
  11. 作成完了後、VPC IDサブネットID(2つ)をメモ

コンソール作成の問題点

  • 設定値がGUI上にのみ存在し、コードとして再利用できない
  • 「なぜその設定にしたか」がドキュメントに残らない
  • 同じ構成を別リージョンや別アカウントに再現するには、手順を繰り返す必要がある

この問題をTerraformのモジュール化が解決します。上記で手動作成したVPCは後で削除してください(Terraformで同等のものを再作成します)。

手動作成とTerraformモジュールの比較

コンソールの「VPCなど」オプションで作成したときに内部的に生成されたリソースと、本ハンズオンのローカルVPCモジュールが作成するリソースを対比します。

AWSリソースコンソール手動作成ローカルVPCモジュール差異
VPC同等
インターネットゲートウェイ同等
パブリックサブネット(2AZ)同等
プライベートサブネット○(デフォルト0に設定)×今回は不要のため省略
NATゲートウェイ○(デフォルトなしに設定)×今回は不要のため省略
ルートテーブル同等
S3 VPCエンドポイント○(コンソール自動提案)×今回は省略

コンソールのウィザードは「便利な追加オプション」を自動提案しますが、Terraformモジュールは必要なリソースのみを明示的に定義できます。「このコードに書いてあるリソースだけが作成される」という透明性がIaCの利点の一つです。

3-3. ローカルモジュール作成: modules/vpc/main.tf

# modules/vpc/main.tf
# ローカルVPCモジュール: パブリックサブネット2つを持つVPCを作成する

terraform {
  required_providers {
 aws = {
source  = "hashicorp/aws"
version = "~> 5.0"
 }
  }
}

# VPC本体
resource "aws_vpc" "this" {
  cidr_block  = var.cidr_block
  enable_dns_hostnames = true
  enable_dns_support= true

  tags = merge(var.tags, {
 Name = "${var.name}-vpc"
  })
}

# インターネットゲートウェイ(パブリックサブネットへの通信に必要)
resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id

  tags = merge(var.tags, {
 Name = "${var.name}-igw"
  })
}

# パブリックサブネット(AZの数だけ作成)
resource "aws_subnet" "public" {
  for_each = toset(var.availability_zones)

  vpc_id= aws_vpc.this.id
  cidr_block  = cidrsubnet(var.cidr_block, 8, index(var.availability_zones, each.value))
  availability_zone = each.value
  map_public_ip_on_launch = true

  tags = merge(var.tags, {
 Name = "${var.name}-public-${each.value}"
 Tier = "public"
  })
}

# パブリック用ルートテーブル
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.this.id

  route {
 cidr_block = "0.0.0.0/0"
 gateway_id = aws_internet_gateway.this.id
  }

  tags = merge(var.tags, {
 Name = "${var.name}-public-rtb"
  })
}

# ルートテーブルとサブネットの関連付け
resource "aws_route_table_association" "public" {
  for_each = aws_subnet.public

  subnet_id= each.value.id
  route_table_id = aws_route_table.public.id
}


3-4. modules/vpc/variables.tf

# modules/vpc/variables.tf

variable "name" {
  description = "VPC・関連リソースの名前プレフィックス"
  type  = string
}

variable "cidr_block" {
  description = "VPCのIPv4 CIDRブロック(例: 10.0.0.0/16)"
  type  = string
  default  = "10.0.0.0/16"

  validation {
 condition  = can(cidrnetmask(var.cidr_block))
 error_message = "cidr_block は有効なCIDR形式でなければなりません(例: 10.0.0.0/16)。"
  }
}

variable "availability_zones" {
  description = "使用するアベイラビリティゾーンのリスト(最低2つ推奨)"
  type  = list(string)
  default  = ["ap-northeast-1a", "ap-northeast-1c"]
}

variable "tags" {
  description = "すべてのリソースに付与する共通タグ"
  type  = map(string)
  default  = {}
}


3-5. modules/vpc/outputs.tf

# modules/vpc/outputs.tf
# VPCモジュールの出力値(呼び出し元からは module.vpc.<OUTPUT_NAME> で参照)

output "vpc_id" {
  description = "作成したVPCのID"
  value = aws_vpc.this.id
}

output "public_subnet_ids" {
  description = "作成したパブリックサブネットのIDリスト"
  value = [for s in aws_subnet.public : s.id]
}

output "public_subnet_cidr_blocks" {
  description = "作成したパブリックサブネットのCIDRブロックリスト"
  value = [for s in aws_subnet.public : s.cidr_block]
}

output "internet_gateway_id" {
  description = "作成したインターネットゲートウェイのID"
  value = aws_internet_gateway.this.id
}


3-6. root module: providers.tf

# providers.tf(プロジェクトルートに配置)

terraform {
  required_version = ">= 1.9.0"

  required_providers {
 aws = {
source  = "hashicorp/aws"
version = "~> 5.0"
 }
  }
}

provider "aws" {
  region = var.aws_region

  default_tags {
 tags = {
Project  = "terraform-advanced"
ManagedBy= "terraform"
Environment = var.environment
 }
  }
}


default_tags ブロックを使うと、provider 配下のすべてのリソースに共通タグが自動付与されます。各リソースで tags を個別に書く手間を省きつつ、タグの一貫性を保てます(Terraform AWS Provider 3.38.0以降で利用可能)。

3-7. root module: main.tf — local vpc + 公式 EC2モジュール呼び出し

# main.tf(プロジェクトルートに配置)

# ローカルVPCモジュールを呼び出す
module "vpc" {
  source = "./modules/vpc"

  name= "${var.environment}-handson"
  cidr_block= var.vpc_cidr
  availability_zones = var.availability_zones

  tags = {
 Environment = var.environment
  }
}

# 公式EC2モジュールを使ってEC2インスタンスを作成
# VPCモジュールの出力(public_subnet_ids)をそのまま渡している
module "ec2" {
  source  = "terraform-aws-modules/ec2-instance/aws"
  version = "~> 5.0"

  name = "${var.environment}-handson-ec2"
  instance_type = var.instance_type

  # モジュール間の出力渡し: vpc モジュールのサブネットIDを ec2 モジュールへ
  subnet_id = module.vpc.public_subnet_ids[0]

  # 最新のAmazon Linux 2023 AMI を自動選択
  ami_ssm_parameter = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"

  associate_public_ip_address = true

  tags = {
 Environment = var.environment
 Role  = "handson"
  }
}

🔑 ポイント: モジュール間の出力渡しsubnet_id = module.vpc.public_subnet_ids[0] の一行が、VPCモジュールとEC2モジュールを接続しています。module.vpcoutputs.tf で定義した public_subnet_ids を、別の module.ec2 の入力として渡しています。このように、Terraformはモジュール間の依存関係を自動的に解決し、VPCが作成されてからEC2を作成する順序を保証します。

3-8. root module: variables.tf と outputs.tf

# variables.tf(プロジェクトルートに配置)

variable "aws_region" {
  description = "デプロイ先のAWSリージョン"
  type  = string
  default  = "ap-northeast-1"
}

variable "environment" {
  description = "環境名(dev / stg / prod)"
  type  = string
  default  = "dev"
}

variable "vpc_cidr" {
  description = "VPCのCIDRブロック"
  type  = string
  default  = "10.0.0.0/16"
}

variable "availability_zones" {
  description = "使用するAZ一覧"
  type  = list(string)
  default  = ["ap-northeast-1a", "ap-northeast-1c"]
}

variable "instance_type" {
  description = "EC2インスタンスタイプ(ハンズオン用: t3.micro)"
  type  = string
  default  = "t3.micro"
}

# outputs.tf(プロジェクトルートに配置)

output "vpc_id" {
  description = "作成したVPCのID"
  value = module.vpc.vpc_id
}

output "public_subnet_ids" {
  description = "パブリックサブネットのIDリスト"
  value = module.vpc.public_subnet_ids
}

output "ec2_public_ip" {
  description = "EC2インスタンスのパブリックIPアドレス"
  value = module.ec2.public_ip
}

output "ec2_instance_id" {
  description = "EC2インスタンスID"
  value = module.ec2.id
}


terraform.tfvars の作成(任意)

variables.tf のデフォルト値を変更したい場合は terraform.tfvars に実際の値を記述します。

# terraform.tfvars(プロジェクトルートに配置)
# デフォルト値(variables.tf)と異なる値のみ書けばよい

aws_region= "ap-northeast-1"
environment  = "dev"
vpc_cidr  = "10.10.0.0/16"
availability_zones = ["ap-northeast-1a", "ap-northeast-1c"]
instance_type= "t3.micro"


注意: terraform.tfvars は通常 git で管理しても問題ありませんが、ファイル名が *.auto.tfvars のものや、シークレット値(パスワード、APIキー等)を含む場合は .gitignore に追加してください。

ファイル構成の確認: ここまでで以下のファイルが揃っているはずです。tree または ls -R で確認してください。

terraform-advanced/
├── main.tf
├── variables.tf
├── outputs.tf
├── providers.tf
├── .gitignore
└── modules/
└── vpc/
├── main.tf
├── variables.tf
└── outputs.tf

3-9. terraform init → plan → apply

# Step 1: 初期化(providerとリモートモジュールをダウンロード)
terraform init

# 出力例(成功時):
# Initializing the backend...
# Initializing modules...
# Downloading registry.terraform.io/terraform-aws-modules/ec2-instance/aws 5.x.x ...
# Initializing provider plugins...
# - Finding hashicorp/aws versions matching "~> 5.0"...
# - Installed hashicorp/aws v5.x.x (signed by HashiCorp)
# Terraform has been successfully initialized!

# Step 2: 変更内容の確認(リソースは作成されない)
terraform plan

# 出力例(成功時):
# Plan: 8 to add, 0 to change, 0 to destroy.
# (VPC/IGW/Subnet×2/RouteTable/RouteTableAssociation×2 + EC2 = 計8リソース)

# Step 3: 実際にリソースを作成(確認プロンプトで yes を入力)
terraform apply

# 自動承認する場合(CI環境等):
# terraform apply -auto-approve

# apply完了後に出力値を確認
terraform output
# vpc_id  = "vpc-0123456789abcdef0"
# public_subnet_ids = tolist(["subnet-aaa...", "subnet-bbb..."])
# ec2_public_ip = "54.123.456.789"
# ec2_instance_id  = "i-0123456789abcdef0"


apply 完了後の tfstate 確認(任意)

terraform apply が完了すると、プロジェクトルートに terraform.tfstate ファイルが生成されます(Section 4でS3バックエンドに移行するまでの一時ファイルです)。

terraform state list コマンドで、Terraformが管理しているリソースの一覧を確認できます。

  • module.ec2.aws_instance.this[0] — 公式EC2モジュールが管理するインスタンス
  • module.vpc.aws_internet_gateway.this — ローカルVPCモジュールのIGW
  • module.vpc.aws_route_table.public — ローカルVPCモジュールのルートテーブル
  • module.vpc.aws_subnet.public["ap-northeast-1a"] — for_eachで作成したサブネット(AZがキー)

module.<name>. というプレフィックスで、どのモジュールが作成したリソースかがtfstate上で明示されています。これにより、複数のモジュールを使っていても、どのモジュールのリソースかを追跡できます。

⚠️ apply の所要時間についてEC2インスタンスの起動には1〜2分かかります。apply完了後すぐにコンソールで確認可能な状態になります。EC2が running 状態になるまで待ってからコンソールを確認してください。

3-10. AWSコンソールでVPC・EC2作成確認

VPCの確認

  1. AWSマネジメントコンソール → VPC → 左メニュー「VPC」
  2. 検索フィルターで dev-handson-vpc(またはタグ Environment: dev)を検索
  3. 確認事項:
  4. IPv4 CIDR: 10.0.0.0/16
  5. サブネット: ap-northeast-1aap-northeast-1c に各1つ(パブリック)
  6. インターネットゲートウェイ: VPCにアタッチ済み
  7. ルートテーブル: 0.0.0.0/0 → IGWの経路が存在

EC2の確認

  1. AWSマネジメントコンソール → EC2 → 「インスタンス」
  2. 名前フィルターで dev-handson-ec2 を検索
  3. 確認事項:
  4. 状態: 実行中(running)
  5. インスタンスタイプ: t3.micro
  6. パブリックIPアドレス: terraform output ec2_public_ip で取得した値と一致
  7. サブネット: VPCモジュールが作成したサブネットID

モジュール出力の確認(terraform output コマンド)

apply完了後、モジュール間の出力が正しく連鎖していることを terraform output で確認できます。

terraform output の値参照元参照先
vpc_idmodule.vpc.vpc_idaws_vpc.this.idroot outputs.tf
public_subnet_idsmodule.vpc.public_subnet_idsaws_subnet.public[*].idEC2モジュールの subnet_id に渡される
ec2_public_ipmodule.ec2.public_ipEC2インスタンスのパブリックIP

EC2コンソールの「ネットワーキング」タブを開き、「サブネットID」が terraform output public_subnet_ids の値と一致していることを確認してください。これによりVPCモジュールの出力がEC2モジュールに正しく渡されたことが実証されます。

コンソール確認 vs 前作の手動作成との比較

コンソールで「VPC作成ウィザード」を使って作成したVPCと、Terraformが作成したVPCを並べて比較してみてください。設定値が同じであることが確認できるはずです。違いは再現性にあります。Terraform で作成したものは main.tf に全設定が記録されており、terraform destroyterraform apply でいつでも完全に同じ状態に戻せます。

また、AWSコンソール上ではEC2インスタンスに Project: terraform-advancedManagedBy: terraformEnvironment: dev の3つのタグが自動付与されていることを確認できます。これは providers.tf に設定した default_tags ブロックの効果です。各リソースで個別に tags を書かなくても共通タグが適用されていることに注目してください。

タグ設計のベストプラクティス

タグキー値の例目的
Projectterraform-advancedコスト配分タグ(AWSコスト管理で集計可能)
ManagedByterraform手動作成リソースとの識別
Environmentdev / stg / prod環境別のリソースフィルタリング
Ownerteam-infra担当チームの明示

default_tags でプロジェクト共通タグを設定し、各モジュールの tags 変数で環境固有タグを追加するのが推奨パターンです。重複したタグキーがある場合は個別リソースの tags が優先されます。

3-11. destroy で全削除

ハンズオン終了後は必ずリソースを削除してください。

# 全リソースを削除(確認プロンプトで yes を入力)
terraform destroy

# 削除対象リソースを事前に確認したい場合:
terraform plan -destroy

# 出力例(destroyの確認メッセージ):
# Plan: 0 to add, 0 to change, 8 to destroy.
# Do you really want to destroy all resources?
#Terraform will destroy all your managed infrastructure, as shown above.
#There is no undo. Only 'yes' will be accepted to confirm.
#
# Enter a value: yes
#
# Destroy complete! Resources: 8 destroyed.


手動作成VPCの削除: 3-2で手動作成したVPCも忘れずに削除してください。VPCコンソール → 該当VPC選択 → 「アクション」→「VPCの削除」(関連リソース含め一括削除できます)。

Section 3 完了チェックリスト[ ] modules/vpc/の3ファイル(main.tf / variables.tf / outputs.tf)が完成した

  • [ ] terraform applyでVPC・EC2が作成された
  • [ ] AWSコンソールでVPC・EC2の存在を確認した
  • [ ] module.vpc.public_subnet_ids[0]がEC2の subnet_id に渡されることを理解した
  • [ ] terraform destroyで全リソースを削除した

Section 1〜3でモジュール化の基礎を習得しました。ここで習得したスキルを整理しておきましょう。

Section 1〜3 で習得したスキル

スキル学んだ場所本番での活用シーン
module ブロックの書き方Section 2-2あらゆるモジュール化で使う基本構文
ローカルモジュールの3ファイル構成Section 2-3, 3-3〜3-5組織内の共通モジュール作成
公式モジュールのバージョン固定Section 2-6再現性のあるインフラ構築
モジュール間の出力渡しSection 3-7VPC→EC2のような依存関係の表現
for_each によるマルチAZサブネットSection 3-3本番グレードのVPC構成
default_tags による共通タグSection 3-6コスト配分タグの一括管理
terraform state listSection 3-9モジュール管理状態の確認

次のSection 4-5では、このプロジェクトのtfstateをS3に移行し、チームで安全に共有する方法を実践します。現在ローカルにある terraform.tfstate を、S3バックエンドに移行する terraform init -migrate-state コマンドも体験します。


Section 4以降は ashigaru24 が担当します)

Section 4: tfstate管理 — S3バックエンド + DynamoDBロック

tfstateファイルは Terraform がインフラの現状を把握するための「唯一の情報源」だ。
ローカルに置いたままチームで運用すると、様々な問題が発生する。
このセクションでは、tfstate を AWS S3 に移行し、DynamoDB でロックを管理するベストプラクティスを実践する。


4-1. ローカルtfstateの問題点

terraform apply を実行すると、デフォルトで作業ディレクトリ直下の terraform.tfstate にインフラ状態が記録される。
開発者1人・手元環境だけならこれでも動くが、チームで運用した瞬間に3つの根本的な問題が顕在化する。

① チーム共有不可 — 状態の不整合

terraform.tfstate はローカルファイルだ。Aさんが apply して EC2 を作成しても、その変更は Bさんのファイルには反映されない。
BさんがそのままPlanを実行すると「EC2が存在しない」前提で差分が計算され、重複リソース作成や意図しない変更が起きる。
Gitにtfstateをコミットする解決策を取ろうとするチームもいるが、同時 push 時のコンフリクト解消ミスで状態が壊れるリスクがある。

② 機密値の平文保存

tfstateにはリソースの全属性が記録される。RDSのパスワード、IAMアクセスキー、Secrets Manager の値まで平文で格納されることがある。
Gitリポジトリに誤ってコミットされた場合、機密情報が恒久的に履歴に残る。

③ 同時 apply による競合

2人が同じリソースに対して同時に apply を実行すると、tfstate への書き込みが競合しファイルが破損する可能性がある。
ロック機構なしでの並行操作は「最後に書いた人が勝つ」状態になり、一方の変更が完全に失われる事故につながる。

ポイント: S3 + DynamoDB の役割分担

| 問題 | 解決策 | 担当 AWS リソース |
|——|——–|—————–|
| チーム共有不可 | リモートバックエンドで一元管理 | S3 バケット |
| 機密値の平文保存 | バケット暗号化(SSE-KMS) | S3 + KMS |
| 同時 apply 競合 | 状態ロック | DynamoDB テーブル |


4-2. S3バックエンドアーキテクチャ

S3バックエンドを使用したときのアーキテクチャを整理しておく。

コンポーネント構成

ローカル開発環境(開発者A・B)から、共通の AWS Cloud 内リソースへアクセスする形だ。
AWS Cloud 内には次の2リソースが配置される。

  • S3 バケット: tfstate を保存(SSE-KMS 暗号化・バージョニング有効)
  • DynamoDB テーブル: ロック管理(apply 中のみ LockID アイテムが存在)

処理フローは次の通りだ。

  1. 開発者Aが terraform apply を開始する
  2. TerraformがDynamoDBにLockIDを書き込む(ロック取得)
  3. S3からtfstateを読み込み、差分を計算して適用する
  4. S3のtfstateを更新する
  5. DynamoDBのLockIDを削除する(ロック解放)
  6. その間に開発者Bが apply を試みると、DynamoDBにLockIDが存在するためエラーとなり待機または停止する

4-3. [ハンズオン] bootstrap: S3バケット + DynamoDBテーブルをTerraformで作成

ここに重要な問題がある。「S3バケットとDynamoDBテーブル自体をTerraformで管理したい」 というニーズだ。
しかし、S3バックエンドを使うにはS3バケットが先に存在していなければならない。
これを bootstrap問題 と呼ぶ。

解決策は「bootstrap層」を別ディレクトリとして作成し、最初の1回だけローカルstateで適用する ことだ。
以降はS3 backend + DynamoDB でmain層を管理する。

プロジェクトは terraform-advanced/ 直下に2つのサブディレクトリを持つ。
bootstrap/ は S3/DynamoDB をローカルstateで作成する初回限り用の層、
main/ は本番リソースを S3 backend で管理する主要作業層だ
(各ディレクトリに main.tf / variables.tf / outputs.tf 等を配置する)。

bootstrap/variables.tf

# bootstrap/variables.tf
variable "aws_region" {
  description = "AWS リージョン"
  type  = string
  default  = "ap-northeast-1"
}

variable "project_name" {
  description = "プロジェクト名(リソース名のプレフィックスに使用)"
  type  = string
  default  = "terraform-advanced"
}

variable "account_id" {
  description = "AWS アカウントID(バケット名の一意性確保に使用)"
  type  = string
}


bootstrap/main.tf

# bootstrap/main.tf
terraform {
  required_version = "~> 1.9"
  required_providers {
 aws = {
source  = "hashicorp/aws"
version = "~> 5.0"
 }
  }
  # bootstrap層はローカルstateを使用(意図的にバックエンド指定なし)
}

provider "aws" {
  region = var.aws_region
}

# ────────────────────────────────────────────
# KMS キー(tfstate 暗号化用)
# ────────────────────────────────────────────
resource "aws_kms_key" "tfstate" {
  description = "KMS key for Terraform state encryption"
  deletion_window_in_days = 10
  enable_key_rotation  = true

  tags = {
 Name = "${var.project_name}-tfstate-kms"
 Project = var.project_name
  }
}

resource "aws_kms_alias" "tfstate" {
  name = "alias/${var.project_name}-tfstate"
  target_key_id = aws_kms_key.tfstate.key_id
}

# ────────────────────────────────────────────
# S3 バケット(tfstate 保存先)
# ────────────────────────────────────────────
resource "aws_s3_bucket" "tfstate" {
  # バケット名はグローバルユニークである必要がある
  bucket = "${var.project_name}-tfstate-${var.account_id}-${var.aws_region}"

  # 誤削除防止: 本番バケットはコンソールやCLIから直接削除できなくなる
  force_destroy = false

  tags = {
 Name = "${var.project_name}-tfstate"
 Project = var.project_name
  }
}

# バージョニング(誤上書き時のロールバック用)
resource "aws_s3_bucket_versioning" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id

  versioning_configuration {
 status = "Enabled"
  }
}

# SSE-KMS 暗号化
resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id

  rule {
 apply_server_side_encryption_by_default {
sse_algorithm  = "aws:kms"
kms_master_key_id = aws_kms_key.tfstate.arn
 }
 # Bucket Key を有効化して KMS API コールを削減(コスト最適化)
 bucket_key_enabled = true
  }
}

# パブリックアクセスブロック(全方向でブロック)
resource "aws_s3_bucket_public_access_block" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id

  block_public_acls = true
  block_public_policy  = true
  ignore_public_acls= true
  restrict_public_buckets = true
}

# ────────────────────────────────────────────
# DynamoDB テーブル(state ロック管理)
# ────────────────────────────────────────────
resource "aws_dynamodb_table" "tfstate_lock" {
  name= "${var.project_name}-tfstate-lock"
  billing_mode = "PAY_PER_REQUEST" # On-Demand(低頻度アクセスに最適)

  # Terraform が使用するロックキー(この名前は変更不可)
  hash_key = "LockID"

  attribute {
 name = "LockID"
 type = "S" # String 型
  }

  tags = {
 Name = "${var.project_name}-tfstate-lock"
 Project = var.project_name
  }
}


bootstrap/outputs.tf

# bootstrap/outputs.tf
output "tfstate_bucket_name" {
  description = "tfstate 保存先 S3 バケット名"
  value = aws_s3_bucket.tfstate.id
}

output "tfstate_bucket_arn" {
  description = "tfstate 保存先 S3 バケット ARN"
  value = aws_s3_bucket.tfstate.arn
}

output "dynamodb_table_name" {
  description = "state ロック用 DynamoDB テーブル名"
  value = aws_dynamodb_table.tfstate_lock.name
}

output "kms_key_arn" {
  description = "tfstate 暗号化用 KMS キー ARN"
  value = aws_kms_key.tfstate.arn
  sensitive= true
}


bootstrap層を適用する。

# bootstrap 層を適用(ローカルstate で実行)
cd bootstrap

# アカウントIDを取得
export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
echo "Account ID: ${AWS_ACCOUNT_ID}"

# 初期化と適用
terraform init
terraform plan -var="account_id=${AWS_ACCOUNT_ID}"
terraform apply -var="account_id=${AWS_ACCOUNT_ID}"

# 出力値を確認(次のステップで使用する)
terraform output
# tfstate_bucket_name = "terraform-advanced-tfstate-123456789012-ap-northeast-1"
# dynamodb_table_name = "terraform-advanced-tfstate-lock"
# kms_key_arn = <sensitive>


注意: bootstrap層のstateについて

bootstrap層自体のtfstate(bootstrap/terraform.tfstate)はローカルに残る。
このファイルは Gitで管理するか、手動でS3にバックアップしておくことを推奨する。
bootstrap層は初期構築後ほとんど変更しないため、ローカルstate管理でも実害は少ない。


4-4. backend.tf 設定

bootstrap層で作成した S3 バケットと DynamoDB テーブルを、main 層のバックエンドとして設定する。

# main/backend.tf
terraform {
  backend "s3" {
 # ──────────────────────────────────────
 # S3 設定
 # ──────────────────────────────────────

 # tfstate を保存する S3 バケット名
 bucket = "terraform-advanced-tfstate-123456789012-ap-northeast-1"

 # S3 内のオブジェクトキー(パス)
 # workspace 使用時は自動的に env:/<workspace>/<key> になる
 key = "terraform.tfstate"

 # バケットのリージョン
 region = "ap-northeast-1"

 # ──────────────────────────────────────
 # 暗号化設定
 # ──────────────────────────────────────

 # tfstate の暗号化を強制
 encrypt = true

 # SSE-KMS に使用する KMS キー ARN(省略時は aws/s3 デフォルトキーを使用)
 kms_key_id = "arn:aws:kms:ap-northeast-1:123456789012:alias/terraform-advanced-tfstate"

 # ──────────────────────────────────────
 # DynamoDB ロック設定
 # ──────────────────────────────────────

 # state ロックに使用する DynamoDB テーブル名
 dynamodb_table = "terraform-advanced-tfstate-lock"
  }
}


各パラメータの意味を表にまとめる。

パラメータ必須説明
buckettfstate を保存する S3 バケット名
keyS3 内のオブジェクトキー(パス)
regionバケットが存在する AWS リージョン
encrypt推奨true で tfstate の暗号化を強制
kms_key_id推奨SSE-KMS に使用する KMS キー ARN またはエイリアス。省略時はデフォルトキー(aws/s3
dynamodb_table推奨state ロック用 DynamoDB テーブル名(Terraform 1.9 以前では必須)

Terraform 1.10 以降: S3 ネイティブロックについて

Terraform 1.10 で S3 ネイティブロックuse_lockfile = true)が実験的機能として導入された。
DynamoDB テーブルを別途作成せず、S3 の条件付き書き込み(If-None-Match ヘッダー)で .tflock ファイルを生成しロックを実現する。

Terraform 1.10+ では backend "s3" ブロックに use_lockfile = true を追加するだけでよく、
dynamodb_table パラメータは省略できる。.tflock 拡張子を持つロックファイルが tfstate と同じパスに作成される。

現時点(2026年4月)では DynamoDB ロックも引き続きサポートされており、両方を同時設定して移行期間を設けることも可能だ。
既存プロジェクトでは DynamoDB ロックの実績が豊富なため、このハンズオンでは DynamoDB ロックをメインに解説する。


4-5. terraform init -migrate-state でローカル→S3移行

backend.tf を作成したら、terraform init -migrate-state でローカルのtfstateをS3に移行する。
この操作は慎重に行う必要がある。移行前のローカルstateがなくなると、Terraformがリソースを認識できなくなる。

# ──────────────────────────────────────────────────────────
# ステップ1: 移行前のバックアップを取得(state 紛失防止)
# ──────────────────────────────────────────────────────────
cd main

# ローカル tfstate のバックアップを作成
cp terraform.tfstate terraform.tfstate.backup.$(date +%Y%m%d_%H%M%S)
ls -la terraform.tfstate*
# terraform.tfstate
# terraform.tfstate.backup.20260415_103000

# ──────────────────────────────────────────────────────────
# ステップ2: backend.tf を確認してから init を実行
# ──────────────────────────────────────────────────────────
cat backend.tf  # bucket名・key・regionが正しいか確認

terraform init -migrate-state
# ↓ 対話形式でマイグレーションの確認が求められる
#
# Initializing the backend...
# Terraform detected that the backend type changed from "local" to "s3".
#
# Do you want to copy existing state to the new backend?
#Pre-existing state was found while migrating the previous "local" backend to the
#newly configured "s3" backend. No existing state was found in the newly configured
#"s3" backend. Do you want to copy this state to the new backend?
#
#Enter a value: yes  ← yes と入力
#
# Successfully configured the backend "s3"!

# ──────────────────────────────────────────────────────────
# ステップ3: S3 への移行を確認
# ──────────────────────────────────────────────────────────
# Terraform が S3 のstateを認識しているか確認
terraform state list
# aws_s3_bucket.example
# aws_vpc.main
# ... (既存リソースが表示されれば移行成功)

# ローカルの terraform.tfstate は空になっている(または削除されている)
cat terraform.tfstate
# {} または空

# ──────────────────────────────────────────────────────────
# ステップ4: plan を実行して差分がないことを確認
# ──────────────────────────────────────────────────────────
terraform plan
# No changes. Your infrastructure matches the configuration.


state 紛失を防ぐための注意事項

移行前に必ずローカルstateをバックアップする(上記ステップ1)
terraform init -migrate-state は入力プロンプトに yes と応答するまで実際の移行は行われない
– 移行後に terraform plan を実行し、差分がないことを確認してから次のステップへ進む
– 移行が失敗した場合はバックアップから復元できる: cp terraform.tfstate.backup.YYYYMMDD_HHMMSS terraform.tfstate


4-6. [AWSコンソール確認] S3オブジェクト・DynamoDB LockID

S3バックエンドが正しく動作しているか、AWSコンソールで確認する。

S3 コンソールでの確認手順

  1. AWS コンソールにログインし、S3 サービスを開く
  2. バケット terraform-advanced-tfstate-123456789012-ap-northeast-1 をクリック
  3. オブジェクト terraform.tfstate が存在することを確認する
  4. オブジェクトをクリックし、プロパティ タブで暗号化の確認:
  5. サーバー側の暗号化: AWS KMS を使用したサーバー側の暗号化 (SSE-KMS)
  6. KMS キー ARN: 設定したキーのARNが表示される

バージョニングの確認

  1. オブジェクト terraform.tfstate を選択
  2. バージョン タブをクリック
  3. apply を実行するたびに新しいバージョンが追加されることがわかる
  4. 過去のバージョンを選択してダウンロードすれば、任意の時点のstateに戻せる(手動ロールバックの手段)

DynamoDB コンソールでの確認手順(ロック中の確認)

通常時はDynamoDBのテーブルは空だ。apply 実行中のみ LockID アイテムが存在する。
次の4-7 でロックが発動している最中に確認する。

  1. AWS コンソールで DynamoDB サービスを開く
  2. テーブル terraform-advanced-tfstate-lock をクリック
  3. アイテムを探索 をクリック
  4. terraform apply 実行中に確認すると、LockID フィールドを持つアイテムが表示される

4-7. apply中のロック発動確認

2つのターミナルを開いて、ロック機構を実際に体験する。

# ターミナル1: 意図的に時間のかかる apply を実行
# (実際のリソースを変更せずに「変更中」状態を作るために sleep リソースを一時的に追加)

# まず長時間 apply をシミュレートするため、aws_instance に time_sleep をかける
# もしくは既存の apply を実行中にターミナル2から試みる

# --- ターミナル1 ---
terraform apply
# Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
# (apply 実行中に他のターミナルから apply を試みる)

# --- ターミナル2: ターミナル1の apply が終わる前に実行 ---
terraform apply
# ╷
# │ Error: Error acquiring the state lock
# │
# │ Error message: ConditionalCheckFailedException: The conditional request failed
# │ Lock Info:
# │ID:  a1b2c3d4-e5f6-7890-abcd-ef1234567890
# │Path:terraform-advanced-tfstate-.../terraform.tfstate
# │Operation: OperationTypeApply
# │Who: user@hostname
# │Version:1.9.x
# │Created:2026-04-15 10:30:00.123456 +0000 UTC
# │Info:
# ╵
# (ロックが存在するため apply が拒否された)


このエラーが表示されれば、ロック機構が正常に動作している証拠だ。


4-8. terraform force-unlock の使いどころと注意

terraform force-unlock最終手段 だ。通常、apply が正常完了すればロックは自動解放される。
しかし次のような状況ではロックが残り続けることがある。

  • ネットワーク障害で apply が中断された
  • Terraformプロセスが強制終了(Ctrl+C など)された
  • CI/CD パイプラインがタイムアウトしてロールバックされた
# ロックIDを確認(エラーメッセージに表示される)
# ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890

# ロックを強制解除
terraform force-unlock a1b2c3d4-e5f6-7890-abcd-ef1234567890
# Do you really want to force-unlock?
#Terraform will remove the lock on the remote state.
#This will allow local Terraform commands to modify this state, even though it
#may be still be in use. Only 'yes' will be accepted to confirm.
#
#Enter a value: yes
#
# Terraform state has been successfully unlocked!

# 解除後、state が一貫した状態か確認
terraform plan


force-unlock を使う前に確認すること

1. 本当に誰も apply していないか確認する — チームメンバーに確認してから実行する
2. ロックIDが現在のロックのものか確認する — 古いロックIDを指定しても意味がない
3. state が一貫した状態か確認する — force-unlock 後は必ず terraform plan で差分がないことを確認する

ConditionalCheckFailedException は「別の apply が実行中」を意味する。
根本解決は「並行実行を防ぐ CI/CD 設計」であり、force-unlock は症状の抑制に過ぎない。


4-9. stateファイルの暗号化確認(SSE-KMS)

bootstrap層で設定した SSE-KMS が正しく機能しているか確認する。

# aws s3api で暗号化情報を取得
aws s3api get-object-attributes \
  --bucket terraform-advanced-tfstate-123456789012-ap-northeast-1 \
  --key terraform.tfstate \
  --object-attributes StorageClass,ObjectSize \
  --query 'StorageClass'
# "STANDARD"

# オブジェクトの暗号化設定を確認
aws s3api head-object \
  --bucket terraform-advanced-tfstate-123456789012-ap-northeast-1 \
  --key terraform.tfstate \
  --query '{SSEAlgorithm:ServerSideEncryption, KMSKeyId:SSEKMSKeyId}'
# {
#  "SSEAlgorithm": "aws:kms",
#  "KMSKeyId": "arn:aws:kms:ap-northeast-1:123456789012:key/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# }


"SSEAlgorithm": "aws:kms" が返ればSSE-KMSが有効だ。
S3コンソールでも同様の確認ができる(オブジェクトのプロパティ → サーバー側の暗号化)。

Section 4 まとめ

| 項目 | 内容 |
|——|——|
| ローカルtfstateの問題 | チーム共有不可・機密値平文・競合の3問題 |
| 解決策 | S3(保存・暗号化)+ DynamoDB(ロック)によるリモートバックエンド |
| bootstrap問題 | S3/DynamoDB自体をローカルstateで初回作成し、以降はS3 backendで管理 |
| 移行コマンド | terraform init -migrate-state(必ず事前バックアップ) |
| ロック確認 | DynamoDBに LockID アイテムが apply 中のみ存在する |
| 強制解除 | terraform force-unlock (最終手段・要確認) |


Section 5: terraform workspace — 環境分離(dev/stg/prod)

Section 4 で S3 バックエンドを構築した。次は terraform workspace を使って、同一のTerraform設定から dev / stg / prod の3環境を分離する方法を学ぶ。


5-1. workspaceの仕組み

terraform workspace は、同一のS3バケット内でtfstateをプレフィックスによって分離する 仕組みだ。

デフォルト状態(default workspace)では、tfstateは key で指定したパス(例: s3://<bucket>/terraform.tfstate)に保存される。
dev workspace を作成して切り替えると、tfstateのパスが自動的に s3://<bucket>/env:/dev/terraform.tfstate に変わる。
同様に stgenv:/stg/prodenv:/prod/ 以下に保存される。

この env: プレフィックスは Terraform がデフォルトで使用する名前だ。
workspace_key_prefix パラメータで変更できる(例: envs/workspaces/)。
各 workspace は独立したtfstateを持つため、dev での変更が prod に影響することはない。


5-2. workspace vs ディレクトリ分離の使い分け基準

環境分離の手法には大きく2つある。

比較軸terraform workspaceディレクトリ分離
設定の重複1セットのTFファイルで複数環境環境ごとにディレクトリ(または terragrunt.hcl
環境差分の表現terraform.workspace 変数で分岐各ディレクトリの terraform.tfvars で制御
大きな構成差異苦手(分岐が複雑になる)得意(環境ごとに独立設計)
state の分離同一バックエンド内でプレフィックス分離完全に独立したバックエンド設定も可能
適している規模軽量な差分(インスタンスサイズ・レプリカ数)大規模差分(サービス構成・VPC設計が異なる)
切り替えミスのリスクあり(workspace select 忘れ)なし(ディレクトリが物理的に別)

workspace を選ぶ基準

– dev/stg/prod で インフラ構成は同じだが、インスタンスサイズや台数だけ違う → workspace
– dev は RDS なし・prod は RDS ありなど、構成そのものが異なるディレクトリ分離

workspace は便利だが、workspace select の切り替え忘れによる prod 誤操作が最大のリスクだ。
5-7 で説明する防護策を必ず実装すること。


5-3. [ハンズオン] terraform workspace new dev / stg / prod

S3バックエンドが設定されている main/ ディレクトリで実行する。

# ──────────────────────────────────────────────────────────
# 現在の workspace 状態を確認
# ──────────────────────────────────────────────────────────
terraform workspace list
# * default
# (デフォルトでは default のみ存在)

terraform workspace show
# default

# ──────────────────────────────────────────────────────────
# 3 環境の workspace を作成
# ──────────────────────────────────────────────────────────
terraform workspace new dev
# Created and switched to workspace "dev"!
# You're now on a new, empty workspace. Newly created workspaces are empty,
# so if you run "terraform plan" now, it will create new resources.

terraform workspace new stg
# Created and switched to workspace "stg"!

terraform workspace new prod
# Created and switched to workspace "prod"!

# ──────────────────────────────────────────────────────────
# workspace 一覧を確認
# ──────────────────────────────────────────────────────────
terraform workspace list
#default
#dev
#stg
# * prod
# (* が現在の workspace を示す)

# ──────────────────────────────────────────────────────────
# workspace を切り替える
# ──────────────────────────────────────────────────────────
terraform workspace select dev
# Switched to workspace "dev".

terraform workspace show
# dev


5-4. terraform.workspace 変数で環境差分制御

各 workspace で terraform.workspace 組み込み変数を参照できる。
この変数には現在の workspace 名("dev", "stg", "prod" など)が入る。

# main/locals.tf
locals {
  env = terraform.workspace  # "dev" / "stg" / "prod"

  # 環境ごとのインスタンスタイプマップ
  instance_type_map = {
 default = "t3.micro"
 dev  = "t3.micro"
 stg  = "t3.small"
 prod = "t3.medium"
  }

  # 環境ごとの EC2 インスタンス台数
  instance_count_map = {
 default = 1
 dev  = 1
 stg  = 2
 prod = 3
  }

  # 環境ごとの RDS インスタンスクラス
  db_instance_class_map = {
 default = "db.t3.micro"
 dev  = "db.t3.micro"
 stg  = "db.t3.small"
 prod = "db.r6g.large"
  }

  # 現在の環境値を取得(map に存在しない workspace 名の場合は "default" を使用)
  instance_type  = lookup(local.instance_type_map, local.env, local.instance_type_map["default"])
  instance_count = lookup(local.instance_count_map, local.env, local.instance_count_map["default"])
  db_instance_class = lookup(local.db_instance_class_map, local.env, local.db_instance_class_map["default"])
}

# main/main.tf
terraform {
  required_version = "~> 1.9"
  required_providers {
 aws = {
source  = "hashicorp/aws"
version = "~> 5.0"
 }
  }
}

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

# EC2 インスタンス(環境ごとに台数・タイプが異なる)
module "ec2_app" {
  source  = "terraform-aws-modules/ec2-instance/aws"
  version = "~> 5.0"

  count = local.instance_count

  name = "${local.env}-app-${count.index + 1}"
  instance_type = local.instance_type

  # ... その他の設定

  tags = {
 Environment = local.env
 Project  = "terraform-advanced"
 ManagedBy= "terraform"
 Workspace= terraform.workspace
  }
}


dev workspace で terraform apply すると t3.micro x1 台が作成され、
prod workspace では t3.medium x3 台が作成される。


5-5. locals.tf で environment-specific map パターン

5-4 で示した locals.tf パターンをより本格的に拡張したのがこちらだ。
複数のリソース設定を1つの locals ブロックに集約することで、環境差分の管理が一元化される。

# main/locals.tf(環境設定の一元管理パターン)
locals {
  env = terraform.workspace

  # ──────────────────────────────────────
  # 環境設定マップ(全環境の設定を1箇所で管理)
  # ──────────────────────────────────────
  env_config = {
 default = {
instance_type  = "t3.micro"
instance_count = 1
db_instance_class = "db.t3.micro"
db_multi_az = false
enable_deletion_protection = false
log_retention_days = 7
 }
 dev = {
instance_type  = "t3.micro"
instance_count = 1
db_instance_class = "db.t3.micro"
db_multi_az = false
enable_deletion_protection = false
log_retention_days = 7
 }
 stg = {
instance_type  = "t3.small"
instance_count = 2
db_instance_class = "db.t3.small"
db_multi_az = false
enable_deletion_protection = false
log_retention_days = 14
 }
 prod = {
instance_type  = "t3.medium"
instance_count = 3
db_instance_class = "db.r6g.large"
db_multi_az = true
enable_deletion_protection = true
log_retention_days = 90
 }
  }

  # 現在の環境設定を取得(未知の workspace は default にフォールバック)
  config = lookup(local.env_config, local.env, local.env_config["default"])

  # 設定値を個別変数として展開(参照しやすくする)
  instance_type  = local.config.instance_type
  instance_count = local.config.instance_count
  db_instance_class = local.config.db_instance_class
  db_multi_az = local.config.db_multi_az
  enable_deletion_protection = local.config.enable_deletion_protection
  log_retention_days = local.config.log_retention_days

  # 共通タグ(全リソースに付与)
  common_tags = {
 Environment = local.env
 Project  = "terraform-advanced"
 ManagedBy= "Terraform"
 Workspace= terraform.workspace
  }
}


このパターンの利点は、新しい環境差分が生まれたときに env_config マップに項目を追加するだけで対応できる点だ。
main.tf 側のリソース定義は local.instance_typelocal.db_multi_az 等を参照するだけでよく、変更が不要になる。


5-6. [AWSコンソール確認] S3 prefix env:/ 以下のstate分離

3環境で terraform apply を実行した後、S3コンソールで state の分離を確認する。

S3 コンソールでの確認手順

  1. AWS コンソールで S3 を開き、tfstate バケットをクリック
  2. env: というプレフィックスのフォルダが自動的に作成されている
  3. バケット内は、ルートに terraform.tfstate(default workspace 用)があり、
    その下に env:/dev/terraform.tfstateenv:/stg/terraform.tfstateenv:/prod/terraform.tfstate の3オブジェクトが存在する

AWS CLI でも確認できる。

# S3 バケット内のオブジェクト一覧(プレフィックス確認)
aws s3 ls s3://terraform-advanced-tfstate-123456789012-ap-northeast-1/ --recursive
# 2026-04-15 10:30:00 2048 terraform.tfstate
# 2026-04-15 10:35:00 2048 env:/dev/terraform.tfstate
# 2026-04-15 10:40:00 3072 env:/stg/terraform.tfstate
# 2026-04-15 10:45:00 4096 env:/prod/terraform.tfstate

# 現在の workspace と state の対応を確認
terraform workspace show
# prod
terraform state list
# module.ec2_app[0].aws_instance.this[0]
# module.ec2_app[1].aws_instance.this[0]
# module.ec2_app[2].aws_instance.this[0]
# (prod は3台なので3つのリソースが表示される)


5-7. workspace切り替えミス防止

workspace の最大のリスクは 切り替え忘れによる prod 誤操作だ。
terraform workspace select dev を忘れたまま apply すると、prod のリソースに dev 設定が適用されてしまう。

防護策1: CI/CD で workspace select を明示する

# GitHub Actions や CI/CD パイプラインでの安全な実行パターン
# workspace を変数で管理し、apply 前に必ず select する

# 環境変数で workspace を指定
export TF_WORKSPACE="dev"  # "stg" or "prod"

# workspace を明示的に select してから plan/apply
terraform workspace select "${TF_WORKSPACE}"
echo "現在の workspace: $(terraform workspace show)"
# 現在の workspace: dev

terraform plan -var-file="envs/${TF_WORKSPACE}.tfvars"
terraform apply -var-file="envs/${TF_WORKSPACE}.tfvars" -auto-approve


防護策2: プロンプト表示 + prod apply 前の二重確認

~/.bashrc または ~/.zshrc にシェル関数を追加すると、コマンドプロンプトに現在の workspace 名が表示され(例: user@host:~/main[prod]$)、誤操作に気付きやすくなる。
さらに scripts/safe_apply.sh のようなラッパースクリプトを用意し、prod workspace での apply 時に「本当に prod に apply しますか? (yes/no)」の入力確認を強制するのが確実だ。

# prod 環境への apply 前に二重確認を強制するスクリプト(scripts/safe_apply.sh)
#!/bin/bash
set -e

# プロンプトにも workspace を表示(~/.bashrc に追加する場合の参考)
# terraform_workspace() { [ -f .terraform/environment ] && echo "[$(cat .terraform/environment)]"; }
# export PS1='\u@\h:\w$(terraform_workspace)\$ '

WORKSPACE=$(terraform workspace show)
echo "現在の workspace: ${WORKSPACE}"

if [ "${WORKSPACE}" = "prod" ]; then
  echo "WARNING: 現在の workspace は 'prod' です!"
  echo "本当に prod に apply しますか? (yes/no)"
  read -r CONFIRM
  if [ "${CONFIRM}" != "yes" ]; then
 echo "apply をキャンセルしました。"
 exit 1
  fi
fi

terraform apply "$@"


CI/CD での workspace 切り替えミス防止のベストプラクティス

1. 環境変数 TF_WORKSPACE を使い、workspace を外部から注入する(スクリプト内でハードコードしない)
2. GitHub Actions では environment protection rules を使い、prod へのデプロイに承認者を必須にする
3. plan 結果のレビュー — apply 前に terraform plan の出力を必ず人間がレビューする(PR コメントへの投稿が有効)
4. state ドリフト検知 — 定期的に terraform plan を実行し、差分がないことを確認する

workspace 切り替えミスは「あってはならない事故」ではなく「いつか必ず起きる事故」として設計する。
多重の防護策を重ねることが重要だ。

Section 5 まとめ

| 項目 | 内容 |
|——|——|
| workspace の仕組み | S3内で env:// プレフィックスで state を分離 |
| 環境差分の表現 | terraform.workspace + locals.tf のマップパターン |
| 向いているケース | インフラ構成は同じで、サイズ・台数だけ異なる軽量差分 |
| 向かないケース | 環境ごとに構成が大きく異なる(ディレクトリ分離を推奨) |
| 最大のリスク | workspace 切り替え忘れによる prod 誤操作 |
| 防護策 | CI/CD での workspace select 明示・environment protection rules・apply 前レビュー |

Section 6: GitHub Actions + OIDC — secretless AWS認証セットアップ

GitHub ActionsからAWSリソースを操作する際、従来はIAMアクセスキーをGitHub Secretsに保存していました。しかしこのアプローチにはセキュリティリスクが伴います。本セクションでは OIDC(OpenID Connect) を使った secretless 認証を実装し、アクセスキー不要でAWSへ安全にアクセスする仕組みを構築します。


6-1. なぜOIDCか — アクセスキー常駐の3大リスク

リスク① 漏洩リスク

IAMアクセスキーは一度発行すると 有効期限がなく、GitHub Secretsやローカルの .env ファイルに長期間保存されます。

  • CI/CDログへの誤出力(echo $AWS_ACCESS_KEY_ID のデバッグ行を残した等)
  • リポジトリのコミット履歴への混入(.env ファイルの誤コミット)
  • ビルドアーティファクトへの混入(Dockerイメージへの埋め込み等)

漏洩後は ただちに無効化と影響調査 が必要で、その間のインシデント対応コストは甚大です。

リスク② ローテーション運用コスト

アクセスキーを定期ローテーションする場合:

  1. 新しいキーペアを発行
  2. 全CI/CD環境のSecretsを更新
  3. 旧キーを無効化

複数のリポジトリ・複数の環境(dev/stg/prod)にキーが散在すると、ローテーション漏れが発生しやすくなります。AWS Security Hub や Config ルールで「90日以上ローテーションされていないキー」を検知できますが、対応は依然として手作業です。

リスク③ 監査困難

長期クレデンシャルは「誰がいつ使ったか」の追跡が困難です。

  • 複数のCI/CDジョブが同じキーを共有していると、CloudTrailログの userIdentity が同一になり、どのワークフローが実行したか判別できない
  • キーが第三者に漏洩しても、正規利用と不正利用を区別しにくい

OIDCが解決する仕組み

OIDC を使うと、GitHub Actions が JWT トークン(一時的な証明書) を AWS STS に提示し、一時クレデンシャル(有効期限1時間) を取得します。

比較項目IAMアクセスキーOIDC一時クレデンシャル
有効期限無期限(手動無効化まで)最大1時間(自動失効)
保存場所GitHub Secrets不要(都度取得)
ローテーション手動不要(毎回新規発行)
監査共有キーで追跡困難ジョブ・リポジトリ・ブランチ単位で特定可
最小権限キー発行時に固定ワークフロー・ブランチ別に動的切り替え可

6-2. OIDC認証フロー — GitHub から AWS STS へ

GitHub Actions が AWS に認証するまでの流れを順を追って説明します。

前提: IAM OIDC Provider と IAM Role が事前に設定されている(6-3、6-4で設定)

認証シーケンス

  1. GitHub Actions ジョブ起動
  2. ワークフローファイルで permissions: id-token: write を宣言することで、GitHub は OIDC トークン発行を許可します
  3. OIDC トークン発行(GitHub → GitHub OIDC Endpoint)
  4. aws-actions/configure-aws-credentials アクションが GitHub の OIDC エンドポイント(https://token.actions.githubusercontent.com)に JWT トークンを要求します
  5. JWT ペイロードには以下が含まれます:
    • sub: repo:OWNER/REPO:ref:refs/heads/main(リポジトリとブランチの識別子)
    • aud: sts.amazonaws.com(対象サービス)
    • iss: https://token.actions.githubusercontent.com(発行者)
  6. AssumeRoleWithWebIdentity(GitHub Actions → AWS STS)
  7. アクションが AWS STS の AssumeRoleWithWebIdentity API を呼び出し、JWT トークンと引き受ける IAM Role ARN を送信します
  8. trust policy 検証(AWS STS)
  9. AWS STS は IAM Role の trust policy を参照し、以下を検証します:
    • iss(発行者)が登録済みの OIDC Provider か
    • sub(主体)が trust policy の Condition に一致するか
    • aud(対象)が sts.amazonaws.com
  10. 一時クレデンシャル発行(AWS STS → GitHub Actions)
  11. 検証に成功すると、STS は AccessKeyIdSecretAccessKeySessionToken(有効期限1時間)を返します
  12. AWS API 呼び出し(GitHub Actions → AWS)
  13. 一時クレデンシャルを使って Terraform や AWS CLI が実行され、IAM Role に付与された権限の範囲でリソース操作が可能になります

関係者と役割の整理

コンポーネント役割
GitHub OIDC EndpointJWT トークンを署名・発行(https://token.actions.githubusercontent.com
IAM OIDC ProviderGitHub の OIDC Endpoint を AWS が信頼する設定(Thumbprint で検証)
IAM Role trust policyどのリポジトリ・ブランチからの AssumeRole を許可するか定義
AWS STSJWT を検証し、一時クレデンシャルを発行
aws-actions/configure-aws-credentials上記フローを自動実行する公式アクション

6-3. [コンソール手順] IAM OIDC Provider 作成

Terraform で自動化する前に、コンソールで手動作成の手順を確認します。コンソール操作を理解することで Terraform コードの意味がより明確になります。

手順

1. IAM コンソールを開く

AWS マネジメントコンソール → IAM → 左メニューの 「ID プロバイダー」 を選択

2. プロバイダーを追加

「プロバイダーを追加」ボタンをクリック

3. プロバイダーの設定

項目
プロバイダーのタイプOpenID Connect
プロバイダーの URLhttps://token.actions.githubusercontent.com
対象者(Audience)sts.amazonaws.com
サムプリント「サムプリントを取得」ボタンをクリック(AWS 2023年以降は自動取得)

4. サムプリントについて

2023年以降、AWS は GitHub の OIDC エンドポイントのサムプリントを自動管理しています。thumbprint_list を空配列([])にすることで AWS が自動取得・更新します。手動で特定のサムプリント値を指定すると、GitHub が証明書を更新した際に認証が失敗するリスクがあります。

5. 「プロバイダーを追加」で保存

作成後、ARN が発行されます。例:

arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com


6. IAM Role を作成

IAM → ロール → 「ロールを作成」

  • 信頼されたエンティティのタイプ: Web ID
  • ID プロバイダー: 作成した token.actions.githubusercontent.com
  • 対象者: sts.amazonaws.com
  • Condition の追加(後述 6-5 参照): token.actions.githubusercontent.com:sub = repo:OWNER/REPO:ref:refs/heads/main

6-4. [Terraform実装] OIDC Provider + IAM ロール

コンソール操作を Terraform に置き換えます。以下のコードは bootstrap/oidc.tf に配置します(Section 8 で述べる2層構造の bootstrap 層)。

# bootstrap/oidc.tf
# Terraform バージョン: >= 1.5.0
# AWS Provider バージョン: ~> 5.0

# ---------------------------------------------------
# GitHub Actions OIDC Provider
# ---------------------------------------------------
resource "aws_iam_openid_connect_provider" "github_actions" {
  url = "https://token.actions.githubusercontent.com"

  # sts.amazonaws.com: AWS STS が audience として受け入れる値
  client_id_list = ["sts.amazonaws.com"]

  # thumbprint_list を空にすることで AWS が自動取得・管理
  # 2023年以降は手動指定不要(手動指定すると証明書更新時に認証失敗のリスクあり)
  thumbprint_list = []

  tags = {
 Name  = "github-actions-oidc"
 ManagedBy= "terraform"
 Environment = "shared"
  }
}

# ---------------------------------------------------
# trust policy — どのリポジトリ・ブランチを許可するか
# ---------------------------------------------------
data "aws_iam_policy_document" "github_oidc_plan_assume" {
  statement {
 effect  = "Allow"
 actions = ["sts:AssumeRoleWithWebIdentity"]

 principals {
type  = "Federated"
identifiers = [aws_iam_openid_connect_provider.github_actions.arn]
 }

 condition {
test  = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values= ["sts.amazonaws.com"]
 }

 # ⚠️ 最重要: sub Condition で特定リポジトリ・ブランチのみを許可
 # この Condition を省略すると、任意のリポジトリから AssumeRole 可能になる
 condition {
test  = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
# PR時に plan を実行するため、全ブランチを許可(apply は main のみ)
values = ["repo:OWNER/REPO:*"]
 }
  }
}

data "aws_iam_policy_document" "github_oidc_apply_assume" {
  statement {
 effect  = "Allow"
 actions = ["sts:AssumeRoleWithWebIdentity"]

 principals {
type  = "Federated"
identifiers = [aws_iam_openid_connect_provider.github_actions.arn]
 }

 condition {
test  = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values= ["sts.amazonaws.com"]
 }

 # apply は main ブランチのマージ時のみ許可
 condition {
test  = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values= ["repo:OWNER/REPO:ref:refs/heads/main"]
 }
  }
}

# ---------------------------------------------------
# terraform-plan ロール(PR時: ReadOnly相当)
# ---------------------------------------------------
resource "aws_iam_role" "terraform_plan" {
  name= "terraform-plan"
  assume_role_policy = data.aws_iam_policy_document.github_oidc_plan_assume.json
  max_session_duration = 3600  # 1時間(デフォルト)

  tags = {
 Name  = "terraform-plan"
 ManagedBy= "terraform"
 Purpose  = "github-actions-ci-plan"
  }
}

# ---------------------------------------------------
# terraform-apply ロール(main merge時: 書き込み権限)
# ---------------------------------------------------
resource "aws_iam_role" "terraform_apply" {
  name= "terraform-apply"
  assume_role_policy = data.aws_iam_policy_document.github_oidc_apply_assume.json
  max_session_duration = 3600

  tags = {
 Name  = "terraform-apply"
 ManagedBy= "terraform"
 Purpose  = "github-actions-cd-apply"
  }
}

# ---------------------------------------------------
# plan ロールへのポリシーアタッチ(ReadOnlyAccess)
# ---------------------------------------------------
resource "aws_iam_role_policy_attachment" "terraform_plan_readonly" {
  role = aws_iam_role.terraform_plan.name
  policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}

# S3 tfstate への読み取り権限(ReadOnlyAccess に含まれるが明示的に追加)
resource "aws_iam_role_policy" "terraform_plan_state" {
  name = "terraform-plan-state-access"
  role = aws_iam_role.terraform_plan.id

  policy = jsonencode({
 Version = "2012-10-17"
 Statement = [
{
  Effect = "Allow"
  Action = [
 "s3:GetObject",
 "s3:ListBucket"
  ]
  Resource = [
 "arn:aws:s3:::tfstate-${data.aws_caller_identity.current.account_id}-ap-northeast-1",
 "arn:aws:s3:::tfstate-${data.aws_caller_identity.current.account_id}-ap-northeast-1/*"
  ]
},
{
  Effect = "Allow"
  Action = [
 "dynamodb:GetItem",
 "dynamodb:PutItem",
 "dynamodb:DeleteItem"
  ]
  Resource = "arn:aws:dynamodb:ap-northeast-1:${data.aws_caller_identity.current.account_id}:table/terraform-lock"
}
 ]
  })
}

# ---------------------------------------------------
# apply ロールへの最小権限ポリシー(ARN限定)
# ---------------------------------------------------
resource "aws_iam_role_policy" "terraform_apply_policy" {
  name = "terraform-apply-minimal"
  role = aws_iam_role.terraform_apply.id

  policy = jsonencode({
 Version = "2012-10-17"
 Statement = [
# tfstate S3 読み書き
{
  Sid = "TfstateAccess"
  Effect = "Allow"
  Action = [
 "s3:GetObject",
 "s3:PutObject",
 "s3:DeleteObject",
 "s3:ListBucket"
  ]
  Resource = [
 "arn:aws:s3:::tfstate-${data.aws_caller_identity.current.account_id}-ap-northeast-1",
 "arn:aws:s3:::tfstate-${data.aws_caller_identity.current.account_id}-ap-northeast-1/*"
  ]
},
# DynamoDB ロック
{
  Sid = "TfstateLock"
  Effect = "Allow"
  Action = [
 "dynamodb:GetItem",
 "dynamodb:PutItem",
 "dynamodb:DeleteItem"
  ]
  Resource = "arn:aws:dynamodb:ap-northeast-1:${data.aws_caller_identity.current.account_id}:table/terraform-lock"
},
# 管理対象リソースの操作権限(VPC/EC2 の例)
{
  Sid = "VpcManagement"
  Effect = "Allow"
  Action = [
 "ec2:*Vpc*",
 "ec2:*Subnet*",
 "ec2:*RouteTable*",
 "ec2:*InternetGateway*",
 "ec2:*SecurityGroup*",
 "ec2:DescribeAvailabilityZones",
 "ec2:DescribeRegions"
  ]
  Resource = "*"
}
# 実際のプロジェクトでは管理対象リソースに応じて Action と Resource を絞り込む
 ]
  })
}

# 現在のアカウントID取得(ARN に埋め込む用)
data "aws_caller_identity" "current" {}

💡 OWNER/REPO の置き換え
上記コードの OWNER/REPO は実際のGitHubオーナー名とリポジトリ名に置き換えてください。例:myorg/terraform-infra。変数化する場合は variable "github_repository" {} を定義して参照します。

6-5. trust policy の sub Condition 設計

⚠️ 【最重要セキュリティ警告】sub Condition 未設定は致命的脆弱性

trust policy から sub Condition を省略すると、GitHub 上のあらゆるリポジトリ(他のユーザーのパブリックリポジトリを含む)から AssumeRoleWithWebIdentity が可能になります。これは最頻出のセキュリティミスであり、外部からの AWS リソース操作・情報窃取・コスト攻撃につながります。

必ず token.actions.githubusercontent.com:sub Condition を trust policy に設定してください。

sub クレームの構造

GitHub の OIDC JWT に含まれる sub クレームは以下の形式です:

repo:{OWNER}/{REPO}:{filter_type}:{filter_value}


主なパターン:

パターンsub 値用途
特定ブランチrepo:ORG/REPO:ref:refs/heads/mainapply ロール(main のみ)
全ブランチrepo:ORG/REPO:*plan ロール(全 PR)
特定環境repo:ORG/REPO:environment:productionGitHub Environments 連携
特定タグrepo:ORG/REPO:ref:refs/tags/v*リリースタグ時のみ
PR からのみrepo:ORG/REPO:pull_requestPR イベント限定

StringEquals vs StringLike

# ✅ 完全一致(推奨: apply ロール)
condition {
  test  = "StringEquals"
  variable = "token.actions.githubusercontent.com:sub"
  values= ["repo:OWNER/REPO:ref:refs/heads/main"]
}

# ✅ ワイルドカード一致(plan ロール: 全ブランチ許可)
condition {
  test  = "StringLike"
  variable = "token.actions.githubusercontent.com:sub"
  values= ["repo:OWNER/REPO:*"]
}

# ❌ 危険: 他リポジトリも一致してしまう
condition {
  test  = "StringLike"
  variable = "token.actions.githubusercontent.com:sub"
  values= ["repo:*"]  # 全 GitHub リポジトリが対象
}


aud Condition も合わせて設定する

sub Condition と合わせて aud Condition も設定することでセキュリティを強化できます:

# aud: sts.amazonaws.com 以外からのトークンを拒否
condition {
  test  = "StringEquals"
  variable = "token.actions.githubusercontent.com:aud"
  values= ["sts.amazonaws.com"]
}


6-6. plan ロールと apply ロールの2ロール分離

CI/CD パイプラインでは 「確認する権限(plan)」と「実行する権限(apply)」を別ロールに分離 することがベストプラクティスです。

分離の理由

観点1ロール共用2ロール分離
権限PR でも write 権限ありPR は ReadOnly、merge 後のみ write
誤操作リスクPR の plan が誤って apply できるplan ロールでは apply 実行不可
侵害時の影響フォークからの PR でも write 可能フォーク PR は plan ロール(ReadOnly)のみ
監査同一 CloudTrail Principal で判別困難plan/apply が別 Principal で明確に区別

権限設計の目安

plan ロール(terraform-plan)
– トリガー: pull_request イベント(全ブランチ)
– 権限: ReadOnlyAccess + tfstate S3/DynamoDB 読み書き
– 理由: terraform plan は既存リソース情報の読み取りが中心

apply ロール(terraform-apply)
– トリガー: push イベント(main ブランチのみ)
– 権限: 管理対象リソースの操作権限(ARN 限定)+ tfstate S3/DynamoDB 読み書き
– 理由: terraform apply で実際にリソースを作成・変更・削除する

📋 フォーク PR への対応
外部コントリビューターからのフォーク PR は、デフォルトで pull_request トリガーが実行されますが、Secrets へのアクセスは制限されます。pull_request_target トリガーは Secrets にアクセスできますが、フォーク PR のコードをそのまま実行するとセキュリティリスクがあります。パブリックリポジトリでは 承認フロー(Actions の「承認が必要なフォーク」設定)の検討を推奨します。

6-7. 最小権限の設計指針 — ARN 限定

apply ロールに AdministratorAccessPowerUserAccess を付与するのは最小権限違反です。実際の権限設計では以下の指針に従います。

ARN 限定の実践例

# ✅ 推奨: Resource を特定 ARN に限定
{
  Effect = "Allow"
  Action = ["ec2:RunInstances", "ec2:TerminateInstances"]
  Resource = [
 "arn:aws:ec2:ap-northeast-1:123456789012:instance/*",
 "arn:aws:ec2:ap-northeast-1::image/ami-*"
  ]
  Condition = {
 StringEquals = {
"aws:RequestedRegion" = "ap-northeast-1"
 }
  }
}

# ❌ 禁止: Resource "*" + 広い Action
{
  Effect = "Allow"
  Action = ["ec2:*"]
  Resource = "*"
}


権限設計のワークフロー

  1. 最初は ReadOnlyAccess で plan のみ実行 → CloudTrail や IAM Access Analyzer で実際に必要な API を確認
  2. IAM Access Analyzer を使ったポリシー生成: CloudTrail ログから必要な Action・Resource を自動生成できます(IAM コンソール → Access Analyzer → ポリシーの生成)
  3. 定期的な権限見直し: IAM Access Analyzer の未使用アクセス分析で不要な権限を削除

リージョン制限の追加

Condition で操作リージョンを限定することで、意図しないリージョンへのリソース作成を防げます:

condition {
  test  = "StringEquals"
  variable = "aws:RequestedRegion"
  values= ["ap-northeast-1"]
}


6-8. セキュリティアンチパターン

アンチパターン①: sub Condition 未設定

前述(6-5参照)の最頻出ミスです。trust policy に sub Condition がないと、GitHub 上の任意リポジトリから AssumeRole が可能になります。

# ❌ 危険: Condition なし(任意リポジトリから AssumeRole 可能)
data "aws_iam_policy_document" "bad_example" {
  statement {
 actions = ["sts:AssumeRoleWithWebIdentity"]
 principals {
type  = "Federated"
identifiers = [aws_iam_openid_connect_provider.github_actions.arn]
 }
 # Condition が全くない → 全 GitHub ユーザーが AssumeRole 可能
  }
}


アンチパターン②: AdministratorAccess 付与

# ❌ 危険: apply ロールに AdministratorAccess
resource "aws_iam_role_policy_attachment" "bad_apply" {
  role = aws_iam_role.terraform_apply.name
  policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
  # → CI/CD が侵害された場合、AWSアカウント全体が操作可能に
}


対策: 6-4 で示した ARN 限定ポリシーを使用。terraform plan 実行後に差分を確認してから apply するワークフローを徹底。

アンチパターン③: Thumbprint 手動管理

# ❌ 古い実装: 特定の thumbprint を手動指定
resource "aws_iam_openid_connect_provider" "github_actions" {
  url = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
  # → GitHub が証明書を更新すると認証が失敗する
}

# ✅ 推奨: 空配列で AWS 自動取得
resource "aws_iam_openid_connect_provider" "github_actions" {
  url = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = []  # AWS 2023年以降は自動管理
}

📋 Section 6 まとめ

✅ IAM OIDC Provider を作成し GitHub を信頼済み IdP として登録
✅ trust policy に sub Condition 必須(省略 = 致命的脆弱性)
✅ plan ロール(ReadOnly)と apply ロール(最小権限)を2ロールに分離
✅ thumbprint_list は空配列で AWS 自動管理
✅ apply ロールは AdministratorAccess ではなく ARN 限定ポリシー

次の Section 7 では、この IAM 設定を使って実際の GitHub Actions ワークフローを実装します。


Section 7: GitHub Actions workflow実装 — plan/apply自動化

Section 6 で設定した OIDC 認証基盤を使って、GitHub Actions ワークフローを実装します。PR 作成時に terraform plan を自動実行し結果をコメントに投稿、main マージ時に terraform apply を自動実行する完全なパイプラインを構築します。


7-1. workflow 構成 — 完成形の全体像

まず .github/workflows/terraform.yml の完成形を掲載します。詳細は後続のサブセクションで解説します。

# .github/workflows/terraform.yml
# GitHub Actions Terraform CI/CD ワークフロー
# 動作環境: GitHub Actions (ubuntu-latest)
# Terraform: >= 1.5.0
# aws-actions/configure-aws-credentials: v4
# hashicorp/setup-terraform: v3

name: Terraform CI/CD

on:
  push:
 branches:
- main
 paths:
- 'terraform/**'
- '.github/workflows/terraform.yml'
  pull_request:
 branches:
- main
 paths:
- 'terraform/**'
- '.github/workflows/terraform.yml'

# OIDC トークン取得に必要な permissions
permissions:
  id-token: write # OIDC JWT トークンの取得を許可
  contents: read  # リポジトリの読み取り
  pull-requests: write  # PR へのコメント投稿

env:
  TF_VERSION: "1.9.5"
  AWS_REGION: "ap-northeast-1"
  TF_WORKING_DIR: "./terraform"

jobs:
  # ----------------------------------------
  # PR 時: terraform plan(ReadOnly ロール)
  # ----------------------------------------
  plan:
 name: Terraform Plan
 runs-on: ubuntu-latest
 if: github.event_name == 'pull_request'
 environment: plan  # オプション: GitHub Environments で承認フローを追加可能

 steps:
- name: Checkout
  uses: actions/checkout@v4

- name: Configure AWS credentials (plan role)
  uses: aws-actions/configure-aws-credentials@v4
  with:
 role-to-assume: ${{ secrets.AWS_PLAN_ROLE_ARN }}
 aws-region: ${{ env.AWS_REGION }}
 role-session-name: GitHubActions-Plan-${{ github.run_id }}

- name: Setup Terraform
  uses: hashicorp/setup-terraform@v3
  with:
 terraform_version: ${{ env.TF_VERSION }}

- name: Terraform Init
  id: init
  working-directory: ${{ env.TF_WORKING_DIR }}
  run: terraform init -input=false

- name: Terraform Format Check
  id: fmt
  working-directory: ${{ env.TF_WORKING_DIR }}
  run: terraform fmt -check -recursive
  continue-on-error: true

- name: Terraform Validate
  id: validate
  working-directory: ${{ env.TF_WORKING_DIR }}
  run: terraform validate -no-color

- name: Terraform Plan
  id: plan
  working-directory: ${{ env.TF_WORKING_DIR }}
  run: |
 terraform plan -input=false -no-color -out=tfplan 2>&1 | tee plan_output.txt
 echo "exitcode=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
  continue-on-error: true

- name: Post Plan to PR
  uses: actions/github-script@v7
  if: github.event_name == 'pull_request'
  env:
 PLAN_OUTPUT: ${{ steps.plan.outputs.stdout }}
  with:
 github-token: ${{ secrets.GITHUB_TOKEN }}
 script: |
const fs = require('fs');
const planOutput = fs.readFileSync('${{ env.TF_WORKING_DIR }}/plan_output.txt', 'utf8');
const maxLength = 60000;
const truncated = planOutput.length > maxLength
  ? planOutput.substring(0, maxLength) + '\n\n... (出力が長すぎるため省略)'
  : planOutput;

const fmtResult = '${{ steps.fmt.outcome }}' === 'success' ? '✅' : '❌';
const validateResult = '${{ steps.validate.outcome }}' === 'success' ? '✅' : '❌';
const planResult = '${{ steps.plan.outcome }}' === 'success' ? '✅' : '❌';

const body = `## Terraform Plan 結果

| 項目 | 結果 |
|------|------|
| Format | ${fmtResult} |
| Validate | ${validateResult} |
| Plan | ${planResult} |

<details>
<summary>Plan の詳細 (クリックして展開)</summary>

\`\`\`terraform
${truncated}
\`\`\`

</details>

*実行者: \`${{ github.actor }}\` | コミット: \`${{ github.sha }}\`*`;

// 既存のコメントを更新(重複を防ぐ)
const { data: comments } = await github.rest.issues.listComments({
  owner: context.repo.owner,
  repo: context.repo.repo,
  issue_number: context.issue.number,
});

const botComment = comments.find(comment =>
  comment.user.type === 'Bot' && comment.body.includes('Terraform Plan 結果')
);

if (botComment) {
  await github.rest.issues.updateComment({
 owner: context.repo.owner,
 repo: context.repo.repo,
 comment_id: botComment.id,
 body: body,
  });
} else {
  await github.rest.issues.createComment({
 owner: context.repo.owner,
 repo: context.repo.repo,
 issue_number: context.issue.number,
 body: body,
  });
}

- name: Plan 失敗時にジョブを失敗させる
  if: steps.plan.outputs.exitcode == '1'
  run: exit 1

  # ----------------------------------------
  # main マージ時: terraform apply(apply ロール)
  # ----------------------------------------
  apply:
 name: Terraform Apply
 runs-on: ubuntu-latest
 if: github.event_name == 'push' && github.ref == 'refs/heads/main'
 environment: production  # GitHub Environments: 承認者を設定可能

 steps:
- name: Checkout
  uses: actions/checkout@v4

- name: Configure AWS credentials (apply role)
  uses: aws-actions/configure-aws-credentials@v4
  with:
 role-to-assume: ${{ secrets.AWS_APPLY_ROLE_ARN }}
 aws-region: ${{ env.AWS_REGION }}
 role-session-name: GitHubActions-Apply-${{ github.run_id }}

- name: Setup Terraform
  uses: hashicorp/setup-terraform@v3
  with:
 terraform_version: ${{ env.TF_VERSION }}

- name: Terraform Init
  working-directory: ${{ env.TF_WORKING_DIR }}
  run: terraform init -input=false

- name: Terraform Apply
  working-directory: ${{ env.TF_WORKING_DIR }}
  run: terraform apply -input=false -auto-approve -no-color

📋 Secrets の設定
上記ワークフローで使用する Secrets をリポジトリに登録してください:
AWS_PLAN_ROLE_ARN: arn:aws:iam::123456789012:role/terraform-plan
AWS_APPLY_ROLE_ARN: arn:aws:iam::123456789012:role/terraform-apply

設定場所: GitHub リポジトリ → Settings → Secrets and variables → Actions → New repository secret


7-2. permissions: id-token: write / contents: read の意味

ワークフローの permissions 設定は OIDC 認証の動作に直結します。

permissions:
  id-token: write # 必須: OIDC JWT トークンの取得を許可
  contents: read  # 必須: git checkout のためのリポジトリ読み取り
  pull-requests: write  # plan コメント投稿に必要


id-token: write が必須な理由

GitHub Actions は デフォルトで OIDC トークン取得が無効 です。id-token: write を明示することで、ランナーが GitHub の OIDC エンドポイント(https://token.actions.githubusercontent.com)に JWT トークンを要求できるようになります。

設定しない場合のエラー例:

Error: No OpenIDConnect token issuer found. 
Please configure permissions: id-token: write in your workflow.


permissions のスコープについて

permissions はワークフロー全体(トップレベル)またはジョブ単位で設定できます。

# ✅ 推奨: ジョブ単位で最小権限を付与
jobs:
  plan:
 permissions:
id-token: write
contents: read
pull-requests: write  # plan ジョブだけ PR 書き込み許可

  apply:
 permissions:
id-token: write
contents: read
# pull-requests は不要(apply は PR に書き込まない)

⚠️ トップレベル permissions と job レベル permissions の関係
トップレベルで permissions を設定すると、その設定が全ジョブのデフォルトになります。ジョブレベルで上書き可能ですが、トップレベルで省略すると全ジョブがリポジトリのデフォルト権限(通常は広め)を継承します。明示的に設定することを推奨します。

7-3. aws-actions/configure-aws-credentials@v4 で role-to-assume

OIDC フローを実行する公式アクションの設定を詳しく解説します。

- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
 # 引き受ける IAM Role の ARN
 role-to-assume: ${{ secrets.AWS_PLAN_ROLE_ARN }}

 # AWS リージョン
 aws-region: ap-northeast-1

 # CloudTrail でジョブを識別するためのセッション名(推奨)
 role-session-name: GitHubActions-Plan-${{ github.run_id }}

 # セッション継続時間(秒): デフォルト 3600(1時間)
 # IAM Role の max_session_duration を超えない範囲で設定
 role-duration-seconds: 3600

 # 出力マスク: true にすると一時クレデンシャルがログに出力されない(デフォルト true)
 mask-aws-account-id: true


v3 から v4 への変更点

aws-actions/configure-aws-credentials@v4 は Node.js 20 ベースです。v3(Node.js 16)は 2024年以降に非推奨となったため、v4 へのアップグレードを推奨します。

# ❌ 非推奨(Node.js 16、廃止済み)
uses: aws-actions/configure-aws-credentials@v3

# ✅ 推奨(Node.js 20)
uses: aws-actions/configure-aws-credentials@v4


role-session-name で CloudTrail を読みやすく

role-session-name にランID(${{ github.run_id }})やジョブ名を含めると、CloudTrail の userIdentity.sessionContext.sessionIssuer.userName でどのワークフロー実行かを特定できます。

# CloudTrail ログの例
"sessionContext": {
  "sessionIssuer": {
 "type": "Role",
 "userName": "GitHubActions-Plan-1234567890"
  }
}


7-4. PR時 terraform plan ジョブ — ReadOnly ロール

plan ジョブの各ステップを詳しく解説します。

plan:
  name: Terraform Plan
  runs-on: ubuntu-latest
  if: github.event_name == 'pull_request'

  steps:
 # Step 1: ソースコードのチェックアウト
 - name: Checkout
uses: actions/checkout@v4

 # Step 2: OIDC 認証(plan ロール)
 - name: Configure AWS credentials (plan role)
uses: aws-actions/configure-aws-credentials@v4
with:
  role-to-assume: ${{ secrets.AWS_PLAN_ROLE_ARN }}
  aws-region: ap-northeast-1
  role-session-name: GitHubActions-Plan-${{ github.run_id }}

 # Step 3: Terraform CLI のセットアップ
 - name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
  terraform_version: "1.9.5"
  # terraform_wrapper: true(デフォルト)で outputs.stdout が使えるが
  # 大きな出力はファイルに保存する方が安全

 # Step 4: terraform init(バックエンド初期化)
 - name: Terraform Init
id: init
working-directory: ./terraform
run: terraform init -input=false
# -input=false: インタラクティブ入力を無効化(CI では必須)

 # Step 5: コードフォーマットチェック
 - name: Terraform Format Check
id: fmt
working-directory: ./terraform
run: terraform fmt -check -recursive
continue-on-error: true
# continue-on-error: true でフォーマットエラーでも後続ステップを実行
# PR コメントでフォーマット違反を通知し、ジョブは成功扱いにする

 # Step 6: 設定検証
 - name: Terraform Validate
id: validate
working-directory: ./terraform
run: terraform validate -no-color
# HCL 構文・プロバイダースキーマとの整合性を確認

 # Step 7: plan 実行・出力をファイルに保存
 - name: Terraform Plan
id: plan
working-directory: ./terraform
run: |
  terraform plan \
 -input=false \
 -no-color \
 -out=tfplan \
 2>&1 | tee plan_output.txt
  echo "exitcode=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
continue-on-error: true
# -out=tfplan: plan 結果をバイナリファイルに保存(apply 時に使用可能)
# tee で stdout とファイルの両方に出力
# PIPESTATUS[0] で terraform コマンドの終了コードを取得


plan の終了コードについて

Terraform plan の終了コードには3種類あります:

終了コード意味
0差分なし(インフラが最新の状態)
1エラー(設定ミス等)
2差分あり(apply すれば変更される)

-detailed-exitcode フラグを使うと差分あり/なしを区別できます:

terraform plan -input=false -detailed-exitcode -out=tfplan
# 終了コード 2 = 差分あり(正常)として扱いたい場合:
EXITCODE=$?
if [ $EXITCODE -eq 1 ]; then exit 1; fi  # エラー時のみ失敗


7-5. plan 結果の PR コメント投稿

actions/github-script@v7 を使って plan 結果を PR にコメントします。同一 PR への重複コメントを防ぐ「更新型」実装を解説します。

- name: Post Plan to PR
  uses: actions/github-script@v7
  if: github.event_name == 'pull_request'
  with:
 github-token: ${{ secrets.GITHUB_TOKEN }}
 script: |
const fs = require('fs');

// plan 出力ファイルを読み込む
let planOutput = '';
try {
  planOutput = fs.readFileSync('./terraform/plan_output.txt', 'utf8');
} catch (e) {
  planOutput = 'plan 出力ファイルが見つかりません: ' + e.message;
}

// GitHub コメントの最大文字数は 65536 文字
const maxLength = 60000;
const truncated = planOutput.length > maxLength
  ? planOutput.substring(0, maxLength) + '\n\n... (出力が長すぎるため省略されました)'
  : planOutput;

// ステータスアイコン
const icons = {
  success: '✅',
  failure: '❌',
  cancelled: '⚠️',
};
const fmtIcon = icons['${{ steps.fmt.outcome }}'] || '⚠️';
const validateIcon = icons['${{ steps.validate.outcome }}'] || '⚠️';
const planIcon = icons['${{ steps.plan.outcome }}'] || '⚠️';

const body = [
  '## Terraform Plan 結果',
  '',
  '| チェック | 結果 |',
  '|---------|------|',
  `| \`terraform fmt\` | ${fmtIcon} \`${{ steps.fmt.outcome }}\` |`,
  `| \`terraform validate\` | ${validateIcon} \`${{ steps.validate.outcome }}\` |`,
  `| \`terraform plan\` | ${planIcon} \`${{ steps.plan.outcome }}\` |`,
  '',
  '<details>',
  '<summary>📋 Plan の詳細 (クリックして展開)</summary>',
  '',
  '```terraform',
  truncated,
  '```',
  '',
  '</details>',
  '',
  `> 実行者: \`${{ github.actor }}\` | ブランチ: \`${{ github.head_ref }}\` | コミット: \`${{ github.sha }}\``,
].join('\n');

// 既存の Bot コメントを検索して更新(重複コメント防止)
const { data: comments } = await github.rest.issues.listComments({
  owner: context.repo.owner,
  repo: context.repo.repo,
  issue_number: context.issue.number,
});

const botComment = comments.find(comment =>
  comment.user.type === 'Bot' &&
  comment.body.includes('Terraform Plan 結果')
);

if (botComment) {
  // 既存コメントを更新
  await github.rest.issues.updateComment({
 owner: context.repo.owner,
 repo: context.repo.repo,
 comment_id: botComment.id,
 body: body,
  });
  console.log('既存コメントを更新しました: ' + botComment.id);
} else {
  // 新規コメントを作成
  await github.rest.issues.createComment({
 owner: context.repo.owner,
 repo: context.repo.repo,
 issue_number: context.issue.number,
 body: body,
  });
  console.log('新規コメントを作成しました');
}


PR コメントの見た目(サンプル)

## Terraform Plan 結果

| チェック | 結果 |
|---------|------|
| `terraform fmt` | ✅ `success` |
| `terraform validate` | ✅ `success` |
| `terraform plan` | ✅ `success` |

<details>
<summary>📋 Plan の詳細 (クリックして展開)</summary>

  # vpc.main will be created
  + resource "aws_vpc" "main" {
+ cidr_block = "10.0.0.0/16"
...
 }

Plan: 5 to add, 0 to change, 0 to destroy.
</details>

> 実行者: `octocat` | ブランチ: `feature/add-vpc` | コミット: `abc1234`


7-6. main マージ時 terraform apply ジョブ — apply ロール + environment protection

apply:
  name: Terraform Apply
  runs-on: ubuntu-latest
  # main ブランチへの push イベントのみ実行
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
  # GitHub Environments: 承認者・デプロイブランチ制限を設定
  environment: production

  steps:
 - name: Checkout
uses: actions/checkout@v4

 # apply ロール(書き込み権限)で認証
 - name: Configure AWS credentials (apply role)
uses: aws-actions/configure-aws-credentials@v4
with:
  role-to-assume: ${{ secrets.AWS_APPLY_ROLE_ARN }}
  aws-region: ap-northeast-1
  role-session-name: GitHubActions-Apply-${{ github.run_id }}

 - name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
  terraform_version: "1.9.5"

 - name: Terraform Init
working-directory: ./terraform
run: terraform init -input=false

 # apply 実行
 - name: Terraform Apply
working-directory: ./terraform
run: terraform apply -input=false -auto-approve -no-color
# -auto-approve: 確認プロンプトをスキップ(CI では必須)
# -no-color: ログ出力をプレーンテキストに(CI ログの可読性向上)

 # apply 後のリソース確認(オプション)
 - name: Show Outputs
working-directory: ./terraform
run: terraform output -no-color
continue-on-error: true


GitHub Environments の設定

environment: production を指定することで、GitHub の Environments 機能 を使った承認フローを追加できます。

設定手順(GitHub コンソール):

  1. リポジトリ → SettingsEnvironmentsNew environment
  2. 環境名: production
  3. Required reviewers: apply を承認できるメンバーを追加(チームリード等)
  4. Deployment branches: main ブランチのみ許可
  5. Wait timer: 承認前の待機時間を設定(例: 5分)

効果:

main マージ → apply ジョブ起動 → Environments 承認待ち → 承認者がレビュー → apply 実行

📋 Environments を使わない場合の代替策
小規模プロジェクトや個人利用では Environments の承認フローが重すぎることがあります。その場合は environment を省略し、PR レビュー + main ブランチ保護ルール(direct push 禁止・PR必須・CI 通過必須)でガードする構成でも十分です。

7-7. [動作確認] PR作成 → plan自動実行 → merge → apply自動実行

ワークフローの動作を一連の流れで確認します。

事前準備チェックリスト

# 1. GitHub Secrets が設定済みか確認
# Settings → Secrets and variables → Actions で以下を確認:
# - AWS_PLAN_ROLE_ARN
# - AWS_APPLY_ROLE_ARN

# 2. IAM OIDC Provider が設定済みか確認
aws iam list-open-id-connect-providers

# 3. IAM Role の trust policy を確認
aws iam get-role --role-name terraform-plan \
  --query 'Role.AssumeRolePolicyDocument' --output json

# 4. S3 バックエンドにアクセスできるか確認(plan ロール権限で)
aws s3 ls s3://tfstate-$(aws sts get-caller-identity --query Account --output text)-ap-northeast-1/


動作確認手順

Step 1: フィーチャーブランチで変更を作成

git checkout -b feature/add-security-group

# terraform ファイルに変更を加える(例: security_group を追加)
cat >> terraform/main.tf << 'EOF'
resource "aws_security_group" "web" {
  name= "web-sg"
  vpc_id = module.vpc.vpc_id
}
EOF

git add terraform/main.tf
git commit -m "feat: add web security group"
git push origin feature/add-security-group


Step 2: PR を作成

GitHub で PR を作成すると、数秒〜1分以内に plan ジョブが自動起動 します。

GitHub コンソールでの確認場所:
– PR ページ → Checks タブ → Terraform Plan のジョブログ
– PR ページ → コメントセクション → Terraform Plan 結果 コメント

Step 3: plan 結果を確認

PR コメントに以下が表示されることを確認:
terraform fmt
terraform validate
terraform plan ✅(差分の内容を確認)

Step 4: PR をマージ

コードレビュー後、main ブランチへ Merge すると apply ジョブが自動起動 します。

GitHub コンソールでの確認場所:
– Actions タブ → Terraform CI/CD ワークフロー → Terraform Apply ジョブ
– Environments ページ → production → デプロイ履歴

Step 5: AWSコンソールでリソース作成を確認

# AWS CLI でも確認可能
aws ec2 describe-security-groups \
  --filters "Name=group-name,Values=web-sg" \
  --query 'SecurityGroups[*].[GroupId,GroupName,VpcId]' \
  --output table


AWS マネジメントコンソール:
– EC2 → セキュリティグループ → web-sg が作成されていることを確認
– IAM → ロール → terraform-apply → アクセスアドバイザーで ec2:CreateSecurityGroup が記録されていることを確認

Step 6: CloudTrail でアクセスログを確認

CloudTrail → イベント履歴で以下を確認:
userIdentity.type: AssumedRole
userIdentity.sessionContext.sessionIssuer.userName: GitHubActions-Apply-{run_id}
eventName: CreateSecurityGroup


7-8. ブランチ戦略ベストプラクティス — 環境別 workflow 分離 vs matrix

プロジェクトの規模によって最適なブランチ戦略は異なります。2つのパターンを比較します。

パターン A: 環境別 workflow ファイル分離

複数環境(dev/stg/prod)を それぞれ独立したワークフローファイル で管理します。

.github/workflows/
├── terraform-dev.yml# dev 環境: feature/* → dev ブランチで自動 apply
├── terraform-stg.yml# stg 環境: develop → stg ブランチで apply
└── terraform-prod.yml  # prod 環境: main → prod ブランチで apply(承認必須)

# .github/workflows/terraform-prod.yml
on:
  push:
 branches: [main]
 paths: ['terraform/environments/prod/**']

jobs:
  apply:
 environment: production  # 承認フロー必須
 steps:
- uses: aws-actions/configure-aws-credentials@v4
  with:
 role-to-assume: ${{ secrets.AWS_PROD_APPLY_ROLE_ARN }}
 aws-region: ap-northeast-1


メリット:
– 環境ごとに IAM ロール・承認フロー・通知先を完全分離できる
– ワークフローが独立しているため、1環境の変更が他環境に影響しない
– 大規模チームで「dev は自由に試せるが prod は厳格に管理」を実現しやすい

デメリット:
– ファイル数が増える(DRY 原則に反する)
– 共通設定の変更時に全ファイルを更新する必要がある

パターン B: matrix ストラテジー

単一のワークフローファイルで複数環境を matrix で管理します。

# .github/workflows/terraform.yml
jobs:
  plan:
 strategy:
matrix:
  environment: [dev, stg, prod]
  include:
 - environment: dev
tf_dir: terraform/environments/dev
role_secret: AWS_DEV_PLAN_ROLE_ARN
branch_pattern: 'refs/heads/feature/*'
 - environment: stg
tf_dir: terraform/environments/stg
role_secret: AWS_STG_PLAN_ROLE_ARN
branch_pattern: 'refs/heads/develop'
 - environment: prod
tf_dir: terraform/environments/prod
role_secret: AWS_PROD_PLAN_ROLE_ARN
branch_pattern: 'refs/heads/main'

 steps:
- uses: aws-actions/configure-aws-credentials@v4
  with:
 role-to-assume: ${{ secrets[matrix.role_secret] }}
 aws-region: ap-northeast-1

- name: Terraform Plan
  working-directory: ${{ matrix.tf_dir }}
  run: terraform plan -input=false -no-color


メリット:
– DRY: 共通ロジックが1ファイルにまとまる
– 環境追加時にワークフロー変更が最小限

デメリット:
– matrix は条件分岐が複雑になりやすい
– 環境ごとに大きく異なる設定(承認フロー等)の表現が難しい
– デバッグ時にどの環境の実行かが分かりにくい

推奨選択基準

条件推奨パターン
小〜中規模(3環境以下)パターン A(シンプルさ優先)
環境間で設定が大きく異なるパターン A
環境が4つ以上パターン B
DRY を重視・環境設定が均質パターン B
prod に厳格な承認フローが必要パターン A(environment 設定の明確化)
📋 Section 7 まとめ

permissions: id-token: write が OIDC トークン取得の必須設定
aws-actions/configure-aws-credentials@v4(Node.js 20)で role-to-assume を実行
✅ PR 時は plan ロール(ReadOnly)、main マージ時は apply ロール(最小権限)を使い分け
✅ PR コメントに plan 結果を自動投稿(更新型で重複防止)
✅ GitHub Environments の environment: production で apply に承認フローを追加
✅ ブランチ戦略は「環境別ファイル分離(小規模)」vs「matrix(大規模・均質)」で選択

次の Section 8 では、bootstrap 層と main 層に分かれた2層ディレクトリ構造でモジュール・バックエンド・OIDC をすべて Terraform で一元管理する完全 IaC 統合を解説します。

Section 8: 完全IaC統合 — モジュール・バックエンド・OIDCをTerraformで一括管理

ここまで、モジュール化(Section 2-3)・tfstate管理(Section 4-5)・GitHub Actions + OIDC CI/CD(Section 6-7)を個別に学んできました。Section 8 では、これらを bootstrap/ と main/ の 2 層構造 に統合し、「空のリポジトリから本番環境を一気通貫で構築する」最終形を示します。


8-1. 最終ディレクトリ構成(bootstrap/ と main/ の2層構造)

2層構造を採用する理由は 鶏と卵問題 の解決にあります。S3 バックエンドと DynamoDB ロックテーブルを Terraform で管理したくても、それらが存在しないうちはリモートバックエンドを使えません。bootstrap 層はこの問題を解消します。

terraform-advanced/
├── bootstrap/  ← S3 / DynamoDB / OIDC をローカル state で管理
│├── main.tf ← S3バケット・DynamoDB・IAM OIDC Providerを定義
│├── variables.tf  ← aws_region, project_name, github_org, github_repo
│└── outputs.tf ← S3バケット名・DynamoDBテーブル名・OIDC ARN を出力
└── main/ ← アプリリソースを S3 backend + workspace で管理
 ├── backend.tf ← S3バックエンド設定(bootstrap outputs の値を使用)
 ├── main.tf ← VPC module・EC2 module 呼び出し
 ├── variables.tf  ← 環境変数・インスタンスサイズなど
 ├── outputs.tf ← VPC ID・EC2 パブリックIPなど
 ├── locals.tf  ← workspace 別 map(インスタンスタイプ・タグ等)
 └── modules/
  └── vpc/
├── main.tf  ← VPC・サブネット・IGW・ルートテーブル
├── variables.tf← vpc_cidr / name / tags
└── outputs.tf  ← vpc_id / public_subnet_ids / private_subnet_ids


2層の役割分担

backend担当リソースapply 頻度
bootstrap/ローカル(terraform.tfstate)S3・DynamoDB・IAM OIDC Provider初回のみ(通常変更なし)
main/S3(bootstrap が作成したバケット)VPC・EC2・SG・アプリリソースPR マージごとに CI/CD が自動実行
ポイント:bootstrap は壊さない
bootstrap 層のリソース(S3 バケット・DynamoDB)は main 層の state を保持する基盤です。誤って terraform destroy すると main 層の state が失われます。本番環境では bootstrap 層への destroy を IAM ポリシーで禁止する運用を推奨します。

8-2. bootstrap 層:S3 / DynamoDB / OIDC Provider をローカル state で作成

bootstrap 層は一度だけ手動で apply します。以下のコードが完成形です。

# bootstrap/main.tf
terraform {
  required_version = "~> 1.9"
  required_providers {
 aws = {
source  = "hashicorp/aws"
version = "~> 5.0"
 }
  }
  # bootstrap はローカル state(backend "local" がデフォルト)
}

provider "aws" {
  region = var.aws_region

  default_tags {
 tags = {
Project= var.project_name
ManagedBy = "Terraform"
Layer  = "bootstrap"
 }
  }
}

# ── S3 バケット(tfstate 保存用) ──────────────────────────────
resource "aws_s3_bucket" "tfstate" {
  bucket = "${var.project_name}-tfstate-${data.aws_caller_identity.current.account_id}-${var.aws_region}"

  lifecycle {
 prevent_destroy = true  # 誤削除防止
  }
}

resource "aws_s3_bucket_versioning" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  versioning_configuration {
 status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  rule {
 apply_server_side_encryption_by_default {
sse_algorithm  = "aws:kms"
kms_master_key_id = aws_kms_key.tfstate.arn
 }
 bucket_key_enabled = true
  }
}

resource "aws_s3_bucket_public_access_block" "tfstate" {
  bucket= aws_s3_bucket.tfstate.id
  block_public_acls = true
  block_public_policy  = true
  ignore_public_acls= true
  restrict_public_buckets = true
}

resource "aws_kms_key" "tfstate" {
  description = "KMS key for Terraform state encryption"
  deletion_window_in_days = 10
  enable_key_rotation  = true
}

resource "aws_kms_alias" "tfstate" {
  name = "alias/${var.project_name}-tfstate"
  target_key_id = aws_kms_key.tfstate.key_id
}

# ── DynamoDB(state ロック用) ─────────────────────────────────
resource "aws_dynamodb_table" "terraform_lock" {
  name= "${var.project_name}-terraform-lock"
  billing_mode = "PAY_PER_REQUEST"
  hash_key  = "LockID"

  attribute {
 name = "LockID"
 type = "S"
  }
}

# ── IAM OIDC Provider(GitHub Actions 用) ────────────────────
resource "aws_iam_openid_connect_provider" "github_actions" {
  url = "https://token.actions.githubusercontent.com"

  client_id_list = [
 "sts.amazonaws.com",
  ]

  # 2023年以降 AWS が thumbprint を自動取得するため、プレースホルダーで可
  thumbprint_list = [
 "6938fd4d98bab03faadb97b34396831e3780aea1",
  ]
}

# ── IAM Role: terraform-plan(PR 時 ReadOnly 相当) ───────────
resource "aws_iam_role" "terraform_plan" {
  name = "${var.project_name}-terraform-plan"

  assume_role_policy = jsonencode({
 Version = "2012-10-17"
 Statement = [{
Effect = "Allow"
Principal = { Federated = aws_iam_openid_connect_provider.github_actions.arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
  StringLike = {
 "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/${var.github_repo}:*"
  }
  StringEquals = {
 "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
  }
}
 }]
  })
}

resource "aws_iam_role_policy_attachment" "terraform_plan_readonly" {
  role = aws_iam_role.terraform_plan.name
  policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}

# S3 state 読み取り・DynamoDB ロック取得権限を追加
resource "aws_iam_role_policy" "terraform_plan_state" {
  name = "tfstate-read-lock"
  role = aws_iam_role.terraform_plan.id
  policy = jsonencode({
 Version = "2012-10-17"
 Statement = [
{
  Effect= "Allow"
  Action= ["s3:GetObject", "s3:ListBucket"]
  Resource = [aws_s3_bucket.tfstate.arn, "${aws_s3_bucket.tfstate.arn}/*"]
},
{
  Effect= "Allow"
  Action= ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem"]
  Resource = aws_dynamodb_table.terraform_lock.arn
},
{
  Effect= "Allow"
  Action= ["kms:Decrypt", "kms:GenerateDataKey"]
  Resource = aws_kms_key.tfstate.arn
}
 ]
  })
}

# ── IAM Role: terraform-apply(main マージ時 書き込み) ────────
resource "aws_iam_role" "terraform_apply" {
  name = "${var.project_name}-terraform-apply"

  assume_role_policy = jsonencode({
 Version = "2012-10-17"
 Statement = [{
Effect = "Allow"
Principal = { Federated = aws_iam_openid_connect_provider.github_actions.arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
  StringEquals = {
 "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/${var.github_repo}:ref:refs/heads/main"
 "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
  }
}
 }]
  })
}

resource "aws_iam_role_policy" "terraform_apply_permissions" {
  name = "terraform-apply-permissions"
  role = aws_iam_role.terraform_apply.id
  policy = jsonencode({
 Version = "2012-10-17"
 Statement = [
{
  Effect = "Allow"
  Action = [
 "s3:*",
 "dynamodb:*",
 "ec2:*",
 "iam:GetRole", "iam:ListRoles",
 "kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey"
  ]
  Resource = "*"
}
 ]
  })
}

data "aws_caller_identity" "current" {}

# bootstrap/variables.tf
variable "aws_region" {
  description = "AWS region to deploy resources"
  type  = string
  default  = "ap-northeast-1"
}

variable "project_name" {
  description = "Project name used as prefix for all resource names"
  type  = string
}

variable "github_org" {
  description = "GitHub organization or username"
  type  = string
}

variable "github_repo" {
  description = "GitHub repository name"
  type  = string
}

# bootstrap/outputs.tf
output "tfstate_bucket_name" {
  description = "S3 bucket name for Terraform state"
  value = aws_s3_bucket.tfstate.id
}

output "dynamodb_table_name" {
  description = "DynamoDB table name for Terraform state locking"
  value = aws_dynamodb_table.terraform_lock.name
}

output "kms_key_arn" {
  description = "KMS key ARN for state encryption"
  value = aws_kms_key.tfstate.arn
}

output "oidc_provider_arn" {
  description = "IAM OIDC Provider ARN for GitHub Actions"
  value = aws_iam_openid_connect_provider.github_actions.arn
}

output "terraform_plan_role_arn" {
  description = "IAM Role ARN for terraform plan (PR)"
  value = aws_iam_role.terraform_plan.arn
}

output "terraform_apply_role_arn" {
  description = "IAM Role ARN for terraform apply (main merge)"
  value = aws_iam_role.terraform_apply.arn
}


8-3. main 層:workspace + module + S3 backend で本番リソース管理

bootstrap が完了したら、main 層で S3 バックエンドを設定し、アプリリソースを管理します。

# main/backend.tf
# bootstrap/outputs.tf の値を参考に手動で設定する
terraform {
  backend "s3" {
 bucket= "YOUR_PROJECT-tfstate-123456789012-ap-northeast-1"  # bootstrap output
 key= "main/terraform.tfstate"
 region= "ap-northeast-1"
 dynamodb_table = "YOUR_PROJECT-terraform-lock"# bootstrap output
 encrypt  = true
 kms_key_id  = "arn:aws:kms:ap-northeast-1:123456789012:key/..."# bootstrap output
  }
}

# main/main.tf
terraform {
  required_version = "~> 1.9"
  required_providers {
 aws = {
source  = "hashicorp/aws"
version = "~> 5.0"
 }
  }
}

provider "aws" {
  region = var.aws_region

  default_tags {
 tags = {
Project  = var.project_name
Environment = terraform.workspace
ManagedBy= "Terraform"
 }
  }
}

# ── ローカル VPC モジュール ───────────────────────────────────
module "vpc" {
  source = "./modules/vpc"

  name  = "${var.project_name}-${terraform.workspace}"
  vpc_cidr = local.env_config[terraform.workspace].vpc_cidr
  tags  = { Environment = terraform.workspace }
}

# ── 公式 EC2 モジュール ───────────────────────────────────────
module "ec2" {
  source  = "registry.terraform.io/terraform-aws-modules/ec2-instance/aws"
  version = "~> 5.0"

  name = "${var.project_name}-${terraform.workspace}-web"
  instance_type = local.env_config[terraform.workspace].instance_type
  subnet_id  = module.vpc.public_subnet_ids[0]

  vpc_security_group_ids = [aws_security_group.web.id]
  associate_public_ip_address = true
}

resource "aws_security_group" "web" {
  name= "${var.project_name}-${terraform.workspace}-web-sg"
  vpc_id = module.vpc.vpc_id

  ingress {
 from_port= 80
 to_port  = 80
 protocol = "tcp"
 cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
 from_port= 0
 to_port  = 0
 protocol = "-1"
 cidr_blocks = ["0.0.0.0/0"]
  }
}

# main/locals.tf
locals {
  env_config = {
 dev = {
vpc_cidr= "10.0.0.0/16"
instance_type = "t3.micro"
 }
 stg = {
vpc_cidr= "10.1.0.0/16"
instance_type = "t3.small"
 }
 prod = {
vpc_cidr= "10.2.0.0/16"
instance_type = "t3.medium"
 }
  }
}


8-4. 全体の依存関係図

リソース間の依存関係をテキスト表形式で整理します。

フェーズ作業依存先
1bootstrap apply(ローカル state)なし(初回)
2main/backend.tf 設定bootstrap outputs(S3バケット名・DynamoDB名・KMS ARN)
3main terraform init -migrate-statebackend.tf 設定済み
4terraform workspace new dev/stg/prodinit 完了
5module.vpc applyworkspace 選択済み
6module.ec2 applymodule.vpc の vpc_id・subnet_ids
7GitHub Actions workflow 有効化bootstrap の OIDC ARN・Role ARN
8PR 作成 → plan 自動実行workflow 有効・oidc_provider・plan_role
9main マージ → apply 自動実行plan 成功・apply_role

シークレット依存の流れ

GitHub Actions → OIDC token → AWS STS → AssumeRoleWithWebIdentity
  ↓↓
  terraform plan/apply一時クレデンシャル(最大1時間)
 ↓
S3 state 読み書き + DynamoDB ロック

依存関係の原則
bootstrap 層は main 層の依存先です。bootstrap を変更・削除する場合は必ず全 main workspace の state ファイルをバックアップしてください(S3 バージョニングを有効化しているので S3 コンソールから過去バージョンを取得できます)。

8-5. [ハンズオン] 空のリポジトリから全構築の一気通貫

以下のチェックリストに従って手順を進めてください。各ステップは前のステップが完了していることを確認してから実行します。

Phase 1: リポジトリ準備

  • [ ] GitHub で新規リポジトリを作成(例: my-terraform-advanced
  • [ ] ローカルにクローン: git clone https://github.com/YOUR_ORG/my-terraform-advanced.git
  • [ ] bootstrap/main/ ディレクトリを作成
  • [ ] 上記コードを各ファイルに配置
  • [ ] .gitignore に以下を追加
# .gitignore
.terraform/
terraform.tfstate
terraform.tfstate.backup
*.tfvars
!example.tfvars
.terraform.lock.hcl  # チームで共有する場合はコメントアウト


Phase 2: bootstrap 実行

  • [ ] cd bootstrap/
  • [ ] terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.94.1...

  • [ ] terraform plan -var='project_name=myapp' -var='github_org=YOUR_ORG' -var='github_repo=my-terraform-advanced'
  • [ ] plan 結果を確認(S3・DynamoDB・IAM OIDC Provider・IAM Role が表示されること)
  • [ ] terraform apply -var='project_name=myapp' -var='github_org=YOUR_ORG' -var='github_repo=my-terraform-advanced'
  • [ ] outputs を記録: terraform output でバケット名・テーブル名・Role ARN をコピー

Phase 3: main 層初期化

  • [ ] cd ../main/
  • [ ] backend.tfbucket / dynamodb_table / kms_key_id を Phase 2 の outputs で更新
  • [ ] terraform init(S3 バックエンドへの接続確認)
Initializing the backend...

Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.

  • [ ] workspace 作成
terraform workspace new dev
terraform workspace new stg
terraform workspace new prod
terraform workspace select dev

  • [ ] terraform plan で dev 環境のリソースを確認
  • [ ] terraform apply で dev 環境を構築

Phase 4: GitHub Actions 設定

  • [ ] .github/workflows/terraform.yml を作成(Section 7 参照)
  • [ ] GitHub Secrets に以下を設定:
  • TF_PLAN_ROLE_ARN: bootstrap output の terraform_plan_role_arn
  • TF_APPLY_ROLE_ARN: bootstrap output の terraform_apply_role_arn
  • [ ] git push → Actions タブで workflow が表示されることを確認
  • [ ] ブランチを作成して PR → plan が自動実行されることを確認
  • [ ] PR をマージ → apply が自動実行されることを確認

Phase 5: 確認・片付け

  • [ ] AWSコンソール → EC2 で新規インスタンス確認
  • [ ] AWSコンソール → S3 → tfstate バケット → env:/dev/main/terraform.tfstate が存在することを確認
  • [ ] 検証完了後: terraform workspace select dev && terraform destroy(全 workspace で実行)
  • [ ] bootstrap: cd bootstrap && terraform destroy(最後に実行)

8-6. ディレクトリ分離型 vs モノレポ型の比較表

大規模プロジェクトでは「環境ごとにディレクトリを分離する」か「workspace で管理する」かの選択が重要です。

観点workspace(本記事の構成)ディレクトリ分離型
環境差分locals.tf の map で吸収ディレクトリごとに独立したコード
state ファイルS3 の env:/ENV/ prefix で自動分離ディレクトリごとに異なる backend.tf
誤適用リスクworkspace select 忘れで prod に dev 変更が混入ディレクトリ移動が必要なので誤適用が起きにくい
コード重複最小(共通コードを1箇所管理)環境差分が大きいと3倍のコードになりうる
CI/CD 設定workspace を引数で切り替えディレクトリパスを引数で切り替え
向いているケース環境差分が小さい(インスタンスサイズ・タグ程度)環境ごとにリソース構成が大きく異なる
代表ツールterraform workspaceTerragrunt (terragrunt.hcl)
選定の目安
環境差分が「インスタンスサイズ・タグ・CIDR の 3 要素以内」であれば workspace で十分です。VPC 構成自体やセキュリティグループルールが環境ごとに大きく異なる場合は Terragrunt を使ったディレクトリ分離型を検討してください。

Section 9: トラブルシューティング

実際の運用で頻繁に遭遇するエラーと解決手順をまとめます。エラーメッセージで検索できるよう、実際のエラー文言をそのまま掲載します。


9-1. state lock 解除できない(ConditionalCheckFailedException)

症状

Error: Error acquiring the state lock

Error message: ConditionalCheckFailedException: The conditional request failed
Lock Info:
  ID:  xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
  Path:myapp-tfstate-123456789012-ap-northeast-1/main/terraform.tfstate
  Operation: OperationTypePlan
  Who: runner@ip-10-0-1-234
  Version:1.9.8
  Created:2026-04-15 09:30:00.123456789 +0000 UTC
  Info:


原因

前回の terraform apply が GitHub Actions のタイムアウト・ネットワーク断・手動キャンセルなどで異常終了し、DynamoDB の LockID 項目が残存したままになっています。

解決手順

  1. AWSコンソール → DynamoDB → テーブル myapp-terraform-lock → 「アイテムを探索」 でロック項目を確認
  2. ロック所有者(Who フィールド)が実際に稼働中のプロセスでないことを確認
  3. 以下のコマンドでロックを強制解除
terraform force-unlock xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

  1. 確認プロンプトに yes と入力
  2. 再度 terraform plan を実行
注意:force-unlock は最終手段
force-unlock は「ロックを所有しているプロセスが存在しないこと」を確認してから実行してください。別の terraform apply が実際に実行中の状態で force-unlock すると、state ファイルが破損する可能性があります。GitHub Actions の場合は Actions タブでジョブが完了していることを確認してから実行します。

9-2. workspace 切り替え忘れで prod に dev 変更を適用

症状

terraform plan の差分が予想より大きく、見覚えのないリソースの変更が含まれている。

# 現在の workspace を確認
terraform workspace show
# → prod  ← dev のつもりが prod だった


発覚方法と防止策

plan 差分が「数十リソースの変更」になっている場合は必ず workspace を確認します。

# plan 実行前に workspace を確認する習慣
terraform workspace show && terraform plan


誤 apply してしまった場合のロールバック手順

  1. 直前の apply で変更されたリソースを terraform show で確認
  2. 変更前の state バージョンを S3 コンソールで確認(バージョニング有効なら復元可能)
  3. 変更を元に戻す terraform コードを書いて apply(もしくは手動でリソースを修正)

CI/CD での防止策

GitHub Actions workflow で workspace を明示的に指定し、ブランチ名と workspace を紐付けます。

# .github/workflows/terraform.yml(抜粋)
- name: Select Terraform workspace
  run: |
 # ブランチ名から環境を決定(main → prod, stg/** → stg, それ以外 → dev)
 if [ "${{ github.ref }}" = "refs/heads/main" ]; then
WORKSPACE=prod
 elif [[ "${{ github.ref }}" == refs/heads/stg/* ]]; then
WORKSPACE=stg
 else
WORKSPACE=dev
 fi
 terraform workspace select $WORKSPACE || terraform workspace new $WORKSPACE
 echo "Selected workspace: $WORKSPACE"


9-3. OIDC 認証失敗(sub Condition mismatch)のログ読み方

症状

GitHub Actions で以下のエラーが発生して AWS に認証できない。

Error: Could not assume role with ARN: arn:aws:iam::123456789012:role/myapp-terraform-plan
An error occurred (AccessDenied) when calling the AssumeRoleWithWebIdentity operation:
Not authorized to perform sts:AssumeRoleWithWebIdentity


原因の特定方法

  1. AWS CloudTrail → 「イベント履歴」 → フィルター: イベント名 = AssumeRoleWithWebIdentity / ユーザー名 = myapp-terraform-plan
  2. エラーイベントをクリック → errorMessage フィールドで詳細を確認

よくある原因

原因CloudTrail の errorMessage解決策
sub 値が trust policy の Condition と不一致Not authorized to perform sts:AssumeRoleWithWebIdentitytrust policy の sub 値を確認・修正
OIDC Provider の Audience 設定漏れ同上sts.amazonaws.com を Audience に追加
IAM Role が別アカウントに存在同上Role ARN のアカウント ID を確認

sub 値の確認方法

GitHub Actions のジョブログで OIDC token の sub 値を確認します。

# デバッグ用ステップ(本番 workflow には含めない)
- name: Debug OIDC token
  run: |
 ENCODED=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL" | jq -r '.value')
 echo $ENCODED | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool | grep sub


取得した sub 値を trust policy の Condition と照合して不一致箇所を修正します。


9-4. terraform init でリモートモジュールのダウンロード失敗

症状

Error: Failed to query available provider packages

Could not retrieve the list of available versions for provider hashicorp/aws:
could not connect to registry.terraform.io: failed to request discovery document


または

Error: Module not found

Module "vpc" (main/main.tf:5) cannot be found in module registry.terraform.io


原因と解決策(3パターン)

パターン1: ネットワーク接続の問題

プロキシ環境や VPC 内での実行時に registry.terraform.io への接続がブロックされる場合があります。

# プロキシ設定を確認
echo $HTTP_PROXY $HTTPS_PROXY

# Terraform 用のプロキシ設定
export HTTPS_PROXY=http://your-proxy.example.com:8080

# または .terraformrc で設定
cat ~/.terraformrc


パターン2: バージョン指定の誤り

# 誤り:存在しないバージョンを指定
module "ec2" {
  source  = "terraform-aws-modules/ec2-instance/aws"
  version = "~> 99.0"  # 存在しないバージョン
}

# 利用可能なバージョンを確認
terraform providers lock -platform=linux_amd64 \
  registry.terraform.io/terraform-aws-modules/ec2-instance/aws

# または Registry ページで確認
# https://registry.terraform.io/modules/terraform-aws-modules/ec2-instance/aws


パターン3: Private Registry のトークン不足

プライベートの Terraform Registry(HCP Terraform)を使用する場合は認証トークンが必要です。

# .terraformrc に token を設定
cat > ~/.terraformrc << 'EOF'
credentials "app.terraform.io" {
  token = "YOUR_HCP_TERRAFORM_TOKEN"
}
EOF


9-5. plan 結果と apply 結果の差分(ドリフト)対応

症状

terraform plan で「変更なし」と表示されたのに、terraform apply 後に予期しないリソースの更新が発生した。または AWS コンソールでリソースを手動変更した後、次回 plan で大量の差分が表示された。

ドリフトの確認方法

# state を AWS の実際の状態に合わせて更新(読み取り専用)
terraform refresh

# または plan で差分を確認(refresh は自動的に実行される)
terraform plan -refresh=true


対応の使い分け

状況適切な対応
AWS コンソールで手動変更したリソースを Terraform 管理に取り込みたいterraform import
Terraform 管理外のリソースを state から削除したいterraform state rm
state のリソース名を変更したい(リファクタリング)terraform state mv
実際の AWS 状態と state を同期させたい(破壊的変更なし)terraform refresh

terraform import の例

# 手動作成した S3 バケットを Terraform 管理に取り込む
# 1. main.tf に対応するリソース定義を追加(apply はしない)
# 2. import コマンドで state に登録
terraform import aws_s3_bucket.example my-manually-created-bucket

# Terraform 1.5 以降: import ブロックで宣言的に記述可能
# main.tf に以下を追加
import {
  to = aws_s3_bucket.example
  id = "my-manually-created-bucket"
}

ドリフト防止の原則
Terraform 管理下のリソースは AWS コンソールで手動変更しない ことが大原則です。コンソール変更は次回の terraform plan で上書きされます。緊急対応で手動変更した場合は必ず Terraform コードに反映してから PR を出してください。

Section 10: まとめ・次のステップ・参考リンク

10-1. 本記事で習得したスキルの振り返り

本記事では Terraform 実践の 3 本柱を学びました。

モジュール化

ローカルモジュール(./modules/vpc/)と公式 Registry モジュール(terraform-aws-modules/ec2-instance/aws)を組み合わせることで、DRY な IaC コードを実現しました。source / version / variable / output の 4 要素を理解すれば、あらゆるモジュールを読み書きできます。

tfstate 管理

S3 バックエンド + DynamoDB ロックでチーム全員が安全に terraform apply できる基盤を構築しました。bootstrap 層でインフラ基盤を先に作り、-migrate-state で移行するパターンは実務で必須の知識です。terraform workspace による環境分離は、コードの重複なしに dev/stg/prod を管理する実践的な手法です。

OIDC CI/CD

アクセスキーを使わない secretless な AWS 認証を GitHub Actions で実現しました。trust policy の sub Condition 設定・plan/apply の 2 ロール分離・PR へのコメント投稿は、セキュアな CI/CD の基本パターンとして覚えてください。


10-2. 次のステップ

本記事の構成(workspace + S3 backend + GitHub Actions)を習得したら、以下のツールへの発展を検討してください。

Terragrunt(DRY を極める)

ディレクトリ分離型の環境管理で backend.tf の重複を排除するツールです。terragrunt.hcl で backend 設定を自動生成し、依存関係のある複数モジュールを run-all apply で一括適用できます。環境差分が大きく workspace では対応困難なケースに適しています。

参考: 公式ドキュメント https://terragrunt.gruntwork.io/

Atlantis(GitOps 式 Pull Request ベースの terraform apply)

GitHub/GitLab の webhook を受け取り、PR へのコメントで atlantis plan / atlantis apply を実行するセルフホスト型ツールです。GitHub Actions と比較して「apply 承認フローの可視化」と「plan/apply のロック管理」が優れています。

参考: 公式ドキュメント https://www.runatlantis.io/

Terraform Cloud / HCP Terraform(マネージド state・Run 管理)

HashiCorp が提供するマネージドサービスです。state 管理・実行環境・SSO 連携・コスト見積もりが統合されています。チーム規模が大きく、セルフホストのインフラ管理コストを削減したい場合に適しています。

ツール向いているケース学習コスト
terraform workspace(本記事)小〜中規模、環境差分が小さい
Terragrunt中〜大規模、環境差分が大きい
Atlantisチーム開発、apply 承認フローが必要
HCP Terraform大規模、マネージドを優先低〜中

10-3. コスト節約:検証後リソース削除チェックリスト

本ハンズオンで作成したリソースは検証後に必ず削除してください。

main 層の削除(全 workspace)

  • [ ] terraform workspace select dev && terraform destroy
  • [ ] terraform workspace select stg && terraform destroy
  • [ ] terraform workspace select prod && terraform destroy

bootstrap 層の削除

bootstrap の S3 バケットは prevent_destroy = true が設定されているため、削除前にコードを変更する必要があります。

  • [ ] bootstrap/main.tflifecycle { prevent_destroy = true } をコメントアウト
  • [ ] S3 バケットのバージョニングを無効化(コンソールまたは Terraform)
  • [ ] S3 バケット内の全オブジェクト(state ファイル・旧バージョン)を削除
  • [ ] cd bootstrap && terraform destroy

手動削除が必要なリソース(terraform destroy では削除されないもの)

リソース確認場所
S3 バケット内の古いバージョンのオブジェクトS3コンソール → バケット → 「バージョンの表示」
CloudWatch ロググループ(GitHub Actions ログ)CloudWatch → ロググループ
KMS キー(削除待機期間: 最低7日)KMS → カスタマーマネージドキー

10-4. 参考公式ドキュメント

本記事で使用した技術の公式ドキュメントです。最新仕様の確認に活用してください。

Terraform

  • Terraform Module Documentation: https://developer.hashicorp.com/terraform/language/modules
  • Terraform S3 Backend: https://developer.hashicorp.com/terraform/language/backend/s3
  • Terraform Workspace: https://developer.hashicorp.com/terraform/language/state/workspaces
  • terraform import コマンド: https://developer.hashicorp.com/terraform/cli/commands/import

AWS Provider

  • hashicorp/aws Provider ドキュメント: https://registry.terraform.io/providers/hashicorp/aws/latest/docs
  • terraform-aws-modules/vpc: https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/latest
  • terraform-aws-modules/ec2-instance: https://registry.terraform.io/modules/terraform-aws-modules/ec2-instance/aws/latest

GitHub Actions + OIDC

  • GitHub Actions: OIDC for AWS: https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services
  • AWS ドキュメント: Creating IAM OIDC identity providers: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html
  • aws-actions/configure-aws-credentials: https://github.com/aws-actions/configure-aws-credentials

10-5. シリーズ次回予告

AWS ハンズオンシリーズ一覧はこちら →

本シリーズでは AWS の各サービスを「コンソール操作 → Terraform 実装」の流れで学ぶハンズオン記事を順次公開しています。

次回は Step Functions 実践編 — ステートマシンを使った非同期ワークフロー設計をテーマに、ECS タスク・Lambda・DynamoDB を組み合わせたサーバーレスアーキテクチャを構築します。

本記事で習得した Terraform モジュール・CI/CD パターンは次回以降の記事でも継続して活用します。ぜひシリーズを通して AWS × IaC のスキルを積み上げてください。