DRY Configuration with include
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”terragrunt.hcl ของ unit ใช้ include block คู่กับ find_in_parent_folders() เพื่อดึงทุกอย่างที่ root.hcl ประกาศไว้เข้ามา และยังใช้วิธีเดียวกันนี้ซ้อน config ระดับกลางที่เล็กกว่าเข้ามาได้อีก ทำให้ config ที่ share กันไม่ต้องเขียนซ้ำแม้แต่ครั้งเดียว
include block: inherit root.hcl โดยไม่ต้องเขียนซ้ำ
หัวข้อที่มีชื่อว่า “include block: inherit root.hcl โดยไม่ต้องเขียนซ้ำ”บทก่อนหน้าโชว์ terragrunt.hcl ของ unit ที่อ้างถึง root.hcl ด้วยสามบรรทัด แต่ยังไม่ได้อธิบายว่าทำงานยังไง กลไกนั้นคือ include block
include "root" { path = find_in_parent_folders("root.hcl")}include "root" บอก Terragrunt ให้อ่านไฟล์ที่ path แล้ว merge remote_state, generate, และ top-level block อื่น ๆ เข้ากับ config ของ unit นี้ เหมือนกับว่า unit เขียนสิ่งเหล่านั้นเองทั้งหมด label "root" เป็นแค่ชื่อของ include block ตัวนี้ unit หนึ่งตัวมี include มากกว่าหนึ่งอันได้ในทางทฤษฎี แต่ละอันดึงคนละไฟล์ label นี่แหละคือตัวแยกความแตกต่าง ถ้าไม่มี block นี้ ทุก unit จะต้องมี remote_state กับ generate เป็นสำเนาของตัวเองจากบทก่อนหน้า นั่นคือความซ้ำซ้อนที่ Terragrunt เกิดมาเพื่อกำจัดพอดี
find_in_parent_folders: เดินขึ้นต้นไม้ directory เพื่อหาไฟล์
หัวข้อที่มีชื่อว่า “find_in_parent_folders: เดินขึ้นต้นไม้ directory เพื่อหาไฟล์”find_in_parent_folders("root.hcl") ไม่ได้สมมติระยะห่างคงที่ระหว่าง unit กับ shared config ชั้นบน แต่เริ่มที่ directory ของ unit เอง แล้วเดินขึ้นไปทีละ parent directory จนเจอไฟล์ชื่อ root.hcl แล้วคืนค่า path ของไฟล์นั้นกลับมา แปลว่า unit ที่ลึกสองชั้นกับ unit ที่ลึกห้าชั้นเขียน include block เหมือนกันเป๊ะ ๆ ได้ และยังหาไฟล์ที่ถูกต้องเจออยู่ดี
acme-infra/├── root.hcl└── prod/ ├── project.hcl └── network/ └── terragrunt.hclจาก prod/network/terragrunt.hcl find_in_parent_folders("root.hcl") ไต่ผ่าน prod/ ขึ้นไปเจอ acme-infra/root.hcl ถ้าไม่มีไฟล์ชื่อนั้นอยู่ที่ไหนเลยเหนือ unit ขึ้นไป Terragrunt จะ throw error ออกมาแทนที่จะรันต่อไปเงียบ ๆ โดยไม่มีไฟล์นั้น root.hcl ที่หายไปถือเป็นความผิดพลาดของ config ไม่ใช่สิ่งที่ข้ามผ่านไปได้
locals กับ read_terragrunt_config: share config ข้ามระดับ
หัวข้อที่มีชื่อว่า “locals กับ read_terragrunt_config: share config ข้ามระดับ”include ถูกออกแบบมาให้ทำงานเฉพาะทางเดียวคือ inherit root.hcl ที่ share กันตัวเดียวนั้น แต่ config จำนวนไม่น้อยไม่ได้ global ขนาดนั้น บางอย่างใช้แค่กับ unit ของ GCP project เดียว ซึ่งอยู่ต่ำกว่า root ลงมาหนึ่งชั้น สำหรับกรณีนี้ locals block คู่กับ read_terragrunt_config() ทำให้ unit ไหนก็ตามอ่านไฟล์ที่เล็กกว่าและเจาะจงกว่าได้ แล้วดึงค่าเฉพาะที่ต้องการออกมา ต่อจาก layout ด้านบน prod/project.hcl เก็บค่าที่เจาะจงสำหรับ GCP project นั้นตัวเดียว
locals { project_id = "acme-prod-123456" region = "us-central1"}ทุก unit ใต้ prod/ อ่านไฟล์นั้นด้วยวิธีเดียวกัน
include "root" { path = find_in_parent_folders("root.hcl")}
locals { project_vars = read_terragrunt_config(find_in_parent_folders("project.hcl"))}
inputs = { project_id = local.project_vars.locals.project_id region = local.project_vars.locals.region cidr_block = "10.0.0.0/16"}read_terragrunt_config() parse ไฟล์ปลายทางแล้วส่งค่าข้างในกลับมาเป็น value เดียว ทำให้ local.project_vars.locals.project_id เอื้อมเข้าไปหยิบ project_id จาก locals block ของไฟล์นั้นได้ ไม่มีอะไรห้าม directory tree จากการมีไฟล์แบบนี้หลายไฟล์คนละระดับ เช่น project.hcl ต่อหนึ่ง GCP project และ env.hcl ต่อหนึ่ง environment แต่ละไฟล์อ่านด้วยวิธีเดียวกัน แต่ละไฟล์ตัดปัญหา config ที่ copy-paste ซ้ำไปอีกหนึ่งจุด
inputs: เทียบเท่า .tfvars ของ Terragrunt
หัวข้อที่มีชื่อว่า “inputs: เทียบเท่า .tfvars ของ Terragrunt”inputs block ที่เห็นในสองตัวอย่างข้างบนคือวิธีที่ terragrunt.hcl ของ unit ส่งค่า variable ให้ Terraform module ข้างใต้ ทำหน้าที่เดียวกับที่ไฟล์ .tfvars ปกติทำ ทุก key ข้างใน inputs ถูกส่งต่อให้ Terraform เป็นค่า variable ของ module ที่ terraform block ของ unit นั้นชี้ไปหา โดยไม่ต้องมีไฟล์ .tfvars แยกอยู่ใน module เลย นี่คือสิ่งที่ทำให้การรวมค่าที่ inherit มากับค่าเฉพาะของ unit ทำได้ตรง ๆ project_id กับ region ข้างบนมาจาก project.hcl ผ่าน local.project_vars ส่วน cidr_block เขียนตรงในตัว unit เอง ทั้งสองแบบไปอยู่ใน inputs map เดียวกันโดยไม่ต้องมี syntax พิเศษมาผสมเข้าด้วยกัน
flowchart TD root["root.hcl (remote_state + generate)"] project["prod/project.hcl (locals: project_id, region)"] unit["prod/network/terragrunt.hcl"] root -->|include + find_in_parent_folders| unit project -->|read_terragrunt_config + find_in_parent_folders| unit unit --> inputsBlock["inputs: project_id, region, cidr_block"]
พิสูจน์: สอง include block พร้อม expose = true, terragrunt render จริง
หัวข้อที่มีชื่อว่า “พิสูจน์: สอง include block พร้อม expose = true, terragrunt render จริง”read_terragrunt_config() ข้างบนคือวิธีหนึ่งในการอ่าน project.hcl เทคนิคที่สอง ตรงกว่า คือ include block ตัวที่สอง ใช้ expose = true กับทั้งคู่เพื่อให้เนื้อหาของมันอ่านได้เป็น include.NAME.FIELD และ merge_strategy = "no_merge" บน include ที่ไม่ใช่ root เพื่อให้ project.hcl มีส่วนร่วมแค่ locals ของตัวเอง:
remote_state { backend = "local" generate = { path = "backend.tf" if_exists = "overwrite_terragrunt" } config = { path = "${get_terragrunt_dir()}/terraform.tfstate" }}locals { project_id = "acme-prod-123456" region = "us-central1"}include "root" { path = find_in_parent_folders("root.hcl") expose = true}
include "project" { path = find_in_parent_folders("project.hcl") expose = true merge_strategy = "no_merge"}
terraform { source = "."}
inputs = { project_id = include.project.locals.project_id region = include.project.locals.region}terraform { required_providers { local = { source = "hashicorp/local", version = "~> 2.5" } }}variable "project_id" { type = string }variable "region" { type = string }resource "local_file" "network" { filename = "${path.module}/network.txt" content = "project=${var.project_id} region=${var.region}\n"}terragrunt render --format json จริง ตัดบางส่วนออก:
$ terragrunt render --format json{ "inputs": { "project_id": "acme-prod-123456", "region": "us-central1" }, "remote_state": { "backend": "local", "config": { "path": ".../terraform.tfstate" }, ... }, "terraform": { "source": "." }}inputs.project_id อ่านได้ค่าจริง — ดึงมาจาก include.project.locals.project_id ซึ่งมีอยู่ได้ก็เพราะ expose = true ถูกตั้งไว้บน include project plan จริงก็พิสูจน์ว่าค่านี้ไปถึง module จริง:
$ terragrunt run --non-interactive -- planSTDOUT terraform: project=acme-prod-123456 region=us-central1🐛 หา bug ในโค้ดนี้
include "root" {path = find_in_parent_folders("root.hcl")}
include "project" {path = find_in_parent_folders("project.hcl")}
terraform {source = "."}
inputs = {project_id = include.project.locals.project_id}เฉลย
ทั้งสอง include block ไม่มี expose = true เลย พิสูจน์ข้างบนว่า expose คือสิ่งที่ทำให้เนื้อหาของ include อ่านได้ผ่าน reference include.NAME — ถ้าไม่มี include.project.locals.project_id ก็ resolve เป็นอะไรที่ใช้ได้ไม่ได้เลย config นี้จะ fail ตอน evaluate แทนที่จะอ่านค่าผิดแบบเงียบ ๆ
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”includeblock ตัวที่สองที่ไม่มีexpose = trueเลย แล้วมีโค้ดที่ referenceinclude.project.locals.project_id— พิสูจน์ข้างบนว่าexposeคือสิ่งที่ทำให้include.NAMEเป็นค่าที่อ่านได้merge_strategyปล่อยเป็นค่า default (deep merge) บน include ตัวกลางที่ตั้งใจให้มีส่วนร่วมแค่locals—project.hclที่ภายหลังเพิ่มremote_stateblock เข้ามาโดยไม่ตั้งใจ จะ deep-merge เข้าทุก unitread_terragrunt_config()ถูกเรียกโดยไม่มี accessor.localsเพิ่ม — return value ซ้อนอยู่ใต้.localsโค้ดที่ลืมขั้นตอนนั้นจะได้ null แทนค่าจริง
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”include กับ find_in_parent_folders คือ copy-paste แบบ dynamic ที่ไม่ขึ้นกับตำแหน่ง expose คือความต่างระหว่าง “merge เข้ามาเป็น block” (default) กับ “อ่านเป็นค่าได้ด้วย” (include.NAME.FIELD) — พิสูจน์ข้างบน
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ทางเลือก | ได้อะไร | เสียอะไร |
|---|---|---|
include ตัวที่สอง (project) พร้อม expose + no_merge | share ค่าให้แค่บางส่วนของ tree ค่าเข้าถึงได้ด้วยชื่อ พิสูจน์ข้างบน | ลืม expose (พิสูจน์ข้างบน) พัง reference ที่อ้างถึงมันเงียบ ๆ |
read_terragrunt_config() แทน include ตัวที่สอง | อ่านไฟล์ Terragrunt ไหนก็ได้โดยไม่ต้องมี include block เลย | return value ซ้อนอยู่ใต้ .locals — พลาด accessor path ผิดได้ง่าย |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “include deep-merge ทุกอย่างจากไฟล์เป้าหมายเสมอ” พิสูจน์ข้างบน:
merge_strategy = "no_merge"บน includeprojectแปลว่ามีแค่ค่าlocalsของมันที่ถูก expose - “expose = true copy เนื้อหาไฟล์แบบ physical เหมือนที่ generate ทำ” พิสูจน์ข้างบนโดยเทียบกับ
generate:exposeแค่ทำให้ config ที่ parse แล้ว อ่านได้ เท่านั้น ไม่มีอะไรถูกเขียนลง disk