Terraform複数人開発の基盤構築 — state管理・DynamoDBロック・drift検知ハンズオン

目次

AWS×Terraform 複数人開発の基盤 — state管理・lock・drift対策ハンズオン

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

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

Git/GitHub × Terraform 実践シリーズ(全5弾)も合わせてどうぞ:

Git入門 /
GitHub入門 /
ブランチ戦略 /
セキュリティ /
Terraform実践

1. この記事について

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

前作(Terraform実践Git/GitHub第4弾セキュリティ)でTerraform IaCとセキュリティ運用の基礎は固まった。本シリーズはこれを複数人開発の現場で運用するために必須の知識を扱う。

本シリーズ「AWS×Terraform 複数人開発運用編」は全3弾構成で、チーム開発特有の課題をステップごとに解消していく。

テーマ内容
第1弾(本記事)state管理・lock・drift対策複数人開発で壊れるポイントを体験し、S3+DynamoDBで防ぐ
第2弾PR駆動CI/CDGitHub Actions+OIDCで複数人レビューフローを構築
第3弾CodePipeline×CodeBuildAWSネイティブなTerraform CI/CDパイプラインを構築

1-2. 前作(ID:1208)との差別化

同じTerraform×state管理というテーマでも、本記事と前作では対象読者・フォーカスが異なる。

観点前作(ID:1208)本記事
対象個人開発者チーム・複数人開発チーム
state移行ローカル→リモートstate移行手順が中心リモートstateを前提に、競合・破損・driftを体験
ロックDynamoDBロックの設定方法を解説ロックがない場合の破壊シナリオを実演し、体験的に理解
CI/CD触れない本シリーズ第2〜3弾で段階的に構築
主なゴールIaC基礎の習得チーム運用の実践力習得

前作を読んでいる方は、本記事で「なぜそれが必要なのか」を体験として再確認できる。未読でも、Terraform基礎(init / plan / apply)を理解していれば問題なく進められる。

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

対象読者

  • Terraformをチームで使い始めた、または使い始めようとしているエンジニア
  • 個人での terraform apply 経験はあるが、チーム運用でのトラブルを経験したことがない方
  • CI/CDパイプラインにTerraformを組み込みたいと考えているインフラ・DevOpsエンジニア

前提知識

以下の知識を持っていることを前提としている。未習得の場合は先に前作シリーズを参照されたい。

  • Terraform基礎: init / plan / apply / destroy の実行経験
  • GitHub基礎: ブランチ・PR・マージフローの理解
  • AWS基礎: IAM・S3・DynamoDB・EC2の概念理解

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

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

  • state競合の再現: 複数人が同時に terraform apply した場合に実際に起きるエラーを体験する
  • DynamoDBロックの仕組みを体感: ロックがない状態・ある状態の両方を試し、なぜ必要かを理解する
  • apply競合のシナリオ把握: 同一リソースに対して2人が別の変更を加えた場合の破壊的な結果を知る
  • コードレビューの重要性を理解: レビューなし apply が招くインシデントのパターンを学ぶ
  • driftの発生と検知: AWSコンソールで手動変更を加えた後、terraform plan で差分を確認する
  • drift対策の実装: terraform refresh と定期的な plan チェックの組み込み方を知る
  • S3+DynamoDBによるリモートstateのベストプラクティス: チーム運用に耐えるbackend設定を構築する

1-5. 必要なもの

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

AWSアカウント

  • 管理者権限またはTerraform実行用IAMユーザー(S3・DynamoDB・EC2の操作権限)
  • ハンズオンで作成するリソースはすべて削除コマンドを示す(コスト最小化)

ローカル環境

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

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

git --version
# → git 2.x 以上

GitHubアカウント

  • 第2弾以降で使用(本記事では不要)

2. 複数人Terraform開発で何が壊れるか — state競合・apply競合・drift事例

2-1. なぜ複数人開発でTerraformは壊れるのか

Terraformはstateファイル(terraform.tfstate)が真実の源(source of truth)として機能する。このファイルには現在のインフラ構成が記録されており、plan / apply はすべてこのファイルと現実のAWSリソースの差分を計算する仕組みになっている。

個人開発では1人しかこのファイルを操作しないため問題は起きにくい。しかしチームで使うと、次の3つの問題が同時に発生しうる。

  1. state競合: 2人が同時にstateファイルを読み書きする
  2. apply競合: 同一リソースに対して2人が別の変更を apply する
  3. drift: AWSコンソールでの手動変更がstateに反映されない

それぞれを具体的なエラーログと実例で見ていく。


2-2. 課題1: state競合 — 2人が同時に apply した場合

シナリオ

AチームとBチームのエンジニアが同時に別々の変更を terraform apply しようとした。リモートstateバックエンドにDynamoDBロックが設定されていない場合、何が起きるか。

Aさんのターミナル

$ terraform apply

Terraform used the selected providers to generate the following execution plan.
...
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Bさんのターミナル(ほぼ同時に実行)

$ terraform apply

Terraform used the selected providers to generate the following execution plan.
...

Error: Failed to persist state to backend

The error shown above has prevented Terraform from writing the updated state
to the configured backend. To allow for recovery, the state has been written
to the file "errored.tfstate" in the current working directory.

Running "terraform apply" again at this point will not work. You must first
rectify the issue above, then manually copy the file "errored.tfstate" back
to its expected location:

 s3://my-terraform-state/terraform.tfstate

この状態では、Bさんのローカルに errored.tfstate が残り、S3上のstateとローカルが乖離する。次回 plan / apply 時に意図しないリソース削除・再作成が発生するリスクがある。

DynamoDBロックが有効な場合

DynamoDBロックが設定されていれば、2人目のアクセスは即座にブロックされる。

$ terraform apply

Acquiring state lock. This may take a few moments...

Error: Error locking state: Error acquiring the state lock: ConditionalCheckFailedException: The conditional request failed
Lock Info:
  ID:  8f5a9d0c-3b2e-4c1a-a7e3-0f9b6d2e1c4f
  Path:s3://my-terraform-state/terraform.tfstate
  Operation: OperationTypeApply
  Who: user-A@hostname
  Version:1.5.7
  Created:2024-01-15 09:23:41.123456789 +0000 UTC
  Info:

Terraform acquires a state lock to protect the state from being written
by multiple users at the same time. Please resolve the issue above and try
again. For most commands, you can disable locking with the "-lock=false"
flag, but this is not recommended.

このエラーが出た場合、Bさんはロックが解放されるまで待つ(またはAさんにロック解放を依頼する)ことになる。破壊的な状態乖離は発生しない。


2-3. 課題2: apply競合 — DynamoDBロックがない場合の破壊シナリオ

シナリオ

DynamoDBロックなし・S3バックエンドのみという構成で、2人のエンジニアが異なるリソース変更を同時に apply した場合を考える。

初期状態(S3上のstate)

{
  "resources": [
 {
"type": "aws_instance",
"name": "web",
"instances": [
  {
 "attributes": {
"instance_type": "t3.micro",
"tags": {
  "Environment": "staging"
}
 }
  }
]
 }
  ]
}

Aさんの変更: instance_typet3.microt3.small に変更して apply

resource "aws_instance" "web" {
  ami  = "ami-0abcdef1234567890"
  instance_type = "t3.small"  # ← 変更
  tags = {
 Environment = "staging"
  }
}

Bさんの変更: tags.Environmentstagingproduction に変更して apply(Aさんとほぼ同時)

resource "aws_instance" "web" {
  ami  = "ami-0abcdef1234567890"
  instance_type = "t3.micro"
  tags = {
 Environment = "production"  # ← 変更
  }
}

結果

タイミングS3 state の内容実際のAWSリソース
Apply前t3.micro / stagingt3.micro / staging
Aさんapply完了後t3.small / stagingt3.small / staging
Bさんapply完了後t3.micro / productiont3.micro / production

Bさんが古いstateをベースに apply したため、Aさんの変更(t3.small)が上書きされて消えた。 さらにBさんのstate(t3.micro / production)が最終stateになるため、次回の plan では差分がゼロと表示され、問題に気づきにくい。

$ terraform plan
No changes. Your infrastructure matches the configuration.

このような「サイレントな上書き」がロックなし運用の最大のリスクである。


2-4. 課題3: review不在 — コードレビューなしで apply が通る危険性

シナリオ

小規模チームでは「個人リポジトリのように main ブランチに直接 push して apply」という運用がされることがある。

# 開発者が直接 main に push
$ git add main.tf
$ git commit -m "fix: EC2のinstance_type変更"
$ git push origin main

# その直後に apply
$ terraform apply -auto-approve

この運用では以下のリスクが生じる。

パターン1: count の誤削除

# 変更前
resource "aws_instance" "worker" {
  count= 3
  ami  = "ami-0abcdef1234567890"
  instance_type = "t3.micro"
}

# 変更後(countを誤って削除)
resource "aws_instance" "worker" {
  ami  = "ami-0abcdef1234567890"
  instance_type = "t3.micro"
}
$ terraform plan

Terraform will perform the following actions:

  # aws_instance.worker[0] will be destroyed
  - resource "aws_instance" "worker" {
- id= "i-0abc123def456" -> null
...
 }

  # aws_instance.worker[1] will be destroyed
  - resource "aws_instance" "worker" {
- id= "i-0abc123def457" -> null
...
 }

  # aws_instance.worker[2] will be destroyed
  - resource "aws_instance" "worker" {
- id= "i-0abc123def458" -> null
...
 }

  # aws_instance.worker will be created
  + resource "aws_instance" "worker" {
...
 }

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

-auto-approve 付きで実行した場合、本番の3台のEC2が削除されてから1台が起動する。レビューがあれば plandestroy 件数で気づける。

パターン2: 誤ったリソース名変更によるリソース再作成

# 変更前
resource "aws_s3_bucket" "logs" {
  bucket = "my-app-logs"
}

# 変更後(リソース名のリファクタリングのつもり)
resource "aws_s3_bucket" "application_logs" {
  bucket = "my-app-logs"
}
$ terraform plan

  # aws_s3_bucket.logs will be destroyed
  - resource "aws_s3_bucket" "logs" { ... }

  # aws_s3_bucket.application_logs will be created
  + resource "aws_s3_bucket" "application_logs" { ... }

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

S3バケット名は同じでも、Terraformのリソースアドレスが変わるため削除→再作成と解釈される。バケット内のデータが消える前にレビューで気づく仕組みが不可欠だ。


2-5. 課題4: drift — 手動コンソール変更による状態乖離の具体例

driftとは

Terraformが管理しているリソースを、AWSコンソール・AWS CLIで直接変更すると、Terraformのstateと実際のリソースの間に乖離(drift)が発生する。

具体例: セキュリティグループの手動変更

Terraformで管理しているセキュリティグループの定義

resource "aws_security_group" "web" {
  name  = "web-sg"
  description = "Web server security group"
  vpc_id= aws_vpc.main.id

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

  ingress {
 from_port= 443
 to_port  = 443
 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"]
  }
}

AWSコンソールで手動追加したルール

担当者がデバッグのために AWSコンソールから SSH(ポート22)のインバウンドルールを追加した。

インバウンドルール追加:
  タイプ: SSH
  プロトコル: TCP
  ポート: 22
  ソース: 0.0.0.0/0  ← 本番環境では危険

drift発生後の terraform plan 出力

$ terraform plan

aws_security_group.web: Refreshing state... [id=sg-0abc123def456789]

Terraform used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # aws_security_group.web will be updated in-place
  ~ resource "aws_security_group" "web" {
  id= "sg-0abc123def456789"
  name = "web-sg"
  # (other attributes hidden)

- ingress {
 - cidr_blocks= ["0.0.0.0/0"] -> null
 - from_port  = 22 -> null
 - protocol= "tcp" -> null
 - to_port = 22 -> null
  }
 }

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

