先日、検証用のつもりで terraform apply を叩いたら、ターミナルの片隅に本番用のバケット名がチラッと見えて心臓が止まりかけました。幸い plan の差分をちゃんと読んでいたので事故にはならなかったんですが、「これ、環境の分け方をちゃんとしないとそのうちやらかすな」と本気で思ったので、今回は環境分離の話です。
前回(第3回)はモジュール化の話で、VPC やコンピュート周りを modules/ に切り出して再利用できる形にするところまでやりました。モジュールができると当然「じゃあこれを dev / stg / prod で使い回したいよね」となるわけで、今回はその続きになります。
この記事でわかること
- Terraformの環境分離(dev/stg/prod)に関わる主な課題
- ワークスペース vs ディレクトリ分割の使い分け
- 環境ごとのディレクトリ構成と実装パターン
- backend設定と-backend-configの使い方
- tfvarsと変数管理の実践的なアプローチ
- 本番環境を保護するための仕組み(prevent_destroy、approval)
環境分離でつまずくポイントは大体3つ
自分が実際に手を動かして詰まったのは、ざっくりこの3つでした。
- state をどう分けるか(分けないと dev の apply が prod を壊す)
- 環境ごとに違う値(インスタンスサイズ、台数、ドメイン)をどこに書くか
- 「今どの環境を触ってるのか」を human error なしで判別できるか
特に3つ目が地味に重要で、技術的には正しく分離できていても、オペミスで prod に apply したら意味がないんですよね。ここを仕組みで潰しにいくのが今回のゴールです。
ディレクトリ分割 vs ワークスペース
Terraform で環境を分ける方法は大きく2つあります。
1. CLI ワークスペース
terraform workspace new stg
terraform workspace select stg
terraform apply
同じ .tf ファイル群に対して state だけを複数持つやり方です。コードの重複がゼロなのは魅力的。ただ、S3 backend の場合は state が env:/stg/terraform.tfstate のように env:(デフォルトの workspace key prefix)配下に入るので、バケットレベルで dev と prod の権限を分けたい、みたいな要件と相性が悪いです。あと terraform workspace select を忘れたまま作業する事故が普通に起きます(起こしました)。
2. ディレクトリ分割
環境ごとにディレクトリを切って、それぞれ独立した backend を持たせる方法です。調べた範囲だと、長生きする環境(dev / stg / prod)はディレクトリ分割、feature ブランチごとの使い捨て環境みたいな短命なものはワークスペース、という使い分けが多数派っぽいです。両方混ぜて使っているチームもあるとのこと。
個人開発レベルだとどっちでも回るんですが、自分はディレクトリ分割にしました。理由は単純で、prod のディレクトリに cd したときに「今から本番を触るぞ」という緊張感が出るからです。技術的な理由じゃないのが我ながらどうかと思いますが、事故防止としては割と効いてます。
実際のディレクトリ構成
terraform/
├── modules/
│ ├── network/
│ ├── compute/
│ └── database/
└── environments/
├── dev/
│ ├── main.tf
│ ├── backend.tf
│ ├── variables.tf
│ └── terraform.tfvars
├── stg/
└── prod/
environments/*/main.tf は基本的にモジュールを呼ぶだけの薄いファイルにしています。
module "network" {
source = "../../modules/network"
env_name = var.env_name
vpc_cidr = var.vpc_cidr
az_count = var.az_count
}
module "compute" {
source = "../../modules/compute"
env_name = var.env_name
subnet_ids = module.network.private_subnet_ids
instance_type = var.instance_type
desired_count = var.desired_count
}
そして環境差分は terraform.tfvars に閉じ込めます。
# environments/prod/terraform.tfvars
env_name = "prod"
vpc_cidr = "10.20.0.0/16"
az_count = 3
instance_type = "t4g.small"
desired_count = 2
# environments/dev/terraform.tfvars
env_name = "dev"
vpc_cidr = "10.0.0.0/16"
az_count = 2
instance_type = "t4g.micro"
desired_count = 1
ここまでやると、dev と prod の違いが tfvars を diff するだけで一目でわかるようになります。これが個人的には一番の恩恵でした。CIDR をわざと被らないようにしているのは、後で VPC ピアリングしたくなったときに詰むのを避けるためです。
backend は変数が使えないので -backend-config で
ここ、最初にハマりました。backend ブロックの中では変数が使えません。var.env_name を書くと普通に怒られます。
# environments/prod/backend.tf
terraform {
backend "s3" {
bucket = "nyanchu-tfstate-prod"
key = "app/terraform.tfstate"
region = "ap-northeast-1"
skip_credentials_validation = true
skip_metadata_api_check = true
}
}
S3 backend でのロック管理は、デフォルトではロックファイル(DynamoDB を使わない方式)で動作します。ディレクトリごとに backend.tf をベタ書きするのが冗長だと感じるなら、backend の中身を空にしておいて -backend-config で外から渡す部分設定(partial configuration)という手もあります。
terraform init -backend-config=../../config/prod.s3.tfbackend
ただ自分は、init のたびにオプションを付け忘れて「あれ、なんで state が空なの」となったので、素直にベタ書きに戻しました。CI から回すならこっちの方が綺麗なんだろうな、とは思ってます。
変数の置き場所をどう決めるか
tfvars に全部書くとサイズ設定が散らかるので、環境名をキーにした map を locals に置くパターンもよく見ます。
locals {
sizing = {
dev = { instance_type = "t4g.micro", min = 1, max = 1 }
stg = { instance_type = "t4g.small", min = 1, max = 2 }
prod = { instance_type = "t4g.small", min = 2, max = 6 }
}
conf = local.sizing[var.env_name]
}
これはこれで見通しがいいんですが、ディレクトリ分割している場合はそもそも環境ごとに tfvars が分かれているので、正直この map は二重管理になりがちです。自分は今のところ「モジュール内部でしか使わない値は locals、環境ごとに変えたい値は variables + tfvars」で運用してますが、この線引きはまだ迷ってます。もっといい整理の仕方がある気がする。
あと、共通で入れておくと後が楽なのが provider の default_tags です。
provider "aws" {
region = var.region
default_tags {
tags = {
Environment = var.env_name
Project = "nyanchu-app"
ManagedBy = "terraform"
}
}
}
全リソースに自動でタグが付くので、Cost Explorer で環境別のコストを見るのがすごく楽になります。dev で立てっぱなしにしていた EC2 が犯人だと判明したときは、便利さと同時に悲しさもありました。
本番を守るための小細工
仕組み側でやれることをいくつか。まず prod の消えたら困るリソースには prevent_destroy を付けます。
resource "aws_s3_bucket" "assets" {
bucket = "${var.env_name}-nyanchu-assets"
lifecycle {
prevent_destroy = true
}
}
これを付けておくと terraform destroy がエラーで止まります。うっかり全消しを防げるので、prod だけでも入れておく価値はあります。ただしモジュール共通で書くと dev の掃除ができなくなって面倒なので、変数で切り替えられないか試したんですが、lifecycle ブロックは変数を受け付けないんですよね。ここは素直に prod 用の設定として割り切っています。
それと、prod だけは手元から apply しない運用にしました。GitHub Actions 側で環境ごとに job を分けて、prod は GitHub Environments の approval を挟むようにしています。
jobs:
plan:
strategy:
matrix:
env: [dev, stg, prod]
steps:
- run: terraform init
working-directory: terraform/environments/${{ matrix.env }}
- run: terraform plan -no-color -out=tfplan
working-directory: terraform/environments/${{ matrix.env }}
この CI 周りは書き始めるとそれだけで一記事になるので、いったんここでは触りません。Terraform Cloud や Terraform Stacks みたいなマネージド寄りの選択肢も出てきていて、気になるところではあります。
まとめ
結局のところ、環境分離は「state を物理的に分ける」「差分を tfvars に閉じ込める」「prod だけ触りにくくする」の3点セットで大体片付く、というのが今回いじってみた実感です。ワークスペースは悪くない機能なんですが、長期運用する環境に使うと権限分離のところで無理が出るので、使い捨て環境用のツールとして考えるのが自分には合っていました。
📚 シリーズ「Terraform × AWS インフラ自動化入門」(第4回 / 全5回)
← 前回の記事: 前回の記事はこちら
→ 次回の記事: 【第5回】Terraform × AWS インフラ自動化入門 — 本番運用で詰まるポイントと安全なインフラ変更のベストプラクティス

