ข้ามไปยังเนื้อหา

Directory-per-Environment Pattern

directory-per-environment ให้แต่ละ environment มี directory ที่แยกจากกันจริง ๆ พร้อม backend configuration ของตัวเอง โดยที่ root config ของทุก environment เรียก shared module เดียวกัน ด้วยค่า input ที่ต่างกัน

แทนที่จะเป็น state file เดียวที่เลือกด้วย workspace pattern นี้ใช้ directory จริงหนึ่งชุดต่อ environment แต่ละชุดมีไฟล์ .tf ระดับ root ของตัวเอง

Terminal window
environments/
├── dev/
│ └── main.tf
├── staging/
│ └── main.tf
└── prod/
└── main.tf
modules/
└── app/
└── main.tf

ความต่างสำคัญจากการ copy-paste environment ทั้งชุดคือสิ่งที่อยู่ข้างใน main.tf แต่ละไฟล์ ไม่ใช่ copy เต็มของ resource ของ application แต่เป็น root configuration เล็ก ๆ ที่มี backend block ของตัวเอง กับ module call เดียวเข้าไปยัง shared module ตัวเดียว

environments/dev/main.tf
terraform {
backend "s3" {
bucket = "acme-terraform-state-dev"
key = "app/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
}
}
provider "aws" {
region = "us-east-1"
profile = "acme-dev"
}
module "app" {
source = "../../modules/app"
environment = "dev"
instance_type = "t3.micro"
instance_count = 1
}
environments/prod/main.tf
terraform {
backend "s3" {
bucket = "acme-terraform-state-prod"
key = "app/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
}
}
provider "aws" {
region = "us-east-1"
profile = "acme-prod"
}
module "app" {
source = "../../modules/app"
environment = "prod"
instance_type = "m5.large"
instance_count = 3
}

สังเกตว่า backend แต่ละอันชี้ไปยัง S3 bucket ที่แยกกันจริง ผูกกับ profile แยก (และในของจริง มักเป็น AWS account ที่แยกกันจริง ๆ ด้วย) dev กับ prod แชร์ state กันโดยไม่ตั้งใจไม่ได้ เพราะไม่มี state ร่วมกันให้ไปแตะโดยพลาด resource จริงของ application อยู่ที่เดียวคือใน modules/app และทุก environment แค่เรียก module นั้นด้วยค่า instance_type กับ instance_count ที่ต่างกัน

ตรงนี้แก้ปัญหา isolation ที่อ่อนแอของ workspace ได้ตรง ๆ แต่ละ environment directory ชี้ไปยัง AWS account ของตัวเอง, region ของตัวเอง, และ backend bucket ของตัวเองได้ โดยไม่มีอะไรผูกมัดกันในระดับ Terraform เลย ไม่มี state file ร่วมให้ชื่อ workspace ที่พิมพ์ผิดไปแตะโดยพลาด และไม่มี provider block ร่วมให้ credential ผิดหลุดเข้าไปได้ ถ้า dev กับ prod ต้องอยู่คนละ AWS account จริง ๆ เพื่อ blast-radius isolation pattern นี้ทำได้ ส่วน workspace ทำไม่ได้

ลองดูสองไฟล์ main.tf นั้นอีกครั้ง input instance_type, instance_count, และ environment เป็นบรรทัดเดียวที่ต่างกันอย่างมีความหมายระหว่างสองไฟล์นี้ คือสามบรรทัดจากทั้งหมดประมาณสิบห้าบรรทัด ที่เหลือทั้งหมด ทั้งรูปของ backend "s3" block, provider "aws" block, และ argument source ของ module "app" block คือ boilerplate ที่ซ้ำกันแทบทุกตัวอักษรในทุก environment directory bump module ไปเวอร์ชันใหม่, เพิ่ม provider argument ที่จำเป็นตัวใหม่, หรือเปลี่ยน convention ของ state key แล้วความเปลี่ยนแปลงนั้นต้องเอาไปใส่มือใน dev/main.tf, staging/main.tf, และ prod/main.tf ทีละไฟล์ เป็น failure mode แบบเดียวกับการทำซ้ำมือจากวิธี copy-paste directory ทั้งชุด เพียงแต่แคบลงเหลือแค่ส่วนหนึ่งของแต่ละไฟล์ที่เล็กกว่าเดิม แต่ก็ยังเป็นปัญหาจริง