terraform plan を実行すると、Terraformは「stateにないルールが追加されている」と検知し、次回の apply手動追加したSSHルールを削除しようとする。

この plan 出力を確認せずに apply すると、担当者が追加したルールは削除される。一方、plan を確認することで「誰かが手動変更した」と気づくことができる。

drift を放置するとどうなるか

状態リスク
セキュリティグループに不要なルールが残る意図しないポート開放によるセキュリティリスク
インスタンスタイプをコンソールで変更Terraform apply で意図せずインスタンス再作成
タグをコンソールで変更課金配賦・モニタリングの誤作動
RDSのパラメータグループをコンソールで変更apply時に意図しないRDS再起動

driftは「見えない変更」であるため、発見が遅れるほど被害が大きくなる。定期的な terraform plan 実行や、後続セクションで解説するdrift検知の自動化が重要だ。


2-6. まとめ: なぜstate管理・ロック・drift検知が必要なのか

ここまで4つの課題を見てきた。それぞれの原因と対策をまとめると次のとおりだ。

課題根本原因対策
state競合複数人が同時にstateを読み書きするDynamoDBロックでアクセスを直列化
apply競合ロックなしで古いstateを上書きするDynamoDBロック + PR駆動apply(第2弾)
review不在main 直接 push + -auto-approvePR必須 + plan 出力のレビュー(第2弾)
driftコンソール手動変更がstateに反映されない定期 plan 実行 + IaC厳守ルール

AWSとTerraform両方の視点での原則

  • AWSコンソールはread-onlyで使う: 確認・モニタリングには使うが、変更はすべてTerraform経由で行う
  • stateは共有リソース: 個人のローカルでstateを管理しない。S3バックエンドを全員が使う
  • ロックは保険ではなく必須: 「小さなチームだから大丈夫」という運用は必ず破綻する

次のセクションからは、これらの課題を解消するS3+DynamoDBバックエンドの構築と、drift検知の実装を進めていく。

3. リモートbackend再入門(複数人視点)— S3+DynamoDB lock の役割と限界

3-1. 前作との関係(前提)

前作(ID:1208「Terraform実践 — モジュール化・tfstate・OIDC CI/CD」)で学んだ方は Section 3-2 のS3バケット作成から 読み進めてください。ローカルstateからリモートbackendへの移行手順はそちらで詳しく解説しています。

前作では「なぜリモートbackendが必要か」「移行コマンドの手順」を中心に解説しました。本記事では 複数人チームでそのbackendを運用したときに何が起きるか に焦点を当てます。

単独開発とチーム開発の違い

観点単独開発(ローカルstate)チーム開発(リモートbackend)
stateの所在手元の terraform.tfstateS3バケット(共有)
同時実行の危険自分しかいないので発生しない複数人が同時に apply する可能性がある
state破損の復旧ローカルで対処S3のバージョニングで過去状態に戻せる
アクセス制御不要IAMポリシーで操作権限を制限する必要がある

ローカルstateで運用していたチームがリモートbackendに移行したとき、最初に直面する問題は「同時 apply 競合」 です。AチームメンバーとBチームメンバーが同時に terraform apply を実行すると、state が上書きされ、どちらかの変更が消えるか、最悪の場合は両方の変更が混在した壊れたstateが残ります。

DynamoDB によるロック機能はこの問題を解決するために存在します。しかし 「ロックさえあれば安全」ではない ことも理解しておく必要があります。


3-2. S3 backend の設定(Terraform + AWSコンソール両方)

AWSコンソールでS3バケットを作成する

  1. AWS マネジメントコンソール → S3 を開く
  2. 「バケットを作成」 をクリック
  3. 以下の設定を入力する
項目設定値説明
バケット名my-terraform-state-<アカウントID>グローバルで一意な名前(アカウントIDを含めると一意になりやすい)
リージョンap-northeast-1(東京)チームが使うリージョンに合わせる
オブジェクト所有者ACL無効(推奨)デフォルトのまま
パブリックアクセスのブロックすべてブロックstateファイルを外部公開しないために必須
バージョニング有効にするstate破損時に過去バージョンへ戻すために必須
デフォルトの暗号化SSE-S3(または SSE-KMS)stateには機密情報(パスワード等)が含まれることがある
  1. 「バケットを作成」 をクリック

バージョニングを有効にすることで、terraform apply のたびに .tfstate ファイルの旧バージョンが保存されます。誤った変更を apply してしまった場合でも、S3コンソールから「バージョンを表示」→古いバージョンを「復元」できます。

AWSコンソールでDynamoDBテーブルを作成する

  1. AWS マネジメントコンソール → DynamoDB を開く
  2. 「テーブルの作成」 をクリック
  3. 以下の設定を入力する
項目設定値説明
テーブル名terraform-state-lock任意の名前でよい
パーティションキーLockID(文字列型)Terraformが使う固定キー名(変えてはいけない)
テーブルクラスDynamoDB 標準低頻度アクセスでも標準で十分
キャパシティモードオンデマンドlock操作は頻度が低いためオンデマンドがコスト効率よい
  1. 「テーブルの作成」 をクリック

パーティションキーは必ず LockID(大文字のL・Iに注意) にしてください。Terraformのバックエンドコードがこのキー名をハードコードしているため、別の名前にするとロックが機能しません。

Terraform でバケットとテーブルを作成する

コンソール操作の代わりに Terraform で作成することもできます。ただし bootstrap 問題 があります。「stateを管理するためのS3バケット自体を Terraform で作る」と、そのS3バケットを管理するstateをどこに置くか問題が生じます。

実用的な解決策は2つあります:
方法A: S3バケットとDynamoDBテーブルだけ手動(コンソール/AWS CLI)で作り、残りのリソースをTerraformで管理する
方法B: bootstrap用の別Terraformプロジェクト(ローカルstate)でS3/DynamoDBを管理し、メインプロジェクトはリモートbackendを使う

以下は方法Bの bootstrap プロジェクト例です:

# bootstrap/main.tf
terraform {
  required_version = ">= 1.5"
  required_providers {
 aws = {
source  = "hashicorp/aws"
version = "~> 5.0"
 }
  }
  # ここだけローカルstateを使う(bootstrapの宿命)
}

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

resource "aws_s3_bucket" "terraform_state" {
  bucket = "my-terraform-state-${var.account_id}"

  lifecycle {
 prevent_destroy = true
  }
}

resource "aws_s3_bucket_versioning" "terraform_state" {
  bucket = aws_s3_bucket.terraform_state.id

  versioning_configuration {
 status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
  bucket = aws_s3_bucket.terraform_state.id

  rule {
 apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
 }
  }
}

