Terraformモジュール構成をリファクタした話 — 個人開発でもIaCは裏切らない💪
個人開発でモバイルアプリを作っていたら、Terraformの管理ファイルが膨れ上がって手に負えなくなりました🥹
リソース単位の薄いモジュールからAWSサービス単位のモジュールにリファクタした話をメモメモ・・・
はじめに — 個人開発でもこの規模になる
保護犬・保護猫のマッチングアプリ「Anipal」を個人で開発しています。技術スタックはReact Native (Expo) + AWSサーバーレス + Terraformです。
気づけばAWSリソースがこんな量になっていました。。。
- Lambda関数: 数十個
- DynamoDBテーブル: 10以上 + 複数のGSI
- Cognito User Pool + Identity Pool + ソーシャルログイン(Google/Apple)
- API Gateway (HTTP API) + Authorizer
- S3 + CloudFront (OAC)
- CloudWatch Alarms + CloudTrail + Slack通知
- KMS(暗号化用CMK)
「個人開発にTerraformは大げさでは?」と思う人もいるかもしれませんが、この規模を手動管理するのは無理です。terraform plan で差分を確認してから terraform apply できる安心感は、一度味わうと手放せません🤤
Before: リファクタ前の問題
リファクタ前のディレクトリ構成はこうでした。(お恥ずかしい)
infra/
├── envs/dev/
│ ├── main.tf # provider + ほぼ全リソース定義
│ ├── auth.tf # 認証設定(200行超)
│ ├── api.tf # API設定
│ ├── storage.tf # ストレージ + CDN(300行超)
│ ├── monitoring.tf # 監視 + アラーム
│ ├── lambda_xxx.tf # ┐
│ ├── lambda_yyy.tf # │ Lambda定義群(10ファイル以上)
│ ├── ... # ┘
│ ├── variables.tf
│ └── outputs.tf
│
└── modules/
├── lambda/ # 単体Lambda作成ユーティリティ
└── dynamodb/ # 単体DynamoDBテーブル作成ユーティリティ
問題点は明確でした。
-
envs/dev/に20ファイル以上が詰まっている — 認証、API、ストレージなどのサービスリソースが環境ディレクトリに直接書かれている - modulesが薄すぎる — Lambda1個、DynamoDBテーブル1個を作るだけのラッパーで、サービス全体の設定がモジュール化されていない
- prod環境を追加するとき、大量コピペが必要 — サービスリソース定義を丸ごと複製することになる
合計数千行のTerraformコードが envs/dev/ に集中していて、見通しが悪くなっていました。
設計判断: リソース単位 vs サービス単位
リファクタにあたって2つの方針を検討しました。
方針A: リソース単位のモジュール化
modules/
├── cognito_user_pool/
├── cognito_identity_pool/
├── cognito_identity_provider/
├── s3_bucket/
├── cloudfront_distribution/
└── ...
AWSリソース1つ1つをモジュール化する方法。粒度は細かいけれど、モジュールの数が爆発して呼び出し側が複雑になります🧐
方針B: AWSサービス単位のモジュール化(採用)
modules/
├── auth/ # User Pool + Identity Pool + Providers + IAM
├── api/ # HTTP API + Stage + Authorizer
├── storage/ # S3 + CloudFront + OAC
└── ...
関連するリソースをサービス単位でまとめる方法。クラスメソッドさんの記事の設計思想を参考にしました。
方針Bを選んだ理由は、"「このサービスの設定を見たい」と思ったときに1ディレクトリで完結"するからです。認証の設定を確認するのに3つのモジュールを行き来するのは辛い。
ただし、Lambda関数群は envs/dev/ に残すことにしました。Lambda関数はサービスリソースというより「ビジネスロジックの定義」で、環境ごとに差異が出やすいためです。
After: サービス単位モジュールの構成
リファクタ後はこうなりました。
infra/
├── envs/dev/
│ ├── main.tf # provider + 全モジュール呼び出し
│ ├── variables.tf # 環境固有の変数
│ ├── outputs.tf # モジュールoutput経由
│ ├── lambda_xxx.tf # ┐
│ ├── lambda_yyy.tf # │ Lambda定義群(変更は参照先のみ)
│ └── ... # ┘
│
└── modules/
├── lambda/ # 既存ユーティリティ
├── dynamodb/ # 既存ユーティリティ
├── auth/ # 【新規】認証基盤
├── api/ # 【新規】APIゲートウェイ
├── database/ # 【新規】全テーブル定義
├── storage/ # 【新規】ストレージ + CDN
└── monitoring/ # 【新規】監視・通知
envs/dev/main.tf がすっきり!
# envs/dev/main.tf(イメージ)
module "storage" {
source = "../../modules/storage"
project = local.project
cors_origins = var.cors_origins
}
module "database" {
source = "../../modules/database"
project = local.project
}
module "auth" {
source = "../../modules/auth"
project = local.project
# ソーシャルログイン設定はSSM経由で取得した値を注入
social_login_config = var.social_login_config
storage_bucket_arn = module.storage.bucket_arn
}
module "api" {
source = "../../modules/api"
project = local.project
auth_config = module.auth.config
}
各モジュールが何を必要としているか、依存関係が一目でわかります。素晴らしい👏
storageモジュールの設計思想
storageモジュールには以下をまとめています。
- S3バケット + パブリックアクセスブロック + バージョニング + 暗号化
- CloudFront Distribution + OAC(Origin Access Control)+ キャッシュポリシー
- S3バケットポリシー(CloudFront OAC連携)
ポイントは、CloudFront OACとS3バケットポリシーの相互参照がモジュール内で完結すること。呼び出し側は project と cors_origins を渡すだけで、CDN付きのセキュアなストレージが出来上がります。
databaseモジュールの工夫
テーブル定義モジュールでは、既存の単体テーブル作成ユーティリティを内部で再利用しています。
設計ポイント:
- PIIを含むテーブル(ユーザー情報、申請情報等)はKMS CMKで暗号化
- それ以外のテーブルはAWS管理キー(デフォルト暗号化)
- GSIの設計は各テーブルのアクセスパターンに応じて定義
- 暗号化キーの使い分けをモジュール内に閉じ込める
こうすることで、「どのテーブルがPII暗号化されているか」の管理が1ファイルで完結します。
terraform state mv との戦い
モジュール化するとリソースアドレスが変わります。
# Before(環境ディレクトリに直接定義)
aws_xxx_resource.main
# After(モジュール経由)
module.xxx.aws_xxx_resource.main
このままだとTerraformは「旧リソースを削除して新リソースを作成する」と判断します。本番環境で認証基盤やデータベースが再作成されたら大事故です。
対策は terraform state mv で既存リソースを新しいアドレスに移動することです。全モジュール合わせて数十回の state mv が必要でした。1つでも漏れるとplanに差分が出るので、モジュール1つずつ移行 → plan確認を繰り返しました。
ハマったこと・学び
モジュール間の依存解決
モジュール化で最も気を使ったのが依存関係の設計です。
storage → 依存なし
database → 依存なし
auth → storage(IAMポリシーでバケットARN参照)
api → auth(JWT Authorizerで認証設定参照)
monitoring → 全Lambda関数名
認証モジュールがストレージに依存するのは、IAMポリシーでS3アクセス権を設定するためと、PreSignUpトリガーにLambda ARNが必要だからです。
循環依存を避けるポイントは**「SSMやシークレットのdata sourceは envs/dev/ 側に残して、値だけをモジュールに渡す」**こと。data sourceまでモジュールに入れると、環境ごとのSSMパス差異に対応できなくなります。
Lambda定義ファイルの参照書き換え
Lambda定義ファイル(10ファイル以上)内の参照を一括で書き換える必要がありました。
| 変更前 | 変更後 |
|---|---|
aws_xxx_resource.main.id |
module.xxx.resource_id |
aws_xxx_authorizer.main.id |
module.api.authorizer_id |
module.dynamodb_xxx.table_name |
module.database.xxx_table_name |
参照パターンが module.モジュール名.output名 に統一されたことで、新しいLambda関数を追加するときに「この値どこから取るんだっけ?」と迷わなくなりました。
リファクタ後の効果
-
envs/dev/のファイル数: 20+ → Lambda定義 + main/variables/outputs に集約 - prod環境の追加: モジュール呼び出し + 変数変更だけで済む
-
新サービスの追加:
modules/にモジュールを作ってmain.tfから呼ぶだけ - コードレビュー: 「このPRは認証モジュールだけの変更」と範囲が明確になる
まとめ
- 個人開発でもTerraformは裏切らない — リソースが数十個を超えると手動管理は不可能
- リソース単位ではなくサービス単位でモジュール化 — "「この設定どこ?」が1ディレクトリで解決"する
- terraform state mv は慎重に — 1モジュールずつ移行してplan確認を繰り返す
- 依存関係の設計が肝 — data sourceは環境側に残し、値だけモジュールに注入する
- Lambda関数は環境側に残す判断もアリ — ビジネスロジックの定義は環境固有になりやすい
個人開発だからこそ、インフラが壊れたときに助けてくれる人はいません。terraform plan が「No changes」と返してくれる安心感は、何物にも代えがたいです🙂↕️
このプロジェクトについて
Anipal(アニパル) は、保護犬・保護猫と里親をつなぐマッチング&SNSアプリです。
「つなぐ、届ける、見届ける」をコンセプトに、保護団体の業務負担を軽減しながら、動物たちに新しい家族との出会いを届けます。
現在CAMPFIREにてクラウドファンディング挑戦中です。応援いただけると嬉しいです🙇
👉 CAMPFIRE: https://camp-fire.jp/projects/934033/view
著者プロフィール
yuto
AWSエンジニア。保護犬猫の「出会いにくい」をゼロにしたいという想いから、個人でAnipalを開発中。
React Native (Expo) + AWS + Terraformで構築しています。
👉 X: @Anipal_app