この記事でわかること
- 2026年版のstateバックエンド構成(S3 native locking が主、DynamoDB はレガシー)
- stateが壊れたときに確実に巻き戻す復旧手順
- 「Thin Module / Thick Module」の判断基準とモジュールのバージョニング戦略
- 前回記事のCloudWatchアラームをモジュール化する実例
- plan を「読める」差分にする tfcmt / tflint / Trivy / infracost の組み合わせ
- 2026年版アンチパターン(Provider版固定漏れ・
ignore_changes濫用・importブロック未活用 ほか)
この記事を書いた経緯
前回の記事(CloudWatchアラームの設計)で、構成をまるごと再現できる Terraform コードを載せました。コードを書くこと自体はそれほど難しくありません。難しいのは「そのコードをチームで安全に運用し続けること」 です。
筆者が見てきた中で頻発するのは次のパターン:
- 引き継いだ環境の
terraform.tfstateが Gitにそのままコミットされている - 2人が同時に apply して stateが壊れて 数時間ロールバック作業
- モジュールを切ったはいいが 巨大化してメンテ不能 → コピペで横展開され始める
- Provider バージョンが固定されていない ため、ある日突然 plan の差分が大量に出る
- DynamoDBロックが2026年現在もそのまま放置されていて、S3 native lockingに移行できていない
これらは全て「設計と運用フローの問題」で、Terraform 自体の不具合ではありません。本記事は、前回記事の続きとして 「書いたコードをチームで安全に運用する」 ために必要な実務知識をまとめたものです。
実務での背景
Terraform は宣言的にインフラを管理できる強力なツールですが、その強さの裏に「状態(state)を持つツールである」という運用負荷があります。state を扱う設計を間違えると、コードがいかに綺麗でも本番事故に直結 します。
本記事では設計を3つの軸で整理します。
- State管理 — どこに置くか、どうロックするか、壊れたらどう戻すか
- モジュール設計 — どう分けるか、どうバージョニングするか
- 運用フロー — plan/apply をチームでどう回すか、CIで何を止めるか
State管理:2026年版バックエンド構成
S3 native locking が主流(DynamoDB はレガシー)
Terraform 1.11 で S3 backend の native locking がGAし、DynamoDBテーブルを別途用意せずとも state ロックが取れるようになりました。新規構築ならこちらを採用するのが2026年現在の標準です。
# backend.tf (2026年版・推奨)
terraform {
required_version = ">= 1.11"
backend "s3" {
bucket = "my-terraform-state-prod"
key = "services/billing-api/terraform.tfstate"
region = "ap-northeast-1"
encrypt = true
kms_key_id = "alias/terraform-state"
use_lockfile = true # ← S3 native locking。DynamoDBテーブル不要
}
}
旧来の DynamoDB ロック構成は以下です。既存環境がこの形なら、いきなり消さず に use_lockfile = true を併用して移行期間を設け、安定したらDynamoDB側を撤去します。
# 旧構成(レガシー、移行期間中の併用は可)
terraform {
backend "s3" {
bucket = "my-terraform-state-prod"
key = "services/billing-api/terraform.tfstate"
region = "ap-northeast-1"
encrypt = true
kms_key_id = "alias/terraform-state"
dynamodb_table = "terraform-state-lock"
use_lockfile = true # ← 併用すると両方でロックを取りに行く
}
}
Stateバケット側の必須設定
State用 S3 バケットには、以下を 必ず 入れます。1つでも欠けると事故時の復旧難度が跳ね上がります。
resource "aws_kms_key" "tfstate" {
description = "Terraform state encryption key"
enable_key_rotation = true
deletion_window_in_days = 30
}
resource "aws_kms_alias" "tfstate" {
name = "alias/terraform-state"
target_key_id = aws_kms_key.tfstate.key_id
}
resource "aws_s3_bucket" "tfstate" {
bucket = "my-terraform-state-prod"
}
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
}
}
}
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
}
| 設定 | 理由 |
|---|---|
| Versioning | これが無いと壊れたstateは戻せない。最重要 |
| KMS暗号化 | state にはARN/IP/シークレット参照が平文で残る。コンプライアンス必須 |
| Public Access Block | 公開事故の物理的禁止 |
State が壊れたときの復旧手順
stateが壊れる現実的なシナリオは2つ。①誰かが手でいじった、②同時applyでロックが効かなかった(旧構成)。どちらも S3 バージョニングがあれば戻せます。
Step 1: 現在の壊れたstateを退避
aws s3 cp s3://my-terraform-state-prod/services/billing-api/terraform.tfstate \
./terraform.tfstate.broken
Step 2: 健全だった世代を特定
# 全バージョン一覧(新しい順)
aws s3api list-object-versions \
--bucket my-terraform-state-prod \
--prefix services/billing-api/terraform.tfstate \
--query 'Versions[].[LastModified,VersionId,Size]' \
--output table
LastModified と Size を見て、壊れた直前のバージョン を見つけます。
Step 3: 該当バージョンを取得して中身を検証
aws s3api get-object \
--bucket my-terraform-state-prod \
--key services/billing-api/terraform.tfstate \
--version-id "abcDEFghi123..." \
./terraform.tfstate.candidate
# state の lineage と serial を確認
jq '{lineage, serial, terraform_version}' < terraform.tfstate.candidate
Step 4: 巻き戻し
# 実Stateを差し替える(最後の砦:ローカルからの push)
terraform init -reconfigure
terraform state push terraform.tfstate.candidate
その後 terraform plan を流して、現実のAWSリソースと state が一致しているかを必ず確認します。ズレていれば terraform import ではなく import ブロック(後述)で1リソースずつ取り込み直します。
モジュール設計:Thin か Thick か
「モジュールに切るべきか直書きするべきか」で迷ったときの判断基準を、表で整理します。
| 判断軸 | Thin Module(薄い) | Thick Module(厚い) |
|---|---|---|
| 中身 | 単一リソース + 周辺の自明な付随リソース | 複合的なシステム(ALB+ASG+RDSなど) |
| 入力変数 | 5〜10個程度 | 大量(数十)になりがち |
| 再利用範囲 | 全社共通 | サービス単位 |
| バージョニング | Semantic Versioning + git tag | サービス毎に最新を直参照でも可 |
| 採用例 |
s3-bucket-secure, iam-role-with-trust
|
web-service-stack |
迷ったらまずThin が原則です。Thick module は最初から作ろうとすると失敗します。Thinを組み合わせて使い込んだあとで、共通パターンをThickに昇格させる流れが安全。
モジュールのバージョニング(git tag必須)
社内モジュールを source = "../../modules/foo" のようなローカル相対パスで参照するのは、最初は楽ですが 環境間で同じバージョンを使い続ける保証が無く なり、いずれ事故ります。git tag でバージョン固定するのが標準です。
module "alarm_cpu" {
source = "git::https://github.com/myorg/terraform-modules.git//cloudwatch-alarm?ref=v1.4.0"
alarm_name = "prod-app-api-p95-latency-high"
metric_namespace = "MyApp"
metric_name = "ApiLatency"
extended_statistic = "p95"
threshold = 500
evaluation_periods = 5
datapoints_to_alarm = 3
}
?ref=v1.4.0 でSemVerタグを指定。「mainを参照」は本番では原則禁止。
実例:前回記事のCloudWatchアラームをモジュール化する
前回の記事 で生のリソース定義として書いた aws_cloudwatch_metric_alarm を、再利用可能なモジュールに切り出します。同じ形のアラームを大量に作るチームほど効果が大きいセクションです。
モジュール本体
# modules/cloudwatch-alarm/variables.tf
variable "alarm_name" {
description = "アラーム名(例: prod-app-api-p95-latency-high)"
type = string
validation {
condition = can(regex("^(prod|stg|dev)-", var.alarm_name))
error_message = "alarm_name は prod- / stg- / dev- のいずれかで始めてください"
}
}
variable "metric_namespace" {
type = string
}
variable "metric_name" {
type = string
}
variable "extended_statistic" {
type = string
default = "p95"
}
variable "period" {
type = number
default = 300
}
variable "evaluation_periods" {
type = number
default = 5
}
variable "datapoints_to_alarm" {
type = number
default = 3
}
variable "threshold" {
type = number
}
variable "comparison_operator" {
type = string
default = "GreaterThanThreshold"
}
variable "treat_missing_data" {
type = string
default = "notBreaching"
validation {
condition = contains(["missing", "ignore", "breaching", "notBreaching"], var.treat_missing_data)
error_message = "treat_missing_data は missing / ignore / breaching / notBreaching のいずれか"
}
}
variable "alarm_actions" {
type = list(string)
default = []
}
variable "ok_actions" {
type = list(string)
default = []
}
variable "dimensions" {
type = map(string)
default = {}
}
# modules/cloudwatch-alarm/main.tf
resource "aws_cloudwatch_metric_alarm" "this" {
alarm_name = var.alarm_name
namespace = var.metric_namespace
metric_name = var.metric_name
extended_statistic = var.extended_statistic
period = var.period
evaluation_periods = var.evaluation_periods
datapoints_to_alarm = var.datapoints_to_alarm
threshold = var.threshold
comparison_operator = var.comparison_operator
treat_missing_data = var.treat_missing_data
dimensions = var.dimensions
alarm_actions = var.alarm_actions
ok_actions = var.ok_actions
}
output "alarm_arn" {
value = aws_cloudwatch_metric_alarm.this.arn
}
output "alarm_name" {
value = aws_cloudwatch_metric_alarm.this.alarm_name
}
呼び出し側
module "alarm_api_latency" {
source = "git::https://github.com/myorg/terraform-modules.git//cloudwatch-alarm?ref=v1.4.0"
alarm_name = "prod-app-api-p95-latency-high"
metric_namespace = "MyApp"
metric_name = "ApiLatency"
threshold = 500
alarm_actions = [aws_sns_topic.warning.arn]
ok_actions = [aws_sns_topic.warning.arn]
}
入力5行で済むようになり、「同じ形のアラームを30個作る」が現実的 になります。前回記事のしきい値設計(M out of N、SLO逆算)と組み合わせることで、設計と実装の両方を標準化できます。
plan を「読める」差分にする
terraform plan の出力は、リソース数が増えると人間に読めません。CIで仕組み的に支援します。
tfcmt: PRに差分をコメント
# tfcmt: GitHub PR に plan 結果を整形してコメント
tfcmt plan -- terraform plan
PR上で「どのリソースがどう変わるか」が要約された日本語コメントが付くようになります。レビュアーが端末を開かずに済むのが大きい。
tflint: 静的解析
tflint --init
tflint --recursive
「使われていない変数」「Provider固有のNGパターン」を plan より早く 落とせます。
infracost: PRに月額コスト差分
infracost breakdown --path . --format json --out-file baseline.json
infracost diff --path . --compare-to baseline.json
「このPRをマージすると月額 +$420」のような差分が出るので、コストレビューを習慣化 できます。
GitHub Actions のフル構成
name: Terraform CI
on: [pull_request]
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.11.0
- name: terraform fmt check
run: terraform fmt -check -recursive
- name: tflint
uses: terraform-linters/setup-tflint@v4
- run: tflint --recursive
- name: Trivy (config scan)
uses: aquasecurity/trivy-action@master
with:
scan-type: config
scan-ref: .
- name: terraform plan
run: |
terraform init
terraform plan -out=tfplan
- name: tfcmt
uses: suzuki-shunsuke/tfcmt-action@v1
with:
subcommand: plan
- name: infracost diff
uses: infracost/actions/setup@v2
with:
api-key: ${{ secrets.INFRACOST_API_KEY }}
構成図
2026年版アンチパターン
❌ required_providers / required_version を書かない
書かないと 環境ごとに別バージョンのProviderで apply されて、同じコードから違う差分が出ます。最低でも次の3行は必ず入れます。
terraform {
required_version = ">= 1.11, < 2.0"
required_providers {
aws = {
source = "hashicorp/aws"
# メジャーバージョンは固定し、マイナー以下の最新を許容する
# 例: 6.x 系を使う場合
version = "~> 6.0"
}
}
}
❌ lifecycle { ignore_changes = ... } の濫用
「差分が出るからとりあえずignore」は禁断の手。ignoreした瞬間にTerraformの管理から外れる のと等価になります。本当に必要なのは以下くらい:
- AMIをAuto Update運用している場合の
ami - ASG の
desired_capacity(Auto Scalingが上下させる) - RDSの
password(Secrets Managerローテーション運用)
それ以外で ignore_changes を足したくなったら、設計が間違っている合図 です。
❌ terraform import コマンドを使い続ける
Terraform 1.5+ で import ブロック が登場し、PR上で import が完結するようになりました。コマンド版は副作用が大きく、レビューも残らないので、新規は必ずこちら。
import {
to = aws_s3_bucket.legacy
id = "my-legacy-bucket-2018"
}
resource "aws_s3_bucket" "legacy" {
bucket = "my-legacy-bucket-2018"
}
terraform plan -generate-config-out=generated.tf で resource 本体の雛形まで生成されます。
❌ モジュールの source で git tag を使わない
社内モジュールが source = "../../modules/foo" で参照されている環境は、全環境のバージョンが暗黙的に一致してしまっている 危険な状態です。git tag (?ref=v1.4.0) に移行します。
❌ prevent_destroy = true だけで安心する
prevent_destroy は コードを書き換えれば外せる ので、最終防壁にはなりません。本当に重要なリソース(本番DB等)には以下を併用します:
- IAM Policy で
*-prod-*系リソースのDelete*を SCP/Permission Boundary で禁止 - S3バケットには Object Lock + バージョニング
- RDSには
deletion_protection = trueと自動スナップショット
❌ stateバケットの versioning を切っている
本記事の前半で書いたとおり、versioning が無いstateは復旧不能。組織標準に組み込んでください。
まとめ:明日から手を付ける順番
| 優先度 | やること | 効果 |
|---|---|---|
| 1 | stateバケットの versioning / KMS / Public Access Block を全環境で確認 | 復旧可能性の担保 |
| 2 |
required_version / required_providers を全プロジェクトに追加 |
環境間Provider差分のブロック |
| 3 | DynamoDB ロック → S3 native locking (use_lockfile) への移行検討 |
運用リソース削減 |
| 4 | 社内モジュールを git tag で参照する形に変更 | バージョン管理の明示化 |
| 5 |
tflint + Trivy + tfcmt + infracost をCIに足す |
レビュー品質の底上げ |
| 6 |
import コマンド禁止、import ブロックに統一 |
取り込み履歴の可視化 |
| 7 | よく作る単一リソースから Thin Module 化(例:本記事の cloudwatch-alarm) | コード量の削減 |
Terraform は「書けば動く」だけのツールではなく、チームで安全に運用するための設計を最初から仕込むツール です。コードのきれいさより、復旧可能性と運用フローの強さを優先してください。
次回は 「S3バケットのセキュリティ設定チェックリスト」 を書きます。本記事で何度も登場した state バケットの保護要件を、汎用のS3セキュリティとして再整理します。