resource "aws_s3_bucket_public_access_block" "terraform_state" {
  bucket = aws_s3_bucket.terraform_state.id

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

resource "aws_dynamodb_table" "terraform_state_lock" {
  name= "terraform-state-lock"
  billing_mode = "PAY_PER_REQUEST"
  hash_key  = "LockID"

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

backend.tf の設定例

S3バケットとDynamoDBテーブルが作成できたら、メインプロジェクトに backend.tf を作成します:

# backend.tf
terraform {
  backend "s3" {
 bucket= "my-terraform-state-123456789012"
 key= "production/terraform.tfstate"
 region= "ap-northeast-1"
 encrypt  = true
 dynamodb_table = "terraform-state-lock"
  }
}
パラメータ説明
bucketstateを格納するS3バケット名
keyS3上のオブジェクトキー(パス)。環境ごとに変えること
regionS3バケットのリージョン
encrypt転送中の暗号化を有効にする(trueを常に指定)
dynamodb_tableロック用DynamoDBテーブル名

terraform init でbackendを切り替える

既存のプロジェクトでlocalbackendからS3 backendに切り替えるには、backend.tf を作成または変更した後に terraform init -migrate-state を実行します:

# S3 backendに切り替え(既存のローカルstateをS3に移行)
terraform init -migrate-state

実行すると以下のような確認が表示されます:

Initializing the backend...
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 "yes" to copy and "no" to start with an empty state.

  Enter a value: yes

yes を入力すると、ローカルの terraform.tfstate がS3にコピーされます。

コンソール画面での確認方法

  1. S3コンソール → 対象バケット → production/terraform.tfstate オブジェクトが存在することを確認
  2. オブジェクトをクリック → 「バージョン」タブ で過去のstateが保存されていることを確認
  3. オブジェクトをダウンロードして内容確認(機密情報が含まれることがあるため取扱注意)

3-3. DynamoDBロックの仕組みと確認方法

ロック取得→apply→ロック解放の流れ

terraform apply を実行すると、Terraformは以下の順序で処理を行います:

1. DynamoDBにLockIDレコードを書き込む(ロック取得)
↓
2. S3からstateを読み込む
↓
3. planを実行(差分計算)
↓
4. ユーザーが「yes」を入力
↓
5. AWSリソースを変更する
↓
6. S3にstateを書き込む
↓
7. DynamoDBのLockIDレコードを削除(ロック解放)

ロックが取得されている間に別のメンバーが terraform apply を実行しようとすると:

╷
│ Error: Error acquiring the state lock
│
│ Error message: ConditionalCheckFailedException: The conditional request failed
│ Lock Info:
│ID:  a1b2c3d4-e5f6-7890-abcd-ef1234567890
│Path:my-terraform-state-123456789012/production/terraform.tfstate
│Operation: OperationTypeApply
│Who: alice@example.com
│Version:1.6.0
│Created:2026-04-18 10:30:00.123456789 +0000 UTC
│Info:
╵

このエラーにより、同時実行による state 破損を防止 できます。

DynamoDBテーブルにロックレコードが作成される様子

terraform apply の実行中に DynamoDB コンソールで terraform-state-lock テーブルを開くと、以下のようなレコードが作成されています:

{
  "LockID": {
 "S": "my-terraform-state-123456789012/production/terraform.tfstate"
  },
  "Digest": {
 "S": "8d777f385d3dfec8815d20f7496026dc"
  },
  "ID": {
 "S": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  },
  "Info": {
 "S": "{\"ID\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"Operation\":\"OperationTypeApply\",\"Info\":\"\",\"Who\":\"alice@example.com\",\"Version\":\"1.6.0\",\"Created\":\"2026-04-18T10:30:00.123456789Z\",\"Path\":\"my-terraform-state-123456789012/production/terraform.tfstate\"}"
  },
  "Operation": {
 "S": "OperationTypeApply"
  },
  "Path": {
 "S": "my-terraform-state-123456789012/production/terraform.tfstate"
  },
  "Version": {
 "S": "1.6.0"
  },
  "Who": {
 "S": "alice@example.com"
  },
  "Created": {
 "S": "2026-04-18 10:30:00.123456789 +0000 UTC"
  }
}

apply が正常完了するとこのレコードは自動的に削除されます。

ロックが残ったままになった場合の確認コマンド

ネットワーク切断・プロセスkill・CI/CD パイプラインの強制停止などにより apply が中断されると、ロックレコードが残ったままになります。

確認方法1: terraform コマンドで確認

# ロック状態を確認(apply を試みることで間接確認)
terraform plan

ロックが残っている場合は先述のエラーメッセージが出力され、ID フィールドにロックIDが表示されます。

確認方法2: AWS CLI で直接確認

# DynamoDBテーブルの全レコードをスキャン
aws dynamodb scan \
  --table-name terraform-state-lock \
  --region ap-northeast-1 \
  --output json

出力例(ロックレコードが残っている場合):

{
  "Items": [
 {
"LockID": { "S": "my-terraform-state-123456789012/production/terraform.tfstate" },
"Who": { "S": "alice@example.com" },
"Created": { "S": "2026-04-18 10:30:00.123456789 +0000 UTC" },
"Operation": { "S": "OperationTypeApply" }
 }
  ],
  "Count": 1
}

正常時(ロックなし)は "Count": 0 になります。

確認方法3: AWSコンソールで確認

  1. DynamoDBコンソール → terraform-state-lock テーブル
  2. 「テーブルアイテムを探索」 をクリック
  3. アイテムが表示されていれば残留ロック → Who フィールドで誰が操作中だったか確認

3-4. force-unlock の使い所と注意

terraform force-unlock LOCK_ID の使い方

ロックが残ったままで planapply を実行できない場合、terraform force-unlock コマンドでロックを強制解除できます。

手順:

  1. まずロックIDを確認する
terraform plan

出力されるエラーメッセージの ID: フィールドのUUIDをメモします。

  1. force-unlock を実行する
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
  1. yes を入力してロック解除
Terraform state has been successfully unlocked!

AWS CLI での直接削除(非推奨・緊急時のみ)

# Terraform コマンドが使えない場合の最終手段
aws dynamodb delete-item \
  --table-name terraform-state-lock \
  --region ap-northeast-1 \
  --key '{"LockID": {"S": "my-terraform-state-123456789012/production/terraform.tfstate"}}'

誤って使う危険性

force-unlock「本当にapplyが止まっている」と確認できた場合にのみ 使用してください。

最も危険なシナリオ: 別の apply 実行中に解除する

【状況】
Alice: terraform apply 実行中(時間がかかっている)
Bob:「Aliceのapplyが止まったかな」と勘違いしてforce-unlockを実行

【結果】
Aliceのapplyがロックなしで続行される
同時にBobもapplyを開始できる状態になる
→ stateが同時書き込みされ破損する可能性

force-unlock 実行前のチェックリスト

□ Slack/チャットで「今applyしている人はいるか」を全員に確認した
□ CI/CDパイプラインが実行中でないことを確認した(パイプライン画面を確認)
□ DynamoDBの "Created" 時刻が古く、明らかに中断されたと判断できる
□ "Who" フィールドに記載された本人に確認を取った(もしくは本人がいない)
□ S3のstateを確認し、中途半端な状態でないことを確認した

stateが中途半端な場合の対処

force-unlock後に terraform plan を実行して差分を確認します。実際のAWSリソースとstateに乖離がある場合(apply途中で止まった場合など)、terraform import や手動での terraform state 操作が必要になることがあります。


4. 環境分離戦略の比較 — workspace vs ディレクトリ分離 vs Terragrunt

4-1. なぜ環境分離が必要か

dev/stg/prod を同一stateで管理する危険性

複数の環境(開発・ステージング・本番)を 1つのstateファイル で管理することは技術的には可能ですが、実運用では重大なリスクがあります。

リソース誤削除のリスク

# 危険な例: 1つのmain.tfでdev/prod両方のリソースを管理
resource "aws_instance" "dev_app" {
  ami  = "ami-0abcdef1234567890"
  instance_type = "t3.micro"
}

resource "aws_instance" "prod_app" {
  ami  = "ami-0abcdef1234567890"
  instance_type = "m5.large"
}

このような構成で terraform destroy を実行すると、dev と prod 両方のリソースが削除されます。-target フラグで対象を絞ることもできますが、指定ミスは本番削除に直結します。

同一stateで複数環境を管理する問題点まとめ

リスク内容
誤削除terraform destroy が全環境のリソースを対象にする
state肥大化全環境のリソースが1ファイルに集中し、planが遅くなる
権限管理の困難devに触れるメンバーがprodのstateも見られる
変数管理の複雑化dev/prod で異なる値を持つ変数の管理が煩雑になる
ロック競合の増加dev作業が本番のapplyをブロックする

適切な環境分離の原則

1つの環境 = 1つのstateファイル

これにより、devのdestroyがprodに影響しない、環境ごとに異なるIAM権限を付与できる、などのメリットが得られます。


4-2. 手法1: Terraform workspace

workspace の基本操作

Terraform workspaceは、1つのTerraformプロジェクトから複数の独立したstateを管理する 仕組みです。

# workspaceの一覧表示
terraform workspace list
# 出力例:
# * default
#dev
#stg
#prod

# 新しいworkspaceを作成
terraform workspace new dev

# workspaceを切り替える
terraform workspace select dev

# 現在のworkspaceを確認
terraform workspace show
# 出力: dev

workspace とstateの関係(S3上のパス構造)

workspaceを使ってS3バックエンドに保存すると、stateファイルのパスが自動的に変わります:

S3バケット内のパス構造:

env:/
  dev/
 production/terraform.tfstate ← dev workspace
  stg/
 production/terraform.tfstate ← stg workspace
  prod/
 production/terraform.tfstate ← prod workspace
production/terraform.tfstate← default workspace

backend.tfkey に指定したパス(production/terraform.tfstate)が、workspace名のサブディレクトリ下に自動配置されます。

workspace 内での環境ごとの設定

terraform.workspace 変数を使って、workspace名に基づいた条件分岐を記述できます:

# variables.tf
variable "instance_type" {
  default = "t3.micro"
}

locals {
  env = terraform.workspace

  # 環境ごとのインスタンスタイプ
  instance_type_map = {
 dev  = "t3.micro"
 stg  = "t3.small"
 prod = "m5.large"
  }

  instance_type = lookup(local.instance_type_map, local.env, var.instance_type)
}

resource "aws_instance" "app" {
  ami  = data.aws_ami.amazon_linux.id
  instance_type = local.instance_type

  tags = {
 Name  = "app-${local.env}"
 Environment = local.env
  }
}

workspace のメリット・デメリット

観点内容
メリットディレクトリ構成がシンプルで済む
メリット既存プロジェクトへの導入が容易
メリットstateが自動的に分離される
デメリットworkspace切り替え忘れによる誤操作リスク
デメリットモジュール構成が大きく変わる場合にstateの移行が困難
デメリットterraform.workspace の多用でコードが読みにくくなる
デメリット環境ごとに全く異なるリソース構成を管理しにくい

workspace に向いているケース: 同じリソース構成で環境ごとにパラメータだけ変える小〜中規模プロジェクト

workspace に向いていないケース: devにはないprod専用リソース(WAF・Shield等)がある、環境ごとにモジュール構成が異なる

実際のディレクトリ構成とbackend設定例

workspace を使う場合、プロジェクト構成はシンプルになります:

my-infra/
├── backend.tf
├── main.tf
├── variables.tf
├── outputs.tf
└── modules/
 ├── vpc/
 └── ec2/
# backend.tf(workspaceを使う場合、keyは1つでよい)
terraform {
  backend "s3" {
 bucket= "my-terraform-state-123456789012"
 key= "my-infra/terraform.tfstate"
 region= "ap-northeast-1"
 encrypt  = true
 dynamodb_table = "terraform-state-lock"
  }
}
# dev環境にapply
terraform workspace select dev
terraform apply -var-file="vars/dev.tfvars"

# prod環境にapply
terraform workspace select prod
terraform apply -var-file="vars/prod.tfvars"

4-3. 手法2: ディレクトリ分離(本シリーズ推奨)

ディレクトリ分離とは

各環境を 完全に独立したTerraformプロジェクト(ディレクトリ) として管理する方法です。本シリーズでは、明示性と安全性の観点からこの方法を推奨します。

my-infra/
├── environments/
│├── dev/
││├── main.tf
││├── backend.tf
││├── variables.tf
││└── terraform.tfvars
│├── stg/
││├── main.tf
││├── backend.tf
││├── variables.tf
││└── terraform.tfvars
│└── prod/
│ ├── main.tf
│ ├── backend.tf
│ ├── variables.tf
│ └── terraform.tfvars
└── modules/
 ├── vpc/
 │├── main.tf
 │├── variables.tf
 │└── outputs.tf
 ├── ec2/
 │├── main.tf
 │├── variables.tf
 │└── outputs.tf
 └── rds/
  ├── main.tf
  ├── variables.tf
  └── outputs.tf

各環境のファイル構成例

各環境は独立したbackend設定を持ちます:

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

key のパスが環境ごとに異なるため、stateが完全に分離されます。

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

provider "aws" {
  region = var.aws_region
}

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

  vpc_cidr = var.vpc_cidr
  env= "dev"
}

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

  vpc_id  = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnet_ids
  instance_type = var.instance_type
  env  = "dev"
}
# environments/dev/terraform.tfvars
aws_region = "ap-northeast-1"
vpc_cidr= "10.0.0.0/16"
instance_type = "t3.micro"
# environments/prod/terraform.tfvars
aws_region = "ap-northeast-1"
vpc_cidr= "10.1.0.0/16"
instance_type = "m5.large"

apply 操作

# dev環境にapply
cd environments/dev
terraform init
terraform apply

# prod環境にapply(別ディレクトリなので誤操作しにくい)
cd environments/prod
terraform init
terraform apply

作業ディレクトリを変えるだけで環境が切り替わるため、workspace切り替え忘れのリスクがありません

各環境が独立したstateを持つ構成

S3バケット内のパス構造:

S3バケット内のパス構造(ディレクトリ分離の場合):

my-terraform-state-123456789012/
├── environments/
│├── dev/
││└── terraform.tfstate
│├── stg/
││└── terraform.tfstate
│└── prod/
│ └── terraform.tfstate
└── bootstrap/
 └── terraform.tfstate

ディレクトリ分離のメリット・デメリット

観点内容
メリット環境間の完全な分離(devのdestroyがprodに絶対影響しない)
メリットworkspace切り替えミスによる事故がない
メリット環境ごとに異なるリソース構成(prod専用WAF等)を自然に表現できる
メリットCI/CDパイプラインの設計がシンプル(ブランチとディレクトリを対応させやすい)
デメリット環境間でコードが重複する(DRY原則に反する)
デメリット設定変更を全環境に適用するときに1つずつ変更が必要
デメリットファイル数が多くなる

コードの重複はモジュール化で緩和できます。modules/ 配下に共通ロジックを切り出すことで、各環境の main.tf はモジュールの呼び出しと変数の差分だけになります。


4-4. 手法3: Terragrunt(紹介のみ)

Terragrunt とは

Terragrunt は、Terraform のラッパーツールです。Gruntwork社が開発するオープンソースプロジェクトで、ディレクトリ分離の冗長さ(コード重複)を解消する ことを主な目的としています。

DRY(Don’t Repeat Yourself)原則に基づき、各環境の backend.tfprovider.tf の重複を terragrunt.hcl に集約できます。

Terragrunt を使ったディレクトリ構成:

my-infra/
├── terragrunt.hcl  ← ルートの共通設定(backend, provider)
├── environments/
│├── dev/
││└── terragrunt.hcl  ← dev固有の変数のみ
│├── stg/
││└── terragrunt.hcl  ← stg固有の変数のみ
│└── prod/
│ └── terragrunt.hcl  ← prod固有の変数のみ
└── modules/
 └── vpc/
# terragrunt.hcl(ルート)— backend設定を1箇所に集約
remote_state {
  backend = "s3"
  config = {
 bucket= "my-terraform-state-123456789012"
 key= "${path_relative_to_include()}/terraform.tfstate"
 region= "ap-northeast-1"
 encrypt  = true
 dynamodb_table = "terraform-state-lock"
  }
}
# environments/dev/terragrunt.hcl
include "root" {
  path = find_in_parent_folders()
}

inputs = {
  instance_type = "t3.micro"
  vpc_cidr= "10.0.0.0/16"
}

各環境の terragrunt.hcl は変数の差分だけ記述すれば済み、backend.tf の重複がなくなります。

採用判断の目安

条件判断
チーム規模: 1〜3名、環境数: 2〜3ディレクトリ分離で十分。Terragruntは過剰
チーム規模: 5名以上、環境数: 5以上Terragruntの導入コストが割に合う
モジュールを社内で多数管理しているTerragruntのモジュール参照機能が有効
Terraform 初心者が多いTerragruntはさらに学習コストが上がるため慎重に
Terraformの知識が成熟しているTerragruntへの移行は比較的スムーズ

本シリーズではTerragruntは扱いませんが、チームの規模やリポジトリが拡大したタイミングで検討してください。


