DRY Configuration with include
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”include block คือวิธีที่ terragrunt.hcl ของ unit หนึ่งดึงทุกอย่างที่ประกาศไว้ใน root.hcl หรือใน intermediate config file ไหนก็ตามเข้ามาใช้ โดยไม่ต้องพิมพ์ซ้ำสักบรรทัด
include กับ find_in_parent_folders
หัวข้อที่มีชื่อว่า “include กับ find_in_parent_folders”vpc/terragrunt.hcl ในบทที่แล้วเปิดด้วย block นี้ ตอนนี้ถึงเวลาอธิบายให้ชัดเจน
include "root" { path = find_in_parent_folders("root.hcl")}find_in_parent_folders("root.hcl") เดินขึ้น directory tree จากตำแหน่งของ unit ปัจจุบัน ทีละชั้น จนกว่าจะเจอไฟล์ที่ชื่อ root.hcl เป๊ะ ๆ จากนั้น include "root" จะ merge ทุกอย่างที่ไฟล์นั้นประกาศไว้เข้ามาใน config ของ unit ปัจจุบัน เหมือนกับพิมพ์ไว้ตรงนั้นเอง ทั้ง remote_state block, backend.tf ที่ generate ไว้, provider generate block ทั้งหมด
ที่การเดินหาแบบนี้สำคัญก็เพราะ include บรรทัดเดียวกันเป๊ะใช้ได้ทั้งกับ unit ที่อยู่ต่ำกว่า root.hcl แค่ชั้นเดียวหรือลึกไปห้าชั้น ย้าย unit ไปอยู่ subdirectory ใหม่ตอน reorganize โครงสร้าง บรรทัดนี้ก็ยังทำงานเหมือนเดิมไม่ต้องแก้ เพราะไม่เคย hard-code relative path แบบ ../../../root.hcl ที่จะพังทันทีที่โครงสร้าง tree เปลี่ยนไป
locals กับ read_terragrunt_config สำหรับหนึ่งชั้นถัดลงมา
หัวข้อที่มีชื่อว่า “locals กับ read_terragrunt_config สำหรับหนึ่งชั้นถัดลงมา”include ไม่ใช่กลไก inherit เดียวที่มี และไม่ใช่ตัวเลือกที่ถูกเสมอไป บางครั้งคุณอยากแชร์ค่าที่เล็กกว่าและเจาะจงกว่าลงไปแค่บางส่วนของ tree ไม่ใช่ทั้งหมด ลองนึกภาพไฟล์ region.hcl ตัวกลางที่อยู่ต่ำกว่า root จริงหนึ่งชั้น ครอบคลุมเฉพาะ unit ที่อยู่ใน AWS region หนึ่งเจาะจง
locals { aws_region = "us-east-1"}unit ที่อยู่ใต้ directory ของ region นั้นอ่านไฟล์นั้นด้วย read_terragrunt_config() ผสมกับ find_in_parent_folders() ตัวเดิมที่คุณรู้จักแล้ว
include "root" { path = find_in_parent_folders("root.hcl")}
locals { region_vars = read_terragrunt_config(find_in_parent_folders("region.hcl"))}
terraform {}
inputs = { aws_region = local.region_vars.locals.aws_region cidr_block = "10.0.0.0/16"}read_terragrunt_config() parse Terragrunt config ไฟล์อื่นแล้วส่งเนื้อหากลับมาเป็นค่า และ locals block ของไฟล์นั้นจะซ้อนอยู่ใต้ .locals ในผลลัพธ์ จึงเขียนเป็น local.region_vars.locals.aws_region unit ที่อยู่ใต้ prod/ap-southeast-1/ ก็จะมี region.hcl ของตัวเองที่ประกาศ aws_region = "ap-southeast-1" ส่วน unit ไหนก็ตามในทั้ง tree ยังคง share root.hcl เดียวกันสำหรับ backend และ provider root เดียวกัน แต่ region ต่างกันในแต่ละ subtree โดยไม่มี unit ไหนต้อง hard-code region ของตัวเองไว้ตรง ๆ เลย
inputs: เทียบเท่า tfvars file ของ Terragrunt
หัวข้อที่มีชื่อว่า “inputs: เทียบเท่า tfvars file ของ Terragrunt”ทุกตัวอย่างที่ผ่านมาจบด้วย inputs = { ... } block ทั้งนั้น และสมควรมีคำอธิบายของตัวเอง นี่คือวิธีที่ unit ส่ง input variable ของ Terraform module ที่อยู่ข้างใต้เข้าไป ทำหน้าที่เหมือน .tfvars file ที่ใช้กับ Terraform root module ธรรมดาทุกประการ เพียงแต่เขียนตรง ๆ เป็น Terragrunt HCL แทนที่จะเป็นไฟล์แยกที่ต้องบอก Terraform ให้โหลด แต่ละ key ใน inputs แม็พไปที่ variable ที่ module ประกาศไว้ และ Terragrunt ส่ง block ทั้งหมดผ่านไปให้ Terraform เป็นค่า -var อยู่เบื้องหลัง
เพราะ inputs คือ HCL จริง ไม่ใช่ .tfvars file แบบ static จึงอ้างอิงอะไรก็ได้ในไฟล์ config เดียวกัน local.region_vars.locals.aws_region ข้างบนคือตัวอย่างหนึ่ง และบทถัดไปจะแนะนำการอ้างอิง output ของ unit อื่นด้วยวิธีเดียวกันนี้
flowchart TD root["root.hcl (remote_state + generate)"] region["prod/us-east-1/region.hcl (locals.aws_region)"] unit["prod/us-east-1/vpc/terragrunt.hcl"] root -->|include + find_in_parent_folders| unit region -->|read_terragrunt_config + find_in_parent_folders| unit unit --> inputs["inputs = aws_region, cidr_block"]
พิสูจน์: สอง include block พร้อม expose = true, terragrunt render จริง
หัวข้อที่มีชื่อว่า “พิสูจน์: สอง include block พร้อม expose = true, terragrunt render จริง”read_terragrunt_config() คือวิธีหนึ่งในการดึงข้อมูลของ region.hcl เข้ามา เทคนิคที่สอง ตรงกว่า คือ include block ตัวที่สอง ใช้ expose = true กับทั้งคู่ เพื่อให้เนื้อหาของมันอ่านได้เป็น include.NAME.FIELD ใน config ของ unit นี้เอง และ merge_strategy = "no_merge" บน include ที่ไม่ใช่ root เพื่อให้ region.hcl มีส่วนร่วมแค่ข้อมูลของตัวเอง (locals) แทนที่จะถูก deep-merge เหมือนเป็น root config ตัวที่สอง:
remote_state { backend = "local" generate = { path = "backend.tf" if_exists = "overwrite_terragrunt" } config = { path = "${get_terragrunt_dir()}/terraform.tfstate" }}locals { aws_region = "us-east-1"}include "root" { path = find_in_parent_folders("root.hcl") expose = true}
include "region" { path = find_in_parent_folders("region.hcl") expose = true merge_strategy = "no_merge"}
terraform { source = "."}
inputs = { aws_region = include.region.locals.aws_region cidr_block = "10.0.0.0/16"}terraform { required_providers { local = { source = "hashicorp/local", version = "~> 2.5" } }}variable "aws_region" { type = string }variable "cidr_block" { type = string }resource "local_file" "vpc" { filename = "${path.module}/vpc.txt" content = "region=${var.aws_region} cidr=${var.cidr_block}\n"}terragrunt render --format json จริง จากข้างใน prod/us-east-1/vpc/ ตัดบางส่วนออก:
$ terragrunt render --format json{ "inputs": { "aws_region": "us-east-1", "cidr_block": "10.0.0.0/16" }, "remote_state": { "backend": "local", "config": { "path": ".../terraform.tfstate" }, ... }, "terraform": { "source": "." }}inputs.aws_region อ่านได้ "us-east-1" — ดึงมาจาก include.region.locals.aws_region ซึ่งมีอยู่ได้ก็เพราะ expose = true ถูกตั้งไว้บน include region รวมกับ merge_strategy = "no_merge" ที่ทำให้ locals ของ region.hcl อยู่ในขอบเขตของ include.region.locals แทนที่จะ merge เข้าไปเป็น locals ระดับบนสุดของ unit นี้เอง ในขณะเดียวกัน remote_state.backend ก็เป็น "local" inherit มาจาก include root เหมือนทุกบทก่อนหน้า และ plan จริงก็พิสูจน์ว่าค่านี้ไปถึง module จริง ไม่ใช่แค่ config ที่ render ออกมา:
$ terragrunt run --non-interactive -- planSTDOUT terraform: region=us-east-1 cidr=10.0.0.0/16unit ที่อยู่ใต้ prod/ap-southeast-1/ ที่มี region.hcl ของตัวเองประกาศ aws_region = "ap-southeast-1" จะ resolve เป็นค่านั้นแทน — root.hcl เดียวกัน โค้ด unit เดียวกัน แค่ region.hcl ต่างกันต่อ subtree โดย include "region" block เองไม่ต้องเปลี่ยนเลย (มันบอกแค่ find_in_parent_folders("region.hcl") เสมอ และ region.hcl ไฟล์จริงที่ resolve ออกมาขึ้นอยู่กับตำแหน่งของ unit ล้วน ๆ)
read_terragrunt_config เทียบ include ตัวที่สอง: เหมาะกับตอนไหน
หัวข้อที่มีชื่อว่า “read_terragrunt_config เทียบ include ตัวที่สอง: เหมาะกับตอนไหน”ทั้งสองกลไกอ่านเนื้อหาของ Terragrunt config ไฟล์อื่นเข้ามาจริง ๆ ทั้งคู่ — ความต่างคือได้อะไรกลับมาและเข้าถึงยังไง read_terragrunt_config(find_in_parent_folders("region.hcl")) (โชว์ไว้ก่อนหน้าในบทนี้) คืน ไฟล์ที่ parse ทั้งไฟล์ เป็นค่าที่ assign เข้า locals key ที่คุณตั้งชื่อเอง (local.region_vars.locals.aws_region ข้างบน) — มีประโยชน์เมื่ออยากอ่านไฟล์ที่ไม่ได้ตั้งใจให้ include เป็น peer config (เช่น อ่าน config ของ sibling unit เพื่อดึงค่าเดียว ไม่ใช่เพื่อ merge block ของมัน) include block ตัวที่สองพร้อม expose = true (พิสูจน์ข้างบน) เหมาะกว่าตรง ๆ สำหรับไฟล์ที่ ตั้งใจ ให้ทำตัวเหมือน root ตัวที่สองแบบเบา ๆ — locals ของมันกลายเป็น include.NAME.locals.KEY โดยไม่ต้องมีขั้นตอนตั้งชื่อเพิ่ม และ (ถ้าไม่มี merge_strategy = "no_merge") ยังมีส่วนร่วมกับ block จริงอื่น ๆ แบบเดียวกับที่ root include ทำได้ด้วย
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”ตอนรีวิว multi-include config ที่ AI สร้าง ให้เช็คสิ่งเหล่านี้:
includeblock ตัวที่สองที่ไม่มีexpose = trueเลย แล้วมีโค้ดที่อื่นพยายาม referenceinclude.region.locals.aws_region— พิสูจน์ข้างบนว่าexposeคือสิ่งที่ทำให้include.NAMEเป็นค่าที่อ่านได้ใน config ของ unit นี้ ถ้าไม่มี reference นั้นก็ resolve ไม่ได้merge_strategyปล่อยเป็นค่า default (deep merge) บน include ตัวกลางที่ตั้งใจให้มีส่วนร่วมแค่locals—region.hclที่ภายหลังเพิ่มremote_stateหรือgenerateblock เข้ามา (อาจ copy-paste มาจากroot.hclตอน refactor) จะ deep-merge block พวกนั้นเข้า unit ไปด้วย ทับสิ่งที่ includerootตั้งไว้แล้วเงียบ ๆ ขึ้นอยู่กับลำดับ includeread_terragrunt_config()ถูกเรียกโดยไม่ wrap ผลลัพธ์ให้ถูกต้อง — return value ซ้อนอยู่ใต้.locals(พิสูจน์ข้างบน:local.region_vars.locals.aws_regionไม่ใช่local.region_vars.aws_region) โค้ดที่ลืม.localsเพิ่มจะได้ null หรือ error แทนค่าจริง- relative path ที่ hardcode ไว้ (
../../../root.hcl) แทนfind_in_parent_folders("root.hcl")— ใช้ได้วันนี้ แต่พังเงียบ ๆ (ไฟล์ผิด หรือไม่มีไฟล์) ทันทีที่ unit ย้ายไปอยู่ระดับความลึกอื่น โดยไม่มี error จนกว่าอะไรบางอย่าง downstream จะอ่านค่าที่ผิดชัด ๆ
🐛 หา bug ในโค้ดนี้
include "root" {path = find_in_parent_folders("root.hcl")}
include "region" {path = find_in_parent_folders("region.hcl")}
terraform {source = "."}
inputs = {aws_region = include.region.locals.aws_region}เฉลย
ทั้งสอง include block ไม่มี expose = true เลย พิสูจน์ข้างบนว่า expose คือสิ่งที่ทำให้เนื้อหาของ include อ่านได้ผ่าน reference include.NAME — ถ้าไม่มี include.region.locals.aws_region ก็ resolve เป็นอะไรที่ใช้ได้ไม่ได้เลย config นี้จะ fail ตอน evaluate แทนที่จะอ่านค่าผิดแบบเงียบ ๆ ทางแก้คือ version ที่โชว์ไว้ก่อนหน้าในบทนี้: เพิ่ม expose = true เข้าทั้งสอง block (และ merge_strategy = "no_merge" เข้าตัวที่ไม่ใช่ root เพื่อให้ region.hcl มีส่วนร่วมแค่ locals ของตัวเอง แทนที่จะถูก deep-merge เป็น root ตัวที่สอง)
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”include กับ find_in_parent_folders คือ copy-paste แบบ dynamic ที่ไม่ขึ้นกับตำแหน่ง: อะไรก็ตามที่ root.hcl (หรือ region.hcl) มี จะถูก merge เข้า config ของ unit ปัจจุบันที่ evaluate แล้ว พิสูจน์ข้างบนผ่าน render จริงที่โชว์ว่า terragrunt.hcl สามบรรทัดของ unit จบลงด้วย remote_state block ที่ resolve ครบ กับ aws_region input ที่ตัวเองไม่เคยพิมพ์เลย expose คือความต่างระหว่าง “merge เข้ามาเป็น block” (default พฤติกรรมที่ทำให้ remote_state ของ root ไปถึงทุก unit) กับ “อ่านเป็นค่าได้ด้วย” (include.NAME.FIELD) — พิสูจน์ข้างบน ถ้าไม่มีมัน locals ของ include region ก็ไม่มีผลอะไรเลยเมื่อ inputs พยายาม reference include.region.locals.aws_region
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ทางเลือก | ได้อะไร | เสียอะไร |
|---|---|---|
include + find_in_parent_folders แทน relative path ที่ hardcode | รอดจากการที่ unit ถูกย้ายไปอยู่ระดับความลึกอื่น พิสูจน์จากกลไกเอง (ค้นหา ไม่ใช่ path ตายตัว) | typo ในชื่อไฟล์เป้าหมาย fail แค่ตอนที่ Terragrunt เดินหาไฟล์นั้นจริง ๆ ไม่มี check ตอน compile |
include ตัวที่สอง (region) พร้อม expose + no_merge | share ค่าให้แค่บางส่วนของ tree ค่าเข้าถึงได้ด้วยชื่อ (include.region.locals.aws_region) พิสูจน์ข้างบน | ต้อง block เพิ่มอีกหนึ่งต่อ unit ให้สม่ำเสมอ ลืม expose (พิสูจน์ข้างบน) พัง reference ที่อ้างถึงมันเงียบ ๆ |
read_terragrunt_config() แทน include ตัวที่สอง | อ่านไฟล์ Terragrunt ไหนก็ได้ แม้จะไม่ได้ตั้งใจให้เป็น peer config โดยไม่ต้องมี include block เลย | return value ซ้อนอยู่ใต้ .locals (พิสูจน์ข้างบน) — พลาด accessor path ผิดได้ง่ายและเงียบ |
inputs เป็น HCL จริง แทน .tfvars file แบบ static | reference ค่า local.*/include.* ที่คำนวณที่อื่นใน config เดียวกันได้ (พิสูจน์ข้างบน: include.region.locals.aws_region) | portable น้อยกว่า .tfvars ธรรมดาถ้า config ต้องรันภายใต้ Terraform เปล่า ๆ โดยไม่มี Terragrunt |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “include deep-merge ทุกอย่างจากไฟล์เป้าหมายเสมอ” พิสูจน์ข้างบน:
merge_strategy = "no_merge"บน includeregionแปลว่ามีแค่ค่าlocalsของมันที่ถูก expose ไม่ได้ deep-merge เป็น block — พฤติกรรม merge default (ที่ใช้กับroot) คือทางเลือกหนึ่ง ไม่ใช่ตัวเดียวที่มี - “expose = true copy เนื้อหาไฟล์แบบ physical เหมือนที่ generate ทำ” พิสูจน์ข้างบนโดยเทียบกับ
generate(บทก่อนหน้า เขียนbackend.tf/provider.tfจริง):exposeแค่ทำให้ config ที่ parse แล้ว อ่านได้ เป็นinclude.NAME.FIELDข้างใน HCL evaluation เท่านั้น ไม่มีอะไรถูกเขียนลง disk เลย - “read_terragrunt_config() กับ include ตัวที่สองคืนข้อมูลรูปร่างเดียวกัน” พิสูจน์ข้างบน: ผลลัพธ์ของ
read_terragrunt_config()ซ้อน locals ของไฟล์เป้าหมายไว้ใต้.localsบนตัวแปรที่คุณ assign ให้ ส่วน include ที่exposeแล้วก็เข้าถึงข้อมูลเดียวกันได้ที่include.NAME.locals.KEY— รูปร่างคล้ายกัน จุดเริ่มต้นต่างกัน และทั้งคู่ไม่มีใครข้าม.localsไปได้เลย - “find_in_parent_folders ต้องรู้ว่าต้องเดินขึ้นกี่ directory” พิสูจน์ข้างบน: มันทำงานเหมือนกันเป๊ะไม่ว่า unit จะอยู่ต่ำกว่า
root.hclหนึ่งชั้นหรือห้าชั้น — มันค้นหา ไม่ได้นับ