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

Why Terragrunt

Terraform workspace แก้ reuse ได้ดีแต่ปล่อยให้ isolation อ่อนแอ directory-per-environment แก้ isolation ได้ดีแต่เอา boilerplate ที่ซ้ำกันกลับมาใหม่ และ Terragrunt มีไว้เพื่อปิดช่องว่างระหว่างสองอันนี้โดยเฉพาะ โดยไม่ต้องให้ยอมสละอย่างใดอย่างหนึ่งไป

สองบทที่แล้วพาไปดูคำตอบแบบ native สองแบบของ Terraform ต่อปัญหา multi-environment และแต่ละแบบแลก goal หนึ่งกับอีก goal หนึ่ง workspace เก็บ .tf config เดียวและ backend/provider block เดียว แค่สลับว่า state file ไหน active อยู่

Terminal window
# Workspaces: one directory, one backend, one provider, one subscription_id
# for every environment, only the active workspace changes
terraform workspace select prod
terraform apply

Directory-per-environment ไปอีกทาง คือ isolation จริงผ่าน directory, backend, และ provider block ที่แยกกันจริง ๆ ต่อ environment แลกมาด้วย backend/provider boilerplate นั้นถูก copy-paste แบบตัวอักษรเป๊ะ ๆ เข้าไปในทุก environment

Terminal window
# Directory-per-environment: real isolation, separate backend and provider
# per environment, but environments/dev, environments/staging, and
# environments/prod each carry their own near-identical backend.tf
cd environments/prod
terraform init
terraform apply

ไม่มีวิธีไหนผิด และไม่มีวิธีไหนเป็น bug ใน Terraform ทั้งสองคือ trade-off ที่ตรงไปตรงมาสองแบบระหว่าง goal เดียวกันสองอัน สิ่งที่ Terraform เองไม่มีให้คือ primitive เดียวที่ให้ isolation ที่แข็งแรงและ configuration duplication เป็นศูนย์ข้าม environment ได้พร้อมกัน

Terragrunt คือ wrapper บาง ๆ และ orchestrator ที่นั่งอยู่บนตัว binary จริงของ terraform (หรือ tofu) ไม่ได้เพิ่ม resource syntax ใหม่ และโค้ด module ที่เขียนเพื่อสร้าง Azure resource จริง ๆ ยังคงเป็นสิ่งเดิมอยู่แล้ว คือ Terraform ธรรมดา ในไฟล์ .tf ธรรมดา ไม่เปลี่ยนแปลง

สิ่งที่ Terragrunt เพิ่มเข้ามาคือ layer ที่อยู่เหนือโค้ดนั้น เล็งตรงไปที่ duplication ที่ directory-per-environment เอากลับมา แทนที่จะเขียน backend.tf กับ provider.tf ที่เหมือนกันแทบทุกตัวอักษรในทุก environment directory ด้วยมือ Terragrunt ให้ define backend และ provider configuration ครั้งเดียว จากศูนย์กลาง แล้ว generate รายละเอียดต่อ environment เช่น state key, subscription_id, resource group จาก definition อันนั้นอันเดียว

# terragrunt.hcl (a conceptual preview only; the actual syntax is the
# subject of the next module, Terragrunt Fundamentals)
# One central place that generates the backend and provider blocks for
# every environment, instead of copy-pasting them into environments/dev,
# environments/staging, and environments/prod by hand
remote_state {
backend = "azurerm"
config = {
resource_group_name = "tfstate-rg"
storage_account_name = "tfstateacct001"
container_name = "tfstate"
key = "app.terraform.tfstate"
}
}

ไอเดียเดียวกันขยายไปถึงการส่ง input variable ของแต่ละ environment และเมื่อ infrastructure ถูกแยกเป็นหลาย unit ที่ apply แยกกันเป็นอิสระต่อกัน ไปถึงการเข้าใจว่า unit ไหนต้องพึ่ง unit ไหนก่อน เพื่อให้ Terragrunt apply ตามลำดับที่ถูกต้อง

