The Directory-per-Environment Pattern
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”แทนที่จะใช้ configuration เดียวสลับไปมาระหว่าง workspace, directory-per-environment pattern ให้ dev, staging, และ prod แต่ละอันมี directory จริงของตัวเอง มี backend และ provider block ของตัวเอง ชี้ไปที่ Azure resource group ของตัวเองหรือแม้แต่ subscription ของตัวเอง โดยที่ทุกอันเรียก module ที่แชร์กันตัวเดียวสำหรับ resource จริง
แยกจริง: หนึ่ง directory ต่อหนึ่ง environment
หัวข้อที่มีชื่อว่า “แยกจริง: หนึ่ง directory ต่อหนึ่ง environment”รูปร่างของ pattern นี้คือชุด environment directory บาง ๆ อยู่ข้าง ๆ module ที่แชร์กันตัวเดียว
tree environments modulesenvironments├── dev│ ├── backend.tf│ └── main.tf├── staging│ ├── backend.tf│ └── main.tf└── prod ├── backend.tf └── main.tfmodules└── app ├── main.tf ├── variables.tf └── outputs.tfdev กับ staging คือเคสที่เบากว่า คือแยก resource group แต่ subscription เดียวกัน backend.tf ในแต่ละอันกำหนด state key ของตัวเองและ subscription_id ของตัวเอง
terraform { backend "azurerm" { resource_group_name = "tfstate-rg" storage_account_name = "tfstateacct001" container_name = "tfstate" key = "dev.terraform.tfstate" }}
provider "azurerm" { features {} subscription_id = "11111111-1111-1111-1111-111111111111"}terraform { backend "azurerm" { resource_group_name = "tfstate-rg" storage_account_name = "tfstateacct001" container_name = "tfstate" key = "staging.terraform.tfstate" }}
provider "azurerm" { features {} subscription_id = "11111111-1111-1111-1111-111111111111"}prod คือเคสที่แข็งกว่า สงวนไว้สำหรับ environment ที่ต้องการแยกแบบแข็งจริง ๆ คือ subscription_id ที่ต่างกันไปเลย ทำให้ความผิดพลาดใน dev หรือ staging แม้แต่ authenticate เข้าไปที่ resource ของ prod ก็ยังทำไม่ได้ ไม่ต้องพูดถึงการเข้าไปแก้ไข
terraform { backend "azurerm" { resource_group_name = "tfstate-rg" storage_account_name = "tfstateacct001" container_name = "tfstate" key = "prod.terraform.tfstate" }}
provider "azurerm" { features {} subscription_id = "22222222-2222-2222-2222-222222222222"}main.tf ของแต่ละ environment เรียก module ที่แชร์กันตัวเดียวกัน ต่างกันแค่ input value ที่ส่งเข้าไป
module "app" { source = "../../modules/app"
resource_group_name = "app-dev-rg" location = "eastus" vm_size = "Standard_B1s" instance_count = 1}module "app" { source = "../../modules/app"
resource_group_name = "app-prod-rg" location = "eastus" vm_size = "Standard_D2s_v5" instance_count = 3}สิ่งที่ pattern นี้แก้ได้จริง
หัวข้อที่มีชื่อว่า “สิ่งที่ pattern นี้แก้ได้จริง”นี่คือ isolation จริง ไม่ใช่แค่ naming convention แบบที่ workspace ให้มา dev กับ staging แต่ละอันตกอยู่ใน resource group ของตัวเอง ทำให้ terraform destroy ที่พลาดใน environment หนึ่งเข้าไปแตะ resource ใน resource group ของอีก environment ไม่ได้ และแต่ละอันมี RBAC scope ของตัวเอง prod ไปไกลกว่านั้นอีก คือนั่งอยู่ใน subscription ของตัวเอง ที่เป็นเส้นแบ่งที่แข็งกว่าของ Azure คือ billing แยก, quota default แยก, และชุด credential ที่ต่างกันจริง ๆ ที่ต้องใช้แม้แต่จะแตะให้ได้ ต่างจาก workspace ตรงที่ไม่มี provider block ที่แชร์กันอยู่ที่ไหนเลยที่จะปล่อยให้ terraform apply ที่พลาดข้ามไปถึงอีก environment ได้ Terraform run ของ directory dev ไม่มี credential path ไปยัง subscription ของ prod เลย
duplication ที่กลับมาใหม่
หัวข้อที่มีชื่อว่า “duplication ที่กลับมาใหม่”ลองดู backend.tf สามไฟล์ข้างบนใกล้ ๆ ทั้งสามเหมือนกันทุกโครงสร้าง คือ backend type เดียวกัน, storage account เดียวกัน, container เดียวกัน ต่างกันแค่สองบรรทัดพอดี คือ key กับ subscription_id เรื่องเดียวกันเกิดกับ module "app" block ใน main.tf ทุกไฟล์ คือ argument source ถูก copy-paste แบบตัวอักษรเป๊ะ ๆ เข้าไปในทุก environment และถ้า module นั้นย้ายที่ไปวันหนึ่ง หรือถูก pin ด้วย version ทุก environment directory ต้องแก้แบบเดียวกันด้วยมือ นี่คือภาพสะท้อนกลับของปัญหา workspace workspace แก้ duplication ได้ด้วยการแชร์ทุกอย่างและ isolate แทบไม่ได้อะไรเลย ส่วน directory-per-environment แก้ isolation ได้ด้วยการแยกทุกอย่าง รวมถึง boilerplate ที่ไม่เคยต้องต่างกันเลยด้วย
flowchart LR devDir["environments/dev"] -->|same subscription, own resource group| subA["Subscription A"] stagingDir["environments/staging"] -->|same subscription, own resource group| subA prodDir["environments/prod"] -->|separate subscription| subB["Subscription B, prod only"] devDir -->|module source = ../../modules/app| sharedModule["modules/app, shared"] stagingDir -->|module source = ../../modules/app| sharedModule prodDir -->|module source = ../../modules/app| sharedModule devDir -.-> dup["backend.tf and provider block nearly identical in every environment"] stagingDir -.-> dup prodDir -.-> dup
พิสูจน์: shared module จริง validate ผ่านทั้ง dev และ prod
หัวข้อที่มีชื่อว่า “พิสูจน์: shared module จริง validate ผ่านทั้ง dev และ prod”azurerm plan แบบ offline ไม่ได้ — ต่างจากเส้นทาง mock-credential ของ AWS azurerm ต้องการ authorizer จริงเสมอ ดังนั้นพิสูจน์จริงที่รันได้ตรงนี้คือ terraform init -backend=false && terraform validate กับ resource hashicorp/azurerm ~> 5.0 จริง พิสูจน์ว่า module และจุดเรียกทั้งสอง environment เป็น HCL ที่ valid จริง ไม่ใช่แค่ text ที่ดูสมเหตุสมผล:
terraform { required_providers { azurerm = { source = "hashicorp/azurerm", version = "~> 5.0" } }}
variable "resource_group_name" { type = string }variable "location" { type = string }variable "vm_size" { type = string }variable "instance_count" { type = number }variable "admin_ssh_public_key" { type = string }
resource "azurerm_resource_group" "app" { name = var.resource_group_name location = var.location}
resource "azurerm_virtual_network" "app" { name = "${var.resource_group_name}-vnet" address_space = ["10.0.0.0/16"] location = azurerm_resource_group.app.location resource_group_name = azurerm_resource_group.app.name}
resource "azurerm_subnet" "app" { name = "app-subnet" resource_group_name = azurerm_resource_group.app.name virtual_network_name = azurerm_virtual_network.app.name address_prefixes = ["10.0.1.0/24"]}
resource "azurerm_network_interface" "app" { count = var.instance_count name = "app-nic-${count.index}" location = azurerm_resource_group.app.location resource_group_name = azurerm_resource_group.app.name
ip_configuration { name = "internal" subnet_id = azurerm_subnet.app.id private_ip_address_allocation = "Dynamic" }}
resource "azurerm_linux_virtual_machine" "app" { count = var.instance_count name = "app-vm-${count.index}" resource_group_name = azurerm_resource_group.app.name location = azurerm_resource_group.app.location size = var.vm_size admin_username = "azureuser" network_interface_ids = [azurerm_network_interface.app[count.index].id]
disable_password_authentication = true
admin_ssh_key { username = "azureuser" public_key = var.admin_ssh_public_key }
os_disk { caching = "ReadWrite" storage_account_type = "Standard_LRS" }
source_image_reference { publisher = "Canonical" offer = "0001-com-ubuntu-server-jammy" sku = "22_04-lts" version = "latest" }}backend.tf ของแต่ละ environment มี provider "azurerm" block เดียวของ unit นั้นอยู่แล้ว ดังนั้น main.tf ตัวจริงจึงเป็นแค่ module call ล้วนๆ — ต้องการแค่ argument SSH key ที่ module ต้องการจริงๆ เพิ่มเข้ามา:
module "app" { source = "../../modules/app"
resource_group_name = "app-dev-rg" location = "eastus" vm_size = "Standard_B1s" instance_count = 1 admin_ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCqIEqHOkp6VjuxNd4l+PksIuf8MtYmMJR3msmrmTlxkbOH1VeUK+p0jBsUqi9nYcgoRfG7bo+3ucOvAIo3lL7cMrRvrQIvyHHGPPkYPzPHy1tS5CYjJYtfavNeQGLJWq5NNGAkK8X1UJyuMfQzo8yCAV83z2BG3QQZxo30zC0giS4OoESLLZ5yAfCMWSRJSXIJxgKQ29ev3s0XhZ5mHIdKMmMlmpy7grMuIP3vqlLIQM8MP+MVKCt9mqdiVHDyw8DTV7dZ119mGupFr6IhVKbY3jQTzuQks0YspudufhFYOr7j4E3Vf20U8zgQNnTmFtkrvHPjZr4EkNmZc0EyOwnr demo"}module "app" { source = "../../modules/app"
resource_group_name = "app-prod-rg" location = "eastus" vm_size = "Standard_D2s_v5" instance_count = 3 admin_ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCqIEqHOkp6VjuxNd4l+PksIuf8MtYmMJR3msmrmTlxkbOH1VeUK+p0jBsUqi9nYcgoRfG7bo+3ucOvAIo3lL7cMrRvrQIvyHHGPPkYPzPHy1tS5CYjJYtfavNeQGLJWq5NNGAkK8X1UJyuMfQzo8yCAV83z2BG3QQZxo30zC0giS4OoESLLZ5yAfCMWSRJSXIJxgKQ29ev3s0XhZ5mHIdKMmMlmpy7grMuIP3vqlLIQM8MP+MVKCt9mqdiVHDyw8DTV7dZ119mGupFr6IhVKbY3jQTzuQks0YspudufhFYOr7j4E3Vf20U8zgQNnTmFtkrvHPjZr4EkNmZc0EyOwnr demo"}ทั้งสอง unit มี provider "azurerm" block เดียวอยู่แล้ว — ตัวที่อยู่ใน backend.tf ของตัวเอง — ดังนั้นตรงนี้ไม่มีการประกาศซ้ำ validate จริง ทั้งสอง directory:
$ cd environments/dev && terraform init -backend=false && terraform validateSuccess! The configuration is valid.
$ cd environments/prod && terraform init -backend=false && terraform validateSuccess! The configuration is valid.ความพยายามแรกไม่ได้สะอาดแบบนี้เลย — module เดิมใช้ network_interface_ids = [] เปล่า ๆ ไม่มี NIC จริง และไม่มี SSH key จริง terraform validate จับ error จริงระดับ provider ได้ทันทีสองตัว:
Error: decoding "admin_ssh_key.0.public_key" for public key dataError: Not enough list itemsAttribute network_interface_ids requires 1 item minimum, but config has only 0 declared.ทางแก้ — chain azurerm_virtual_network/azurerm_subnet/azurerm_network_interface จริงที่ป้อน network_interface_ids list จริง และ SSH public key ที่ syntax จริง (แม้จะเป็นแค่ throwaway) — คือสิ่งที่โชว์ข้างบน และคือสิ่งที่ validate ผ่านจริง
🐛 หา bug ในโค้ดนี้
resource "azurerm_linux_virtual_machine" "app" {count = var.instance_countname = "app-vm-${count.index}"network_interface_ids = []admin_ssh_key { username = "azureuser" public_key = "not-a-real-key"}}เฉลย
พิสูจน์ข้างบน: network_interface_ids = [] fail terraform validate จริงด้วย “Attribute network_interface_ids requires 1 item minimum” — azurerm_linux_virtual_machine มีอยู่ไม่ได้ถ้าไม่มี NIC ต่ออยู่เลย list เปล่าที่เป็น placeholder ไม่ใช่ค่าที่ Terraform ยอมรับเลยด้วยซ้ำ ไม่ต้องพูดถึง Azure public_key string ก็เป็น real-error territory เหมือนกัน azurerm provider decode SSH key ฝั่ง client ระหว่าง validate/plan และ string ที่ไม่ใช่ SSH key data จริงจะ fail ทันทีด้วย “decoding … for public key data” — ไม่ใช่เรื่องเซอร์ไพรส์ตอน plan หรือ apply แต่เป็นตอน 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 (กลไกไม่ขึ้นกับว่า provider ไหนที่ unit ที่ generate มาใช้ในที่สุด):
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 มาจริงเท่านั้น commit ค่า placeholder terragrunt.values.hcl เข้า template เอง (ค่าที่ stack-generated copy เขียนทับเสมอ) ทำให้ 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/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 โดยเฉพาะ ตาม release changelog
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”- module call ที่ขาด argument ที่จำเป็น ที่ module ประกาศไว้โดยไม่มี default — พิสูจน์ข้างบน
terraform validateจริงจับได้ทันทีเป็น “Missing required argument” และเพราะ module นี้ share กัน บั๊กเดียวกันพังทุก environment ที่เรียกมันพร้อมกัน network_interface_ids = []หรือ placeholder list แบบ “เดี๋ยวใส่ทีหลัง” บน resource รูปทรง Azure VM — พิสูจน์ข้างบน Azure resource จริงที่มี required attached-resource list ปฏิเสธ list เปล่าจริง ๆ ตอน validateunitsource ที่ชี้ไปที่ local relative path ที่อยู่ใน tree เดียวกับที่stack runจะไปเจอเป็น unit หลงทาง — catalog จริงใช้ Git ref ที่ pin ไว้เพื่อเลี่ยงปัญหานี้โดยเฉพาะ
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”Directory-per-environment คือ module composition ที่ใช้ในระดับ environment — พิสูจน์ข้างบน azurerm_linux_virtual_machine block เดียวกันเป๊ะข้างใน modules/app validate ผ่านทั้ง dev แบบ 1 instance และ prod แบบ 3 instance มาจาก inputs ของ directory ไหนที่เรียกมันล้วน ๆ terragrunt stack คือไอเดียเดียวกันยกขึ้นไปอีกชั้น: unit template เดียวถูก stamp ออกมาหลายครั้งด้วย values ต่างกัน แต่ละ copy ที่ stamp ออกมากลายเป็น Terragrunt unit ที่มี state เป็นของตัวเองอิสระ — พิสูจน์ข้างบนด้วย .terragrunt-stack/{dev,prod} directory จริงสองอันที่ apply แยกกันจริง
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ทางเลือก | ได้อะไร | เสียอะไร |
|---|---|---|
| Directory-per-environment พร้อม shared module | isolation ของ backend/provider/subscription จริงต่อ environment พิสูจน์ข้างบนด้วย environment directory สอง directory ที่ validate ผ่านอิสระจากกัน | boilerplate ของ backend + provider + source ยังคงซ้ำต่อ directory |
terragrunt stack แทน environment directory ที่ maintain ด้วยมือ | ไฟล์ catalog เดียว generate N unit อิสระ พิสูจน์ข้างบนด้วย stack generate/run apply/output จริง | unit source แบบ local (ไม่ใช่ Git ref) อาจถูก stack run เจอเป็น unit หลงทางได้เอง |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “azurerm resource plan-test แบบ offline ได้เหมือน AWS resource ที่ใช้ mock credential” พิสูจน์ข้างบนโดยเทียบกัน:
azurermไม่มีอะไรเทียบเท่าskip_credentials_validationของ AWS เลย — พิสูจน์จริงของคอร์สนี้สำหรับ azurerm คือvalidateไม่ใช่planเพราะ azurerm ต้องการ authorizer จริงเสมอ - “terragrunt stack แทนที่ module call ข้างใน directory-per-environment setup” ทั้งคู่แก้ปัญหาที่ใกล้กันแต่ต่างกัน —
modulecall share โค้ด Terraform ภายใน state เดียวterragrunt stackgenerate หลาย Terragrunt unit อิสระจาก template เดียว พิสูจน์ข้างบนด้วยผลลัพธ์stack outputต่อ unit