4-5. 比較まとめ表

手法向きメリットデメリット推奨場面
Terraform workspace小〜中規模構成がシンプル、導入が容易切り替えミスのリスク、複雑な構成差分に弱い同一構成で環境ごとにパラメータを変えるだけの場合
ディレクトリ分離中〜大規模完全分離で安全、環境差異を自然に表現できるコード重複(モジュール化で緩和可能)チーム開発・CI/CD連携・本番環境を持つプロジェクト
Terragrunt大規模・複数プロジェクトDRY原則でコード重複を解消学習コスト、Terragrunt自体のバージョン管理が必要環境数が多く、ディレクトリ分離の重複が問題になってきた場合

本シリーズの推奨: ディレクトリ分離

理由は3点あります。第一に、workspace切り替えミスという人的エラーの排除。第二に、CI/CDパイプラインとのブランチ戦略の対応(devブランチ→dev環境、mainブランチ→prod環境)が直感的。第三に、チームメンバーへのTerraformの学習コストが最小で済む(Terragruntの追加学習不要)。

コード重複のデメリットはモジュール化で解消でき、environments/ 配下の各ファイルは「モジュールを呼び出す薄いラッパー」として維持できます。

次のセクションでは、このディレクトリ分離構成を実際のCI/CDパイプライン(GitHub Actions + AWS)と組み合わせる方法を解説します。

5. state competition ハンズオン — 2人同時 apply を体験する

複数人でTerraformを運用するとき、最も避けたいのがstate の競合(state competition)だ。S3 リモートバックエンドと DynamoDB ロックを正しく設定していれば、2人が同時に terraform apply を試みても安全に排他制御される。このセクションでは、その動作を意図的に再現し、「ロックが機能する場合」「ロックを無効化した場合」「apply 中断後のロック残留」という3つのケースを通じて、ロック機能の価値を肌で理解する。


5-1. ハンズオン概要と準備

5-1-1. このハンズオンで体験すること

ケース内容結果
ケース 1DynamoDB ロックが有効な状態で 2 つの apply を同時実行後発の apply がロックエラーで弾かれる
ケース 2-lock=false でロックを無効化して同時実行2 つの apply が競合し state が破損するリスクがある
ケース 3apply 中に Ctrl+C で強制終了ロックが残留し次回の apply がブロックされる

5-1-2. 前提条件

このハンズオンは Section 3 で構築した S3 + DynamoDB バックエンドが使用可能であることを前提とする。まだ設定していない場合は先に Section 3 を完了させること。

必要なリソース(Section 3 で作成済みのもの):

