0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Terraform実務Tips:stateファイル管理とモジュール設計のベストプラクティス

0
Posted at

この記事でわかること

  • 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.tfstateGitにそのままコミットされている
  • 2人が同時に apply して stateが壊れて 数時間ロールバック作業
  • モジュールを切ったはいいが 巨大化してメンテ不能 → コピペで横展開され始める
  • Provider バージョンが固定されていない ため、ある日突然 plan の差分が大量に出る
  • DynamoDBロックが2026年現在もそのまま放置されていて、S3 native lockingに移行できていない

これらは全て「設計と運用フローの問題」で、Terraform 自体の不具合ではありません。本記事は、前回記事の続きとして 「書いたコードをチームで安全に運用する」 ために必要な実務知識をまとめたものです。


実務での背景

Terraform は宣言的にインフラを管理できる強力なツールですが、その強さの裏に「状態(state)を持つツールである」という運用負荷があります。state を扱う設計を間違えると、コードがいかに綺麗でも本番事故に直結 します。

本記事では設計を3つの軸で整理します。

  1. State管理 — どこに置くか、どうロックするか、壊れたらどう戻すか
  2. モジュール設計 — どう分けるか、どうバージョニングするか
  3. 運用フロー — 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 }}

構成図

image.png


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セキュリティとして再整理します。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?