flowchart LR
  mod["modules/app (shared source)"]
  devEnv["environments/dev/main.tf"] -->|module call with dev inputs| mod
  stagingEnv["environments/staging/main.tf"] -->|module call with staging inputs| mod
  prodEnv["environments/prod/main.tf"] -->|module call with prod inputs| mod
  devEnv -.->|near-identical backend + provider + source blocks| stagingEnv
  stagingEnv -.->|near-identical backend + provider + source blocks| prodEnv
สาม environment directory ที่ isolate กัน shared module เดียว แต่ boilerplate เกือบเหมือนกันเป๊ะซ้ำอยู่ทุก directory

สอง main.tf ข้างบนใช้ S3 backend จริงต่อ environment (รูปแบบ production); คอร์สนี้ไม่มี AWS credential เลย ดังนั้นพิสูจน์ จริง รันได้ ข้างล่างใช้ backend "local" {} แทน — กลไก share-module เดียวกัน resource aws_instance เดียวกัน พิสูจน์ด้วย terraform plan จริงภายใต้ AWS mock credential (skip_credentials_validation ฯลฯ วิธีเดียวกับที่ state-management ใช้):

modules/app/main.tf
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 6.0" }
}
}
variable "environment" { type = string }
variable "instance_type" { type = string }
variable "instance_count" { type = number }
resource "aws_instance" "app" {
count = var.instance_count
ami = "ami-0abcdef1234567890"
instance_type = var.instance_type
tags = {
Name = "app-${var.environment}-${count.index}"
Environment = var.environment
}
}
environments/dev/main.tf
terraform {
backend "local" {}
required_providers {
aws = { source = "hashicorp/aws", version = "~> 6.0" }
}
}
provider "aws" {
region = "us-east-1"
access_key = "mock"
secret_key = "mock"
skip_credentials_validation = true
skip_requesting_account_id = true
skip_metadata_api_check = true
skip_region_validation = true
}
module "app" {
source = "../../modules/app"
environment = "dev"
instance_type = "t3.micro"
instance_count = 1
}
environments/prod/main.tf
terraform {
backend "local" {}
required_providers {
aws = { source = "hashicorp/aws", version = "~> 6.0" }
}
}
provider "aws" {
region = "us-east-1"
access_key = "mock"
secret_key = "mock"
skip_credentials_validation = true
skip_requesting_account_id = true
skip_metadata_api_check = true
skip_region_validation = true
}
module "app" {
source = "../../modules/app"
environment = "prod"
instance_type = "m5.large"
instance_count = 3
}

terraform plan จริง จากข้างในแต่ละ environment directory shared module เดียวกัน มีแค่ค่า input ที่ต่างกัน:

$ cd environments/dev && terraform plan
+ instance_type = "t3.micro"
+ "Name" = "app-dev-0"
Plan: 1 to add, 0 to change, 0 to destroy.
$ cd environments/prod && terraform plan
+ instance_type = "m5.large"
+ "Name" = "app-prod-0"
+ "Name" = "app-prod-1"
+ "Name" = "app-prod-2"
Plan: 3 to add, 0 to change, 0 to destroy.

modules/app directory เดียว ไม่มี resource block ซ้ำเลย และ plan output จริงพิสูจน์ว่า instance_count กับ instance_type มาจาก input ของแต่ละ environment จริง ๆ — dev plan instance เดียวเป๊ะ prod plan สามตัวเป๊ะ ทั้งคู่มาจาก aws_instance block เดียวกันเป๊ะ

🐛 หา bug ในโค้ดนี้

# environments/dev/main.tf, environments/prod/main.tf — both call this module
module "app" {
source = "../../modules/app"
}
เฉลย

