Blogへ戻る

Language: 日本語

「terraform state mv」を手動で叩いていませんか? 745 件のリソースを安全に移動させた moved ブロック設計

肥大化してスパゲッティになった Terraform のコードベースを前に、**「ディレクトリ構造を整理して module に分割したい……でも state が壊れるのが怖くて手が出せない……」** と悩んだことはありませんか?

肥大化してスパゲッティになった Terraform のコードベースを前に、「ディレクトリ構造を整理して module に分割したい……でも state が壊れるのが怖くて手が出せない……」 と悩んだことはありませんか?

リソースのアドレスやモジュール構成を変更しようとすると、従来はターミナルで terraform state mv を何十回も手動実行する必要がありました。

しかし、手動の state 操作には 「PR でコードレビューができない」「1 文字タイポしただけで次の apply で本番リソースが Destroy & 再作成される」 という恐ろしいリスクが常に付きまといます。

2 つのリージョンにまたがる 6 つの root(dev / stg / prd)で構成された本番 Terraform 基盤を、1,500 行の巨大な god module から 8 つのドメイン別サブシステムへ完全再設計 しました。

このリファクタリングにおいて、手動の state mv は 1 回も使っていません。Terraform の moved ブロックを駆使して 745 件のリソース移動を宣言的にコード化し、すべての PR で「plan 差分ゼロ(No changes)」を機械検証 しながら安全に完遂しました。

この記事では、大規模 Terraform リファクタリングを無停止・破壊ゼロで成功させる moved ブロック設計と、index の罠、そして連鎖(Chained moved)を防ぐライフサイクル管理 を解説します。

得られること

  • terraform state mv(命令的手作業)を脱却し、moved ブロックで宣言的にリソース移動をコードレビュー可能にする手法がわかる
  • 1,500 行の god module を 8 つのドメイン別サブシステムへ分割し、root を薄い Composition Root に整える設計が学べる
  • countfor_each 移行時の index 指定ミスによる Destroy 事故を防ぎ、PR 上で「差分ゼロ」を機械検証するガードレールが身につく!

改善前の地獄: 1,500 行の god module と散らばる三項演算子

リファクタリング前の Terraform は、以下の問題を抱えていました。

【Before: 巨大な god module と複雑な依存】
Terraform Root (6 環境: 2 regions × dev / stg / prd)

  ├─ regional-environment module (約 1,500 行の巨大ファイルに全リソースが同居) 💥
  ├─ root への直書きリソースが多数散乱
  └─ 環境差分が 53 箇所の三項演算子で分岐して可読性が崩壊

直面した絶対の制約

「コード構造は根本から綺麗に作り替えたい。しかし、稼働中の本番リソース(Cloud SQL やロードバランサ、Cloud Run)を作り直す(Destroy & Recreate)ことは絶対に許されない

この厳しい制約をクリアするために採用したのが、Terraform 1.1 で導入された moved ブロック による宣言的リファクタリングでした。

8 つのドメイン別サブシステムへの分割

まず、1,500 行の god module を解体し、明確な責任境界を持つ 8 つのドメイン別サブシステム に分割しました。

【After: 8 つのサブシステムと薄い Composition Root】
Terraform Root (薄い Composition Root: サブシステムの配線のみを記述)
  ├── network         # VPC, Subnet, Cloud NAT, Firewall
  ├── data            # Cloud SQL (PostgreSQL), Redis
  ├── identity        # IAM, Service Accounts, Workload Identity
  ├── secrets_mgmt    # Secret Manager, KMS
  ├── runtime         # Cloud Run, Cloud Run Jobs
  ├── edge            # Cloud Load Balancing, Cloud Armor (WAF), SSL
  ├── observability   # Cloud Logging, Cloud Trace, Alerting, Slack Relay
  └── data_platform   # BigQuery, Cloud Storage

root 側には個別のリソース定義を一切置かず、「どのサブシステムをどのパラメータで組み合わせるか」という宣言だけを残す Composition Root パターン に統一しました。

moved ブロックによる宣言的リファクタリング

構造を変更する際、リソースのアドレス変更を moved ブロックとして .tf ファイルに記述します。

