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

The Directory-per-Environment Pattern

แทนที่จะใช้ configuration เดียวสลับไปมาระหว่าง workspace, directory-per-environment pattern ให้ dev, staging, และ prod แต่ละอันมี directory จริงของตัวเอง มี backend และ provider block ของตัวเอง ชี้ไปที่ Azure resource group ของตัวเองหรือแม้แต่ subscription ของตัวเอง โดยที่ทุกอันเรียก module ที่แชร์กันตัวเดียวสำหรับ resource จริง

รูปร่างของ pattern นี้คือชุด environment directory บาง ๆ อยู่ข้าง ๆ module ที่แชร์กันตัวเดียว

Terminal window
tree environments modules
environments
├── dev
│ ├── backend.tf
│ └── main.tf
├── staging
│ ├── backend.tf
│ └── main.tf
└── prod
├── backend.tf
└── main.tf
modules
└── app
├── main.tf
├── variables.tf
└── outputs.tf

dev กับ staging คือเคสที่เบากว่า คือแยก resource group แต่ subscription เดียวกัน backend.tf ในแต่ละอันกำหนด state key ของตัวเองและ subscription_id ของตัวเอง

environments/dev/backend.tf
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"
}
environments/staging/backend.tf
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 ก็ยังทำไม่ได้ ไม่ต้องพูดถึงการเข้าไปแก้ไข

environments/prod/backend.tf
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 ที่ส่งเข้าไป

environments/dev/main.tf
module "app" {
source = "../../modules/app"
resource_group_name = "app-dev-rg"
location = "eastus"
vm_size = "Standard_B1s"
instance_count = 1
}
environments/prod/main.tf
module "app" {
source = "../../modules/app"
resource_group_name = "app-prod-rg"
location = "eastus"
vm_size = "Standard_D2s_v5"
instance_count = 3
}

นี่คือ 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 เลย

ลองดู 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
สาม environment directory ที่แยกกันจริง dev กับ staging ได้ resource group ของตัวเอง prod ได้ subscription ของตัวเอง แต่ backend, provider, module source block เกือบเหมือนกันเป๊ะทั้งสาม

azurerm plan แบบ offline ไม่ได้ — ต่างจากเส้นทาง mock-credential ของ AWS azurerm ต้องการ authorizer จริงเสมอ ดังนั้นพิสูจน์จริงที่รันได้ตรงนี้คือ terraform init -backend=false && terraform validate กับ resource hashicorp/azurerm ~> 5.0 จริง พิสูจน์ว่า module และจุดเรียกทั้งสอง environment เป็น HCL ที่ valid จริง ไม่ใช่แค่ text ที่ดูสมเหตุสมผล:

modules/app/main.tf
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 ต้องการจริงๆ เพิ่มเข้ามา:

environments/dev/main.tf
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"
}
environments/prod/main.tf
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 validate
Success! The configuration is valid.
$ cd environments/prod && terraform init -backend=false && terraform validate
Success! 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 data
Error: Not enough list items
Attribute 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_count
name = "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 หลาย Terragrunt unit จากไฟล์ catalog terragrunt.stack.hcl เดียว เป็นไอเดีย shared-template เดียวกับ module ข้างบนที่ยกขึ้นไปอีกชั้น — พิสูจน์ด้วย unit local_file ที่ cloud-agnostic (กลไกไม่ขึ้นกับว่า provider ไหนที่ unit ที่ generate มาใช้ในที่สุด):

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 มาจริงเท่านั้น commit ค่า placeholder terragrunt.values.hcl เข้า 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
}

stack generate, stack run, stack output จริง:

$ terragrunt stack generate
INFO Generating unit prod from ./terragrunt.stack.hcl
INFO 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 output
dev = {
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

  • 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 เปล่าจริง ๆ ตอน validate
  • unit source ที่ชี้ไปที่ local relative path ที่อยู่ใน tree เดียวกับที่ stack run จะไปเจอเป็น unit หลงทาง — catalog จริงใช้ Git ref ที่ pin ไว้เพื่อเลี่ยงปัญหานี้โดยเฉพาะ

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 แยกกันจริง

ทางเลือกได้อะไรเสียอะไร
Directory-per-environment พร้อม shared moduleisolation ของ 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” ทั้งคู่แก้ปัญหาที่ใกล้กันแต่ต่างกัน — module call share โค้ด Terraform ภายใน state เดียว terragrunt stack generate หลาย Terragrunt unit อิสระจาก template เดียว พิสูจน์ข้างบนด้วยผลลัพธ์ stack output ต่อ unit
directory-per-environment pattern แก้อะไรที่ Terraform workspace แก้ไม่ได้
ส่วนไหนที่ยังแชร์และ DRY อยู่ข้าม environment ใน pattern นี้
อะไรที่ยังคงถูกซ้ำข้าม environment directory ใน pattern นี้ ทั้งที่แก้ isolation ได้แล้ว
ใน pattern นี้ dev กับ staging ถูก isolate ต่างจาก prod ยังไง
resource azurerm_linux_virtual_machine ของ module เดิมใช้ network_interface_ids = [] และ public_key string แบบ placeholder terraform validate จริงโชว์อะไร พิสูจน์ข้างบน
ทำไมบทนี้ใช้ terraform validate แทน terraform plan เป็นพิสูจน์จริงสำหรับ azurerm module ต่างจากเวอร์ชัน AWS ของคอร์สนี้