ทั้งสองที่เรียกไม่ได้ตั้งค่า environment, instance_type, หรือ instance_count เลย — ทุก argument ที่ module ประกาศเป็น variable โดยไม่มี default พิสูจน์ข้างบนว่า module นี้ต้องการ input สามตัวนี้จริง ๆ call site ที่ขาดมันไปจะทำให้ terraform plan fail ทันทีด้วย “Missing required argument” ทั้งใน dev และ prod พร้อมกัน เพราะเป็น module source เดียวกันที่ไม่ได้ copy ไปที่ไหนเลย — บั๊กนี้ (หรือที่เป็นไปได้พอ ๆ กัน คือ merge ที่ทำ argument block หายไปโดยไม่ตั้งใจ) พัง environment ทุกตัวพร้อมกัน ไม่ใช่แค่ตัวเดียว

ทุกอย่างข้างบนคือ Terraform module call ธรรมดา — module Terragrunt-fundamentals ของคอร์สนี้พูดถึง include/dependency ไปแล้ว และมี Terragrunt feature อีกตัวหนึ่งที่เข้ากับหัวข้อบทนี้พอดี: terragrunt stack generate หลาย Terragrunt unit (ไม่ใช่ Terraform module) จากไฟล์ catalog terragrunt.stack.hcl เดียว ซึ่งเป็นไอเดีย shared-module ของ directory-per-environment ที่ยกขึ้นไปอีกชั้นของ abstraction — แทนที่จะเป็น module เดียวที่ถูกเรียกด้วย input ต่างกัน unit template เดียวกันจะ generate หลาย unit อิสระ แต่ละตัวมี state ของตัวเอง

terragrunt.stack.hcl
unit "dev" {
source = "./units/app"
path = "dev"
values = {
environment = "dev"
}
}
unit "prod" {
source = "./units/app"
path = "prod"
values = {
environment = "prod"
}
}
units/app/terragrunt.hcl
remote_state {
backend = "local"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
path = "${get_terragrunt_dir()}/terraform.tfstate"
}
}
terraform {
source = "."
}
inputs = {
environment = values.environment
}

values.environment แบบดิบ ๆ แบบนี้ resolve ได้แค่ข้างใน unit ที่ stack generate generate มาจริงเท่านั้น — Terragrunt อ่าน values จากไฟล์ terragrunt.values.hcl sibling ข้าง terragrunt.hcl และนอก stack ไฟล์นั้นก็ยังไม่มีอยู่เลย commit ค่า placeholder เข้า template เอง (ค่าที่ stack-generated copy เขียนทับเสมออยู่แล้ว) ทำให้ template ยัง valid ได้ด้วยตัวเอง:

units/app/terragrunt.values.hcl
environment = "app"
units/app/main.tf
terraform {
required_providers {
local = { source = "hashicorp/local", version = "~> 2.5" }
}
}
variable "environment" { type = string }
resource "local_file" "app" {
filename = "${path.module}/app-${var.environment}.txt"
content = "environment=${var.environment}\n"
}
output "environment" {
value = var.environment
}

terragrunt stack generate จริง รันจากข้างๆ terragrunt.stack.hcl:

$ terragrunt stack generate
INFO Generating unit prod from ./terragrunt.stack.hcl
INFO Generating unit dev from ./terragrunt.stack.hcl

คำสั่งนี้เขียน directory .terragrunt-stack/dev/ และ .terragrunt-stack/prod/ จริง แต่ละอันมี terragrunt.hcl (copy มาจาก units/app) บวก terragrunt.values.hcl ที่ generate มา:

$ cat .terragrunt-stack/dev/terragrunt.values.hcl
# Auto-generated by the terragrunt.stack.hcl file by Terragrunt. Do not edit manually
environment = "dev"

ไฟล์นั้นแหละคือสิ่งที่ทำให้ values.environment ข้างใน units/app/terragrunt.hcl resolve ได้ — values คือ namespace พิเศษที่มีเฉพาะ unit ที่ generate มาจาก stack เท่านั้น มาจากไฟล์ที่ generate มาตัวนี้เป๊ะ ๆ terragrunt stack run --working-dir .terragrunt-stack -- apply -auto-approve apply ทั้งคู่จริง ๆ และ terragrunt stack output รวม output ของทุก unit ที่ generate มาไว้ใน call เดียว:

$ terragrunt stack run --non-interactive --working-dir .terragrunt-stack -- apply -auto-approve
❯❯ Run Summary 2 units
Succeeded 2
$ terragrunt stack output
dev = {
environment = "dev"
}
prod = {
environment = "prod"
}

