Terraform Workspacesで環境別インフラを管理
Terraform運用の現場で繰り返し観測されるのは、環境が増えるほど作業コストとヒューマンエラーのリスクが上がるという当たり前だが避けにくい傾向です。モノレポでディレクトリ分割だけに頼ると、planの待ち時間やレビューの停滞が累積しがちです。一方で、Terraform Workspacesとリモートバックエンド(例: S3+DynamoDB)を組み合わせ、状態を明確に分離しつつワークフローを標準化すると、並列実行や責務分担がしやすくなります。数値は規模やクラウド構成によって幅がありますが、一般に「状態の分離」と「操作の一貫性」を早い段階で固めることが、可観測性と変更容易性を両立する近道になります。この記事では、意思決定で迷いやすいポイント――Workspacesの適用範囲、バックエンド設計、変数・プロバイダ戦略、CI/CDへの落とし込み、そしてディレクトリ分割やTerraform Cloudとの使い分け――を、現実装に直結する観点で整理します。
Workspacesは何を解決し、何を解決しないか
Terraform Workspacesは、同一のHCL構成を保ちながら状態(State: Terraformが管理する実体のスナップショット)を環境ごとに分離する仕組みです。構成ファイルは共通のまま、devやstg、prodといった論理的な環境だけを切り替え、Stateとロック(同時変更を防ぐ排他制御)を個別に扱えます。重要なのは、Workspacesは「リソース構成差」を生む道具ではなく、あくまで「状態の分離機構」であることです[1][2]。環境ごとの設定差は変数やlocals、モジュール入力で表現し、Workspacesは「その差分をもった状態をどこに保存し、どうロックするか」を担います。ここを取り違えると、環境間で大きく異なる構成や、別クラウドアカウントへのデプロイまでをWorkspacesだけで吸収しようとして行き詰まります[2]。
また、Workspacesは誤操作の抑止にも効きます。terraform workspace selectで現在の環境を明示し、S3バックエンドとDynamoDBロックを組み合わせれば、同一環境への並行applyを物理的に防げます[3][4]。一方で、プロダクションとステージングが別AWSアカウント、プロバイダ設定やネットワーク境界も大きく異なる場合は、ディレクトリやレポジトリを環境単位で分け、Stateも別管理にする方がわかりやすい選択です。Workspacesは小〜中規模で構成差が限定的、もしくは短命なエフェメラル環境の量産といった要件に相性が良い、と捉えるのが健全です[7]。
S3+DynamoDBバックエンドの基礎
バックエンド(Stateの保存先とロック方式)は運用の要です。AWSではS3バックエンドとDynamoDBロックの組み合わせが定番で、破損や並行更新を避けやすい構成になります[3][4]。Workspacesと併用する前提で、Stateキーの設計を早めに固めておくと移行コストを避けられます。S3はworkspace_key_prefixを設定すると、最終的なStateキーが「
terraform {
required_version = ">= 1.6"
backend "s3" {
bucket = "my-tfstate-bucket"
key = "core/terraform.tfstate"
region = "ap-northeast-1"
dynamodb_table = "tfstate-locks"
encrypt = true
workspace_key_prefix = "env"
}
}
この時点で、env/dev/core/terraform.tfstate、env/stg/core/terraform.tfstateのようにワークスペースごとのStateが並びます。S3のバージョニングやSSE-KMSを有効化しておくと、ロールバックや監査のしやすさが向上します[5]。
設計と実装の要点(変数・プロバイダ・モジュール)
Workspacesの本質は「状態の分離」ですが、日々の運用を安定させるには、環境差分を局所化して露出面積を小さく保つ設計が欠かせません。localsに環境マップを定義し、terraform.workspaceで選択する方針にすると、差分が明示されレビューもしやすくなります。加えて、プロバイダのassume_role(別アカウント権限の一時委譲)やdefault_tagsをlocalsから一元的に流すと、タグ整備やコスト可視化の基盤が整います。
localsで環境差分を一元管理
locals {
env = terraform.workspace
account_map = {
dev = {
account_id = "111111111111"
role_arn = "arn:aws:iam::111111111111:role/PlatformTerraform"
region = "ap-northeast-1"
cidr = "10.10.0.0/16"
}
stg = {
account_id = "222222222222"
role_arn = "arn:aws:iam::222222222222:role/PlatformTerraform"
region = "ap-northeast-1"
cidr = "10.20.0.0/16"
}
prod = {
account_id = "333333333333"
role_arn = "arn:aws:iam::333333333333:role/PlatformTerraform"
region = "ap-northeast-1"
cidr = "10.30.0.0/16"
}
}
cfg = lookup(local.account_map, local.env, local.account_map["dev"]) # 未定義ワークスペースはdevにフォールバック
tags = {
System = "core"
Environment = local.env
Owner = "platform"
}
}
このパターンでは、環境追加もマップへの追記で完結します。StateはWorkspacesで分離し、設定差分はlocalsに閉じ込めるため、構造の見通しが良くなります。
プロバイダでassume_roleとデフォルトタグを統制
provider "aws" {
region = local.cfg.region
assume_role {
role_arn = local.cfg.role_arn
session_name = "terraform-${local.env}"
}
default_tags {
tags = local.tags
}
}
data "aws_availability_zones" "available" {}
devとprodが別アカウントでも、localsを切り替えるだけで同一のHCLから展開できます。環境固有のタグが全リソースに行き渡るため、コスト配賦や監査レポートも整備しやすくなります。
モジュールへの入力は環境マップから供給する
module "network" {
source = "terraform-aws-modules/vpc/aws"
name = "core-${local.env}"
cidr = local.cfg.cidr
azs = slice(data.aws_availability_zones.available.names, 0, 3)
private_subnets = [for i in range(0,3) : cidrsubnet(local.cfg.cidr, 4, i)]
public_subnets = [for i in range(3,6) : cidrsubnet(local.cfg.cidr, 4, i)]
enable_nat_gateway = local.env == "prod"
single_nat_gateway = local.env != "prod"
enable_dns_hostnames = true
enable_dns_support = true
tags = local.tags
}
リソース側に環境分岐を散らすと保守性が落ちます。分岐はlocalsに寄せ、モジュール入力として限定的に流すことで、差分の「面」を広げずに済みます。たとえばprodだけNATを冗長化する条件も、入力に留めると事故の温床になりにくくなります。
ワークスペース連動のvar-fileと安全なラッパー
CLI操作の属人化を避けるため、ワークスペース名に対応するvar-fileを自動選択する薄いラッパーを用意すると、ヒューマンエラーをさらに減らせます。存在しないワークスペースは作成し、対応するtfvarsがなければ即座に失敗させます。
#!/usr/bin/env bash
set -Eeuo pipefail
workspace="${1:-}"
if [[ -z "${workspace}" ]]; then
echo "Usage: $0 <workspace> [plan|apply]" >&2
exit 2
fi
if ! terraform workspace list | grep -qE "^\*?\s*${workspace}$"; then
terraform workspace new "${workspace}" >/dev/null
fi
terraform workspace select "${workspace}" >/dev/null
var_file="env/${workspace}.tfvars"
if [[ ! -f "${var_file}" ]]; then
echo "missing var file: ${var_file}" >&2
exit 3
fi
cmd="${2:-plan}"
terraform init -upgrade
if [[ "${cmd}" == "plan" ]]; then
terraform plan -var-file="${var_file}" -out="plan.${workspace}.tfplan"
elif [[ "${cmd}" == "apply" ]]; then
terraform apply -auto-approve -var-file="${var_file}"
else
echo "unknown command: ${cmd}" >&2
exit 4
fi
このラッパーをCI/CDからも呼び出せるようにしておくと、ローカルと同じフローをパイプラインにそのまま持ち込めます。同じコマンドでどこでも動くことは、チームスループットに直結します。
CI/CDと運用(並列化、ロック、ドリフト検出)
複数環境を扱うときは、ロックと排他を尊重しつつ、ジョブは極力並列化します。GitHub Actionsでワークスペースをマトリクス展開し、同一ブランチ・同一ワークスペースでの競合をconcurrencyで抑止するのが扱いやすい方法です。OIDCとassume_roleでクラウド資格情報を短命化し、秘匿情報の配布を避けるのも標準化の要点です。
name: tf-plan
on:
pull_request:
paths:
- 'infra/**'
jobs:
plan:
runs-on: ubuntu-latest
strategy:
matrix:
workspace: [dev, stg]
concurrency:
group: tf-${{ github.ref }}-${{ matrix.workspace }}
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.8.5
- name: Configure AWS
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets['ROLE_' + matrix.workspace] }}
aws-region: ap-northeast-1
- name: Plan
working-directory: infra
run: |
./scripts/tfw.sh ${{ matrix.workspace }} plan
差分の有無を判定し、レビューのノイズを減らすには、detailed-exitcodeの扱いを明確にします。ゼロは差分なし、2は差分あり、1はエラーという戻り値に従って、コメント投稿や失敗扱いを切り替えます。
#!/usr/bin/env bash
set -Eeuo pipefail
rc=0
terraform plan -detailed-exitcode -out=tf.plan || rc=$?
case "${rc}" in
0) echo "no changes" ;;
2) echo "diff detected" ;;
1) echo "plan failed" >&2; exit 1 ;;
*) echo "unexpected code: ${rc}" >&2; exit 1 ;;
esac
ドリフト(実環境とStateの乖離)を定期的に検出すると、手作業変更の早期発見が可能です。refresh-onlyプランで軽量に回すとクラウドAPIの負荷を抑えられます。また、-parallelismは控えめに設定するとAPIスロットリングを避けやすく、特にprodでは効きます。たとえばdevは16、stgは8、prodは4といった保守的な並列度から始め、対象リソース数とAPI制限を見ながら段階的に調整すると安定します。計測値は環境差が大きいため、目標は「数分〜十数分で収束する計画時間」と「再実行で再現性が高いこと」と置くのが現実的です。
バックエンド移行の際は、Stateの安全な引っ越しを第一に考えます。localや別S3からの移行には-migrate-stateオプションが有効です。CI/CDで自動初期化する前に、手元で一度だけ明示的に実行しておくと、環境ごとのStateが正しく配置されます。
terraform init -migrate-state -reconfigure \
-backend-config="bucket=my-tfstate-bucket" \
-backend-config="key=core/terraform.tfstate" \
-backend-config="region=ap-northeast-1" \
-backend-config="dynamodb_table=tfstate-locks" \
-backend-config="workspace_key_prefix=env"
ディレクトリ分割やTerraform Cloudとの比較と使い分け
Workspacesは「同一構成・環境差分小」で真価を発揮します。逆に、環境ごとにネットワークやセキュリティ境界が大きく異なる場合は、ディレクトリを環境単位で完全に分け、Stateも別バケットや別アカウントにする方が扱いやすいことが多い。レビューや権限設計の単位が環境で完全に分かれるため、誤操作時のBlast Radiusを小さく保てます。その代わり、重複コードが増えやすく、モジュール化の成熟が求められます。
Terraform Cloud/Enterpriseを採用する選択肢も検討に値します。実行環境・認証情報・ポリシーガードレール(Sentinelなど)を集中管理でき、ワークスペースをプラットフォーム側の一級概念として扱えるため、監査やサプライチェーン整備が進めやすくなります。CLI Workspacesと連携する場合は、remoteバックエンドのworkspacesプレフィックスを使います。名前を固定してしまうとCLI側のワークスペース切替が効かなくなるため、prefix運用が実務的です。
terraform {
backend "remote" {
hostname = "app.terraform.io"
organization = "my-org"
workspaces {
prefix = "platform-" # platform-dev, platform-stg, ...
}
}
}
ここまでの比較を踏まえると、短命なプレビュー環境を量産したい、環境差は主に変数で表現できる、Stateの保管戦略を簡潔にしたい――といった要件ならWorkspacesが第一候補です。一方で、アカウントやリージョンが大きく分かれ、チームも環境ごとに縦割りで運用するなら、ディレクトリやレポジトリの分離、あるいはTerraform Cloudのワークスペース分割の方が、責任境界が明瞭でスケールします。意思決定の軸は「差分の大きさ」と「責任境界」。道具に構成を合わせるのではなく、境界に道具を合わせると誤りが減ります。
まとめ:状態の分離がチームの速度を上げる
環境が増えるほど、操作ミスとレビュー遅延は現実的なボトルネックになります。Terraform Workspacesは、同一HCLで状態を環境単位に分離し、S3+DynamoDBのロックと組み合わせて基盤の信頼性を底上げできます[3][4]。localsで差分を一元管理し、プロバイダとモジュールに限定的に流す設計は、レビューしやすく変更の意図も伝わりやすい。CI/CDに同じフローを落とし込めば、人に依存しない反復可能な運用が実現します。まずはdevとstgの2環境をWorkspacesで切り、ラッパーとdetailed-exitcodeを導入して、planの安定化とレビュー速度の改善を体感してみてください。そこで得た学びを起点に、prodやエフェメラル環境に拡張するか、Terraform Cloudやディレクトリ分割に切り替えるかを検討すると、無理のない最適解に近づけます。
参考文献
- HashiCorp Developer: Terraform CLI Workspaces
https://developer.hashicorp.com/terraform/cli/workspaces - HashiCorp Developer: Terraform CLI Workspaces — Intended Use and Environment Isolation
https://developer.hashicorp.com/terraform/cli/workspaces#:~:text=In%20particular%2C%20organizations%20commonly%20want,isolation%20mechanism%20for%20this%20scenario - AWS Prescriptive Guidance: Terraform on AWS ベストプラクティス — バックエンド(日本語)
https://docs.aws.amazon.com/ja_jp/prescriptive-guidance/latest/terraform-aws-provider-best-practices/backend.html#:~:text=%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E7%92%B0%E5%A2%83%E3%81%94%E3%81%A8%E3%81%AB%E5%80%8B%E5%88%A5%E3%81%AE%20Terraform%20%E3%83%90%E3%83%83%E3%82%AF%E3%82%A8%E3%83%B3%E3%83%89%E3%82%92%E4%BD%BF%E7%94%A8%E3%81%97%E3%81%BE%E3%81%99%E3%80%82 - AWS Prescriptive Guidance: Terraform on AWS ベストプラクティス — リモートステートとDynamoDBロック(日本語)
https://docs.aws.amazon.com/ja_jp/prescriptive-guidance/latest/terraform-aws-provider-best-practices/backend.html#:~:text=Amazon%20DynamoDB%20%E3%82%92%E4%BD%BF%E7%94%A8%E3%81%97%E3%81%A6%20Terraform%20%E7%8A%B6%E6%85%8B%E3%82%92%E3%83%AA%E3%83%A2%E3%83%BC%E3%83%88%E3%81%A7 - HashiCorp Developer: Terraform S3 Backend — Warnings and Versioning
https://developer.hashicorp.com/terraform/language/backend/s3#:~:text=Warning%21%20It%20is%20highly%20recommended,accidental%20deletions%20and%20human%20error - HashiCorp Developer: Terraform S3 Backend — Workspaces and workspace_key_prefix
https://developer.hashicorp.com/terraform/language/backend/s3#:~:text=When%20using%20workspaces%2C%20the%20state,env%3A%2Fdevelopment%2Fpath%2Fto%2Fmy%2Fkey - クラスメソッド開発者ブログ: Terraformのworkspaceの使い方
https://dev.classmethod.jp/articles/how-to-use-terraform-workspace/#:~:text=%E7%9A%86%E3%81%95%E3%82%93%E3%80%81%20terraform%E3%81%AEworkspace%E3%81%AF%E3%81%94%E5%AD%98%E7%9F%A5%E3%81%A7%E3%81%97%E3%82%87%E3%81%86%E3%81%8B%E3%80%82%20%E3%81%93%E3%82%8C%E3%81%AF%E7%92%B0%E5%A2%83%E3%82%92%E8%A4%87%E6%95%B0%E7%94%A8%E6%84%8F%E3%81%99%E3%82%8B%E9%9A%9B%E3%81%ABterraform%E5%81%B4%E3%81%A7state%E3%82%92%E5%88%86%E3%81%91%E3%81%A6%E7%AE%A1%E7%90%86%E3%81%8C%E3%81%A7%E3%81%8D%E3%82%8B%E6%A9%9F%E8%83%BD%E3%81%AB%E3%81%AA%E3%82%8A%E3%81%BE%E3%81%99%E3%80%82