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

Directory-per-Environment Pattern

directory-per-environment pattern ให้แต่ละ environment มี directory ของตัวเอง, backend ของตัวเอง, และ GCP project ของตัวเองเพื่อ isolation จริง โดยที่ทุก directory เรียก shared module ตัวเดียวกัน ทำให้ logic ของ infrastructure จริง ๆ อยู่ที่เดียวเป๊ะ

workspace ใช้ backend เดียวกัน, provider project เดียวกัน, และโค้ด .tf ชุดเดียวกันข้ามทุก environment ซึ่งคือ isolation ที่อ่อนแอตามที่บทก่อนหน้าชี้ไว้ pattern แบบ directory-per-environment แก้ตรงนี้ด้วยการให้แต่ละ environment มี root configuration ที่แยกกันจริง แต่ละอันมี backend block ของตัวเองและ provider "google" block ของตัวเองที่ชี้ไปที่ GCP project ของ environment นั้น

Terminal window
tree environments
environments
├── dev
│ └── main.tf
├── staging
│ └── main.tf
└── prod
└── main.tf
environments/dev/main.tf
terraform {
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
}
environments/staging/main.tf
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
}
environments/prod/main.tf
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 ให้ไม่ได้พอดี

สังเกตว่าทั้งสาม directory เรียก source = "../../modules/app" เดียวกัน resource จริง ๆ ที่ประกอบเป็น “app” อยู่ที่เดียวเป๊ะ

modules/app/main.tf
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
สาม environment directory สาม GCP project shared module เดียว และ backend/provider boilerplate ที่เกือบเหมือนกันเป๊ะ

พิสูจน์: 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 variable
An input variable with the name "instance_count" has not been declared.
Error: Reference to undeclared input variable
An input variable with the name "environment" has not been declared.
Error: Reference to undeclared input variable
An input variable with the name "machine_type" has not been declared.
Error: Insufficient network_interface blocks
At 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 validate
Success! The configuration is valid.
$ cd environments/dev && terraform init -backend=false && terraform validate
Success! The configuration is valid.

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

modules/app/main.tf
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"
}
}
}
เฉลย

พิสูจน์ข้างบนด้วย 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 หลาย Terragrunt unit จาก catalog terragrunt.stack.hcl เดียว — ไอเดีย shared-template เดียวกับ module ข้างบน ยกขึ้นไปอีกชั้น พิสูจน์ด้วย unit local_file ที่ cloud-agnostic:

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.hcl แบบ placeholder ทำให้ 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 เลย และทุกคำสั่งข้างบนไม่มี --experiment warning เลยสักครั้ง sub-feature stack-dependencies graduate เป็น general availability ใน Terragrunt 1.1.0 โดยเฉพาะ ตาม release changelog

  • module ที่ reference ชื่อ var.* โดยไม่มี variable block คู่กัน — พิสูจน์ข้างบนด้วย error จริง “undeclared input variable” สามตัว
  • network_interface หายไปจาก google_compute_instance — พิสูจน์ข้างบนและในบทก่อนหน้า เป็น error จริงที่ provider บังคับ
  • unit source ที่ชี้ไปที่ local relative path ที่ stack run จะไปเจอเป็น unit หลงทาง — catalog จริงใช้ Git ref ที่ pin ไว้

Directory-per-environment คือ module composition ที่ใช้ในระดับ environment — พิสูจน์ข้างบน module เดียวกันที่ validate ผ่านแล้วถูกเรียกจากสาม environment directory ด้วย input ต่างกัน terragrunt stack คือไอเดียเดียวกันยกขึ้นไปอีกชั้น: unit template เดียวถูก stamp ออกมาหลายครั้งด้วย values ต่างกัน

ทางเลือกได้อะไรเสียอะไร
Directory-per-environment พร้อม shared moduleisolation ของ backend/provider/project จริง พิสูจน์ข้างบนด้วย environment directory ที่ validate ผ่านอิสระจากกันboilerplate ของ backend + provider + source ยังคงซ้ำต่อ directory
terragrunt stackcatalog เดียว 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
directory-per-environment pattern แก้ปัญหาอะไรที่ Terraform workspace แก้ไม่ได้
อะไรที่ยัง DRY อยู่ที่เดียวเป๊ะใน pattern นี้
อะไรที่ยังซ้ำซ้อนข้าม environment directory ใน pattern นี้ ทั้งที่แก้ปัญหา isolation ได้แล้ว
ทำไม boundary แข็ง ๆ ระหว่าง environment ใน pattern นี้มาจาก GCP เองแทนที่จะเป็นแค่ข้อตกลง
modules/app resource reference var.instance_count, var.environment, และ var.machine_type โดยไม่มี variable block ประกาศไว้ที่ไหนในไฟล์เลย terraform validate จริงโชว์อะไร พิสูจน์ข้างบน
บน Terragrunt 1.1.0 ที่ติดตั้งในคอร์สนี้ terragrunt stack generate/run/output ต้องมี --experiment flag ไหม