Terragrunt ไม่ใช่ fork ของ Terraform ไม่ได้ compile ไปเป็น infrastructure-as-code language อื่น และไม่ใช่ตัวแทนของ terraform binary ทุกคำสั่ง Terragrunt สุดท้ายแล้ว shell out ไปเรียกคำสั่ง terraform หรือ tofu จริง ๆ เบื้องหลัง รันกับ provider เดียวกันและ state format เดียวกันที่ Terraform เข้าใจอยู่แล้ว

Terminal window
# Every terragrunt command wraps a real terraform (or tofu) command
terragrunt plan # generates config, then runs a real `terraform init` and `terraform plan`
terragrunt apply # generates config, then runs a real `terraform apply`

ความต่างนั้นสำคัญด้วยเหตุผลอีกอย่างนอกจากลด boilerplate Terraform configuration เดียวมี dependency graph ภายในของตัวเองระหว่าง resource อยู่แล้ว แต่ graph นั้นหยุดอยู่ที่ขอบของ terraform apply หนึ่งครั้ง เมื่อ infrastructure ของ environment หนึ่งถูกตั้งใจแยกเป็นหลาย unit ที่ apply แยกกันเป็นอิสระต่อกัน เช่น network unit, database unit, application unit แต่ละอันมี state ของตัวเอง ไม่มีอะไรใน Terraform เองรู้ว่า application unit ต้องการ output ของ database unit ก่อน นั่นคือปัญหาอีกแบบที่ Terragrunt ถูกสร้างมาแก้ คือการเรียงลำดับและส่ง output ข้าม unit ที่ apply แยกกัน ซึ่งบทต่อไปจะลงรายละเอียด

flowchart LR
  workspaces["Terraform workspaces: DRY, weak isolation"] --> gap["The gap: no native primitive gives both at once"]
  dirPerEnv["Directory-per-environment: isolated, duplicated"] --> gap
  gap --> terragrunt["Terragrunt: generates config, removes duplication"]
  terragrunt --> realTf["Real terraform / tofu binary actually runs"]
Workspace และ directory-per-environment ต่างปิดช่องว่างได้แค่ครึ่งเดียว Terragrunt ห่อ terraform binary จริงเพื่อปิดส่วนที่เหลือ

Terraform เปล่า ๆ มีวิธีอ่าน output ของ state อื่นอยู่แล้ว ไม่ต้องมี Terragrunt เลย: terraform_remote_state พิสูจน์จริง two-unit vnet apply ก่อนด้วย local backend:

vnet/main.tf
resource "local_file" "vnet" {
filename = "${path.module}/subnet-id.txt"
content = "subnet-0123456789abcdef0\n"
}
output "subnet_id" {
value = "subnet-0123456789abcdef0"
}
vm/main.tf
data "terraform_remote_state" "vnet" {
backend = "local"
config = {
path = "../vnet/terraform.tfstate"
}
}
resource "local_file" "vm" {
filename = "${path.module}/vm-subnet-ref.txt"
content = "launched into ${data.terraform_remote_state.vnet.outputs.subnet_id}\n"
}

apply จริงใน vm/ หลังจาก vnet/ apply ไปแล้ว:

$ terraform apply -auto-approve
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ cat vm-subnet-ref.txt
launched into subnet-0123456789abcdef0

นี่ทำงานได้จริงโดยไม่มี Terragrunt เกี่ยวข้องเลย ความต่างจริง ๆ จาก dependency block ของ Terragrunt (module terragrunt-fundamentals) โผล่มาทันทีที่ upstream state ยังไม่มีอยู่ — vm/main.tf เดิม ลบ vnet/terraform.tfstate ทิ้งก่อน:

$ terraform plan
Error: Unable to find remote state
No stored state was found for the given workspace in the given backend.

เทียบกับ dependency + mock_outputs: สถานการณ์ “state ยังไม่มีอยู่” เดียวกันเป๊ะ แต่ที่นั่น plan สำเร็จได้ ใช้ค่า placeholder ส่วน apply ปฏิเสธแยกต่างหากอย่างชัดเจน terraform_remote_state ไม่มีอะไรเทียบเท่าเลย — มัน hard-fail ทันที ทุกครั้ง ทั้ง plan และ apply ช่องว่างนั้น — ไม่ใช่ “อ่าน state ของ unit อื่น” ซึ่ง Terraform เปล่า ๆ ทำได้อยู่แล้ว — คือสิ่งที่ dependency เพิ่มเข้ามาจริง ๆ

