Directory-per-Environment Pattern
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”directory-per-environment pattern ให้แต่ละ environment มี directory ของตัวเอง, backend ของตัวเอง, และ GCP project ของตัวเองเพื่อ isolation จริง โดยที่ทุก directory เรียก shared module ตัวเดียวกัน ทำให้ logic ของ infrastructure จริง ๆ อยู่ที่เดียวเป๊ะ
directory จริงต่อ environment ชี้ไปที่ project จริง
หัวข้อที่มีชื่อว่า “directory จริงต่อ environment ชี้ไปที่ project จริง”workspace ใช้ backend เดียวกัน, provider project เดียวกัน, และโค้ด .tf ชุดเดียวกันข้ามทุก environment ซึ่งคือ isolation ที่อ่อนแอตามที่บทก่อนหน้าชี้ไว้ pattern แบบ directory-per-environment แก้ตรงนี้ด้วยการให้แต่ละ environment มี root configuration ที่แยกกันจริง แต่ละอันมี backend block ของตัวเองและ provider "google" block ของตัวเองที่ชี้ไปที่ GCP project ของ environment นั้น
tree environmentsenvironments├── dev│ └── main.tf├── staging│ └── main.tf└── prod └── main.tfterraform { backend "gcs" { bucket = "acme-terraform-state-dev" prefix = "app" }}
provider "google" { project = "acme-dev-123456" region = "us-central1"}
module "app" { source = "../../modules/app" environment = "dev" machine_type = "e2-small" instance_count = 1}terraform { backend "gcs" { bucket = "acme-terraform-state-staging" prefix = "app" }}
provider "google" { project = "acme-staging-234567" region = "us-central1"}
module "app" { source = "../../modules/app" environment = "staging" machine_type = "e2-medium" instance_count = 2}terraform { backend "gcs" { bucket = "acme-terraform-state-prod" prefix = "app" }}
provider "google" { project = "acme-prod-345678" region = "us-central1"}
module "app" { source = "../../modules/app" environment = "prod" machine_type = "e2-standard-4" instance_count = 5}แต่ละ directory มี state bucket ของตัวเองและ project ของตัวเอง ทำให้ความผิดพลาดที่เกิดขึ้นตอน terraform apply รันอยู่ใน environments/dev ถูกจำกัดอยู่แค่ acme-dev-123456 เท่านั้น ความผิดพลาดนั้นไม่มีทางไปถึง acme-staging-234567 หรือ acme-prod-345678 เลย นี่คือ boundary แข็ง ๆ ที่ GCP IAM กับ billing บังคับไว้ ไม่ใช่แค่ข้อตกลงที่ต้องมีใครจำไว้เอง นั่นคือสิ่งที่ workspace ให้ไม่ได้พอดี
shared module ตัวเดียว caller สามตัวที่ต่างกัน
หัวข้อที่มีชื่อว่า “shared module ตัวเดียว caller สามตัวที่ต่างกัน”สังเกตว่าทั้งสาม directory เรียก source = "../../modules/app" เดียวกัน resource จริง ๆ ที่ประกอบเป็น “app” อยู่ที่เดียวเป๊ะ
terraform { required_providers { google = { source = "hashicorp/google", version = "~> 8.0" } }}
variable "environment" { type = string }variable "machine_type" { type = string }variable "instance_count" { type = number }
resource "google_compute_instance" "app" { count = var.instance_count name = "app-${var.environment}-${count.index}" machine_type = var.machine_type zone = "us-central1-a"
boot_disk { initialize_params { image = "debian-cloud/debian-12" } }
network_interface { network = "default" }}bug fix, label ใหม่, หรือการทำ security-group ให้แน่นขึ้นที่ทำใน modules/app/main.tf จะมีผลกับ dev, staging, และ prod ในครั้งถัดไปที่แต่ละ environment รัน terraform apply มี resource logic แค่ชุดเดียวที่ต้องแก้ ซึ่งคือคุณสมบัติ DRY ที่การ copy-paste directory ตรง ๆ จากบทแรกไม่เคยมี
สิ่งที่ยังซ้ำซ้อนอยู่
หัวข้อที่มีชื่อว่า “สิ่งที่ยังซ้ำซ้อนอยู่”module เองยัง DRY อยู่ แต่ลองดูสามไฟล์ main.tf ข้างบนอีกครั้ง block terraform { backend "gcs" { ... } }, block provider "google" { ... }, และการต่อสาย module "app" { source = "../../modules/app" ... } คือ boilerplate ที่แทบเหมือนกันเป๊ะ ซ้ำทุกตัวอักษรในทุก environment directory มีแค่ชื่อ bucket, project ID, และ input value ของ module ไม่กี่ตัวที่ต่างกันจริง ๆ เพิ่ม environment ที่สี่เข้ามาแล้ว boilerplate ทั้งก้อนนั้นก็ถูก copy-paste เป็นครั้งที่สี่ เปลี่ยนวิธีจัดระเบียบ state เช่นเพิ่ม argument location เข้าไปใน backend block ทุกตัว การเปลี่ยนนั้นต้องเอาไปใส่มือใน environment directory ทุกอันที่มีอยู่ เป็นความเสี่ยงจากการทำซ้ำมือแบบเดียวกับที่บทแรกของ module นี้เตือนไว้เป๊ะ แค่ย้ายจาก module logic ไปอยู่ที่ backend กับ provider wiring รอบ ๆ แทน
flowchart TD shared["modules/app (shared source)"] devDir["environments/dev/main.tf"] -->|source| shared stagingDir["environments/staging/main.tf"] -->|source| shared prodDir["environments/prod/main.tf"] -->|source| shared devDir --> devProject["acme-dev-123456"] stagingDir --> stagingProject["acme-staging-234567"] prodDir --> prodProject["acme-prod-345678"] devDir -.->|nearly identical backend + provider block| stagingDir stagingDir -.->|nearly identical backend + provider block| prodDir
พิสูจน์: module และ environment directory จริง validate ผ่านทั้งคู่ — กับบั๊กจริงสองตัวที่จับได้ระหว่างทาง
หัวข้อที่มีชื่อว่า “พิสูจน์: module และ environment directory จริง validate ผ่านทั้งคู่ — กับบั๊กจริงสองตัวที่จับได้ระหว่างทาง”modules/app และ environments/dev fence ข้างบนเป็น HCL จริงที่ validate ผ่าน — แต่ module ที่โชว์ก่อนหน้านี้ในบทนี้ไม่ได้เริ่มต้นแบบนั้น รัน terraform init -backend=false && terraform validate กับ module เดิม (ไม่มี variable declaration ไม่มี network_interface block) ได้ error จริงสี่ตัว:
Error: Reference to undeclared input variableAn input variable with the name "instance_count" has not been declared.Error: Reference to undeclared input variableAn input variable with the name "environment" has not been declared.Error: Reference to undeclared input variableAn input variable with the name "machine_type" has not been declared.Error: Insufficient network_interface blocksAt least 1 "network_interface" blocks are required.error undeclared-variable สามตัวบวกกับบั๊ก network_interface หายที่เจอในบทก่อนหน้าเป๊ะ ๆ ทางแก้ — เพิ่ม variable block สำหรับ environment/machine_type/instance_count และ network_interface { network = "default" } block — คือสิ่งที่โชว์ข้างบน และคือสิ่งที่ validate ผ่านจริง:
$ cd modules/app && terraform init -backend=false && terraform validateSuccess! The configuration is valid.
$ cd environments/dev && terraform init -backend=false && terraform validateSuccess! The configuration is valid.🐛 หา bug ในโค้ดนี้
resource "google_compute_instance" "app" {count = var.instance_countname = "app-${var.environment}-${count.index}"machine_type = var.machine_typezone = "us-central1-a"
boot_disk { initialize_params { image = "debian-cloud/debian-12" }}}เฉลย
พิสูจน์ข้างบนด้วย error จริงของ terraform validate: module นี้ reference var.instance_count, var.environment, และ var.machine_type โดยไม่มี variable block คู่กันเลยที่ไหนในไฟล์ — error จริงแบบ “Reference to undeclared input variable” สามตัว — และ google_compute_instance resource ไม่มี network_interface block เลย เป็น error จริงตัวที่สี่ module ที่ “ดูครบแล้ว” เพราะมี count, name ที่คำนวณ, และ boot_disk ก็ยังขาด configuration สองหมวดเลยที่มีแค่ terraform validate เท่านั้นที่จับได้แน่นอน
terragrunt stack: generate ทั้ง tree ของ unit จาก catalog เดียว
หัวข้อที่มีชื่อว่า “terragrunt stack: generate ทั้ง tree ของ unit จาก catalog เดียว”terragrunt stack generate หลาย Terragrunt unit จาก catalog terragrunt.stack.hcl เดียว — ไอเดีย shared-template เดียวกับ module ข้างบน ยกขึ้นไปอีกชั้น พิสูจน์ด้วย unit local_file ที่ cloud-agnostic:
unit "dev" { source = "./units/app" path = "dev" values = { environment = "dev" }}
unit "prod" { source = "./units/app" path = "prod" values = { environment = "prod" }}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.hcl แบบ placeholder ทำให้ template ยัง valid ได้ด้วยตัวเอง:
environment = "app"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}stack generate, stack run, stack output จริง:
$ terragrunt stack generateINFO Generating unit prod from ./terragrunt.stack.hclINFO Generating unit dev from ./terragrunt.stack.hcl
$ terragrunt stack run --non-interactive --working-dir .terragrunt-stack -- apply -auto-approve❯❯ Run Summary 2 units Succeeded 2
$ terragrunt stack outputdev = { environment = "dev" }prod = { environment = "prod" }stacks stable หรือ experimental บน Terragrunt 1.1.0 ที่ติดตั้งในคอร์สนี้ docs.terragrunt.com/features/stacks เอกสาร terragrunt.stack.hcl/stack generate/run/output ไม่มี label experimental เลย และทุกคำสั่งข้างบนไม่มี --experiment warning เลยสักครั้ง sub-feature stack-dependencies graduate เป็น general availability ใน Terragrunt 1.1.0 โดยเฉพาะ ตาม release changelog
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”- module ที่ reference ชื่อ
var.*โดยไม่มีvariableblock คู่กัน — พิสูจน์ข้างบนด้วย error จริง “undeclared input variable” สามตัว network_interfaceหายไปจากgoogle_compute_instance— พิสูจน์ข้างบนและในบทก่อนหน้า เป็น error จริงที่ provider บังคับunitsource ที่ชี้ไปที่ local relative path ที่stack runจะไปเจอเป็น unit หลงทาง — catalog จริงใช้ Git ref ที่ pin ไว้
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”Directory-per-environment คือ module composition ที่ใช้ในระดับ environment — พิสูจน์ข้างบน module เดียวกันที่ validate ผ่านแล้วถูกเรียกจากสาม environment directory ด้วย input ต่างกัน terragrunt stack คือไอเดียเดียวกันยกขึ้นไปอีกชั้น: unit template เดียวถูก stamp ออกมาหลายครั้งด้วย values ต่างกัน
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ทางเลือก | ได้อะไร | เสียอะไร |
|---|---|---|
| Directory-per-environment พร้อม shared module | isolation ของ backend/provider/project จริง พิสูจน์ข้างบนด้วย environment directory ที่ validate ผ่านอิสระจากกัน | boilerplate ของ backend + provider + source ยังคงซ้ำต่อ directory |
terragrunt stack | catalog เดียว generate N unit อิสระ พิสูจน์ข้างบนด้วย stack generate/run apply/output จริง | unit source แบบ local อาจถูก stack run เจอเป็น unit หลงทางได้เอง |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “module ที่ reference count, name ที่คำนวณ, และ boot_disk ถูกต้องแล้วต้องครบสมบูรณ์” พิสูจน์ข้างบน: module ตัวนี้เป๊ะ ๆ ยัง fail
terraform validateจริงสี่ทาง - “terragrunt stack แทนที่ module call” ทั้งคู่แก้ปัญหาต่างกัน — พิสูจน์ข้างบน
stack outputคืนผลลัพธ์ต่อ unit ไม่ใช่ output set เดียวของ shared module