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

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 ชื่อ network, unit ชื่อ gke, และ unit ชื่อ cloud-sql คือ unit อิสระสามตัว มี directory สามอัน มี terragrunt.hcl สามไฟล์ มี state สามไฟล์ ทั้งที่ถ้าอยู่ในโลกของ Terraform โปรเจกต์เดียว คุณอาจเผลอจัดการ resource ทั้งสามตัวจาก root module เดียวและ state เดียวไปเลย

การแบ่ง infrastructure เป็น unit แบบนี้คือ trade-off ที่ตั้งใจทำ state ที่เล็กลงและ apply แยกอิสระจากกันแปลว่าความผิดพลาดใน unit หนึ่งไม่ทำให้ state ของอีก unit เสียหรือถูก lock ไปด้วย และ plan ของ gke ก็ไม่ต้อง evaluate ทุก resource ใน network กับ cloud-sql แค่เพื่อบอกว่าอะไรเปลี่ยนไป ต้นทุนที่ต้องแลกคือ unit เหล่านี้ต้องมีวิธี share config ที่ยังใช้ร่วมกันอยู่ ซึ่งนั่นคือหน้าที่ของ root.hcl พอดี

ลองนึกภาพ directory layout ที่มี config ไฟล์เดียวอยู่บนสุดของ project หรือ 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 บน GCP เกือบทั้งหมดคือ Google Cloud Storage (GCS) bucket สำหรับเก็บ state file การประกาศ backend ด้วยมือในทุก ๆ unit คือความซ้ำซ้อนแบบที่ Terragrunt เกิดมาเพื่อกำจัดพอดี แทนที่จะทำแบบนั้น root.hcl ประกาศไว้ครั้งเดียวด้วย remote_state block และ generate sub-block ข้างในบอก Terragrunt ให้เขียนไฟล์ backend.tf ลงไปในทุก unit ก่อนที่ Terraform จะรันที่นั่น

root.hcl
remote_state {
backend = "gcs"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "acme-terraform-state"
prefix = "${path_relative_to_include()}/terraform.tfstate"
project = "acme-prod-123456"
location = "US"
}
}

path_relative_to_include() ให้ prefix ที่ unique เฉพาะของแต่ละ unit ภายใน bucket เดียวกัน โดยอิงจากตำแหน่งของ unit นั้นเทียบกับไฟล์ที่ include เข้ามา ทำให้ unit network กับ unit gke ไปลง state path คนละที่กันโดยอัตโนมัติ โดยไม่มี unit ไหนต้องเขียน path นั้นตรง ๆ เอง if_exists = "overwrite_terragrunt" บอก Terragrunt ว่า overwrite backend.tf ที่ generate ไว้จาก run ก่อนหน้าได้อย่างปลอดภัย ต่างจาก backend ของ cloud อื่นบางเจ้า GCS ไม่ต้องมี lock-table argument แยกต่างหากเลย เพราะ GCS จัดการ state locking ให้เองอยู่แล้ว จึงไม่มี setting แบบ dynamodb_table ให้ต้องตั้งตรงนี้

top-level generate block ก็ทำงานแบบเดียวกันกับ provider configuration มักวางไว้ข้าง ๆ remote_state ใน root.hcl นี่แหละ

generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "google" {
project = "acme-prod-123456"
region = "us-central1"
}
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

network/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "git::[email protected]:acme/infrastructure-modules.git//network?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 สามบรรทัดนี้ได้ GCS backend กับ Google provider ที่ใช้งานได้จริงมาฟรี โดยไม่ต้องเขียนซ้ำแม้แต่บรรทัดเดียว

flowchart TD
  root["root.hcl (remote_state + generate)"]
  root --> network["network/terragrunt.hcl"]
  root --> gke["gke/terragrunt.hcl"]
  root --> sql["cloud-sql/terragrunt.hcl"]
  network --> networkmod["terraform.source: modules//network"]
  gke --> gkemod["terraform.source: modules//gke"]
  sql --> sqlmod["terraform.source: modules//cloud-sql"]
  network -.->|inherits generated backend.tf + provider.tf| root
  gke -.->|inherits generated backend.tf + provider.tf| root
  sql -.->|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 = "gcs"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "acme-terraform-state"
prefix = "${path_relative_to_include()}/terraform.tfstate"
project = "acme-prod-123456"
location = "US"
}
}
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "google" {
project = "acme-prod-123456"
region = "us-central1"
}
EOF
}

และ network/terragrunt.hcl ที่ใช้ find_in_parent_folders("root.hcl"):

network/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "."
}
inputs = {
network_name = "app-network"
}

รัน terragrunt render --format json จากข้างใน network/ (ไม่มี apply ไม่มี network call — render แค่ parse กับ evaluate HCL) จะพิมพ์ config ที่ resolve ครบแล้ว ผลจริง ตัดบางส่วนออก:

