なぜTerraformがつらくなるのか?モジュール設計とその解決策
※本記事は Terraform を AWS のインフラ構築に利用する前提で書いています。
※ここで挙げる内容は、あくまで筆者の経験ベースの指針であり、唯一の正解というわけではありません。
Terraform は「初期構築のときに作って、その後必要なときだけ触るもの」になりがちです。
しかし時間が経つにつれて、次のような状況に直面します。
- どのファイルに何が書かれているか分からない
- 変更の影響範囲が読めず
terraform applyが怖い - 消したい機能に関連する module が分離できず、安全に削除できない
そして、動いている環境がある以上、改善はどうしても後回しになり、最終的には Terraform 自体が負債化してしまいます。
この記事では、その中でも 「module の設計」 に絞って、長期運用でも破綻しにくい考え方をまとめます。
結論:module 設計で特に意識していること
Terraform を長期運用する上で、module 設計で特に意識しているのは次のとおりです。
-
module は機能単位で作る
例:EC2 + IAM + SecurityGroup + APIGW → WebServer
→ 機能ごとに削除・交換・複製・移植がしやすくなる。 -
AWS Service 単位では module にしない
→ IAM だけ、S3 だけ、EC2 だけの module だと「単体では意味を持たない構造」になりがち。 -
module は可能な範囲で小さく保つ
→ 可読性・テスト性が上がり、変更時の影響範囲が読みやすくなる。
このあとで、なぜこの3つが効いてくるのかを具体例とあわせて見ていきます。
module 設計の考え方
一般的な「モジュール」の定義は、だいたい次のようなものです。
- ひとまとまりの機能を持った部品
- インターフェース(入力/出力)が明確
- 交換・削除・追加が容易
Terraform の module も、できるだけこのイメージをそのまま当てはめて設計したいところです。
よくあるアンチパターン(Service単位での分割)
modules/
├─ iam/
├─ s3/
├─ ec2/
一見きれいに分類されているように見えますが、実際には次のような問題が出やすいです。
- module 単体では「何をしているのか」意味を持たない
- root module 側に依存関係が散らばりやすい
- module 同士の依存が循環しやすく、構造が複雑になる
結果として、「Web サーバ機能を消したい」「API 基盤だけ別環境にコピーしたい」といったときに、
どの module をどう触ればいいのか分かりづらくなります。
望ましいパターン(Feature単位での分割)
modules/
├─ ec2_web_server/
│ ├─ ec2.tf
│ ├─ iam.tf
│ ├─ security_group.tf
│ ├─ apigw_route.tf
このように「機能(feature)」単位で module を切ると、次のようなメリットがあります。
- module 単体で「何のための塊か」が分かる
- module を消せば、その機能に紐づくリソースがまとめて消える
- module を他のシステムに持っていく、といった移植がしやすい
つまり、機能単位で module を切る = 変更の単位・削除の単位をそのままコードに落とし込む ことになります。
実際に起きた問題例(設計まわり)
初期構築では、module の切り方が原因で次のような問題がありました。
-
IAM module が巨大化し用途が不明
- どの IAM がどの機能のためのものか追いづらい
- 機能を削除するときに、どの IAM を消してよいか判断が難しい
-
Required variable が 10 個以上必要な module
- 外から大量の値を渡さないと動かない
- module 側にほとんど主体性がなく、「ただの入れ物」になっている
-
locals.tfが肥大化し意図が隠れる- どの値がどこから来ているか追いづらい
- 環境ごとの差分や命名ロジックが locals の中に埋もれる
これらはすべて、
- 機能単位ではなく Service 単位で module を切っていた
- module の責務が広すぎた
ことの副作用でした。
解決方針
改善を進める中で、設計面では次のような方針をとりました。
1. IAM は「機能 module」の中で作る
IAM を独立した module にまとめるのではなく、各機能 module の中に含めるようにしました。
例:
modules/
├─ ec2_web_server/
│ ├─ ec2.tf
│ ├─ iam.tf # ec2_web_server 用の IAM
│ ├─ security_group.tf
│ ├─ apigw_route.tf
こうすることで、
- 「この IAM はこの機能のためのもの」とすぐに分かる
- 機能を削除すれば、その機能専用の IAM も一緒に削除できる
という状態に近づきます。
2. Required variable を減らす方向で設計する
外部でしか決められない値だけを variable とし、それ以外は module 内部で組み立てるようにしました。
-
外部で決めるべきものの例
-
env(dev/stg/prodなど) - 既存 VPC ID や Subnet ID など、インフラ全体の都合に依存するもの
-
-
module 内で決めてしまうものの例
- naming rule(
"${var.env}-${local.service_name}-web"など) - 共通の Tag(
Name/System/Serviceなど)
- naming rule(
feature 単位で module を切っていくと、その module の中だけで完結する情報が増え、
外から与える値(Required variable)は自然と少なくなっていきます。
3. locals を小さく維持する
locals にロジックを詰め込みすぎると、次のような問題が出ます。
- 設計の意図が
localsの中に隠れてしまう - 変更時に「この値どこから来てるんだっけ?」となりやすい
そこで、
- 「可読性のための名前をつける」程度にとどめる
- 設計の依存関係はなるべく
variable/outputとして明示する
という方針に切り替えました。
module の責務を小さくしていくと、そもそも locals に頼らなくてもよい場面が増えていきます。
まとめ
いろいろ書きましたが、最終的には次の一文に集約されます。
module を feature 単位で設計すると、その他の設計ルールが自然とついてくる。
この前提に立つと、
- Required variable は「結果として」減っていく
-
localsに頼らなくてもシンプルに書ける - 「この機能を消したい/移植したい」がコード構造と素直に対応する
といった状態に近づきます。
Terraform は一度書いて終わりではなく、
システムと共に更新され続けるコードです。
その入口となる module の切り方から、少しずつ見直していくと、
半年後・一年後の自分やチームがだいぶ楽になるはずです。
Discussion