Terraform モジュール設計の考え方 — modules / envs / providers alias
Terraform を本格的に使い始めると、最初に頭を悩ませるのが 「どう構造化するか」 です。1 つの巨大な main.tf に全部書くと管理不能になり、細かく分割しすぎても apply が遅くなる。本記事では、本シリーズで採用した 「modules / envs」二層構造 と、複数リージョンを扱う際の provider alias について書きます。
本シリーズのディレクトリ構成
terraform/
├── bootstrap/ # 手動で 1 回だけ実行する初期化
│ ├── README.md
│ ├── setup.sh
│ └── setup.ps1
│
├── modules/ # 再利用可能なレゴブロック
│ ├── network_dns/ # ACM 証明書 + DNS 検証
│ ├── frontend_cdn/ # S3 + CloudFront + OAC
│ ├── backend_api/ # API Gateway + Lambda + DynamoDB
│ ├── ci_oidc/ # GitHub OIDC + IAM Role
│ └── observability/ # CloudWatch Alarms + SNS + Budgets
│
└── envs/
└── prod/ # 本番環境の組み立て
├── backend.tf # state を S3 に置く設定
├── providers.tf # AWS provider 定義(default + us_east_1)
├── versions.tf # Terraform / provider バージョン
├── variables.tf # 入力変数の定義
├── terraform.tfvars # 入力変数の実値
├── main.tf # モジュール呼び出し
└── outputs.tf # apply 後に表示する値
原則は modules = レゴブロック、envs = 組み立て側。同じ modules を dev / prod で別々の値で呼び出せる構造にしています。
モジュールを「いくつに分けるか」の判断基準
本シリーズでは、最終的に 5 モジュールに収束しました。実は 3 回書き直して ここに落ち着いています:
- 1 回目: 全部 envs/prod/main.tf に直書き(200 行超で管理不能)
- 2 回目: 細かく 10 モジュールに分割(apply 時の依存解決が遅すぎ)
- 3 回目: 5 モジュールに統合(現在の形)
この経験から得たモジュール分割の判断基準:
| 基準 | 意味 |
|---|---|
| ライフサイクル | 同じタイミングで作成・破棄するリソース群を 1 モジュールに |
| 変更頻度 | 頻繁に触る部分と、ほぼ触らない部分は分ける |
| 責務の単位 | 「何を実現するためのモジュールか」を一言で言える単位 |
| 再利用の単位 | 他のプロジェクトでも使い回せる単位 |
本シリーズの 5 モジュール
| モジュール | 責務 | 主な変更タイミング |
|---|---|---|
network_dns | ACM 証明書 + DNS 検証 | 初回構築のみ |
frontend_cdn | S3 + CloudFront + OAC | 新パターン追加時 |
backend_api | API GW + Lambda + DynamoDB | API 機能追加時に頻繁 |
ci_oidc | GitHub OIDC + IAM Role | 初回構築のみ |
observability | CloudWatch Alarms + SNS + Budgets | 監視強化時 |
全部 envs/prod/main.tf から呼び出されます。
モジュールの基本構造
1 つのモジュール(例: network_dns)の中身は基本 5 ファイル:
terraform/modules/network_dns/
├── versions.tf # Terraform / provider 要件
├── variables.tf # 入力(domain_name など)
├── main.tf # リソース本体
├── outputs.tf # 出力(certificate_arn など)
└── README.md # 使い方ドキュメント
これは Terraform Registry の慣例に従った形。同じ構造を 5 モジュール全部で使うことで、誰が見ても「ここに何が書いてあるか」が分かるようにしています。
モジュール呼び出しの実例
envs/prod/main.tf でモジュールをこう呼び出します:
module "network_dns" {
source = "../../modules/network_dns"
domain_name = var.domain_name
providers = {
aws = aws
aws.us_east_1 = aws.us_east_1
}
}
module "frontend_cdn" {
source = "../../modules/frontend_cdn"
domain_name = var.domain_name
certificate_arn = module.network_dns.certificate_arn # ← ここで他モジュールの output を参照
bucket_name = local.web_bucket_name
providers = {
aws = aws
aws.us_east_1 = aws.us_east_1
}
}
module.network_dns.certificate_arn のように、モジュール間で値を受け渡しできます。これが疎結合の要。
provider alias — 複数リージョンを同時に扱う
本シリーズで重要なのが provider alias。AWS でリソースを作るときは「どのリージョンか」が必要ですが、CloudFront 用の ACM 証明書だけは 必ず us-east-1 でないといけない仕様。
そこで envs/prod/providers.tf でこう定義:
# default provider: ap-northeast-1 (東京)
provider "aws" {
region = var.region # 通常 ap-northeast-1
default_tags {
tags = local.common_tags # 全リソースに自動でタグ付与
}
}
# alias: us_east_1 (バージニア北部) — CloudFront / ACM 専用
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
default_tags {
tags = local.common_tags
}
}
これで aws(=東京)と aws.us_east_1(=バージニア)の 2 つの provider が並列で使えます。
モジュール側で alias を要求する書き方
モジュール内で「この alias を使いたい」を宣言するには configuration_aliases:
# modules/network_dns/versions.tf
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
configuration_aliases = [aws.us_east_1]
}
}
}
そしてモジュール内のリソースで provider = aws.us_east_1 を指定:
# modules/network_dns/main.tf
resource "aws_acm_certificate" "this" {
provider = aws.us_east_1 # ← us-east-1 で証明書発行
domain_name = var.domain_name
...
}
provider alias の注意点
本シリーズで、provider alias 関係でハマったポイントを 2 つ:
① モジュール呼び出し時の providers map で aws = aws を忘れない
module "frontend_cdn" {
source = "../../modules/frontend_cdn"
providers = {
aws = aws # ← これを忘れるとデフォルト provider が消える
aws.us_east_1 = aws.us_east_1
}
}
「aws = aws なんて自明だから書かなくても」と思いがちですが、providers map を使った瞬間、デフォルト provider の暗黙継承が止まります。
② alarm_actions の SNS topic は同一 region 必須
CloudFront のメトリクスは us-east-1 にしか存在しないため、CloudFront 用 CloudWatch Alarm は us-east-1 で作る必要があります。その alarm_actions に渡す SNS topic も us-east-1 にないとダメ。
これに気付かず、東京の SNS topic を us-east-1 alarm から参照しようとして 30 分ハマりました。最終的に「us-east-1 用の SNS topic」を別途作って解決しました(記事 #18 で詳説)。
local 値で DRY
2 か所以上で使う計算値は locals ブロックに集約します:
data "aws_caller_identity" "current" {} # AWS の現在のアカウント ID を取得
locals {
web_bucket_name = "iigtn-lab-web-prod-${data.aws_caller_identity.current.account_id}"
common_tags = {
Project = "iigtn"
Environment = "prod"
ManagedBy = "Terraform"
}
}
S3 バケット名はアカウント ID を suffix にしてグローバル一意にする慣例。data.aws_caller_identity.current.account_id で実行時に取得できます。
variables.tf と terraform.tfvars の使い分け
環境ごとに変わる値は variables.tf で定義し、実値は terraform.tfvars に書く:
# terraform/envs/prod/variables.tf
variable "domain_name" {
description = "メインドメイン"
type = string
}
variable "ses_to" {
description = "SES 送信先"
type = string
default = ""
}
# terraform/envs/prod/terraform.tfvars
domain_name = "lab.iigtn.com"
ses_to = "<your-email>"
tfvars はリポジトリにコミットしますが、機密値が入る場合は .gitignore に追加。本シリーズでは domain と email アドレスのみで機密値ゼロなので、コミット対象。
envs/dev を後で増やすには
本シリーズは prod だけですが、dev を増やすときは envs/dev/ を作って同じファイルを置くだけ:
envs/
├── prod/
│ ├── backend.tf # key = "envs/prod/terraform.tfstate"
│ ├── main.tf # module を呼ぶ
│ └── terraform.tfvars # domain_name = "lab.iigtn.com"
└── dev/
├── backend.tf # key = "envs/dev/terraform.tfstate"
├── main.tf # module を呼ぶ(同じソース)
└── terraform.tfvars # domain_name = "dev.iigtn.com"
これで「同じモジュールで違う domain で違う環境」が作れます。テストや staging 用途で使えます。
次の記事
モジュール設計の考え方が整理できたところで、最初のモジュール network_dns を作ります。次の記事では ACM 証明書の発行と DNS 検証 について書きます。CloudFront 用 ACM が us-east-1 限定という仕様は、最初のハマりどころです。
📚 用語集
- module (Terraform モジュール)
- Terraform のリソース群を再利用可能にまとめた単位。ファイル群が入ったディレクトリ。
- envs (環境ディレクトリ)
- 本シリーズの慣例で、prod / dev など環境ごとの組み立て先ディレクトリ。modules を呼び出して具象化する。
- source 引数
- module ブロックで「どこからモジュールを読むか」を指定。ローカルパス / Git URL / Terraform Registry が指定可能。
- provider
- Terraform で AWS / GCP など各クラウドを操作するためのプラグイン。AWS provider は CRUD する API の集合。
- provider alias
- 同じ provider を複数の設定(リージョン違いなど)で使うときの識別子。
provider "aws" { alias = "us_east_1" ... }と定義し、リソース側でprovider = aws.us_east_1と参照。 - configuration_aliases
- モジュール側で「この alias を呼び出し側から渡してね」を宣言する仕組み。
versions.tfのrequired_providers内に書く。 - providers map (モジュール呼出時)
- モジュール呼び出し時に「どの provider をどう渡すか」を指定する。
providers = { aws = aws, aws.us_east_1 = aws.us_east_1 }形式。 - data source
- Terraform が「既存リソースを読むだけ(管理しない)」ための仕組み。
data "aws_caller_identity" "current" {}で現在のアカウント情報が取れる。 - local 値 (locals ブロック)
- モジュール内の中間計算値。
locals { foo = "bar-${var.x}" }と書いてlocal.fooで参照。 - variable
- モジュールの入力。
variable "x" { type = string }で定義し、var.xで参照。 - output
- モジュールの出力。
output "x" { value = ... }で定義し、呼出側からmodule.foo.xで参照。 - terraform.tfvars
- variable の実値を書くファイル。
terraform plan時に自動的に読まれる。 - default_tags
- AWS provider のオプション。指定したタグが、その provider で作る全リソースに自動付与される。タグ書き忘れを構造的に防ぐ。
- 疎結合 (Loose Coupling)
- 各モジュールが独立しており、単独で理解・変更できる状態。output / variable で値をやり取りすることで実現。
- DRY (Don't Repeat Yourself)
- 同じ値・処理を複数箇所に書かない設計原則。Terraform では locals / module で実現する。
- ライフサイクル
- リソースが「作られて、変更されて、消される」までの一連の時系列。同じライフサイクルのリソースは 1 モジュールにまとめる。