gotcha จริงหนึ่งเรื่อง พิสูจน์ตรงนี้ ไม่ใช่แค่พูดลอย ๆ: รัน terragrunt stack run จาก directory ที่มี terragrunt.stack.hcl เอง (ไม่ใช้ --working-dir .terragrunt-stack) จะเจอ directory template units/app เป็น unit ที่สามด้วย เพราะมันก็มี terragrunt.hcl จริงของตัวเองเหมือนกัน — และ template นั้นไม่มี values object เลยนอกบริบทที่ stack generate ให้ เลย fail ด้วย error จริง: Unknown variable; There is no variable named "values" ตัวอย่างทางการทุกอันดึง unit source จาก Git ref แยกต่างหากเพื่อไม่ให้ template อยู่เป็น sibling directory แบบที่ demo offline นี้ทำด้วย local path การรันคำสั่งกับ .terragrunt-stack ตรง ๆ (แบบข้างบน) หลบปัญหานี้ไปได้ทั้งหมด

stacks stable หรือ experimental บน Terragrunt 1.1.0 ที่ติดตั้งในคอร์สนี้ เช็คทั้งสองทาง: docs.terragrunt.com/features/stacks เอกสาร terragrunt.stack.hcl, stack generate/run/output ไม่มี label experimental/beta ที่ไหนในหน้าเลย และรันทุกคำสั่งข้างบนไม่มี --experiment warning เลยสักครั้ง — เทียบกับ azure-backend ใน module ก่อนหน้าที่ต้องมี experiment flag จริงบน version เดียวกันที่ติดตั้งอยู่นี้ sub-feature stack-dependencies (dependency block ทำงานข้าม unit ที่ generate จาก stack โดยเฉพาะ) มีเอกสารแยกว่า graduate เป็น general availability ใน Terragrunt 1.1.0 ตาม changelog หัวข้อ “Six experiments completed” สรุป: terragrunt stack เองใช้ได้โดยไม่ต้องมี flag เลยบน 1.1.0 และการ integrate กับ dependency block ก็ graduate ใน version นี้เป๊ะ

ตอนรีวิว directory-per-environment หรือ terragrunt.stack.hcl config ที่ AI สร้าง ให้เช็คสิ่งเหล่านี้:

  • module call ที่ขาด argument ที่จำเป็น ที่ module ประกาศไว้โดยไม่มี default — พิสูจน์ข้างบน นี่คือ terraform plan fail จริงทันที และเพราะ module นี้ share กัน มันพังทุก environment ที่เรียกมันพร้อมกัน ไม่ใช่แค่ตัวเดียว
  • unit source ที่ชี้ไปที่ local relative path ที่อยู่ใน tree เดียวกับที่ stack run จะไปเจอ — พิสูจน์ข้างบนด้วย error จริง “no variable named values” catalog จริงใช้ Git ref ที่ pin ไว้เพื่อเลี่ยงปัญหานี้โดยเฉพาะ
  • โค้ดที่สมมติว่า values.* มีอยู่ข้างใน terragrunt.hcl ธรรมดา (ที่ไม่ได้ generate จาก stack) — พิสูจน์ข้างบน values มีอยู่แค่ใน unit ที่ terragrunt stack generate generate มาจริง มาจากไฟล์ terragrunt.values.hcl จริงที่คำสั่งนั้นเขียน
  • terragrunt stack output ที่ใช้ตรงที่โค้ดต้องการ output ของ unit เดียว — พิสูจน์ข้างบน มันรวม output ของทุก unit ที่ generate มาไว้ใต้ key ระดับบนสุดของตัวเอง (dev = {...}, prod = {...}) ไม่ใช่ flat map

Directory-per-environment คือ module composition ที่ใช้ในระดับ environment แทนระดับ resource — พิสูจน์ข้างบน aws_instance block เดียวกันเป๊ะข้างใน modules/app ให้ plan instance เดียวหรือ plan สามตัว มาจาก inputs ของ directory ไหนที่เรียกมันล้วน ๆ terragrunt stack คือไอเดียเดียวกันยกขึ้นไปอีกชั้น: แทนที่จะเป็น module เดียวที่ถูกเรียกด้วยค่า variable ต่างกัน unit template เดียว (ทั้ง terragrunt.hcl + module ที่รองรับ) ถูก stamp ออกมาหลายครั้งด้วย values ต่างกัน แต่ละ copy ที่ stamp ออกมากลายเป็น Terragrunt unit ที่มี state เป็นของตัวเองอิสระ — พิสูจน์ข้างบนด้วย .terragrunt-stack/{dev,prod} directory จริงสองอันที่ apply แยกกันจริง