$ terragrunt render --format json
{
"remote_state": {
"backend": "gcs",
"config": {
"bucket": "acme-terraform-state",
"location": "US",
"prefix": "network/terraform.tfstate",
"project": "acme-prod-123456"
},
"generate": { "if_exists": "overwrite_terragrunt", "path": "backend.tf" }
},
"generate": {
"provider": {
"contents": "provider \"google\" {\n project = \"acme-prod-123456\"\n region = \"us-central1\"\n}\n",
"if_exists": "overwrite_terragrunt",
"path": "provider.tf"
}
},
"inputs": { "network_name": "app-network" },
"terraform": { "source": "." }
}

หลักฐานอยู่ที่ "prefix": "network/terraform.tfstate" — path_relative_to_include() evaluate ออกมาเป็น string "network" แล้วถูก interpolate เข้าไปใน prefix โดย unit ฝั่งนี้ไม่ต้อง hardcode อะไรเลย unit gke/terragrunt.hcl ที่มี body สามบรรทัดแบบเดียวกันเป๊ะ ก็จะ resolve ออกมาเป็น "prefix": "gke/terraform.tfstate" แทน

ตัว module เองก็จริงด้วย — google_compute_network พร้อม auto_create_subnetworks = false และ project ตรง ๆ validate ด้วย terraform init -backend=false && terraform validate จริงกับ hashicorp/google ~> 8.0:

$ terraform validate
Success! The configuration is valid.

backend.tf จริงที่ generate เขียน — และสิ่งที่มันตัดออกไปเจตนา

หัวข้อที่มีชื่อว่า “backend.tf จริงที่ generate เขียน — และสิ่งที่มันตัดออกไปเจตนา”

generate ไม่ได้เรียก GCP มันแค่เขียนไฟล์ .tf ธรรมดาก่อนที่ Terraform จะรัน เนื้อหาจริง capture มาจาก .terragrunt-cache ของ Terragrunt เอง:

backend.tf
# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa
terraform {
backend "gcs" {
bucket = "acme-terraform-state"
prefix = "network/terraform.tfstate"
}
}

ดูดี ๆ ว่าอะไร หายไป เทียบกับ remote_state.config ข้างบน: project กับ location ไม่โผล่มาใน backend block ที่ generate เลย — พิสูจน์ข้างบน มีแค่ bucket กับ prefix เท่านั้นที่เข้าไปใน terraform { backend "gcs" {} } block จริง เพราะ GCS backend เองไม่มี argument project/location เลย สองคีย์นี้มีไว้เพื่อ auto-create logic ของ Terragrunt เอง (หัวข้อถัดไป) ไม่ใช่สำหรับ backend configuration ของ Terraform ลองรันจริง (terragrunt plan) จะไปเจอกำแพงจริงที่คาดไว้: storage.NewClient() failed: dialing: credentials: could not find default credentials — auth failure ของ GCP จริง ไม่ใช่ error ปลอม พิสูจน์ว่า generate ส่ง backend ที่ shape ถูกต้องให้ Terraform จริง ๆ ก่อนจะหยุดที่กำแพงเดียวที่คอร์สนี้ข้ามไม่ได้ (ไม่มี GCP credentials)

remote_state ทำมากกว่าแค่ generate backend file — สำหรับ backend บางกลุ่ม Terragrunt จะสร้าง storage resource ให้เองด้วยตอนรันครั้งแรก ถ้ายังไม่มีอยู่ quote ตรงจากเอกสาร Terragrunt: “If you are using the GCS backend for remote state storage and 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.” พิสูจน์ข้างบน สองคีย์นี้ถูก Terragrunt ใช้ไปทั้งหมดและไม่เคยไปถึง backend.tf ที่ generate เลย — ขาดตัวใดตัวหนึ่งไป auto-create ก็ทำไม่ได้เงียบ ๆ เพราะ GCS bucket name อยู่ใน namespace เดียวกันทั้ง global ไม่มีแนวคิด “project ปัจจุบัน” แบบที่ S3 bucket มี owning account โดยปริยาย เทียบให้เห็นภาพ: S3 auto-create โดยไม่ต้องมี key เพิ่มเลย ส่วน lifecycle bootstrap ของ azurerm ต้องมี --experiment azure-backend flag ตรง ๆ บน Terragrunt 1.1.0 ที่ติดตั้งในคอร์สนี้ — auto-create ของ GCS ไม่ต้องมี experiment flag และไม่ใช่ zero extra config เลย ต้องมีสองคีย์นี้เจาะจง

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

root.hcl
remote_state {
backend = "gcs"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "acme-terraform-state"
prefix = "terraform.tfstate"
}
}
เฉลย