# moved ブロックによる宣言的なリソース移動
moved {
  from = google_cloud_run_v2_service.backend
  to   = module.runtime.google_cloud_run_v2_service.backend
}

moved {
  from = module.regional_environment.google_sql_database_instance.default
  to   = module.data.google_sql_database_instance.default
}

moved ブロックの絶大なメリット

  1. PR でレビューできる: どのリソースがどこへ移動するのかがコード差分として Git に残り、チーム全員でレビューできます。
  2. plan 差分で「移動のみ」を機械的に証明できる: terraform plan を実行すると、以下のように「移動のみが行われ、追加・変更・破棄がゼロであること」が事前に 100% 保証されます。
    Plan: 0 to add, 0 to change, 0 to destroy.

踏み抜いた罠: count と index の位置ミス

moved ブロックを書く上で、最も注意すべきなのが countfor_each が付いたモジュール移行時の index の位置 です。

# ❌ 間違い: 移動元の index を指定し忘れた
moved {
  from = module.foo
  to   = module.bar[0]
}

# ⭕️ 正解: 正しい index を明示指定
moved {
  from = module.foo[0]
  to   = module.bar
}

この index の位置を 1 箇所でも間違えると、Terraform は「旧リソースの削除(Destroy)と新リソースの新規作成(Create)」として plan を生成してしまいます。

実際のリファクタリング中にもこのミスが 1 件見つかりましたが、PR の CI で「Plan に Destroy が含まれていたら即座に失敗させる」という機械検証 を敷いていたため、本番適用前に確実に防ぐことができました。

module 入力の整理: nullable 化とセンチネル値の撤廃

構造の分割と並行して、module の入力インターフェースも徹底的にクリーンアップしました。

以前は「空文字 "" や sentinel 値」を渡して内部で var.endpoint != "" と判定していましたが、これを Terraform 1.1+ の nullable = true!= null に全面移行しました。

# Before: 空文字を未指定の代わりに使う
enabled = var.endpoint != ""

# After: 未指定は null で表現し、型で縛る
enabled = var.endpoint != null

この変更によって、6 root × 7 リソースの全真偽値テーブルを作成して挙動が一切変わらないことを確認し、散乱していた 53 箇所の三項演算子を排除して全環境が同じコードパスを通るように統一しました。

moved / import / removed の撤去とライフサイクル管理

movedimportremoved は、移行が完了したら消せます。 残しておくと、次に構造を変える人がどれが現役か分からなくなる。

最終的に 745 件(moved 741、import 1、removed 3)を撤去しました。

ただし、この撤去はマージのタイミングが危ない。 prd に apply される前に moved を消すと、prd の plan は「リネーム済みのリソースを destroy して create する」形で現れます。

そこで、この PR は prd と prd-b の apply が完了するまで Draft のままにしました。 dev、dev-b、stg、stg-b は先行して apply 済みで、destroy がゼロであることを確認してあります。

同じ理由で、moved の chain にも注意が要ります。 prd がまだ Phase 3/4 の moved を apply していない状態で次の変更を入れると、prd の apply は「god module → サブシステム → for_each のキー」という 2 段の chain を一度に解決することになる。 dev と stg は 1 段ずつ消化しているので、同じ変更でも挙動が違います。

検証の組み方

この規模の変更では、テストが通っても安心できません。 リソースが変わっていないことは、テストではなく plan で見るしかないのです。

やったのは次の組み合わせです。

  • 全 7 スタックの validatefmt
  • module 単位の tftest
  • SQL ファイルの sha256 ドリフト検査(28 件)
  • PR plan CI での no-change の機械検証
  • 別モデルによる敵対的レビュー

敵対的レビューでは、実際に REJECT が出ています。 data platform の正規化では、回帰テストが消えているという指摘を受けて修正し、再検証しました。 サブシステム化の Phase 3 でも、moved の index と contract の配線、開発環境の backend への IAP 誤付与といった指摘を受けています。

自分で書いて自分で確認すると、同じ前提のまま見落とします。 構造を大きく動かす変更では、前提を共有しない読み手を挟むことにしています。