Terragrunt HCL and the Root Config
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”Terragrunt unit คือ infrastructure ที่ deploy ได้หนึ่งชิ้น มี terragrunt.hcl ของตัวเอง มี Terraform state ของตัวเอง ส่วนไฟล์ root.hcl ที่อยู่ชั้นบนสุดเก็บ config ที่ทุก unit ต้องใช้ร่วมกัน โดยไม่มี unit ไหนต้องเขียนซ้ำ
unit ของ Terragrunt คืออะไรกันแน่
หัวข้อที่มีชื่อว่า “unit ของ Terragrunt คืออะไรกันแน่”ใน module ก่อนหน้านี้ (Modules) คุณเห็นแล้วว่า Terraform root module ก็คือ directory ที่คุณรัน terraform apply ตรง ๆ Terragrunt เอาไอเดียเดียวกันนี้มาใช้ แล้วตั้งชื่อใหม่ว่า unit unit คือ directory ที่มี terragrunt.hcl ของตัวเอง ห่อ Terraform root module หนึ่งตัวไว้ข้างใน และมี state file แยกของตัวเอง unit ชื่อ vpc, unit ชื่อ ec2, และ unit ชื่อ rds คือ unit อิสระสามตัว มี directory สามอัน มี terragrunt.hcl สามไฟล์ มี state สามไฟล์ ทั้งที่ถ้าอยู่ในโลกของ Terraform โปรเจกต์เดียว คุณอาจเผลอจัดการ resource ทั้งสามตัวจาก root module เดียวและ state เดียวไปเลย
การแบ่ง infrastructure เป็น unit แบบนี้คือ trade-off ที่ตั้งใจทำ state ที่เล็กลงและ apply แยกอิสระจากกันแปลว่าความผิดพลาดใน unit หนึ่งไม่ทำให้ state ของอีก unit เสียหรือถูก lock ไปด้วย และ plan ของ ec2 ก็ไม่ต้อง evaluate ทุก resource ใน vpc กับ rds แค่เพื่อบอกว่าอะไรเปลี่ยนไป ต้นทุนที่ต้องแลกคือ unit เหล่านี้ต้องมีวิธี share config ที่ยังใช้ร่วมกันอยู่ ซึ่งนั่นคือหน้าที่ของ root.hcl พอดี
root.hcl: config ที่ทุก unit ยืนอยู่บน
หัวข้อที่มีชื่อว่า “root.hcl: config ที่ทุก unit ยืนอยู่บน”ลองนึกภาพ directory layout ที่มี config ไฟล์เดียวอยู่บนสุดของ account หรือ environment tree แล้วมี terragrunt.hcl อยู่ในทุก unit directory ข้างใต้ ไฟล์บนสุดนี้เมื่อก่อนก็ตั้งชื่อว่า terragrunt.hcl เหมือนกัน เพราะ Terragrunt ไม่เคยบังคับชื่อเฉพาะไว้ การตั้งชื่อแบบนั้นก็ยังใช้ได้อยู่ทุกวันนี้ และ repository กับ tutorial เก่า ๆ จำนวนมากก็ยังใช้แบบนั้น แต่กำกวมทันทีที่มีคนพูดว่า “ไปดู terragrunt.hcl” เพราะไม่รู้ว่าหมายถึงไฟล์ root ที่ share กันหรือ unit เฉพาะที่กำลังยืนอยู่
convention ที่แนะนำตอนนี้เปลี่ยนมาตั้งชื่อไฟล์ parent ที่ share กันนี้ว่า root.hcl แทน เพื่อตัดความกำกวมนั้นออกไปตรง ๆ root.hcl คือไฟล์เดียวที่ share กันบนสุดอย่างไม่ต้องเดา ส่วน terragrunt.hcl คือ config เฉพาะของ unit นั้น ๆ อย่างชัดเจน คอร์สนี้ใช้ root.hcl ตลอดทั้งคอร์ส
remote_state กับ generate: backend เดียว ไม่ต้อง copy-paste ทุก unit
หัวข้อที่มีชื่อว่า “remote_state กับ generate: backend เดียว ไม่ต้อง copy-paste ทุก unit”ทุก unit ต้องการ Terraform backend ในงาน AWS เกือบทั้งหมดคือ S3 bucket สำหรับเก็บ state file การประกาศ backend ด้วยมือในทุก ๆ unit คือความซ้ำซ้อนแบบที่ Terragrunt เกิดมาเพื่อกำจัดพอดี แทนที่จะทำแบบนั้น root.hcl ประกาศไว้ครั้งเดียวด้วย remote_state block และ generate sub-block ข้างในบอก Terragrunt ให้เขียนไฟล์ backend.tf ลงไปในทุก unit ก่อนที่ Terraform จะรันที่นั่น
remote_state { backend = "s3"
generate = { path = "backend.tf" if_exists = "overwrite_terragrunt" }
config = { bucket = "acme-terraform-state" key = "${path_relative_to_include()}/terraform.tfstate" region = "us-east-1" encrypt = true use_lockfile = true }}path_relative_to_include() ให้ key ที่ unique เฉพาะของแต่ละ unit ภายใน bucket เดียวกัน โดยอิงจากตำแหน่งของ unit นั้นเทียบกับไฟล์ที่ include เข้ามา ทำให้ unit vpc กับ unit ec2 ไปลง state path คนละที่กันโดยอัตโนมัติ โดยไม่มี unit ไหนต้องเขียน path นั้นตรง ๆ เอง if_exists = "overwrite_terragrunt" บอก Terragrunt ว่า overwrite backend.tf ที่ generate ไว้จาก run ก่อนหน้าได้อย่างปลอดภัย
top-level generate block ก็ทำงานแบบเดียวกันกับ provider configuration มักวางไว้ข้าง ๆ remote_state ใน root.hcl นี่แหละ
generate "provider" { path = "provider.tf" if_exists = "overwrite_terragrunt" contents = <<EOFprovider "aws" { region = "us-east-1"}EOF}ทั้งสอง block ผลิตไฟล์ .tf ธรรมดา คือ backend.tf กับ provider.tf วางอยู่ข้าง ๆ โค้ด Terraform จริงที่ Terragrunt รัน Terraform เองไม่รู้เลยว่าไฟล์พวกนี้ถูก generate มา จากมุมมองของ Terraform คือ config ที่บังเอิญมีอยู่แล้วเฉย ๆ
source ใน terraform block: ชี้ unit ไปหา module ของตัวเอง
หัวข้อที่มีชื่อว่า “source ใน terraform block: ชี้ unit ไปหา module ของตัวเอง”remote_state กับ provider generate block อยู่ใน root.hcl เพราะทุก unit ใช้ร่วมกัน สิ่งที่ต่างกันในแต่ละ unit คือ module Terraform ตัวไหนที่ unit นั้นรันจริง ๆ ซึ่งอยู่ใน terragrunt.hcl ของ unit นั้นเอง ข้างใน terraform block
include "root" { path = find_in_parent_folders("root.hcl")}
terraform {}
inputs = { cidr_block = "10.0.0.0/16"}source ชี้ไปที่ local relative path ก็ได้ตอนพัฒนา หรือชี้ไปที่ Git URL แบบ pin tag เหมือนตัวอย่างข้างบนก็ได้ เป็นวิธี address module แบบเดียวกับที่คุณรู้จักจาก source argument ใน Terraform module block เพียงแต่ย้ายมาเขียนที่ระดับ unit แทนที่จะอยู่ใน main.tf บรรทัด include คือสิ่งที่ดึงทุกอย่างที่ root.hcl ประกาศไว้เข้ามา กลไกเต็ม ๆ ของ include เป็นหัวข้อของบทถัดไป ตอนนี้แค่สังเกตว่า unit config สามบรรทัดนี้ได้ S3 backend กับ AWS provider ที่ใช้งานได้จริงมาฟรี โดยไม่ต้องเขียนซ้ำแม้แต่บรรทัดเดียว
flowchart TD root["root.hcl (remote_state + generate)"] root --> vpc["vpc/terragrunt.hcl"] root --> ec2["ec2/terragrunt.hcl"] root --> rds["rds/terragrunt.hcl"] vpc --> vpcmod["terraform.source: modules//vpc"] ec2 --> ec2mod["terraform.source: modules//ec2"] rds --> rdsmod["terraform.source: modules//rds"] vpc -.->|inherits generated backend.tf + provider.tf| root ec2 -.->|inherits generated backend.tf + provider.tf| root rds -.->|inherits generated backend.tf + provider.tf| root
พิสูจน์: terragrunt render resolve find_in_parent_folders แบบจริง
หัวข้อที่มีชื่อว่า “พิสูจน์: terragrunt render resolve find_in_parent_folders แบบจริง”สิ่งที่พูดไว้ข้างบนไม่ได้เดาเอาเอง — มาจาก Terragrunt 1.1.0 จริงกับ unit tree ในสคแรตช์ root.hcl บนสุดของ tree:
remote_state { backend = "s3"
generate = { path = "backend.tf" if_exists = "overwrite_terragrunt" }
config = { bucket = "acme-terraform-state" key = "${path_relative_to_include()}/terraform.tfstate" region = "us-east-1" encrypt = true use_lockfile = true }}
generate "provider" { path = "provider.tf" if_exists = "overwrite_terragrunt" contents = <<EOFprovider "aws" { region = "us-east-1"}EOF}และ vpc/terragrunt.hcl ที่ใช้ find_in_parent_folders("root.hcl") — Terragrunt เดินขึ้นไปจาก unit directory ปัจจุบัน หาไฟล์ที่ชื่อตรงนี้เป๊ะ ๆ ทำให้ vpc/ ไม่ต้อง hardcode ว่าต้องเดินขึ้นกี่ directory ถึงจะถึง root:
include "root" { path = find_in_parent_folders("root.hcl")}
terraform { source = "."}
inputs = { cidr_block = "10.0.0.0/16"}รัน terragrunt render --format json จากข้างใน vpc/ (ไม่มี apply ไม่มี network — render แค่ parse กับ evaluate HCL เฉย ๆ) จะพิมพ์ config ที่ resolve ครบแล้วที่ Terragrunt กำลังจะส่งให้ Terraform ผลจริง ตัดบางส่วนออก:
$ terragrunt render --format json{ "remote_state": { "backend": "s3", "config": { "bucket": "acme-terraform-state", "encrypt": true, "key": "vpc/terraform.tfstate", "region": "us-east-1", "use_lockfile": true }, "generate": { "if_exists": "overwrite_terragrunt", "path": "backend.tf" } }, "generate": { "provider": { "contents": "provider \"aws\" {\n region = \"us-east-1\"\n}\n", "if_exists": "overwrite_terragrunt", "path": "provider.tf" } }, "inputs": { "cidr_block": "10.0.0.0/16" }, "terraform": { "source": "." }}หลักฐานอยู่ที่ "key": "vpc/terraform.tfstate" — path_relative_to_include() evaluate ออกมาเป็น string "vpc" เพราะ unit นี้อยู่ตรงนั้นเทียบกับ root.hcl ที่มัน include เข้ามา แล้วถูก interpolate เข้าไปใน key โดย unit ฝั่งนี้ไม่ต้อง hardcode อะไรเลย unit ec2/terragrunt.hcl ที่มี body สามบรรทัดแบบเดียวกันเป๊ะ ก็จะ resolve ออกมาเป็น "key": "ec2/terraform.tfstate" แทน — root.hcl เดียวกัน key ต่างกัน มาจากตำแหน่ง directory ล้วน ๆ
backend.tf จริงที่ generate เขียน
หัวข้อที่มีชื่อว่า “backend.tf จริงที่ generate เขียน”generate ไม่ได้เรียก AWS มันแค่เขียนไฟล์ .tf ธรรมดาเข้าไปใน working copy ของ unit ก่อนที่ Terraform จะรัน เนื้อหาจริง capture มาจาก working directory .terragrunt-cache ของ Terragrunt เอง (ไม่เคย init กับ bucket จริงเลย — คอร์สนี้ไม่มี AWS credentials และบทนี้พิสูจน์ตัว ไฟล์ ไม่ใช่ backend ที่ใช้งานจริง):
# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEaterraform { backend "s3" { bucket = "acme-terraform-state" encrypt = true key = "vpc/terraform.tfstate" region = "us-east-1" use_lockfile = true }}comment # Generated by Terragrunt กับบรรทัด signature นั้นเป็นของจริง — Terragrunt เขียนไว้เพื่อให้ run ครั้งถัดไปรู้ว่าไฟล์นี้เป็นของตัวเอง แล้ว if_exists = "overwrite_terragrunt" ก็ overwrite ได้อย่างปลอดภัย โดยไม่ไปแตะ backend.tf ที่คนเขียนเองด้วยมือภายใต้ signature คนละแบบ
remote_state auto-create ต่างกันยังไงในแต่ละ cloud
หัวข้อที่มีชื่อว่า “remote_state auto-create ต่างกันยังไงในแต่ละ cloud”remote_state ทำมากกว่าแค่ generate backend file — สำหรับ backend บางกลุ่ม Terragrunt จะสร้าง storage resource ให้เองด้วยตอนรันครั้งแรก ถ้ายังไม่มีอยู่ ตามเอกสารทางการของ backend (หัวข้อ “Create remote state resources automatically”) พฤติกรรมนี้ต่างกันไปตาม cloud แบบที่ควรพูดให้ชัด ไม่ใช่พูดกว้าง ๆ:
- S3 (คอร์สนี้) — ถ้า
bucketที่ระบุในconfigยังไม่มีอยู่ Terragrunt จะสร้างให้พร้อมเปิด versioning, server-side encryption, access logging ไม่ต้องมี flag พิเศษเพิ่มuse_lockfile = true(ตัวอย่างข้างบน) คือ S3-native locking ผ่าน conditional writes เพิ่มเข้ามาใน Terraform 1.10 แบบ experimental และ stable ตั้งแต่ Terraform 1.11 ไม่ต้องมี DynamoDB table แยกเลย ทางเดิมdynamodb_table = "..."ก็ยังใช้ได้อยู่ Terragrunt ก็ auto-create table นั้นให้เหมือนกัน (primary keyLockID, เข้ารหัส) เอกสารเองก็โชว์ตรง ๆ ว่าตั้งค่าทั้งสองพร้อมกันได้ตอน migrate เพราะ lock ทั้งคู่ต้องสำเร็จก่อนถึงจะทำ operation ต่อได้ - Azure (
azurerm) — รองรับเหมือนกัน แต่มีเส้นแบ่ง version ที่ควรพูดให้ชัด quote ตรงจาก Terragrunt experiments registry: “Terragrunt manages lifecycle operations for the Azure Storage (azurerm) remote state backend, matching what it already does for S3 buckets and GCS buckets: Bootstrap: creates the resource group (when allowed), storage account, and blob container backing the state… Delete… Migrate…” — แต่บน Terragrunt 1.1.0 ที่ติดตั้งในคอร์สนี้ lifecycle bootstrap/delete/migrate ตัวนี้ยังถูก gate อยู่: “azurerm backend …<Before version="v1.2.0">(experimental; bootstrap, delete, and migrate require theazure-backendexperiment)” ตาม docs.terragrunt.com/features/state-backend มัน graduate เป็น on-by-default ใน Terragrunt 1.2.0 ดังนั้นบน 1.1.0 การ auto-create storage account ต้องเปิด--experiment azure-backendเอง — ถ้าไม่เปิดremote_stateสำหรับazurermก็ยังเขียนbackend.tfผ่านgenerateได้เหมือน S3/GCS ทุกอย่าง (พิสูจน์แล้ว:terragrunt render/terraform initของคอร์สนี้กับazurermblock ไปเจอ error auth จริงของ Azure ไม่ใช่ error แบบ “experiment ไม่ได้เปิด”) แต่มันจะ ไม่ พยายามสร้าง storage account ให้ - GCP (
gcs) — รองรับเหมือนกัน แต่ต้องมี key เพิ่มสองตัวที่ S3 backend ไม่ต้องมี quote ตรงจากเอกสาร: “If thebucketyou specify inremote_state.configdoesn’t already exist, Terragrunt will create it automatically, with versioning enabled. For this to work correctly you must also specifyprojectandlocationkeys inremote_state.config, so Terragrunt knows where to create the bucket.” ถ้าขาดตัวใดตัวหนึ่งไป auto-create ก็ทำไม่ได้เลย — GCS bucket name อยู่ใน namespace เดียวกันทั้ง global ไม่มีแนวคิด “project ปัจจุบัน” แบบที่ S3 bucket มี owning account โดยปริยาย Terragrunt เลยไม่มีที่ให้เดาสองค่านี้เอง
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”ตอนรีวิว root.hcl/unit config ที่ AI สร้าง ให้เช็คสิ่งเหล่านี้:
keyในremote_state.configที่ hardcode แทนที่จะเป็น"${path_relative_to_include()}/terraform.tfstate"— พิสูจน์ข้างล่าง ทำให้ทุก unit ที่ include root เดียวกันแอบ share state file เดียวกัน แล้วapplyของ unit ที่สองอาจทำลาย resource ของ unit แรกได้โดยไม่มี error ใด ๆ เตือนเลยgenerateblock ที่ไม่มีif_exists = "overwrite_terragrunt"— ค่า default คือ"error"ดังนั้น run ครั้งที่สองของ checkout ใหม่จะ fail ทันทีด้วย “file already exists” ตอนที่ run ก่อนหน้าเขียนbackend.tfไปแล้วremote_stateblock ของ GCS ที่ขาดprojectหรือlocation— auto-create bucket ไม่ได้เงียบ ๆ (พิสูจน์ข้างบนจากเอกสาร) โผล่มาเป็น error “bucket not found” กว้าง ๆ ตอน apply ที่ดูไม่เกี่ยวกับ key ที่ขาดไปเลยazurermremote_stateblock บน Terragrunt < 1.2.0 ที่สมมติว่า storage account ถูกสร้างอัตโนมัติโดยไม่ต้องมี flag เพิ่ม — พิสูจน์ข้างบน bootstrap ต้องมี--experiment azure-backendบน 1.1.0 ที่ติดตั้งในคอร์สนี้terraform { source = "..." }ที่ไม่ pin?ref=บน Git URL — ทุก unit resolve ตาม default branch ปัจจุบัน ทำให้applyสองครั้งห่างกันแค่ชั่วโมงเดียวอาจดึง module โค้ดคนละเวอร์ชันจริง ๆ โดยไม่มี diff ให้เห็นในterragrunt.hclของ unit เลย
🐛 หา bug ในโค้ดนี้
# root.hcl — shared by every unitremote_state {backend = "s3"
generate = { path = "backend.tf" if_exists = "overwrite_terragrunt"}
config = { bucket = "acme-terraform-state" key = "terraform.tfstate" region = "us-east-1" encrypt = true}}เฉลย
บั๊กคือ key = "terraform.tfstate" — literal string ธรรมดา ไม่ใช่ "${path_relative_to_include()}/terraform.tfstate" พิสูจน์ข้างบนแล้วว่า path_relative_to_include() คือตัวที่ทำให้ unit vpc resolve เป็น "vpc/terraform.tfstate" และ unit ec2 resolve เป็น "ec2/terraform.tfstate" ถ้า hardcode literal ทั้งสอง unit จะ resolve ไปที่ S3 object key เดียวกันเป๊ะ Terragrunt ไม่เตือนเรื่องนี้เลย ไม่มีการเช็คว่า bucket+key คู่นี้ “เป็น state ของ unit อื่นอยู่แล้ว” อาการแรกที่มักเจอคือ apply ของ ec2 รายงานว่าอยากจะ destroy resource ที่ไม่มีใครจำได้ว่าสร้างไว้ — จริง ๆ นั่นคือ resource ของ vpc เพราะทั้งสอง unit อ่านและเขียนทับ state object เดียวกันมาตลอด
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”root.hcl ไม่ใช่ inheritance แบบ magic — มันคือไฟล์เดียวที่ include copy เข้าไปใน config ของทุก unit ที่ evaluate แล้ว เหมือน shared header ที่ถูก #include เข้าไปใน C source หลายไฟล์ ไม่มีอะไร share กันตอน runtime จริง ๆ แต่ละ unit ก็ยังรัน terraform init/plan/apply ของตัวเองแยกอิสระ กับ backend.tf ที่ generate ออกมาของตัวเอง ความ unique ของ key (ผ่าน path_relative_to_include()) คือสิ่งเดียวที่กั้นระหว่าง “unit อิสระที่มี state อิสระ” กับ “unit ที่แอบทำลาย state กันเอง” — พิสูจน์ตรง ๆ ข้างบนแล้ว ให้มอง Terragrunt unit เหมือน Terraform root module เป๊ะ ๆ เพราะมันคือแบบนั้นจริง ๆ terragrunt.hcl กับ include แค่ automate การเขียน backend กับ provider block ที่ root module ที่รันด้วยมือต้องพิมพ์เองทุกครั้ง
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ทางเลือก | ได้อะไร | เสียอะไร |
|---|---|---|
| แบ่ง infrastructure เป็น unit (state แยกต่อ directory) | ความผิดพลาดหรือ lock ใน unit หนึ่งแตะ unit อื่นไม่ได้ plan เล็กลง | reference ข้าม unit ต้องใช้ dependency block (บทถัดไป) แทนการ reference resource ใน state เดียวกัน |
remote_state + generate รวม backend ไว้ที่เดียว | ที่เดียวที่ต้องแก้ bucket/region ของทุก unit ไม่มี backend block copy-paste เลย | ไฟล์ที่ generate มองไม่เห็นจนกว่าจะไปหาใน .terragrunt-cache — รัน terraform init ตรง ๆ ใน unit directory (ข้าม Terragrunt) จะไม่เห็นมัน |
| cloud auto-create (S3/GCS ทันที Azure ถูก gate ไว้หลัง experiment ก่อน 1.2.0) | สะดวกตอน run แรก ไม่ต้องมีขั้น bootstrap แยกสำหรับ state bucket เอง เมื่อพร้อมใช้ | พิสูจน์ข้างบนว่าความสะดวกนี้ไม่เท่ากันทุก cloud ทำผิดบน Azure ก่อน 1.2.0 จะดูเหมือน auth fail จริง ไม่ใช่ flag หาย |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “root.hcl กับ terragrunt.hcl เป็นไฟล์ format คนละแบบ” เป็น HCL dialect เดียวกัน — พิสูจน์ข้างบน
root.hclของคอร์สนี้ใช้remote_state/generateblock เดียวกันเป๊ะกับที่terragrunt.hclของ unit ใช้ได้ ชื่อเป็นแค่ convention ไม่มีอะไรบังคับ - “generate เขียนตรงเข้า unit directory ของฉันเลย ต้องเห็น backend.tf อยู่ตรงนั้น” พิสูจน์ข้างบน
backend.tfจริงที่บทนี้ capture มาอยู่ใน.terragrunt-cache/HASH/HASH/backend.tfซึ่งเป็น working copy ของ Terragrunt — ไม่ใช่ source directory ที่คุณแก้ไข - “cloud backend ทุกตัวที่ Terragrunt รองรับ auto-create storage แบบเดียวกันหมด” พิสูจน์ข้างบนด้วย quote จากเอกสารและ run จริง: S3 กับ GCS auto-create โดยไม่ต้องมี flag เพิ่ม (GCS ต้องมี
project/locationให้รู้ว่าจะสร้างที่ไหน)azurermบน Terragrunt 1.1.0 ที่ติดตั้งในคอร์สนี้ต้องมี--experiment azure-backendสำหรับ lifecycle bootstrap/delete/migrate โดยเฉพาะ กลายเป็น on-by-default ใน 1.2.0 เท่านั้น - “use_lockfile เป็น feature ของ Terragrunt” มันคือ feature ของ Terraform/OpenTofu S3 backend (stable ตั้งแต่ Terraform 1.11 experimental ใน 1.10) ที่
remote_state.configของ Terragrunt แค่ส่งผ่านเข้าไปในbackend.tfที่ generate ออกมาเฉย ๆ — พิสูจน์ข้างบน มันโผล่มาเป๊ะ ๆ ข้างในterraform { backend "s3" {} }block