บั๊กสองตัว พิสูจน์ทั้งคู่ข้างบน อันแรก prefix = "terraform.tfstate" คือ literal string ไม่ใช่ "${path_relative_to_include()}/terraform.tfstate" — พิสูจน์ข้างบน ไม่มี function call นี้ ทุก unit ที่ include root นี้จะ resolve ไปที่ GCS object prefix เดียวกันเป๊ะ แอบ share state file เดียวกัน อันที่สอง project กับ location หายไปจาก config ทั้งคู่ — พิสูจน์ข้างบนจาก quote เอกสาร auto-create bucket ต้องมีทั้งสองตัว ถ้าไม่มี run แรกกับ bucket ที่ยังไม่มีอยู่จะ fail แทนที่จะ bootstrap ให้ และ failure mode จะดูเหมือน error “bucket not found” ที่ไม่เกี่ยวกันเลย แทนที่จะชี้ไปที่สองคีย์ที่หายไป

root.hcl ไม่ใช่ inheritance แบบ magic — มันคือไฟล์เดียวที่ include copy เข้าไปใน config ของทุก unit ที่ evaluate แล้ว ไม่มีอะไร share กันตอน runtime จริง ๆ แต่ละ unit ก็ยังรัน terraform init/plan/apply ของตัวเองแยกอิสระ กับ backend.tf ที่ generate ออกมาของตัวเอง พิสูจน์ข้างบน ไฟล์ที่ generate มีแค่สิ่งที่ GCS backend เอง เข้าใจจริง ๆ (bucket, prefix) — project/location คือคำสั่ง bootstrap ฝั่ง Terragrunt ที่ evaluate และใช้ไปก่อนที่ Terraform จะเห็นไฟล์นี้ด้วยซ้ำ

ทางเลือกได้อะไรเสียอะไร
แบ่ง infrastructure เป็น unit (state แยกต่อ directory)ความผิดพลาดหรือ lock ใน unit หนึ่งแตะ unit อื่นไม่ได้ plan เล็กลงreference ข้าม unit ต้องใช้ dependency block (บทถัดไป)
remote_state + generate รวม backend ไว้ที่เดียวที่เดียวที่ต้องแก้ bucket ของทุก unitพิสูจน์ข้างบน ไฟล์ที่ generate ตัดคำ project/location ออกไปทั้งหมด — อ่าน backend.tf ดิบ ๆ อย่างเดียวได้ภาพไม่ครบว่า remote_state block ตั้งค่าอะไรไว้จริง ๆ
GCS auto-create ผ่าน project/locationสะดวกตอน run แรก ไม่ต้องมีขั้น bootstrap แยกพิสูจน์ข้างบน ไม่ทำอะไรเลยเงียบ ๆ ถ้าคีย์ใดคีย์หนึ่งหายไป พร้อม error downstream ที่เข้าใจผิดได้
  • “ทุก key ข้างใน remote_state.config จบลงใน backend.tf ที่ generate มา” พิสูจน์ข้างบน: project กับ location ถูก Terragrunt ใช้ไปทั้งหมดสำหรับ auto-create logic ของตัวเอง ไม่เคยโผล่ใน terraform { backend "gcs" {} } block จริงเลย มีแค่ bucket กับ prefix เท่านั้น
  • “GCS ต้องมี lock-table setting แยกเหมือน backend บางเจ้า” ไม่จริง — GCS จัดการ state locking เองอยู่แล้ว ไม่มี argument แบบ dynamodb_table ที่ไหนเลยใน remote_state.config ที่โชว์ข้างบน
  • “generate เขียนตรงเข้า unit directory ของฉันเลย” พิสูจน์ข้างบน backend.tf จริงที่บทนี้ capture มาอยู่ใน .terragrunt-cache/HASH/HASH/backend.tf ไม่ใช่ source directory ที่คุณแก้ไข
Terragrunt unit คืออะไร
ทำไม shared parent config file ถึงตั้งชื่อตาม convention ว่า root.hcl แทนที่จะเป็น terragrunt.hcl
remote_state block ที่มี generate sub-block ผลิตอะไรออกมาจริง ๆ
terraform source attribute ที่ชี้ไปที่โค้ด module จริง ควรอยู่ตรงไหน
root.hcl ประกาศ remote_state.config พร้อม key bucket, project, และ location สำหรับ GCS backend จริง ๆ อะไรจบลงใน backend.tf ที่ generate จริง พิสูจน์ข้างบน
remote_state block สำหรับ GCS ตั้ง bucket แต่ไม่มีทั้ง project และ location เกิดอะไรขึ้นถ้า bucket นั้นยังไม่มีอยู่ ตามที่เอกสารข้างบน quote ไว้