ทางเลือกได้อะไรเสียอะไร
Directory-per-environment พร้อม shared moduleisolation ของ backend/provider จริงต่อ environment พิสูจน์ข้างบนด้วย plan ของ dev/prod แยกกันจาก module เดียวboilerplate ของ backend + provider + source ยังคงซ้ำต่อ directory พิสูจน์ข้างบนจากความเหมือนกันของสอง environment main.tf นอกเหนือจาก input
terragrunt stack แทน environment directory ที่ maintain ด้วยมือไฟล์ catalog เดียว (terragrunt.stack.hcl) generate N unit อิสระ พิสูจน์ข้างบนด้วย stack generate + stack run apply + stack output จริงunit source แบบ local (ไม่ใช่ Git ref) อาจถูก stack run เจอเป็น unit หลงทางได้เอง พิสูจน์ข้างบนด้วย error จริง — catalog จริงเลี่ยงปัญหานี้ได้โดยโครงสร้าง
  • “terragrunt stack แทนที่ module call ข้างใน directory-per-environment setup” ทั้งคู่แก้ปัญหาที่ใกล้กันแต่ต่างกัน: module call share โค้ด Terraform ภายใน state เดียว terragrunt stack generate หลาย Terragrunt unit อิสระ (แต่ละตัวมี state ของตัวเอง) จาก template เดียว — พิสูจน์ข้างบน stack output คืนผลลัพธ์ต่อ unit (dev = {...}, prod = {...}) ไม่ใช่ output set เดียวของ shared module
  • “values ข้างใน terragrunt.hcl ของ unit ที่ generate จาก stack มาจากที่เดียวกับ locals built-in ของ Terragrunt” พิสูจน์ข้างบน: values.* มาจากไฟล์ terragrunt.values.hcl ที่ generate มาโดยเฉพาะ มีอยู่แค่ใน unit ที่ stack generate generate มาจริง unit ธรรมดาที่ไม่ได้มาจาก stack ไม่มี values object เลย พิสูจน์ด้วย error จริง “no variable named values” ข้างบน
  • “เพราะไม่ต้องมี —experiment flag terragrunt stack ก็เสถียรมานานแล้ว” เช็คตรง ๆ ข้างบน: feature พื้นฐานไม่มี label experimental บน docs.terragrunt.com เลยและไม่ต้องมี flag บน 1.1.0 ที่ติดตั้งในคอร์สนี้ แต่การ integrate กับ dependency block (stack-dependencies) เพิ่ง graduate เป็น general availability ใน version เดียวกันนี้เอง คือ 1.1.0 — นี่คือการ stabilize ที่ค่อนข้างเพิ่งเกิด ไม่ใช่ที่นิ่งมานานแล้ว
directory-per-environment pattern แก้ปัญหาอะไรที่ Terraform workspace แก้ไม่ได้
สิ่งไหนยังคง DRY (เขียนครั้งเดียว ใช้ซ้ำได้ทุกที่) ใน directory-per-environment pattern
อะไรที่ยังถูก duplicate ข้าม environment directory ใน pattern นี้ ทั้งที่แก้ปัญหา isolation ได้แล้ว
ทำไม environments/dev กับ environments/prod ถึงใช้ AWS account ที่ต่างกันโดยสิ้นเชิงได้อย่างปลอดภัยใน pattern นี้
modules/app ประกาศ instance_type กับ instance_count เป็น variable ที่จำเป็นโดยไม่มี default module "app" call ที่ generate มาไม่ได้ตั้งค่าทั้งคู่ เกิดอะไรขึ้น พิสูจน์ข้างบน
บน Terragrunt 1.1.0 ที่ติดตั้งในคอร์สนี้ terragrunt stack generate/run/output ต้องมี --experiment flag ไหม