リソース用途
S3 バケット(例: my-tfstate-bucketstate ファイルの保管
DynamoDB テーブル(例: terraform-lock-table)、パーティションキー LockIDロック管理
IAM ユーザー or ロール(必要な S3/DynamoDB 権限付き)Terraform 実行権限

バックエンド設定が正しく機能しているかを確認する:

cd ~/terraform-team-handson
terraform init
Initializing the backend...

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

Successfully configured the backend "s3"! が表示されればバックエンドは正常に設定されている。

5-1-3. ハンズオン用サンプル Terraform コード

競合を再現するために、applyに数秒かかる最小構成のリソースを用意する。aws_s3_bucket はプロビジョニング自体が速いため、time_sleep リソースを使って意図的に apply 時間を延ばす。

main.tf:

terraform {
  required_version = ">= 1.0"

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

  backend "s3" {
 bucket= "my-tfstate-bucket" # Section 3 で作成したバケット名に変更
 key= "handson/competition.tfstate"
 region= "ap-northeast-1"
 dynamodb_table = "terraform-lock-table" # Section 3 で作成したテーブル名に変更
 encrypt  = true
  }
}

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

# apply に時間をかけるためのダミーリソース
resource "time_sleep" "wait" {
  create_duration = "20s"
}

# ダミーバケット(名前は自分のアカウントに合わせて変更すること)
resource "aws_s3_bucket" "demo" {
  bucket = "tf-competition-demo-${random_id.suffix.hex}"

  depends_on = [time_sleep.wait]
}

resource "random_id" "suffix" {
  byte_length = 4
}

terraform init でプロバイダを取得する:

terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Finding hashicorp/time versions matching "~> 0.11"...
- Finding hashicorp/random versions matching "~> 3.0"...
- Installed hashicorp/aws v5.x.x
- Installed hashicorp/time v0.11.x
- Installed hashicorp/random v3.x.x

Terraform has been successfully initialized!

5-1-4. ターミナルを 2 つ用意する

ケース 1 と 2 では、2 つのターミナルを同時に操作する。以下のいずれかの方法でペインを用意する。

方法A: ターミナルウィンドウを 2 つ開く

macOS Terminal.app や iTerm2 であれば、新しいウィンドウ(Cmd+N)を開き、両方のウィンドウで同じ作業ディレクトリに移動する:

cd ~/terraform-team-handson

方法B: tmux で 2 ペイン分割

tmux new-session -s competition
# 左右に分割
tmux split-window -h
# 左ペインに移動(Ctrl+b → ← キー)
# 右ペインに移動(Ctrl+b → → キー)
# 両ペインで作業ディレクトリに移動
cd ~/terraform-team-handson

以降の手順では「ターミナルA」「ターミナルB」と表記する。どちらのウィンドウ/ペインを A・B にするかは任意で構わない。


5-2. ケース 1: DynamoDB ロックが機能する場合

最も重要なケースだ。S3 + DynamoDB バックエンドが正しく設定されていれば、2 人が同時に apply を実行しても後発の apply はロックエラーで即座に弾かれる。

5-2-1. ターミナルA で apply を開始する

ターミナルA で apply を実行する。time_sleep リソースにより約 20 秒間 apply が継続する:

# ターミナルA
terraform apply -auto-approve
Terraform used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_s3_bucket.demo will be created
  + resource "aws_s3_bucket" "demo" {
...
 }

  # random_id.suffix will be created
  + resource "random_id" "suffix" {
...
 }

  # time_sleep.wait will be created
  + time_sleep.wait will be created
  ...

Apply complete! ...(20秒後に完了)

apply が走り始めたら、完了を待たずにすぐにターミナルB に切り替える。

5-2-2. ターミナルB で apply を即座に実行する

ターミナルA の apply がまだ実行中の状態で、ターミナルB から同じ apply を実行する:

# ターミナルB
terraform apply -auto-approve

ロックが機能していれば、ターミナルB には以下のエラーが即座に表示される:

╷
│ Error: Error locking state: Error acquiring the state lock: ConditionalCheckFailedException: The conditional request failed
│
│ Terraform acquires a state lock to protect the state from being written
│ by multiple users at the same time. Please resolve the issue above and try
│ again. For most commands, you can disable locking with the "-lock=false"
│ flag, but this is not recommended.
│
│ Lock Info:
│ID:  a3f2b1c4-d5e6-7890-abcd-ef1234567890
│Path:s3://my-tfstate-bucket/handson/competition.tfstate
│Operation: OperationTypeApply
│Who: alice@MacBook-Pro.local
│Version:1.8.x
│Created:2026-04-18 06:10:30.123456789 +0000 UTC
│Info:
╵

このエラーメッセージのポイント:

フィールド意味
IDDynamoDB の LockID に保存されているロック識別子(UUID)
Pathロックされている state ファイルのパス
Operationどの操作がロックを取得しているか(OperationTypeApply
Whoロックを取得したユーザーとホスト名
Createdロック取得時刻(UTC)

ターミナルB は即座に終了する。ターミナルA の apply が完了するまでロックは保持され続ける。

5-2-3. DynamoDB テーブルでロックレコードを確認する

apply が実行中の間(ターミナルA が走っている状態)、DynamoDB テーブルにロックレコードが存在することを確認する。

AWS CLI で確認する場合:

aws dynamodb scan \
  --table-name terraform-lock-table \
  --region ap-northeast-1 \
  --query "Items[*]" \
  --output json
[
  {
 "LockID": {
"S": "my-tfstate-bucket/handson/competition.tfstate"
 },
 "Info": {
"S": "{\"ID\":\"a3f2b1c4-d5e6-7890-abcd-ef1234567890\",\"Operation\":\"OperationTypeApply\",\"Info\":\"\",\"Who\":\"alice@MacBook-Pro.local\",\"Version\":\"1.8.x\",\"Created\":\"2026-04-18T06:10:30.123456789Z\",\"Path\":\"s3://my-tfstate-bucket/handson/competition.tfstate\"}"
 }
  }
]

LockID<バケット名>/<stateパス> の形式でレコードが存在していることを確認できる。

AWS マネジメントコンソールで確認する場合:

  1. DynamoDB コンソールを開く
  2. 左メニューから「テーブル」→ terraform-lock-table を選択
  3. 「項目を探索」タブをクリック
  4. ロック実行中は LockID に state のパスが入ったレコードが表示される

5-2-4. apply 完了後のロック解放を確認する

ターミナルA の apply が完了すると(20秒後):

# ターミナルA
time_sleep.wait: Creating...
time_sleep.wait: Still creating... [10s elapsed]
time_sleep.wait: Still creating... [20s elapsed]
time_sleep.wait: Creation complete after 20s
aws_s3_bucket.demo: Creating...
aws_s3_bucket.demo: Creation complete after 2s [id=tf-competition-demo-a1b2c3d4]

Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

apply 完了後、再度 DynamoDB を確認するとロックレコードが消えている:

aws dynamodb scan \
  --table-name terraform-lock-table \
  --region ap-northeast-1 \
  --query "Items[*]" \
  --output json
[]

空配列が返り、ロックが解放されたことが確認できる。


5-3. ケース 2: DynamoDB ロックがない場合(ロック無効化テスト)

警告: このケースは本番環境では絶対に実行しないこと。state ファイルの破損や、インフラの意図しない変更・削除が発生するリスクがある。検証環境・ハンズオン用リソースのみで実施すること。

5-3-1. -lock=false フラグとは

-lock=false は Terraform の state ロックを完全に無効化するフラグだ。このフラグを使うと、DynamoDB のロックチェックが行われず、複数のプロセスが同時に state を読み書きできる状態になる。

ロックが無効な場合に発生しうる問題:

問題説明
state の上書き競合2 つの apply がほぼ同時に完了した場合、先に書き込んだ state が後から上書きされる
リソースの二重作成state に記録される前に 2 つの apply が同じリソースを作成しようとする
ドリフトの発生state と実際のインフラが一致しなくなり、以降の plan/apply が正しく動作しなくなる

5-3-2. -lock=false で同時 apply を実行する

まず、ケース 1 で作成したリソースを一旦削除してから実施する:

terraform destroy -auto-approve
aws_s3_bucket.demo: Destroying...
aws_s3_bucket.demo: Destruction complete after 1s
time_sleep.wait: Destroying...
time_sleep.wait: Destruction complete after 0s

Destroy complete! Resources: 3 destroyed.

ターミナルA と ターミナルB で、それぞれ -lock=false を付けて apply を同時に実行する。タイミングを合わせるために、両ターミナルでコマンドを入力した後、同時に Enter を押す:

# ターミナルA(先に入力して待機)
terraform apply -auto-approve -lock=false
# ターミナルB(ターミナルA と同タイミングで実行)
terraform apply -auto-approve -lock=false

両方が同時に走り出す:

# ターミナルA# ターミナルB
random_id.suffix: Creating... random_id.suffix: Creating...
random_id.suffix: Creation complete random_id.suffix: Creation complete
time_sleep.wait: Creating...  time_sleep.wait: Creating...
time_sleep.wait: Still creating...  time_sleep.wait: Still creating...
time_sleep.wait: Creation complete  time_sleep.wait: Creation complete
aws_s3_bucket.demo: Creating...  aws_s3_bucket.demo: Creating...

このとき、両方の apply が独立した random_id.suffix を生成するため、S3 バケット名が異なり 2 つのバケットが作成されることがある。あるいは、片方が失敗する場合もある。いずれの場合も state ファイルには最後に書き込んだプロセスの内容だけが残り、もう片方の変更は state に反映されない。

5-3-3. state の不整合を確認する

両方の apply が完了した後、state を確認する:

terraform state list
aws_s3_bucket.demo
random_id.suffix
time_sleep.wait

state には 1 セットのリソースしか記録されていない。しかし AWS コンソールでは 2 つのバケットが存在していることがある。この状態がドリフトだ。state と実際のインフラが一致していない。

ドリフトを解消するには terraform importterraform refresh が必要になり、運用コストが大幅に増加する。DynamoDB ロックがいかに重要かを実感できるはずだ。

5-3-4. 後処理(作成されたリソースの削除)

# terraform state に記録されているリソースを削除
terraform destroy -auto-approve

state に記録されていないリソース(バケット等)が残っている場合は AWS CLI で手動削除する:

# 残留バケットを確認
aws s3 ls | grep tf-competition-demo

# バケットを空にして削除
aws s3 rb s3://<バケット名> --force

5-4. ケース 3: apply 中断後のロック残留

apply 実行中に Ctrl+C で強制終了すると、DynamoDB のロックレコードが残留することがある。ロックが残留したままでは次回の apply がブロックされる。

5-4-1. apply を途中で強制終了する

クリーンな状態から開始する(前のケースで destroy 済みであることを確認する):

terraform state list
No state file was found!

apply を開始し、time_sleep が実行中の間に Ctrl+C を押す:

terraform apply -auto-approve
random_id.suffix: Creating...
random_id.suffix: Creation complete after 0s [id=e5f6a7b8]
time_sleep.wait: Creating...
time_sleep.wait: Still creating... [10s elapsed]
^C
╷
│ Error: operation canceled
╵

There are 3 resource(s) in your configuration. 1 have been successfully created,
and 0 have been modified. There is 1 resource(s) that could not be created.
Terraform does not guarantee that it will successfully rollback the created resources.

Ctrl+C(^C)を押すと apply が中断される。このとき、Terraform は DynamoDB のロックレコードを削除できない場合がある。

5-4-2. ロックが残留していることを確認する

aws dynamodb scan \
  --table-name terraform-lock-table \
  --region ap-northeast-1 \
  --query "Items[*]" \
  --output json
[
  {
 "LockID": {
"S": "my-tfstate-bucket/handson/competition.tfstate"
 },
 "Info": {
"S": "{\"ID\":\"b4c5d6e7-f8a9-0123-bcde-f456789012ab\",\"Operation\":\"OperationTypeApply\",\"Info\":\"\",\"Who\":\"alice@MacBook-Pro.local\",\"Version\":\"1.8.x\",\"Created\":\"2026-04-18T06:25:10.987654321Z\",\"Path\":\"s3://my-tfstate-bucket/handson/competition.tfstate\"}"
 }
  }
]

ロックレコードが残留している。この状態で terraform apply を実行すると:

terraform apply -auto-approve
╷
│ Error: Error locking state: Error acquiring the state lock: ConditionalCheckFailedException: The conditional request failed
│
│ Lock Info:
│ID:  b4c5d6e7-f8a9-0123-bcde-f456789012ab
│Path:s3://my-tfstate-bucket/handson/competition.tfstate
│Operation: OperationTypeApply
│Who: alice@MacBook-Pro.local
│Version:1.8.x
│Created:2026-04-18 06:25:10.987654321 +0000 UTC
│Info:
╵

apply がブロックされる。

5-4-3. terraform force-unlock でロックを解除する

リスク説明: terraform force-unlock は、本当にそのロックが孤立した(= ロックを取得したプロセスがすでに終了している)ことを確認してから実行すること。別のユーザーが正当に apply を実行中の状態でこのコマンドを実行すると、同時書き込みを許可してしまい state が破損するリスクがある。チームで使用する場合は必ずコミュニケーションを取ってから実行すること。

ロック ID をエラーメッセージから取得し、force-unlock を実行する:

terraform force-unlock b4c5d6e7-f8a9-0123-bcde-f456789012ab
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!

The state has been unlocked, and Terraform commands should now be able to
run successfully. Please verify that no other Terraform process is
currently attempting to use this state, and that the local state and remote
state are the same.

yes を入力してロックを解除する。解除後、DynamoDB のロックレコードが削除されたことを確認する:

aws dynamodb scan \
  --table-name terraform-lock-table \
  --region ap-northeast-1 \
  --query "Items[*]" \
  --output json
[]

5-4-4. DynamoDB コンソールからロックレコードを手動削除する方法

force-unlock コマンドが使えない状況(例: Terraform バイナリがない環境など)では、AWS マネジメントコンソールから直接 DynamoDB のレコードを削除することも可能だ。

  1. DynamoDB コンソールを開く
  2. 左メニューから「テーブル」→ terraform-lock-table を選択
  3. 「項目を探索」タブをクリック
  4. ロックレコードを選択(LockID が state パスになっているレコード)
  5. 「アクション」→「項目を削除」をクリック
  6. 確認ダイアログで「削除」をクリック

注意: コンソールからの手動削除は Terraform を完全にバイパスする操作だ。本当にロックが孤立していることを確認してから実施すること。Terraform の force-unlock コマンドが使える場合はそちらを優先する。

5-4-5. ロック解除後に apply を再実行する

ロック解除後、apply を再実行する前に state の状態を確認する:

terraform state list
random_id.suffix

中断前に random_id.suffix だけが作成済みの状態だ。apply を再実行すると残りのリソースが作成される:

terraform apply -auto-approve
time_sleep.wait: Creating...
time_sleep.wait: Still creating... [10s elapsed]
time_sleep.wait: Still creating... [20s elapsed]
time_sleep.wait: Creation complete after 20s
aws_s3_bucket.demo: Creating...
aws_s3_bucket.demo: Creation complete after 2s [id=tf-competition-demo-e5f6a7b8]

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

正常に完了する。


5-5. ベストプラクティス: plan/apply の実行制御

ここまでのハンズオンで、DynamoDB ロックがチームの同時 apply から state を守る最後の防衛線であることを体感した。ただし、ロックはあくまで競合を「防ぐ」仕組みであり、「誰がいつ apply してよいか」という運用ルールを補完するものではない。このセクションでは、ロックと合わせて使うことで apply をより安全にする実践的なプラクティスを紹介する。

5-5-1. Makefile による apply 前確認ゲート

make apply のようなラッパーを作ることで、apply 実行前に必ず plan の確認を挟む習慣を強制できる。

Makefile:

.PHONY: init plan apply destroy

TFPLAN := tfplan.out

init:
 terraform init

plan:
 terraform plan -out=$(TFPLAN)
 @echo "---"
 @echo "プランを確認し、問題がなければ 'make apply' を実行してください。"

apply: $(TFPLAN)
 @echo "以下のプランを適用します:"
 terraform show -no-color $(TFPLAN)
 @echo "---"
 @read -p "本当に apply を実行しますか? [yes/no]: " confirm && \
[ "$$confirm" = "yes" ] && terraform apply $(TFPLAN) || echo "apply をキャンセルしました。"
 rm -f $(TFPLAN)

$(TFPLAN):
 @echo "先に 'make plan' を実行してプランファイルを生成してください。"
 @exit 1

destroy:
 @read -p "本当に destroy を実行しますか? [yes/no]: " confirm && \
[ "$$confirm" = "yes" ] && terraform destroy -auto-approve || echo "destroy をキャンセルしました。"

使い方:

# 1. プランを生成
make plan

# 出力を確認した後で apply
make apply

make apply$(TFPLAN) ファイルが存在しない場合に失敗するため、terraform plan をスキップした apply を防止できる。また、apply 直前に再度プランを表示して確認を求めるため、意図しない変更を実行するリスクが低減される。

5-5-2. terraform plan -out=tfplan による事前確認フロー

-out オプションでプランをバイナリファイルとして保存すると、plan 時点のプランを apply 時に正確に再現できる。プランファイルなしの apply では、plan と apply の間にリソースの状態が変わっていた場合に意図しない差分が生じることがある。

# ステップ 1: プランを生成してファイルに保存
terraform plan -out=tfplan.out
Terraform will perform the following actions:

  # aws_s3_bucket.demo will be created
  + resource "aws_s3_bucket" "demo" {
...
 }

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

Saved the plan to: tfplan.out

To perform exactly these actions, run the following command to apply:
 terraform apply "tfplan.out"
# ステップ 2: プランファイルの内容を人間が読める形式で確認
terraform show tfplan.out
# ステップ 3: 確認済みのプランファイルを使って apply
terraform apply tfplan.out
time_sleep.wait: Creating...
time_sleep.wait: Creation complete after 20s
aws_s3_bucket.demo: Creating...
aws_s3_bucket.demo: Creation complete after 2s

Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

プランファイルを使った apply では、plan と apply の間に別の変更があってもプランファイルの内容だけが適用される。意図しない追加変更の適用を防ぐ効果がある。

プランファイルにはアクセスキーなどの機密情報が含まれる場合がある。.gitignore*.out を追加してバージョン管理に含めないようにすること。

.gitignore の設定例:

# Terraform
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.backup
*.tfplan
*.out
tfplan.out
crash.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json

5-5-3. PR レビュー後のみ apply を許可するフロー(次弾への橋渡し)

ここまでのプラクティスはローカル環境での実行制御だ。チームで安全に Terraform を運用するためには、CI/CD パイプラインに apply を組み込み、PR レビューをゲートにする構成が最も効果的だ。

代表的なフローを示す:

開発者A がコードを変更
 ↓
feature ブランチを push
 ↓
Pull Request を作成
 ↓
CI が自動で terraform plan を実行 → プラン差分を PR コメントに投稿
 ↓
チームメンバーがプランを確認してレビュー・承認
 ↓
main ブランチへマージ
 ↓
CI が terraform apply を自動実行

このフローの利点:

利点説明
apply 前にチームがプランを確認できる意図しないリソース変更・削除を PR レビューで防止
apply は CI が実行するため個人差がないローカル環境の差異による問題を排除
apply の実行履歴が CI ログに残る誰がいつどの変更を適用したか追跡可能
main へのマージ = apply の承認レビュープロセスと apply 実行が自然に連動する

具体的な実装(GitHub Actions + Terraform Cloud / Atlantis / tfcmt など)は第 2 弾で詳しく解説する。このセクションでは概念と目指すべきフローを理解しておこう。


まとめ

このセクションで体験した内容を振り返る:

ケース結果学び
ケース 1 (ロック有効・同時 apply)後発の apply がエラーで弾かれたDynamoDB ロックが state 競合を確実に防ぐ
ケース 2 (-lock=false・同時 apply)state が不整合になるリスクを確認ロック無効化は本番では絶対に禁止
ケース 3 (apply 中断・ロック残留)次回 apply がブロックされたforce-unlock は孤立確認後のみ実行

S3 + DynamoDB バックエンドの組み合わせが機能することを、エラーメッセージや DynamoDB レコードを通じて実際に確認できた。また、ロックはあくまでも最終防衛線であり、Makefile や plan ファイルによる実行制御、PR レビューフローを組み合わせることで、より安全な運用が実現できる。

第 2 弾では、このバックエンド構成を土台に、GitHub Actions を使った CI/CD パイプラインへの Terraform 統合を解説する。

6. drift検知の運用 — 定期 terraform plan + GitHub Actions + Slack通知

Terraformでインフラをコード管理していても、AWSコンソールから誰かが手動変更を加えた瞬間に「コードと実態のズレ」が生まれる。このズレを drift(ドリフト) と呼ぶ。driftを放置すると、次の terraform apply 時に意図しない変更や削除が実行される危険がある。本セクションでは、driftを自動検出してSlackに通知するGitHub Actionsワークフローを構築する。


6-1. driftとは何か

driftの定義

Terraformが管理するインフラの「あるべき状態(.tf ファイル)」と「実際のAWS上の状態」が乖離することをdriftという。Terraformは .tfstate ファイルにリソースの状態を記録しているが、AWSコンソールや他のツールで直接変更を加えると .tfstate の記録と実態がずれる。

.tf ファイル(コード)
 ↓ terraform apply
.tfstate(管理記録)  ←── ここが実態と乖離する
 ↓
AWS上の実リソース  ←── コンソール手動変更でここだけ変わる

driftが発生しやすい典型例

シナリオ結果
開発者がコンソールからEC2のセキュリティグループにルールを手動追加次の terraform apply で削除される
RDSのパラメータグループをコンソールから変更Terraformが「差分あり」と判定して上書き
S3バケットのバージョニングをコンソールから有効化.tf に記述がなければ無効に戻される
IAMロールにポリシーを手動アタッチTerraform管理外のポリシーとして扱われ削除対象になる可能性

具体例: EC2のSecurityGroup手動追加 → terraform apply で削除

1. Terraform管理のEC2に対し、コンソールからポート8080の許可ルールを手動追加
2. この時点でdriftが発生(.tfstateには8080の記録なし)
3. 別の開発者が terraform apply を実行
4. Terraformは「8080のルールは管理外(driftによる追加)」と判断
5. 8080のルールが削除される → 本番サービスに影響が出る可能性

このような事故を防ぐには、driftを定期的に自動検出し、チームに通知する仕組みが必須である。


6-2. drift検知の仕組み: terraform plan の活用

terraform plan でdriftを確認する

terraform plan はコードと実際のインフラの差分を表示するコマンドである。driftが発生していれば plan の出力に差分が表示される。

terraform plan

出力例(driftがある場合):

Terraform will perform the following actions:

  # aws_security_group_rule.allow_http will be destroyed
  - resource "aws_security_group_rule" "allow_http" {
- cidr_blocks = ["0.0.0.0/0"]
- from_port= 8080
- protocol = "tcp"
- to_port  = 8080
- type  = "ingress"
 }

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

-detailed-exitcode オプション

自動化スクリプトでdriftの有無をプログラム的に判断するには -detailed-exitcode オプションを使う。

終了コード意味
0差分なし(driftなし)
1エラー(認証失敗・構文エラー等)
2差分あり(driftあり)
terraform plan -detailed-exitcode -out=tfplan
EXIT_CODE=$?
if [ $EXIT_CODE -eq 2 ]; then
  echo "Drift detected!"
elif [ $EXIT_CODE -eq 1 ]; then
  echo "Error occurred during plan"
else
  echo "No drift detected"
fi

-out=tfplan オプションでplan結果をファイルに保存しておくと、後から terraform show tfplan で詳細を確認できる。

plan出力をテキストに変換する

Slackへの通知やログ保存のために、plan出力を人が読めるテキスト形式に変換するには terraform show を使う。

# planファイルをテキストに変換
terraform show -no-color tfplan > plan_output.txt

-no-color オプションでANSIエスケープコードを除去し、Slackやログファイルで読みやすくする。


6-3. 定期drift検知のGitHub Actionsワークフロー

毎日定刻に terraform plan を実行し、driftを検出したらSlackに通知する完全なGitHub Actionsワークフローを構築する。

ワークフローファイルの配置

.github/
└── workflows/
 └── drift-detection.yml

完全版ワークフロー: .github/workflows/drift-detection.yml

name: Drift Detection

on:
  schedule:
 - cron: '0 0 * * *'  # 毎日 09:00 JST(UTC 00:00)
  workflow_dispatch:# 手動実行も可能

jobs:
  drift-check:
 name: Terraform Drift Check
 runs-on: ubuntu-latest
 permissions:
id-token: write# OIDC認証に必要
contents: read

 env:
TF_VERSION: '1.7.5'
AWS_REGION: 'ap-northeast-1'
WORKING_DIR: './terraform'

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

- name: Configure AWS credentials (OIDC)
  uses: aws-actions/configure-aws-credentials@v4
  with:
 role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
 aws-region: ${{ env.AWS_REGION }}

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

- name: Terraform Init
  working-directory: ${{ env.WORKING_DIR }}
  run: |
 terraform init \
-backend-config="bucket=${{ secrets.TF_STATE_BUCKET }}" \
-backend-config="key=terraform.tfstate" \
-backend-config="region=${{ env.AWS_REGION }}" \
-backend-config="dynamodb_table=${{ secrets.TF_LOCK_TABLE }}" \
-input=false

- name: Terraform Plan (drift detection)
  id: plan
  working-directory: ${{ env.WORKING_DIR }}
  run: |
 set +e
 terraform plan \
-detailed-exitcode \
-no-color \
-out=tfplan \
-input=false \
2>&1 | tee plan_output.txt
 EXIT_CODE=${PIPESTATUS[0]}
 echo "exit_code=${EXIT_CODE}" >> $GITHUB_OUTPUT
 echo "Plan exit code: ${EXIT_CODE}"
 set -e

- name: Convert plan to readable format
  if: steps.plan.outputs.exit_code == '2'
  working-directory: ${{ env.WORKING_DIR }}
  run: |
 terraform show -no-color tfplan > plan_readable.txt

- name: Upload plan artifact
  if: steps.plan.outputs.exit_code == '2'
  uses: actions/upload-artifact@v4
  with:
 name: drift-plan-${{ github.run_id }}
 path: |
${{ env.WORKING_DIR }}/tfplan
${{ env.WORKING_DIR }}/plan_readable.txt
 retention-days: 30

- name: Notify Slack (drift detected)
  if: steps.plan.outputs.exit_code == '2'
  run: |
 PLAN_SUMMARY=$(head -50 ${{ env.WORKING_DIR }}/plan_readable.txt || echo "(詳細はArtifactを参照)")
 RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"

 PAYLOAD=$(cat <<EOF
 {
"text": "🚨 *Terraform Drift Detected!*",
"attachments": [
  {
 "color": "#ff0000",
 "fields": [
{
  "title": "Repository",
  "value": "${{ github.repository }}",
  "short": true
},
{
  "title": "Triggered at",
  "value": "$(date -u '+%Y-%m-%d %H:%M UTC')",
  "short": true
},
{
  "title": "Workflow Run",
  "value": "<${RUN_URL}|Click here to view details>",
  "short": false
},
{
  "title": "Plan Summary (first 50 lines)",
  "value": "\`\`\`${PLAN_SUMMARY}\`\`\`",
  "short": false
}
 ],
 "footer": "Terraform Drift Detection | Action required: investigate and fix"
  }
]
 }
 EOF
 )

 curl -s -X POST \
-H 'Content-type: application/json' \
--data "${PAYLOAD}" \
"${{ secrets.SLACK_WEBHOOK_URL }}"

- name: Notify Slack (plan error)
  if: steps.plan.outputs.exit_code == '1'
  run: |
 RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
 curl -s -X POST \
-H 'Content-type: application/json' \
--data "{
  \"text\": \"⚠️ *Terraform Plan Error in Drift Detection*\",
  \"attachments\": [{
 \"color\": \"#ff9900\",
 \"fields\": [{
\"title\": \"Workflow Run\",
\"value\": \"<${RUN_URL}|Click here to view details>\",
\"short\": false
 }]
  }]
}" \
"${{ secrets.SLACK_WEBHOOK_URL }}"

