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. 1 回目: 全部 envs/prod/main.tf に直書き(200 行超で管理不能)
  2. 2 回目: 細かく 10 モジュールに分割(apply 時の依存解決が遅すぎ)
  3. 3 回目: 5 モジュールに統合(現在の形)

この経験から得たモジュール分割の判断基準:

基準意味
ライフサイクル同じタイミングで作成・破棄するリソース群を 1 モジュールに
変更頻度頻繁に触る部分と、ほぼ触らない部分は分ける
責務の単位「何を実現するためのモジュールか」を一言で言える単位
再利用の単位他のプロジェクトでも使い回せる単位

本シリーズの 5 モジュール

モジュール責務主な変更タイミング
network_dnsACM 証明書 + DNS 検証初回構築のみ
frontend_cdnS3 + CloudFront + OAC新パターン追加時
backend_apiAPI GW + Lambda + DynamoDBAPI 機能追加時に頻繁
ci_oidcGitHub OIDC + IAM Role初回構築のみ
observabilityCloudWatch 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.tfrequired_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 モジュールにまとめる。