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 に整える設計が学べる
countやfor_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 ブロックの絶大なメリット
- PR でレビューできる: どのリソースがどこへ移動するのかがコード差分として Git に残り、チーム全員でレビューできます。
- plan 差分で「移動のみ」を機械的に証明できる:
terraform planを実行すると、以下のように「移動のみが行われ、追加・変更・破棄がゼロであること」が事前に 100% 保証されます。Plan: 0 to add, 0 to change, 0 to destroy.
踏み抜いた罠: count と index の位置ミス
moved ブロックを書く上で、最も注意すべきなのが count や for_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 の撤去とライフサイクル管理
moved と import と removed は、移行が完了したら消せます。
残しておくと、次に構造を変える人がどれが現役か分からなくなる。
最終的に 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 スタックの
validateとfmt - module 単位の
tftest - SQL ファイルの sha256 ドリフト検査(28 件)
- PR plan CI での no-change の機械検証
- 別モデルによる敵対的レビュー
敵対的レビューでは、実際に REJECT が出ています。
data platform の正規化では、回帰テストが消えているという指摘を受けて修正し、再検証しました。
サブシステム化の Phase 3 でも、moved の index と contract の配線、開発環境の backend への IAP 誤付与といった指摘を受けています。
自分で書いて自分で確認すると、同じ前提のまま見落とします。 構造を大きく動かす変更では、前提を共有しない読み手を挟むことにしています。