Directory-per-Environment Pattern
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”directory-per-environment ให้แต่ละ environment มี directory ที่แยกจากกันจริง ๆ พร้อม backend configuration ของตัวเอง โดยที่ root config ของทุก environment เรียก shared module เดียวกัน ด้วยค่า input ที่ต่างกัน
directory จริง, backend จริง, ต่อ environment
หัวข้อที่มีชื่อว่า “directory จริง, backend จริง, ต่อ environment”แทนที่จะเป็น state file เดียวที่เลือกด้วย workspace pattern นี้ใช้ directory จริงหนึ่งชุดต่อ environment แต่ละชุดมีไฟล์ .tf ระดับ root ของตัวเอง
environments/├── dev/│ └── main.tf├── staging/│ └── main.tf└── prod/ └── main.tfmodules/└── app/ └── main.tfความต่างสำคัญจากการ copy-paste environment ทั้งชุดคือสิ่งที่อยู่ข้างใน main.tf แต่ละไฟล์ ไม่ใช่ copy เต็มของ resource ของ application แต่เป็น root configuration เล็ก ๆ ที่มี backend block ของตัวเอง กับ module call เดียวเข้าไปยัง shared module ตัวเดียว
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}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 ที่ต่างกัน
สิ่งที่ pattern นี้แก้ได้จริง
หัวข้อที่มีชื่อว่า “สิ่งที่ pattern นี้แก้ได้จริง”ตรงนี้แก้ปัญหา 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 ทำไม่ได้
ปัญหา DRY ที่ pattern นี้พาเอากลับมา
หัวข้อที่มีชื่อว่า “ปัญหา DRY ที่ pattern นี้พาเอากลับมา”ลองดูสองไฟล์ 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
พิสูจน์: tree จริงต่อ environment, shared module, plan ที่ต่างกัน
หัวข้อที่มีชื่อว่า “พิสูจน์: tree จริงต่อ environment, shared module, plan ที่ต่างกัน”สอง 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 ใช้):
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 }}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}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 modulemodule "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 ทุกตัวพร้อมกัน ไม่ใช่แค่ตัวเดียว
terragrunt stack: generate ทั้ง tree ของ unit จาก catalog เดียว
หัวข้อที่มีชื่อว่า “terragrunt stack: generate ทั้ง tree ของ unit จาก catalog เดียว”ทุกอย่างข้างบนคือ 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 ของตัวเอง
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 จากไฟล์ terragrunt.values.hcl sibling ข้าง terragrunt.hcl และนอก stack ไฟล์นั้นก็ยังไม่มีอยู่เลย commit ค่า placeholder เข้า 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}terragrunt stack generate จริง รันจากข้างๆ terragrunt.stack.hcl:
$ terragrunt stack generateINFO Generating unit prod from ./terragrunt.stack.hclINFO 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 manuallyenvironment = "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 outputdev = { 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 นี้เป๊ะ
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”ตอนรีวิว directory-per-environment หรือ terragrunt.stack.hcl config ที่ AI สร้าง ให้เช็คสิ่งเหล่านี้:
- module call ที่ขาด argument ที่จำเป็น ที่ module ประกาศไว้โดยไม่มี default — พิสูจน์ข้างบน นี่คือ
terraform planfail จริงทันที และเพราะ module นี้ share กัน มันพังทุก environment ที่เรียกมันพร้อมกัน ไม่ใช่แค่ตัวเดียว unitsource ที่ชี้ไปที่ local relative path ที่อยู่ใน tree เดียวกับที่stack runจะไปเจอ — พิสูจน์ข้างบนด้วย error จริง “no variable named values” catalog จริงใช้ Git ref ที่ pin ไว้เพื่อเลี่ยงปัญหานี้โดยเฉพาะ- โค้ดที่สมมติว่า
values.*มีอยู่ข้างในterragrunt.hclธรรมดา (ที่ไม่ได้ generate จาก stack) — พิสูจน์ข้างบนvaluesมีอยู่แค่ใน unit ที่terragrunt stack generategenerate มาจริง มาจากไฟล์terragrunt.values.hclจริงที่คำสั่งนั้นเขียน terragrunt stack outputที่ใช้ตรงที่โค้ดต้องการ output ของ unit เดียว — พิสูจน์ข้างบน มันรวม output ของทุก unit ที่ generate มาไว้ใต้ key ระดับบนสุดของตัวเอง (dev = {...},prod = {...}) ไม่ใช่ flat map
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”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 แยกกันจริง
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ทางเลือก | ได้อะไร | เสียอะไร |
|---|---|---|
| Directory-per-environment พร้อม shared module | isolation ของ 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” ทั้งคู่แก้ปัญหาที่ใกล้กันแต่ต่างกัน:
modulecall share โค้ด Terraform ภายใน state เดียวterragrunt stackgenerate หลาย 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 generategenerate มาจริง unit ธรรมดาที่ไม่ได้มาจาก stack ไม่มีvaluesobject เลย พิสูจน์ด้วย error จริง “no variable named values” ข้างบน - “เพราะไม่ต้องมี —experiment flag terragrunt stack ก็เสถียรมานานแล้ว” เช็คตรง ๆ ข้างบน: feature พื้นฐานไม่มี label experimental บน docs.terragrunt.com เลยและไม่ต้องมี flag บน 1.1.0 ที่ติดตั้งในคอร์สนี้ แต่การ integrate กับ
dependencyblock (stack-dependencies) เพิ่ง graduate เป็น general availability ใน version เดียวกันนี้เอง คือ 1.1.0 — นี่คือการ stabilize ที่ค่อนข้างเพิ่งเกิด ไม่ใช่ที่นิ่งมานานแล้ว