- name: Notify Slack (no drift)
  if: steps.plan.outputs.exit_code == '0'
  run: |
 curl -s -X POST \
-H 'Content-type: application/json' \
--data "{
  \"text\": \"✅ *No Terraform Drift Detected* — Infrastructure is in sync with code ($(date -u '+%Y-%m-%d %H:%M UTC'))\"
}" \
"${{ secrets.SLACK_WEBHOOK_URL }}"

- name: Fail job if drift detected
  if: steps.plan.outputs.exit_code == '2'
  run: |
 echo "Drift was detected. Please investigate and resolve."
 exit 1

ワークフローのポイント解説

設定項目説明
schedule: cron: '0 0 * * *'UTC 00:00 = JST 09:00 に毎日実行
workflow_dispatchGitHubのUI/APIから手動実行可能
id-token: writeOIDC認証でSecretなしにAWSへアクセス
set +e / PIPESTATUSterraform planの終了コードをteeと共存させて取得
exit 1(最後のステップ)drift検知時にワークフローをFailed扱いにし、GitHubのUI上で視認性を上げる

6-4. Slack通知の設定

Slack Incoming Webhook URLの取得

  1. Slackワークスペースにブラウザからアクセスし、 「その他の管理機能」→「アプリを管理する」 を開く
  2. 検索ボックスで「Incoming WebHooks」を検索し、アプリを追加する
  3. 「新しい設定を追加する」 をクリック
  4. 通知先のチャンネル(例: #terraform-alerts)を選択して 「Incoming Webhookインテグレーションの追加」 をクリック
  5. 表示される Webhook URL をコピーする
Webhook URLの形式:
https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXX

GitHub Repository SecretsへのWebhook URLの登録

GitHubリポジトリの Settings → Secrets and variables → Actions → New repository secret から登録する。

Secret名
SLACK_WEBHOOK_URLコピーしたIncoming Webhook URL
AWS_ROLE_ARNOIDCで使用するIAMロールのARN
TF_STATE_BUCKETTerraform状態ファイルを保存するS3バケット名
TF_LOCK_TABLEロック管理用DynamoDBテーブル名

AWSコンソールでの登録手順:
1. GitHubリポジトリのページを開く
2. Settings タブをクリック
3. 左サイドバーの Secrets and variables → Actions を選択
4. New repository secret ボタンをクリック
5. Name欄に SLACK_WEBHOOK_URL を入力し、Secret欄にWebhook URLを貼り付けて Add secret をクリック

plan出力をSlackメッセージに含める方法

ワークフロー内で plan_readable.txt の先頭50行をSlackメッセージに含めている。Slackのメッセージ文字数制限(約3,000文字)を超えないように先頭行数を調整すること。

# 先頭50行を取得
PLAN_SUMMARY=$(head -50 plan_readable.txt)

# 文字数が多い場合は切り詰め
PLAN_SUMMARY=$(head -50 plan_readable.txt | head -c 2000)

Slackメッセージのフォーマット例

driftが検出された場合の通知:

🚨 Terraform Drift Detected!

Repository: myorg/infrastructure
Triggered at: 2026-04-18 00:00 UTC
Workflow Run: [Click here to view details]

Plan Summary (first 50 lines):
  # aws_security_group_rule.manual_rule will be destroyed
  - resource "aws_security_group_rule" "manual_rule" {
- from_port = 8080
- to_port= 8080
...
  }
  Plan: 0 to add, 0 to change, 1 to destroy.

Action required: investigate and fix

driftがない場合の通知:

✅ No Terraform Drift Detected — Infrastructure is in sync with code (2026-04-18 00:00 UTC)

6-5. AWSコンソールでの確認

GitHub ActionsのWorkflow実行結果の確認

  1. GitHubリポジトリの Actions タブを開く
  2. 左サイドバーで Drift Detection ワークフローを選択
  3. 実行履歴の一覧が表示される。各実行の結果は以下のアイコンで確認できる:
  4. ✅ 緑チェック: 正常完了(driftなし)
  5. ❌ 赤×: Failedまたはdrift検出
  6. ⚠️ 黄△: ワークフロー自体のエラー

  7. 実行行をクリックしてジョブの詳細ログを表示し、 Terraform Plan ステップを展開して差分の詳細を確認する

plan出力のArtifact保存

ワークフロー内の actions/upload-artifact ステップにより、drift検出時のplanファイルは自動的にArtifactとして30日間保存される。

- name: Upload plan artifact
  if: steps.plan.outputs.exit_code == '2'
  uses: actions/upload-artifact@v4
  with:
 name: drift-plan-${{ github.run_id }}
 path: |
${{ env.WORKING_DIR }}/tfplan
${{ env.WORKING_DIR }}/plan_readable.txt
 retention-days: 30

Artifactのダウンロード手順:
1. ワークフロー実行詳細ページを開く
2. ページ下部の Artifacts セクションで drift-plan-<run_id> をクリック
3. ZIPファイルとしてダウンロードし、plan_readable.txt を開いて差分を確認する

CloudTrailで誰がコンソール操作したかを追跡する方法

driftの原因となったコンソール操作者を特定するには、AWS CloudTrailを使用する。

AWSコンソールでの手順:
1. CloudTrail サービスを開く
2. 左サイドバーの イベント履歴 を選択
3. 以下のフィルタを設定して絞り込む:

フィルタ設定例
時間範囲drift検出時刻の前後24時間
イベント名AuthorizeSecurityGroupIngress(SG手動追加の場合)
ユーザー名特定のIAMユーザーを絞り込む場合
リソース名対象のセキュリティグループID
  1. 該当イベントをクリックすると イベントの詳細userIdentity が表示される:
{
  "eventVersion": "1.08",
  "userIdentity": {
 "type": "IAMUser",
 "userName": "taro.yamada",
 "arn": "arn:aws:iam::123456789012:user/taro.yamada"
  },
  "eventName": "AuthorizeSecurityGroupIngress",
  "eventTime": "2026-04-18T08:30:00Z",
  "requestParameters": {
 "groupId": "sg-0123456789abcdef0",
 "ipPermissions": {
"items": [
  {
 "ipProtocol": "tcp",
 "fromPort": 8080,
 "toPort": 8080
  }
]
 }
  }
}

主要なCloudTrailイベント名とdriftの対応

手動操作CloudTrailイベント名
セキュリティグループルール追加AuthorizeSecurityGroupIngress / AuthorizeSecurityGroupEgress
EC2インスタンスタグ変更CreateTags / DeleteTags
S3バケット設定変更PutBucketVersioning / PutBucketPolicy
IAMポリシーアタッチAttachRolePolicy / AttachUserPolicy
RDSパラメータグループ変更ModifyDBParameterGroup

6-6. drift検知のベストプラクティス

定期実行 + PR時にも実行

driftの検知頻度を高めるため、定期実行に加えてPullRequest時にも terraform plan を実行する。

on:
  schedule:
 - cron: '0 0 * * *'  # 毎日09:00 JST
  pull_request:
 paths:
- 'terraform/**'
  workflow_dispatch:

PR時は差分コメントとして残すことで、コードレビュー時にインフラの変更意図を確認できる。

drift発見時のオペレーション手順

1. Slack通知を受け取る
2. GitHub ActionsのWorkflow実行詳細を確認し、plan_readable.txtで差分を把握する
3. CloudTrailで誰がいつどのような操作をしたかを調査する
4. 差分の対応方針を決定する:

A. コンソール変更を「正」とする場合 → terraform import でstateに取り込む
B. コードを「正」とする場合 → terraform apply でインフラをコードの状態に戻す
C. コンソール変更を恒久反映する場合 → .tfファイルに変更を加えてPR → apply

drift対応フローチャート

driftを検知
 │
 ▼
意図的な変更か?
 │
 ├─ YES → .tf ファイルに反映して PR
 │→ import または plan/apply で同期
 │
 └─ NO  → 誰が変更したかCloudTrailで調査
  → 変更を元に戻す or インポートして管理下に置く
  → 再発防止: SCPやIAMポリシーでコンソール直接操作を制限

terraform import でdriftを解消するケース

コンソールで作成・変更したリソースをTerraformの管理下に取り込む場合は terraform import を使う。

# 書式
terraform import <リソースタイプ>.<リソース名> <AWSリソースID>

# 例: コンソールで手動追加したセキュリティグループルールを管理下に
terraform import aws_security_group_rule.allow_8080 sg-0123456789abcdef0_ingress_tcp_8080_8080_0.0.0.0/0

# 例: コンソールで作成したS3バケットを管理下に
terraform import aws_s3_bucket.logs my-log-bucket-name

terraform import でstateにリソースを取り込んだ後は、対応する .tf ファイルのリソース定義も追加・修正する必要がある。

# import 後に .tf ファイルにも追加
resource "aws_security_group_rule" "allow_8080" {
  type  = "ingress"
  from_port= 8080
  to_port  = 8080
  protocol = "tcp"
  cidr_blocks = ["0.0.0.0/0"]
  security_group_id = aws_security_group.main.id
}

その後 terraform plan を実行して差分がゼロになったことを確認してからPRを作成する。

ガードレールの設置: SCP によるコンソール直接操作の制限

driftの根本原因はコンソール直接操作にある。AWS Organizations の Service Control Policy(SCP) を使って、Terraform管理外の操作を制限するのが最も効果的な予防策である。

{
  "Version": "2012-10-17",
  "Statement": [
 {
"Sid": "DenyDirectConsoleModification",
"Effect": "Deny",
"Action": [
  "ec2:AuthorizeSecurityGroupIngress",
  "ec2:AuthorizeSecurityGroupEgress",
  "ec2:RevokeSecurityGroupIngress",
  "ec2:RevokeSecurityGroupEgress"
],
"Resource": "*",
"Condition": {
  "StringNotEquals": {
 "aws:PrincipalArn": [
"arn:aws:iam::*:role/TerraformExecutionRole",
"arn:aws:iam::*:role/GitHubActionsRole"
 ]
  }
}
 }
  ]
}

このSCPにより、TerraformのIAMロールとGitHub ActionsのIAMロール以外からのSG変更操作を拒否できる。

まとめ: drift管理のチェックリスト

  • [ ] drift検知GHAワークフローを設置し、毎日定期実行している
  • [ ] Slack Incoming WebhookをRepository Secretsに登録した
  • [ ] drift検出時のplan出力がArtifactとして保存されている
  • [ ] CloudTrailでコンソール操作者を追跡できる体制がある
  • [ ] drift発見時のオペレーション手順がチームに周知されている
  • [ ] 必要に応じてSCPでコンソール直接操作を制限している
  • [ ] terraform import の使い方をチームメンバーが理解している

7. state破損からの復旧 — 強制unlock・state pull/push・rm/import

チームでTerraformを運用していると、避けられない事故が起きることがあります。terraform apply の途中でネットワークが切れた、誰かがコンソールからリソースを手動削除した、最悪のケースではS3バケットごとstateファイルが消えた——こうした状況に直面したとき、冷静に対処できるかどうかがプロダクションの安定性を左右します。

このセクションでは、state破損の代表的なシナリオを整理し、各状況で使うべきコマンドと手順を実践形式で解説します。「壊れてから調べる」ではなく、事前に手順を把握しておくことが最大のリスク軽減策です。


7-1. state破損のシナリオ

state破損には大きく3つのパターンがあります。それぞれ原因と症状が異なるため、まず状況を正確に診断することが重要です。

シナリオA: apply中断・ネットワーク断によるstateの不整合

terraform apply の実行中にプロセスが強制終了した場合、AWSリソースは中途半端な状態で作成されているのに、stateはそのリソースを認識していない、あるいは逆にstateにはレコードがあるのにリソースが存在しない、という不整合が生じます。

症状の例:

Error: Error acquiring the state lock

Error message: ConditionalCheckFailedException: The conditional request failed
Lock Info:
  ID:  xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
  Path:s3://my-tfstate-bucket/prod/terraform.tfstate
  Operation: OperationTypeApply
  Who: user@hostname
  Version:1.7.0
  Created:2024-03-15 09:23:11.123456789 +0000 UTC
  Info:

このエラーは、前回のapplyが異常終了してDynamoDBのロックレコードが残存しているときに発生します。別のapplyが本当に実行中なのか、ゾンビロックなのかを区別することが最初のステップです。

シナリオB: 複数メンバーが同一リソースをTerraform管理外で削除

チームの誰かがAWSコンソールやCLIでリソースを手動削除すると、stateにはそのリソースが「存在する」と記録されているのに、実際にはAWS上に存在しないという「ドリフト」が発生します。

症状の例:

│ Error: error reading S3 Bucket (my-app-bucket): NoSuchBucket: The specified bucket does not exist
│with aws_s3_bucket.app,
│on main.tf line 12, in resource "aws_s3_bucket" "app":
│12: resource "aws_s3_bucket" "app" {

次のterraform plan実行時にエラーが出るか、該当リソースの再作成が提案されます。チームでのTerraform運用において、コンソール操作禁止ルールの徹底がいかに重要かを示す典型例です。

シナリオC: S3バケットの誤削除でstateファイルが消えた

最も深刻なケースです。tfstateを格納しているS3バケット自体を誰かが削除してしまうと、すべてのリソースの管理情報が失われます。バージョニングが有効であれば復元できますが、バケットごと削除された場合は手動でstateを再構築する必要があります。

症状の例:

│ Error: Failed to get existing workspaces: S3 bucket does not exist.
│
│The referenced S3 bucket must have been previously created. If the S3 bucket
│was created within the last minute, please wait for a minute or two and try
│again.

このエラーを見たら、まずS3コンソールで対象バケットの存在確認と、削除保護・バージョニング設定の状況を確認してください。


7-2. 緊急対応: force-unlock

ロックが残存している場合の対処手順を説明します。必ず「本当に別のapplyが実行中でないか」を確認してから実行してください。 force-unlockを誤って実行すると、実行中のapplyと競合してstateが破損する可能性があります。

ロックIDの確認方法

方法1: エラーメッセージから取得

前述のエラーメッセージの ID: フィールドに記載されているUUIDがロックIDです。

方法2: DynamoDBコンソールで確認

  1. AWSコンソール → DynamoDB → テーブル → terraform-state-lock(または設定したテーブル名)
  2. 「項目を探索」をクリック
  3. ロックレコードが存在する場合、LockID 列に s3://バケット名/パス の形式で表示されます

方法3: AWS CLIで確認

aws dynamodb scan \
  --table-name terraform-state-lock \
  --query "Items[*].{LockID:LockID.S,Info:Info.S}" \
  --output table

出力例:

---------------------------------------------------------
| Scan |
+------------------------+------------------------------+
| Info |  LockID |
+------------------------+------------------------------+
|  {"ID":"xxxxxxxx-...}  |  s3://my-tfstate-bucket/...  |
+------------------------+------------------------------+

Info カラムの JSON を確認し、Who フィールドで誰がロックを取得したかを確認します。そのメンバーに「applyが今も実行中か」を確認してから次のステップに進んでください。

force-unlockの実行

ゾンビロックであることを確認したら、以下のコマンドで強制解除します:

terraform force-unlock LOCK_ID

実際のコマンド例:

terraform force-unlock xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

確認プロンプトが表示されます:

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

yes を入力すると、DynamoDBのロックレコードが削除されます。

解除後の確認:

aws dynamodb scan \
  --table-name terraform-state-lock \
  --select COUNT
{
 "Count": 0,
 "ScannedCount": 0,
 "ResponseMetadata": { ... }
}

Count: 0 を確認してから、terraform plan を実行して現状を把握してください。


7-3. state操作コマンドの使い方

Terraformには、stateファイルを直接操作するためのサブコマンド群があります。これらは「壊れたときの修復ツール」であると同時に、「リソースの所有権をTerraformに移管・返還するツール」でもあります。

terraform state pull — stateをローカルにダウンロード

# stateファイルをローカルにダウンロード(バックアップ)
terraform state pull > backup-$(date +%Y%m%d).tfstate

用途:
– 作業前のバックアップ取得
– stateの現状確認(JSONとして読める)
– 手動編集が必要な場面(後述の state push と組み合わせ)

実行前の確認事項:
terraform init が完了しており、backendへの接続が確立されていること
– 出力ファイルの保存先に書き込み権限があること

取得したstateファイルの中身を確認するには:

cat backup-$(date +%Y%m%d).tfstate | jq '.resources[].type' | sort | uniq
"aws_dynamodb_table"
"aws_iam_role"
"aws_s3_bucket"
"aws_s3_bucket_versioning"

管理対象リソースの種別一覧が確認できます。

terraform state push — stateをリモートにアップロード

# stateファイルをリモートにアップロード(復旧)
terraform state push backup-20240101.tfstate

用途:
– バックアップからの復旧
state pull → 手動編集 → state push による修復

実行前の確認事項:
– アップロード先のstateバージョンが現在より古いことを確認(新しいstateを古いもので上書きしない)
– チームメンバーが同時に作業していないことを確認
terraform plan でアップロード後の差分を把握してから apply を実行する

警告: state push は強力なコマンドです。誤ったstateをpushすると、それ以降の plan / apply がそのstateを正として動作します。作業前のstateを必ず別名でバックアップし、作業記録をチームのSlackやNotionに残してください。

terraform state rm — stateから特定リソースを削除

# stateから特定リソースを削除(Terraform管理から外す)
terraform state rm aws_s3_bucket.example

用途:
– 手動削除済みのリソースをstateから取り除く(次の plan でエラーが出なくなる)
– リソースをTerraform管理から外して別の管理方法に移行する

実行前の確認事項:
– 削除対象のリソース名を terraform state list で正確に確認する
– そのリソースが本当にAWS上に存在しないか、または意図的に管理対象から外すのかを確認する

まず管理対象リソースの一覧を確認:

terraform state list
aws_dynamodb_table.terraform_locks
aws_s3_bucket.app
aws_s3_bucket.tfstate
aws_s3_bucket_versioning.tfstate_versioning

特定リソースの詳細確認:

terraform state show aws_s3_bucket.app
# aws_s3_bucket.app:
resource "aws_s3_bucket" "app" {
 bucket  = "my-app-bucket-prod"
 id= "my-app-bucket-prod"
 ...
}

確認後に削除:

terraform state rm aws_s3_bucket.app
Removed aws_s3_bucket.app
Successfully removed 1 resource instance(s).

terraform import — 既存リソースをstateに取り込む

# 既存リソースをstateに取り込む
terraform import aws_s3_bucket.example my-existing-bucket

用途:
– コンソールで手動作成したリソースをTerraform管理下に置く
state rm でTerraform管理から外したリソースを再度取り込む
– 別のTerraformプロジェクトで管理していたリソースを移管する

実行手順:

  1. まずTerraformコード(.tfファイル)に対応する resource ブロックを記述する
resource "aws_s3_bucket" "example" {
  bucket = "my-existing-bucket"
}
  1. terraform import を実行する
terraform import aws_s3_bucket.example my-existing-bucket
aws_s3_bucket.example: Importing from ID "my-existing-bucket"...
aws_s3_bucket.example: Import prepared!
  Prepared aws_s3_bucket for import
aws_s3_bucket.example: Refreshing state... [id=my-existing-bucket]

Import successful!

The resources that were imported are shown above. These resources are now in
your Terraform state and will henceforth be managed by Terraform.
  1. terraform plan を実行して差分を確認する
terraform plan

コードと実際のリソース状態に差がある場合、ここで差分が表示されます。コードを実際の状態に合わせるか、apply で実際のリソースをコードの定義に合わせるかを判断してください。

importアドレスの調べ方:

各リソースタイプのimportアドレス形式は、Terraform公式ドキュメントの各リソースページの「Import」セクションに記載されています。主要リソースの例:

リソースタイプimportアドレス形式
aws_s3_bucketバケット名my-bucket
aws_dynamodb_tableテーブル名my-table
aws_vpcVPC IDvpc-12345678
aws_iam_roleロール名my-role
aws_security_groupセキュリティグループIDsg-12345678

7-4. S3バージョニングを使ったstate復元

Section 2でS3バケットにバージョニングを設定しました。この設定が、stateファイルの事故復旧に直接役立ちます。

AWSコンソールでのバージョン確認・ダウンロード

  1. S3コンソール → 対象バケット(例: my-tfstate-bucket)を開く
  2. 左ペインの「バケットを表示」で terraform.tfstate ファイルを選択
  3. 「バージョン」タブをクリック
  4. バージョン一覧が表示される(最新から古い順)
バージョンID  最終更新日時  サイズ
--------------------------------------------------------------------
abc123def456ghi789 2024-03-15 09:30:0045.2 KB  ← 最新(破損している可能性)
xyz987uvw654rst321 2024-03-15 09:00:0044.8 KB  ← 復元候補
mno111pqr222stu333 2024-03-14 18:00:0044.7 KB  ← さらに古いバージョン
  1. 復元したいバージョンを選択し、「ダウンロード」をクリック
  2. ダウンロードしたファイルを restore-YYYYMMDD.tfstate のような名前で保存

AWS CLIでのバージョン確認・ダウンロード

# バージョン一覧の確認
aws s3api list-object-versions \
  --bucket my-tfstate-bucket \
  --prefix prod/terraform.tfstate \
  --query "Versions[*].{VersionId:VersionId,LastModified:LastModified,Size:Size}" \
  --output table
---------------------------------------------------------
|  ListObjectVersions  |
+------------------+------------------------------+------+
|LastModified| VersionId  | Size |
+------------------+------------------------------+------+
|  2024-03-15T...  |  abc123def456ghi789... | 46310|
|  2024-03-15T...  |  xyz987uvw654rst321... | 45875|
+------------------+------------------------------+------+
# 特定バージョンをダウンロード
aws s3api get-object \
  --bucket my-tfstate-bucket \
  --key prod/terraform.tfstate \
  --version-id xyz987uvw654rst321 \
  restore-20240315.tfstate

復元後の整合性確認

# 復元したstateをリモートに適用
terraform state push restore-20240315.tfstate

# 現状との差分を確認
terraform plan

terraform plan の出力で差分が表示される場合、それはstateを復元した時点からの変更(または削除)を示しています。各差分について:

  • # aws_xxx.yyy will be created → stateには記録がないがAWS上にリソースがある(terraform import が必要)
  • # aws_xxx.yyy will be destroyed → stateには記録があるがAWS上にリソースがない(terraform state rm が必要)
  • # aws_xxx.yyy will be updated → 設定値の差分(コードまたはstateを修正)

重要: 復元後にいきなり terraform apply を実行しないでください。plan の出力を精査し、チームで影響範囲を確認してから apply を実行します。


7-5. 破損を防ぐためのバックアップ設計

事後対応より事前予防が重要です。以下の設計をTerraformプロジェクト開始時に組み込んでおくことで、state破損のリスクを大幅に低減できます。

S3バージョニングの有効化(必須)

Section 2で設定済みですが、改めて強調します。バージョニングなしのS3 backendは運用すべきでありません。 設定は以下の通りです:

resource "aws_s3_bucket_versioning" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id

  versioning_configuration {
 status = "Enabled"
  }
}

バージョニングを有効にすると、terraform apply のたびにstateの新バージョンが自動保存されます。

lifecycle policyで古いバージョンを自動削除

バージョニングを有効にし続けると、時間の経過とともに古いバージョンが蓄積してストレージコストが増大します。lifecycle policyで自動削除のルールを設定してください:

resource "aws_s3_bucket_lifecycle_configuration" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id

  rule {
 id  = "tfstate-version-cleanup"
 status = "Enabled"

 noncurrent_version_expiration {
noncurrent_days  = 90
newer_noncurrent_versions = 10
 }

 abort_incomplete_multipart_upload {
days_after_initiation = 7
 }
  }
}

このルールの意味:
noncurrent_days = 90: 最新バージョン以外のバージョンは90日後に削除
newer_noncurrent_versions = 10: 常に最新10バージョンを保持(90日以内でも)
abort_incomplete_multipart_upload: 中断したアップロードは7日後に自動削除

重要環境のstateファイルの定期エクスポート

本番環境のstateは、GitHub Actionsのスケジュールワークフローで定期的にエクスポートしておくと安心です:

name: Backup Terraform State

on:
  schedule:
 - cron: '0 1 * * *'  # 毎日午前1時(UTC)
  workflow_dispatch:

jobs:
  backup:
 runs-on: ubuntu-latest
 permissions:
id-token: write
contents: write

 steps:
- uses: actions/checkout@v4

- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
 role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
 aws-region: ap-northeast-1

- name: Backup state file
  run: |
 DATE=$(date +%Y%m%d)
 aws s3 cp \
s3://my-tfstate-bucket/prod/terraform.tfstate \
s3://my-tfstate-backup-bucket/prod/terraform-${DATE}.tfstate

このワークフローは毎朝1時にstateファイルを別バケットに日次バックアップします。バックアップ専用バケットにはバージョニングと削除保護を有効化し、本番バケットとは別のIAMポリシーで保護することをお勧めします。

まとめ: state保護の3層防御

対策効果
第1層S3バージョニング有効化apply単位でのロールバックが可能
第2層DynamoDBロック同時書き込みによる競合を防止
第3層別バケットへの日次バックアップバケット誤削除からの復旧が可能

3層すべてを設定することで、ほぼすべてのstate破損シナリオに対応できます。


8. まとめと次のステップ

8-1. この記事のまとめ

この記事では、複数人でTerraformを安全に運用するための基盤として、以下の内容を実践しました:

  • S3 remote backend の構築: tfstateをローカルからクラウドに移行し、チームで共有できる状態を実現した
  • DynamoDBによるstate locking: 複数人が同時にapplyを実行しても、stateが競合しない排他制御の仕組みを導入した
  • ワークスペース分離: terraform workspace を使って、同一コードベースからdev/staging/prodの環境を分離する方法を習得した
  • ディレクトリ分離戦略: workspaceと比較して、ディレクトリ分離がどのような場面で有効かを理解した
  • drift検知: GitHub Actions のスケジュールワークフローで毎日 terraform plan を実行し、コンソール操作による設定ズレを自動検出する仕組みを構築した
  • state操作コマンド: state pull/push/rm/import の使い方と、適切な使用場面を理解した
  • state破損からの復旧: force-unlock、S3バージョニングを使ったロールバック、手動importによる再構築の手順を把握した

チーム開発Terraformの「最低限やること」チェックリスト

新しいTerraformプロジェクトをチームで開始する際、以下の項目をすべて確認してください:

□ S3 backend 設定済み(バージョニング有効、MFA削除保護有効)
□ DynamoDB lock テーブル設定済み(PAY_PER_REQUEST)
□ 環境分離(workspace or ディレクトリ)設計済み・合意済み
□ drift検知workflow(GHA schedule: terraform plan)設定済み
□ state操作コマンドをチーム全員が理解済み(勉強会推奨)
□ state破損時の連絡フロー・対応手順をドキュメント化済み
□ tfstateバケットのバックアップ設計済み(lifecycle policy + 日次コピー)

このチェックリストをプロジェクト開始時のオンボーディングドキュメントに組み込んでおくことで、後から「ロックを設定し忘れていた」「バージョニングが無効だった」というヒヤリハットを防げます。


8-2. 次弾予告と末尾CTA

state管理の基盤が整った。次回はこれをGitHub PRフローと連携させ、チームで安全にapplyを実行するCI/CDパイプラインを構築する。

具体的には、PRを作成したときに自動で terraform plan を実行してレビュワーに差分を見せ、マージ後に terraform apply を自動実行するワークフローを、GitHub Actions + OIDC(secretlessな認証)を使って実装します。「誰がいつapplyを承認したか」のトレーサビリティも確保しながら、ヒューマンエラーを排除する自動化の仕組みを構築します。

次の記事 → 第2弾: PR駆動TerraformCI/CD — GitHub Actions+OIDCで複数人レビューフローを構築


8-3. 参考リンク

Terraform公式ドキュメント

HashiCorp Learn / Terraform Tutorials

前作シリーズ(Git/GitHub × Terraform 実践シリーズ)