前回は Terraform の基本構文と S3 リモートバックエンドの設定を紹介しました。今回はそこから一歩進んで、コードのモジュール化と複数環境(dev/stg/prod)の管理方法を整理していきます。
正直、この辺りから Terraform の「設計」っぽい話になってきて、調べれば調べるほど「どれが正解?」ってなりがちなんですが、今回は個人・小規模チームでも使えるシンプルな構成に絞って書いています。
- Terraform モジュールの基本的な作り方
- ディレクトリ分割で dev/stg/prod を管理する構成
- workspace と比較したときの使い分けの考え方
- tfvars で環境差分を吸収する方法
モジュール化とは何か、なぜ分割するのか
ひとことで言うと、「何度も使う構成をひとつの部品にまとめる仕組み」です。たとえば VPC や EC2 インスタンスの定義を毎回 main.tf に書いていくと、dev 用と prod 用でほぼ同じコードが2セット並ぶことになります。それを modules/ ディレクトリ以下にまとめておき、各環境から呼び出す、というのが Terraform モジュール化の基本的な考え方です。
モジュールを使う主なメリットはこのあたり:
- コードの重複を減らせる(DRY 原則)
- 「このモジュールを直せば全環境に反映」という管理ができる
- 役割ごとに分割するので、どこを触ればいいか分かりやすくなる
余談ですが、Terraform のモジュールは Terraform Registry から公式・コミュニティ製のものを source で指定して使うこともできます。ただし最初はローカルモジュールから慣れていくのが無難だと感じています。
ディレクトリ構成の全体像
今回作る構成はこんな感じです。シンプルに EC2 + VPC を例に取ります。
terraform/
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── terraform.tfvars
│ │ └── backend.tf
│ ├── stg/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── terraform.tfvars
│ │ └── backend.tf
│ └── prod/
│ ├── main.tf
│ ├── variables.tf
│ ├── terraform.tfvars
│ └── backend.tf
└── modules/
├── networking/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── compute/
├── main.tf
├── variables.tf
└── outputs.tf
environments/ 以下が各環境のルートモジュールで、ここから terraform init / plan / apply を実行します。modules/ は呼び出される側で、直接 apply するものではないです。
モジュールを作ってみる(networking の例)
まず modules/networking/variables.tf で受け取る変数を定義します。
# modules/networking/variables.tf
variable "env" {
description = "環境名 (dev/stg/prod)"
type = string
}
variable "vpc_cidr" {
type = string
default = "10.0.0.0/16"
}
variable "public_subnet_cidr" {
type = string
default = "10.0.1.0/24"
}
次に modules/networking/main.tf でリソースを定義します。
# modules/networking/main.tf
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
tags = {
Name = "${var.env}-vpc"
Env = var.env
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidr
availability_zone = "ap-northeast-1a" # ここは例として固定しています(環境や運用方針によっては変数化した方がよいです)
tags = {
Name = "${var.env}-public-subnet"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.env}-igw"
}
}
そして modules/networking/outputs.tf で、呼び出し側が参照できる値を出力します。
# modules/networking/outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
}
output "public_subnet_id" {
value = aws_subnet.public.id
}
outputs はモジュール間の連携に使います。networking が出力した vpc_id を compute モジュールが受け取る、という流れですね。ここを最初見たとき「なんで outputs.tf が必要なんだろう」と思ったんですが、モジュール外から直接 aws_vpc.main.id は参照できないので、出力として公開する必要があるんです。
環境ディレクトリからモジュールを呼び出す
environments/dev/main.tf はこんな感じになります。
# environments/dev/main.tf
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "ap-northeast-1"
}
module "networking" {
source = "../../modules/networking"
env = var.env
vpc_cidr = var.vpc_cidr
public_subnet_cidr = var.public_subnet_cidr
}
module "compute" {
source = "../../modules/compute"
env = var.env
vpc_id = module.networking.vpc_id # ここで output を参照
subnet_id = module.networking.public_subnet_id
instance_type = var.instance_type
ami_id = var.ami_id
}
environments/dev/variables.tf で変数を宣言して:
# environments/dev/variables.tf
variable "env" {
type = string
default = "dev"
}
variable "vpc_cidr" {
type = string
}
variable "public_subnet_cidr" {
type = string
}
variable "instance_type" {
type = string
}
variable "ami_id" {
type = string
}
environments/dev/terraform.tfvars に実際の値を書きます。
# environments/dev/terraform.tfvars
env = "dev"
vpc_cidr = "10.0.0.0/16"
public_subnet_cidr = "10.0.1.0/24"
instance_type = "t3.micro"
ami_id = "ami-xxxxxxxxxxxxxxxxx"
prod 環境なら environments/prod/terraform.tfvars で instance_type = "t3.medium" に変えるだけ。同じモジュールを使いながら環境ごとのスペック差分を表現できます。
実行するときは環境ディレクトリに移動してから:
cd environments/dev
terraform init
terraform plan
terraform apply
plan の出力で module.networking.aws_vpc.main のようにモジュール名が頭に付くので、何を作っているかが分かりやすいです。
backend.tf で State ファイルも環境別に分ける
前回の記事で S3 バックエンドを設定しましたが、モジュール構成と合わせて State ファイルのパスも環境別にしておきます。
# environments/dev/backend.tf
terraform {
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "dev/terraform.tfstate" # ここが環境ごとに変わる
region = "ap-northeast-1"
}
}
prod なら key = "prod/terraform.tfstate" にするだけです。State ファイルを分離しておくと、dev で terraform destroy しても prod に影響が出ないので安心感があります。
workspace との使い分けについて
Terraform には terraform workspace new dev のように workspace で環境を切り替える方法もあります。コマンド一発で切り替えられるのは楽なんですが、個人的にはディレクトリ分割の方が好みです。
理由を整理すると:
- workspace: 同一の設定ファイルを共有するので、環境ごとの差分が大きくなると
terraform.workspace == "prod" ? ... : ...みたいな条件分岐がコード中に増えてカオスになりがち - ディレクトリ分割: 各環境が独立しているので State が混ざる心配がなく、IAM ロールや認証情報も環境ごとに分けやすい
ただし workspace が向いているケースもあって、「PR ごとに一時的なテスト環境を作りたい」「構成がほぼ同じでパラメータだけ違う」みたいな場面では便利らしいです。長期的に運用する dev/stg/prod のような固定環境はディレクトリ分割、一時的な検証環境は workspace、という使い分けが現実的かなと思っています。
モジュール設計で気をつけること
ここだけちょっと失敗談を共有しておきます。最初にモジュールを作ったとき、「全部モジュール化すれば再利用できてよさそう」と思って細かく切り分けすぎました。modules/subnet modules/igw modules/route_table みたいに。
結果として、呼び出し側の main.tf が長くなり、モジュール間で値を受け渡す outputs / variables が増えて、逆に見通しが悪くなりました。モジュールを小さい単位に分割しすぎると、各環境ディレクトリでのモジュール呼び出しが煩雑になるというのはよく言われることで、身をもって体験しました。関連するリソースはまとめて1モジュールにする方が現実的です。networking なら VPC・サブネット・IGW・ルートテーブルをまとめて1つ、というくらいの粒度がちょうどいい感じがしています。
今後の学習について
GitHub Actions を使って terraform plan / apply を自動化する CI/CD パイプラインの話も気になっていて、別途ちゃんと検証してまとめたいところです。あと Terraform の設計パターンについても、調べていく中でベストプラクティスが見えてくるといいなと思っています。

