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

Terragrunt HCL and the Root Config

Terragrunt unit คือ infrastructure ที่ deploy ได้หนึ่งชิ้น มี terragrunt.hcl ของตัวเอง มี Terraform state ของตัวเอง ส่วนไฟล์ root.hcl ที่อยู่ชั้นบนสุดเก็บ config ที่ทุก unit ต้องใช้ร่วมกัน โดยไม่มี unit ไหนต้องเขียนซ้ำ

ใน 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 พอดี

ลองนึกภาพ 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 ตลอดทั้งคอร์ส

ทุก unit ต้องการ Terraform backend ในงาน AWS เกือบทั้งหมดคือ S3 bucket สำหรับเก็บ state file การประกาศ backend ด้วยมือในทุก ๆ unit คือความซ้ำซ้อนแบบที่ Terragrunt เกิดมาเพื่อกำจัดพอดี แทนที่จะทำแบบนั้น root.hcl ประกาศไว้ครั้งเดียวด้วย remote_state block และ generate sub-block ข้างในบอก Terragrunt ให้เขียนไฟล์ backend.tf ลงไปในทุก unit ก่อนที่ Terraform จะรันที่นั่น

root.hcl
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 = <<EOF
provider "aws" {
region = "us-east-1"
}
EOF
}

ทั้งสอง block ผลิตไฟล์ .tf ธรรมดา คือ backend.tf กับ provider.tf วางอยู่ข้าง ๆ โค้ด Terraform จริงที่ Terragrunt รัน Terraform เองไม่รู้เลยว่าไฟล์พวกนี้ถูก generate มา จากมุมมองของ Terraform คือ config ที่บังเอิญมีอยู่แล้วเฉย ๆ

remote_state กับ provider generate block อยู่ใน root.hcl เพราะทุก unit ใช้ร่วมกัน สิ่งที่ต่างกันในแต่ละ unit คือ module Terraform ตัวไหนที่ unit นั้นรันจริง ๆ ซึ่งอยู่ใน terragrunt.hcl ของ unit นั้นเอง ข้างใน terraform block

vpc/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "git::[email protected]:acme/infrastructure-modules.git//vpc?ref=v1.4.0"
}
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
root.hcl หนึ่งไฟล์ป้อนหลาย unit แต่ละอันชี้ไปที่ module ของตัวเอง

สิ่งที่พูดไว้ข้างบนไม่ได้เดาเอาเอง — มาจาก Terragrunt 1.1.0 จริงกับ unit tree ในสคแรตช์ root.hcl บนสุดของ tree:

root.hcl
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 = <<EOF
provider "aws" {
region = "us-east-1"
}
EOF
}

และ vpc/terragrunt.hcl ที่ใช้ find_in_parent_folders("root.hcl") — Terragrunt เดินขึ้นไปจาก unit directory ปัจจุบัน หาไฟล์ที่ชื่อตรงนี้เป๊ะ ๆ ทำให้ vpc/ ไม่ต้อง hardcode ว่าต้องเดินขึ้นกี่ directory ถึงจะถึง root:

vpc/terragrunt.hcl
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 ล้วน ๆ

generate ไม่ได้เรียก AWS มันแค่เขียนไฟล์ .tf ธรรมดาเข้าไปใน working copy ของ unit ก่อนที่ Terraform จะรัน เนื้อหาจริง capture มาจาก working directory .terragrunt-cache ของ Terragrunt เอง (ไม่เคย init กับ bucket จริงเลย — คอร์สนี้ไม่มี AWS credentials และบทนี้พิสูจน์ตัว ไฟล์ ไม่ใช่ backend ที่ใช้งานจริง):

backend.tf
# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa
terraform {
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 ทำมากกว่าแค่ 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 key LockID, เข้ารหัส) เอกสารเองก็โชว์ตรง ๆ ว่าตั้งค่าทั้งสองพร้อมกันได้ตอน 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 the azure-backend experiment)” ตาม 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 ของคอร์สนี้กับ azurerm block ไปเจอ error auth จริงของ Azure ไม่ใช่ error แบบ “experiment ไม่ได้เปิด”) แต่มันจะ ไม่ พยายามสร้าง storage account ให้
  • GCP (gcs) — รองรับเหมือนกัน แต่ต้องมี key เพิ่มสองตัวที่ S3 backend ไม่ต้องมี quote ตรงจากเอกสาร: “If the bucket you specify in remote_state.config doesn’t already exist, Terragrunt will create it automatically, with versioning enabled. For this to work correctly you must also specify project and location keys in remote_state.config, so Terragrunt knows where to create the bucket.” ถ้าขาดตัวใดตัวหนึ่งไป auto-create ก็ทำไม่ได้เลย — GCS bucket name อยู่ใน namespace เดียวกันทั้ง global ไม่มีแนวคิด “project ปัจจุบัน” แบบที่ S3 bucket มี owning account โดยปริยาย Terragrunt เลยไม่มีที่ให้เดาสองค่านี้เอง

ตอนรีวิว 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 ใด ๆ เตือนเลย
  • generate block ที่ไม่มี if_exists = "overwrite_terragrunt" — ค่า default คือ "error" ดังนั้น run ครั้งที่สองของ checkout ใหม่จะ fail ทันทีด้วย “file already exists” ตอนที่ run ก่อนหน้าเขียน backend.tf ไปแล้ว
  • remote_state block ของ GCS ที่ขาด project หรือ location — auto-create bucket ไม่ได้เงียบ ๆ (พิสูจน์ข้างบนจากเอกสาร) โผล่มาเป็น error “bucket not found” กว้าง ๆ ตอน apply ที่ดูไม่เกี่ยวกับ key ที่ขาดไปเลย
  • azurerm remote_state block บน 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 unit
remote_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 เดียวกันมาตลอด

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 ที่รันด้วยมือต้องพิมพ์เองทุกครั้ง

ทางเลือกได้อะไรเสียอะไร
แบ่ง 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/generate block เดียวกันเป๊ะกับที่ 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
Terragrunt unit คืออะไร
ทำไม shared parent config file ถึงตั้งชื่อตาม convention ว่า root.hcl แทนที่จะเป็น terragrunt.hcl
remote_state block ที่มี generate sub-block ผลิตอะไรออกมาจริง ๆ
terraform source attribute ที่ชี้ไปที่โค้ด module จริง ควรอยู่ตรงไหน
root.hcl ตั้ง key = "terraform.tfstate" (literal string) แทนที่จะเป็น "${path_relative_to_include()}/terraform.tfstate" เกิดอะไรขึ้นจริงเมื่อสอง unit คือ vpc กับ ec2 include root.hcl นี้ทั้งคู่
บน Terragrunt 1.1.0 ที่ติดตั้งในคอร์สนี้ ถ้าตั้ง azurerm remote_state block โดยยังไม่มี storage account สร้างไว้ และไม่มี --experiment azure-backend เกิดอะไรขึ้น