รักษาความตรงไปตรงมา: Terraform เปล่า ๆ ทำอะไรได้อยู่แล้วบ้าง

หัวข้อที่มีชื่อว่า “รักษาความตรงไปตรงมา: Terraform เปล่า ๆ ทำอะไรได้อยู่แล้วบ้าง”

-backend-config กับ -var-file ลด duplication ได้จริง พิสูจน์จริง main.tf ไฟล์เดียวใช้ซ้ำได้สอง environment โดยไม่มี directory ซ้ำเลย:

# env/main.tf — the only .tf file, used for every environment
terraform {
backend "local" {}
}
variable "environment" { type = string }
variable "instance_count" { type = number }
resource "local_file" "app" {
filename = "${path.module}/app-${var.environment}.txt"
content = "environment=${var.environment} count=${var.instance_count}\n"
}
dev.backend.tfvars
path = "dev.tfstate"
dev.tfvars
environment = "dev"
instance_count = 1

init กับ apply จริง parameterize ทั้ง backend และ input ทั้งหมดจาก command line ไม่ต้องมี directory environments/dev/ เลย:

$ terraform init -backend-config=../dev.backend.tfvars
$ terraform apply -var-file=../dev.tfvars -auto-approve
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ cat app-dev.txt
environment=dev count=1

สิ่งที่วิธีนี้ ไม่ได้ ให้จริง ๆ คือ: การอ่าน output ข้าม unit แบบ dependency พร้อม mock_outputs (terraform_remote_state คือสิ่งที่ใกล้เคียงที่สุดของ Terraform เปล่า ๆ และมันไม่มี mock fallback เลย พิสูจน์ข้างบน), การจัดลำดับหลาย unit อัตโนมัติ (run --all), และจุดเดียวสำหรับเปลี่ยน รูปร่าง ของ logic การ generate backend/provider เอง ไม่ใช่แค่ค่าที่ป้อนเข้าไป คุณค่าจริงของ Terragrunt คือ dependency graph, generation logic, และ orchestration ข้ามหลาย unit โดยเฉพาะ ไม่ใช่ parameterization เอง

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

# vm/main.tf — applied as part of a brand-new environment's very first apply, before vnet exists
data "terraform_remote_state" "vnet" {
backend = "local"
config = {
path = "../vnet/terraform.tfstate"
}
}
resource "azurerm_linux_virtual_machine" "app" {
network_interface_ids = [data.terraform_remote_state.vnet.outputs.nic_id]
}
เฉลย

พิสูจน์ข้างบน terraform_remote_state ไม่มีกลไก mock-output เลย — ต่างจาก Terragrunt dependency block ไม่มีทางให้ plan นี้สำเร็จด้วย placeholder พร้อมส่งสัญญาณว่า apply ควรยังปฏิเสธอยู่ ผลลัพธ์จริงที่เลี่ยงไม่ได้ตรงนี้คือ Error: Unable to find remote state และทางแก้เดียวภายใน Terraform เปล่า ๆ คือเรียง apply ด้วยมือ — คือปัญหาเรื่องลำดับเป๊ะ ๆ ที่ dependency กับ run --all แก้ให้อัตโนมัติ

ช่องว่างไหนที่ทั้ง Terraform workspace และ directory-per-environment pattern ปิดไม่ได้เต็มที่ด้วยตัวเอง
Terragrunt คืออะไรจริง ๆ เทียบกับที่คนมักเข้าใจผิด
Terragrunt แก้ปัญหาแบบไหนเพิ่มเติมที่ dependency graph ภายในของ Terraform configuration เดียวแก้ไม่ได้
ทุกคำสั่ง Terragrunt สุดท้ายแล้วทำอะไรเบื้องหลัง
plain Terraform vm/main.tf ใช้ data "terraform_remote_state" "vnet" อ่าน output ของ vnet บน environment ใหม่เอี่ยมที่ vnet ไม่เคยถูก apply เลย เกิดอะไรขึ้น พิสูจน์ข้างบน
พูดว่า Terraform เปล่า ๆ "ทำไม่ได้" เรื่องปรับ configuration ต่อ environment โดยไม่มี Terragrunt ถูกต้องไหม