Infrastructure as Code(IaC)ツールとして広く普及している Terraform において、コードの保守性、再利用性、安全性を高めるための最も重要なコンポーネントが「モジュール(Modules)」です。
しかし、無計画にモジュール化を進めると、不必要な抽象化によってコードの可読性が低下したり、State の操作が困難になったり、意図しないリソースの破棄を引き起こすリスクがあります。
本記事では、Terraform モジュールの基本概念から、本番環境で耐えうる標準的な HCL 実装例、State 管理のテクニック、主要クラウド(AWS / GCP / Azure)利用時の注意点、アンチパターン、そして良くある質問までを網羅的に解説します。
1. Terraform モジュールの基本概念と役割
Terraform におけるモジュールとは、複数の関連するリソースをまとめた「コンテナ」のような存在です。1つのディレクトリルートにある .tf ファイルの集まりがそのまま1つのモジュールとして扱われます。
モジュール化の主な目的
-
カプセル化と抽象化
複雑なリソース構成(例: 冗長化されたVPC、サブネット、ルートテーブル、NATゲートウェイの組み合わせ)を内部に隠蔽し、外部には限定された入力パラメータ(variables)と出力(outputs)のみを露出させます。 -
DRY(Don't Repeat Yourself)原則の適用
Webアプリケーションサーバーの構成やデータベース接続設定など、複数の環境(Development, Staging, Production)で同一のアーキテクチャパターンを繰り返し使用する際、重複コードを削減します。 -
一貫性とポリシーの強制
暗号化の必須化、標準タグの付与、ログ出力の有効化などをモジュール内部で強制することで、セキュリティやコンプライアンスの標準化を図ることができます。
モジュールの種類
- ルートモジュール(Root Module):
terraform initやterraform applyを実行する作業ディレクトリのモジュール。 - チャイルドモジュール(Child Module): ルートモジュールや他のモジュールから呼び出されるモジュール。
- ローカルモジュール: 同一リポジトリ内のローカルパスから読み込まれるモジュール。
- リモートモジュール: Git リポジトリ、Terraform Registry、S3 / GCS バケットなどから取得されるモジュール。
2. モジュール構成の推奨ディレクトリパターン
保守性の高いチャイルドモジュールを作成するには、ファイル構成の標準化が不可欠です。以下は、一般的に推奨されるチャイルドモジュールの基本構造です。
terraform-aws-secure-s3/
├── README.md # terraform-docs 等で自動生成したドキュメント
├── LICENSE # ライセンス情報
├── main.tf # 主要なリソース定義
├── variables.tf # 入力変数の定義(型、説明、バリデーション)
├── outputs.tf # 出力値の定義
├── versions.tf # TerraformバージョンおよびProviderの制約
├── examples/ # モジュールの利用例
│ ├── complete/ # フル機能を利用したサンプル
│ └── basic/ # 最少構成のサンプル
└── tests/ # 統合テストコード (tf-test / Terratest)
この構成を守ることで、別のエンジニアがモジュールを参照した際にも、どこに何が定義されているかを瞬時に理解できるようになります。
3. ベストプラクティスに基づく HCL 実装例
ここからは、実際に再利用性と堅牢性を兼ね備えたチャイルドモジュールと、それを呼び出すルートモジュールの具体的な HCL コードを示します。
3.1 チャイルドモジュールの実装
ここでは例として、「暗号化とパブリックアクセスブロックが強制された AWS S3 バケット」を作成するモジュールを定義します。
versions.tf
モジュール内で利用する Terraform のバージョンや Provider の必要条件を明示します。モジュール内では provider ブロックを設定せず、バージョンの指定のみに留めます。
# modules/s3_bucket/versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0.0"
}
}
}
variables.tf
入力変数には、必ず type(型指定)と description(説明文)を記述します。また、可能な限り validation ブロックを活用して不正な入力を未然に防ぎます。
# modules/s3_bucket/variables.tf
variable "bucket_name" {
type = string
description = "作成するS3バケットの名称。グローバルで一意である必要があります。"
validation {
condition = can(regex("^[a-z0-9.-]{3,63}$", var.bucket_name))
error_message = "bucket_name は小文字の英数字、ハイフン、ドットのみ使用可能で、3〜63文字である必要があります。"
}
}
variable "environment" {
type = string
description = "実行環境 (dev, stg, prod)"
validation {
condition = contains(["dev", "stg", "prod"], var.environment)
error_message = "environment は 'dev', 'stg', 'prod' のいずれかである必要があります。"
}
}
variable "enable_versioning" {
type = bool
default = true
description = "バージョニングを有効化するかどうかのフラグ。"
}
variable "tags" {
type = map(string)
default = {}
description = "リソースに付与する追加のタグ。"
}
main.tf
リソース間の依存関係を明確にし、ハードコードを排除して var を活用します。
# modules/s3_bucket/main.tf
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
tags = merge(
var.tags,
{
Environment = var.environment
ManagedBy = "Terraform"
}
)
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = var.enable_versioning ? "Enabled" : "Suspended"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
outputs.tf
必要な情報のみを明示的にエクスポートします。他のモジュールやルートモジュールで参照される属性を出力します。
# modules/s3_bucket/outputs.tf
output "bucket_id" {
type = string
value = aws_s3_bucket.this.id
description = "作成されたS3バケットのID(名前)"
}
output "bucket_arn" {
type = string
value = aws_s3_bucket.this.arn
description = "作成されたS3バケットのARN"
}
output "bucket_domain_name" {
type = string
value = aws_s3_bucket.this.bucket_regional_domain_name
description = "S3バケットのリージョン固有ドメイン名"
}
3.2 ルートモジュールでの呼び出し例
作成したチャイルドモジュールをルートモジュールから呼出・利用する例です。
# main.tf (ルートモジュール)
terraform {
required_version = ">= 1.5.0"
backend "s3" {
bucket = "my-tf-state-bucket"
key = "environments/prod/terraform.tfstate"
region = "ap-northeast-1"
dynamodb_table = "terraform-locks"
}
}
provider "aws" {
region = "ap-northeast-1"
}
# ローカルモジュールの呼び出し
module "app_storage" {
source = "./modules/s3_bucket"
bucket_name = "my-company-app-data-prod"
environment = "prod"
enable_versioning = true
tags = {
Project = "PaymentSystem"
Owner = "PaymentTeam"
}
}
# リモートモジュール(Git)の呼び出し例(バージョンタグ指定)
module "network" {
source = "git::https://github.com/my-org/terraform-aws-vpc.git?ref=v2.1.0"
vpc_cidr = "10.0.0.0/16"
availability_zones = ["ap-northeast-1a", "ap-northeast-1c"]
environment = "prod"
}
4. モジュールと State 管理の実務
モジュールを導入する際、最も注意を払うべきなのが Terraform の State(状態管理ファイル)への影響です。
4.1 State 内のリソース識別子
モジュール内で定義されたリソースは、State ファイル内部で module.<モジュール名>.<リソースタイプ>.<リソース名> という階層化されたアドレスで管理されます。
例えば、先ほどの例の場合、S3バケットのアドレスは以下のようになります。
module.app_storage.aws_s3_bucket.this
4.2 リファクタリングと moved ブロックの活用
既存のコード(モジュール化されていない直書きのコード)をモジュール化したり、モジュールの名称を変更したりすると、Terraform は「既存リソースの削除」と「新規リソースの作成」を行おうとします。本番環境のデータベースやストレージでこれが起きるとデータ消失事故に直結します。
以前は terraform state mv コマンドを手動で実行していましたが、Terraform 1.1 以降では HCL 内に moved ブロックを記述することで安全かつ自動的に State 内のアドレスを変更できます。
例: インラインで書かれていたリソースをモジュールへ移行する場合
# 移行前のアドレス: aws_s3_bucket.my_bucket
# 移行後のアドレス: module.app_storage.aws_s3_bucket.this
moved {
from = aws_s3_bucket.my_bucket
to = module.app_storage.aws_s3_bucket.this
}
moved ブロックを追記した状態で terraform plan を実行すると、以下のように「破壊・作成」ではなく「移動(State の付け替え)」として評価されるため、ダウンタイムなしにリファクタリングが可能です。
# terraform plan の実行結果例
Terraform will perform the following actions:
# module.app_storage.aws_s3_bucket.this has moved to module.app_storage.aws_s3_bucket.this
resource "aws_s3_bucket" "this" {
id = "my-company-app-data-prod"
# (属性に変更がないことが確認できる)
}
Plan: 0 to add, 0 to change, 0 to destroy.
5. クラウドプロバイダー(AWS / GCP / Azure)との関係性と考慮事項
Terraform モジュールを作成・運用する際、対象となるクラウドプロバイダー固有の事情を考慮する必要があります。
5.1 「マルチクラウド共通モジュール」という幻想と現実
よくある失敗例として、「1つのモジュールで AWS, GCP, Azure すべてに対応させようとする」設計があります。クラウドごとにネットワーク構造(AWS VPC vs GCP VPC Native vs Azure VNet)や IAM モデル、リソースのライフサイクルが根本的に異なるため、単一のモジュールでこれらを抽象化しようとすると、条件分岐(count や dynamic)が肥大化し、保守不能なスパゲティコードになります。
ベストプラクティス:
マルチクラウド対応は「ルートモジュールの呼び出し側」で制御し、チャイルドモジュールはクラウドプロバイダーごとに独立させて作成します(例: terraform-aws-vm, terraform-gcp-vm)。
5.2 Provider の伝達と configuration_aliases
チャイルドモジュール内でマルチリージョンや複数アカウント(AWS IAM Role の切り替えなど)のリソースを扱う場合は、チャイルドモジュール内で provider を直書きせず、configuration_aliases を利用して呼び出し元から明示的に Provider を渡します。
モジュール側(チャイルド)の定義: versions.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0.0"
configuration_aliases = [ aws.primary, aws.secondary ]
}
}
}
モジュール側(チャイルド)のリソース定義: main.tf
resource "aws_s3_bucket" "primary" {
provider = aws.primary
bucket = var.primary_bucket_name
}
resource "aws_s3_bucket" "secondary" {
provider = aws.secondary
bucket = var.secondary_bucket_name
}
呼び出し側(ルート)の定義: main.tf
provider "aws" {
alias = "tokyo"
region = "ap-northeast-1"
}
provider "aws" {
alias = "osaka"
region = "ap-northeast-2"
}
module "multi_region_bucket" {
source = "./modules/multi_region_s3"
providers = {
aws.primary = aws.tokyo
aws.secondary = aws.osaka
}
primary_bucket_name = "my-app-data-tokyo"
secondary_bucket_name = "my-app-data-osaka"
}
6. アンチパターンと設計上の注意点
モジュール設計において避けるべき典型的なパターン(アンチパターン)とその回避策をまとめます。
6.1 パススルーモジュール(1リソース = 1モジュール)
単一のリソース(例: aws_iam_role だけ、または google_compute_instance だけ)をそのまま包んだだけのモジュール。
- 問題点: 入力変数(
variables)と出力値(outputs)がリソースの属性と全く同じになり、単に HCL の行数と複雑さを増やすだけの無用なラップになってしまいます。 - 解決策: モジュールは「意味のある粒度のリソース群」(例: IAM Role + IAM Policy + Attachment)や「アーキテクチャのパターン」(例: VPC + Subnets + RouteTables)としてまとめます。
6.2 モノリシックモジュール(巨大すぎるモジュール)
インフラ全体(VPC、EKS、RDS、ElastiCache、CloudFront)を1つの巨大なモジュールに詰め込むパターン。
- 問題点:
terraform plan/applyの実行時間が極端に長くなる。- ステートロックの競合が発生しやすく、チームでの並行作業が不可能になる。
- 一部(例: Webのセキュリティグループ)の修正時に、誤ってデータベース等の重要リソースを破壊するリスクが高まる。
- 解決策: 変更のライフサイクル(更新頻度)と影響範囲に基づいてモジュールを分割します。
- ネットワーク層 (VPC, Subnet): 変更頻度「低」
- データストア層 (RDS, ElastiCache): 変更頻度「中」
- アプリケーション層 (ECS, EKS, EC2, Lambda): 変更頻度「高」
6.3 バージョン未固定の参照
リモートモジュールを呼び出す際に、Git の main ブランチや最新版を動的に参照する指定。
# BAD EXAMPLE
module "vpc" {
source = "git::https://github.com/org/terraform-aws-vpc.git" # ブランチ未指定(mainを参照してしまう)
}
- 問題点: モジュール側の更新によって、意図しないタイミングで仕様変更や破壊的変更(Breaking Changes)がルートモジュールに伝播し、
terraform applyが失敗またはリソースの再作成を引き起こします。 - 解決策: セマンティックバージョニング(Semantic Versioning)に基づいた Git Tag を作成し、必ずバージョンを指定して呼び出します。
# GOOD EXAMPLE
module "vpc" {
source = "git::https://github.com/org/terraform-aws-vpc.git?ref=v2.3.1"
}
7. モジュールの運用・テスト・ドキュメント化
本番運用におけるモジュールの品質維持のためのプラクティスです。
7.1 ドキュメントの自動生成 (terraform-docs)
手動で README.md をメンテナンスすると、必ず HCL のコードと乖離します。terraform-docs ツールを利用して、variables や outputs から自動的に Markdown テーブルを生成するパイプラインを構築します。
# terraform-docs の実行例
terraform-docs markdown table --output-file README.md .
7.2 静的解析とフォーマットの自動化
CI/CD パイプライン(GitHub Actions や GitLab CI など)に以下のチェックを組み込みます。
terraform fmt -check -recursive: コードの整形チェックtflint: 潜在的なエラーやプロバイダー固有のベストプラクティス違反の検知tfsec/trivy: モジュール内のセキュリティ脆弱性(例: 暗号化されていないストレージ)のチェック
7.3 テストコードの導入
Terraform 1.6 以降ではネイティブなテストフレームワーク(.tftest.hcl)が導入されました。モジュールの挙動を統合テストで検証可能です。
# tests/s3_test.tftest.hcl
run "verify_bucket_name" {
command = plan
variables {
bucket_name = "test-bucket-for-ci"
environment = "dev"
}
assert {
condition = aws_s3_bucket.this.bucket == "test-bucket-for-ci"
error_message = "S3 bucket name did not match expected input"
}
}
8. FAQ(よくある質問)
Q1. 1つのモジュールにまとめるリソースの適切な「単位・粒度」はどのように判断すればよいですか?
A. 「同時に作成・変更・破棄されるライフサイクルが同じリソース群」を1つの単位とします。
例えば、AWS VPC、パブリック/プライベートサブネット、インターネットゲートウェイ、ルートテーブルは通常、ネットワークという単一のコンポーネントとして同時に構築され、相互に深く依存します。これらは1つのモジュールにするのが適切です。
一方で、そのVPC内に配置される RDS データベースは、ネットワークとはライフサイクルも変更頻度も異なります(RDS は頻繁に変更されたりスケールされたりしますが、VPC は一度作るとめったに変更しません)。そのため、VPC と RDS は別のモジュールに分けるのが適切です。
Q2. 既存のモノリシックな Terraform コードをモジュール化する安全な手順を教えてください。
A. moved ブロックを利用して、段階的に State を壊さずに移行します。
具体的には以下のステップを踏みます。
- チャイルドモジュールの作成: 共通化したい部分のコードを
modules/xxxディレクトリに抽出し、variables.tfとoutputs.tfを定義します。 - ルートモジュールでの呼び出し: ルートモジュールから抽出したコードを削除し、
module "xxx" { ... }のブロックに置き換えます。 movedブロックの記述: 旧リソースから新モジュールへのマッピングを記述します。hcl moved { from = aws_instance.web to = module.web_server.aws_instance.this }terraform planの検証:Plan: 0 to add, 0 to change, 0 to destroyとなることを確認し、リソースの再作成が発生しないことを確信した上でterraform applyを実行します。- クリーンアップ: リファクタリングが成功したら、
movedブロックをコードから削除します(一度適用されれば State は更新されるため、削除しても問題ありません)。
Q3. サードパーティ製(Terraform Registry 等)の公式モジュールはそのまま使うべきですか、それとも自作すべきですか?
A. 原則として「公式/サードパーティ製モジュールをファーストチョイス」とし、組織独自のルールが強い場合は「ラッパーモジュールを作成」または「完全自作」を検討します。
-
サードパーティ製(例:
terraform-aws-modules/vpc/awsなど)のメリット:
コミュニティによって徹底的にテストされており、エッジケース(特殊な構成)への対応や機能追加が迅速に行われています。 -
自作/ラッパーの検討が必要なケース:
サードパーティ製モジュールは汎用性を高めるために大量の変数(数百個のvariables)が存在し、設定の複雑化を招くことがあります。また、組織固有のセキュリティポリシー(特定のタグの自動付与、特定のログ機能の強制など)を強制したい場合は、サードパーティ製モジュールを内部で呼び出す「ラッパーモジュール」を作成するか、必要最小限の機能を持つ自作モジュールを作成するのが有効です。
9. まとめ
Terraform モジュールは、単なる「コードの共通化ツール」にとどまらず、インフラアーキテクチャの標準化とガバナンスを実現するための強力なフレームワークです。
ベストプラクティスのポイントを再掲します。
- カプセル化: 関連するリソースを適切な粒度でまとめ、
variablesとoutputsを最小限かつ厳密に定義する。 - 安全なリファクタリング: モジュールの追加・変更時は
movedブロックを活用し、既存リソースの非破壊的な移行を保証する。 - バージョン管理: リモートモジュールは必ず Semantic Versioning のタグでバージョンを固定する。
- 明示的な Provider 伝達: モジュール内で
providerブロックを定義せず、必要に応じてconfiguration_aliasesを使用する。 - 自動化とテスト:
terraform-docsによるドキュメント化と CI での静的解析・テストを組み込む。
これらの原則をチームの設計標準に組み込むことで、大規模かつ長期的に運用可能なインフラストラクチャ基盤を構築できます。