DRY Configuration with include
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”include block ร่วมกับ find_in_parent_folders() ทำให้ unit ดึงทุกอย่างที่ shared config file กำหนดไว้มาใช้ได้เลย และ locals ร่วมกับ read_terragrunt_config() ก็ขยายแนวคิดเดียวกันนี้ไปยัง intermediate file ที่อยู่ระหว่าง root กับ unit ได้อีกกี่ชั้นก็ได้
include block: สืบทอด root.hcl โดยไม่ต้องเขียนซ้ำ
หัวข้อที่มีชื่อว่า “include block: สืบทอด root.hcl โดยไม่ต้องเขียนซ้ำ”บทก่อนหน้าแสดงให้เห็นว่า root.hcl ประกาศ remote_state และ provider generate block ไว้ครั้งเดียวที่ด้านบนสุดของ tree ส่วน unit จะเข้าถึงทั้งสองอย่างนี้ได้โดยไม่ต้อง copy บรรทัดใดเลย ผ่าน include block
include "root" { path = find_in_parent_folders("root.hcl")}find_in_parent_folders("root.hcl") จะไต่ขึ้นไปตาม directory tree เริ่มจาก directory ของ unit นั้นเอง จนกว่าจะเจอไฟล์ชื่อ root.hcl ไม่ว่า unit จะอยู่ลึกแค่ไหนในเชิงชั้น เช่น แค่ชั้นเดียวใต้ root หรือลึกถึงห้าชั้น ฟังก์ชันนี้ก็จะไต่ขึ้นไปหาจนเจอ ทำให้การย้าย unit ไปอยู่ที่ระดับความลึกอื่นไม่ทำให้ reference พังไป ส่วน label ของ include block อย่าง "root" ในตัวอย่างนี้ คือชื่อที่ Terragrunt ใช้ track การ include นี้ภายใน unit หนึ่งสามารถมี include block ได้มากกว่าหนึ่งตัว แต่ละตัวมี label ของตัวเอง และชี้ไปยังไฟล์คนละไฟล์ได้
เมื่อ include แล้ว ทุก block ที่ root.hcl กำหนดไว้ ไม่ว่าจะเป็น remote_state, provider generate block หรืออะไรก็ตามที่วางไว้ตรงนั้น จะทำงานเหมือนถูกเขียนอยู่ใน terragrunt.hcl ของ unit เองโดยตรง นี่คือกลไกทั้งหมด include block เพียงตัวเดียวแทนที่บรรทัดซ้ำนับสิบที่มิฉะนั้นต้องเขียนซ้ำในทุก unit ทั่วทั้ง tree
แชร์ config แบบละเอียดลงไปด้วย locals และ read_terragrunt_config
หัวข้อที่มีชื่อว่า “แชร์ config แบบละเอียดลงไปด้วย locals และ read_terragrunt_config”root.hcl ไม่ใช่ที่เดียวที่ shared configuration จะอยู่ได้ tree ขนาดใหญ่มักมีชั้นกลางอีกชั้นด้วย เช่น ไฟล์ subscription.hcl หนึ่งไฟล์ต่อหนึ่ง Azure subscription วางอยู่เหนือทุก unit ที่อยู่ภายใต้ subscription นั้น เก็บค่าที่เจาะจงสำหรับ subscription นั้นโดยเฉพาะ
locals { subscription_id = "11111111-1111-1111-1111-111111111111" environment = "networking"}unit ที่อยู่ภายใต้ subscription นั้นอ่านไฟล์นี้ด้วย read_terragrunt_config() ห่อไว้ใน locals block ของตัวเอง
include "root" { path = find_in_parent_folders("root.hcl")}
locals { subscription_vars = read_terragrunt_config(find_in_parent_folders("subscription.hcl"))}
inputs = { subscription_id = local.subscription_vars.locals.subscription_id address_space = ["10.1.0.0/16"] tags = { environment = local.subscription_vars.locals.environment }}read_terragrunt_config() จะ parse ไฟล์ที่ส่งเข้าไป แล้วคืนค่าเป็น map ของทุกอย่างที่ประกาศไว้ในไฟล์นั้น ทำให้ local.subscription_vars.locals.subscription_id เข้าถึง locals block ของ subscription.hcl ได้โดยตรง สังเกตว่านี่เป็นกลไกคนละแบบกับ include โดย include จะสืบทอด block อย่าง remote_state มาทั้งหมด ส่วน read_terragrunt_config() ให้ unit เข้าถึงค่าเฉพาะจุดจากไฟล์ที่เลือกจะอ่าน โดยไม่ต้องสืบทอดทั้งไฟล์ ความต่างนี้เองที่ทำให้เหมาะกับ intermediate file root.hcl ยังคง apply กับทุก unit ทั่วทั้ง tree ผ่าน include ส่วน subscription.hcl ก็แค่ต้องถูกอ่านโดย unit จำนวนหนึ่งที่อยู่ภายใต้ subscription นั้นจริง ๆ เท่านั้น
inputs: เทียบเท่ากับไฟล์ tfvars ใน Terragrunt
หัวข้อที่มีชื่อว่า “inputs: เทียบเท่ากับไฟล์ tfvars ใน Terragrunt”ทุกตัวอย่างที่ผ่านมาจบด้วย inputs block ซึ่งสมควรได้รับคำอธิบายของตัวเอง ใน Terraform ธรรมดา คุณส่งค่า variable ให้ root module ผ่านไฟล์ .tfvars หรือ flag -var บน command line ส่วน inputs block ของ Terragrunt ก็ทำหน้าที่เดียวกัน เพียงแต่ค่าต่าง ๆ อยู่ใน HCL ของ unit เองโดยตรง แทนที่จะอยู่ในไฟล์แยกต่างหาก
inputs = { address_space = ["10.0.0.0/16"] location = "westeurope"}ทุก key ใน inputs ต้องตรงกับ variable ที่ underlying Terraform module ประกาศไว้จริง Terragrunt จะส่งแต่ละค่าผ่านไปเหมือนถูกส่งด้วย -var เพราะ inputs เป็น HCL ธรรมดา จึง reference locals, ผลลัพธ์จาก read_terragrunt_config() หรือในบทถัดไปของ module นี้ คือ output ของ unit อื่นได้ ซึ่งไฟล์ .tfvars แบบ static ทำแบบนี้ไม่ได้เลย
flowchart TD root["root.hcl (remote_state, provider generate)"] sub["subscription.hcl (locals: subscription_id, environment)"] unit["vnet/terragrunt.hcl"] root -->|include find_in_parent_folders root.hcl| unit sub -->|read_terragrunt_config find_in_parent_folders subscription.hcl| unit unit --> inputs["inputs = merge of root, subscription, and unit values"]
พิสูจน์: สอง include block พร้อม expose = true, terragrunt render จริง
หัวข้อที่มีชื่อว่า “พิสูจน์: สอง include block พร้อม expose = true, terragrunt render จริง”read_terragrunt_config() ข้างบนคือวิธีหนึ่งในการอ่าน subscription.hcl เทคนิคที่สอง ตรงกว่า คือ include block ตัวที่สอง ใช้ expose = true กับทั้งคู่เพื่อให้เนื้อหาของมันอ่านได้เป็น include.NAME.FIELD และ merge_strategy = "no_merge" บน include ที่ไม่ใช่ root เพื่อให้ subscription.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 { subscription_id = "11111111-1111-1111-1111-111111111111" environment = "networking"}include "root" { path = find_in_parent_folders("root.hcl") expose = true}
include "subscription" { path = find_in_parent_folders("subscription.hcl") expose = true merge_strategy = "no_merge"}
terraform { source = "."}
inputs = { subscription_id = include.subscription.locals.subscription_id address_space = ["10.1.0.0/16"]}terraform { required_providers { local = { source = "hashicorp/local", version = "~> 2.5" } }}variable "subscription_id" { type = string }variable "address_space" { type = list(string) }resource "local_file" "vnet" { filename = "${path.module}/vnet.txt" content = "subscription=${var.subscription_id} address_space=${join(",", var.address_space)}\n"}terragrunt render --format json จริง ตัดบางส่วนออก:
$ terragrunt render --format json{ "inputs": { "address_space": ["10.1.0.0/16"], "subscription_id": "11111111-1111-1111-1111-111111111111" }, "remote_state": { "backend": "local", "config": { "path": ".../terraform.tfstate" }, ... }, "terraform": { "source": "." }}inputs.subscription_id อ่านได้ค่าจริง — ดึงมาจาก include.subscription.locals.subscription_id ซึ่งมีอยู่ได้ก็เพราะ expose = true ถูกตั้งไว้บน include subscription รวมกับ merge_strategy = "no_merge" ที่ทำให้ locals ของมันอยู่ในขอบเขตของ include.subscription.locals แทนที่จะ merge เข้าไปเป็น locals ระดับบนสุดของ unit นี้เอง และ plan จริงก็พิสูจน์ว่าค่านี้ไปถึง module จริง ไม่ใช่แค่ config ที่ render ออกมา:
$ terragrunt run --non-interactive -- planSTDOUT terraform: subscription=11111111-1111-1111-1111-111111111111 address_space=10.1.0.0/16unit ที่อยู่ใต้ sub-compute/ directory อื่นที่มี subscription.hcl ของตัวเองประกาศ subscription_id ต่างออกไป จะ resolve เป็นค่านั้นแทน — root.hcl เดียวกัน โค้ด unit เดียวกัน แค่ subscription.hcl ต่างกันต่อ subtree
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”ตอนรีวิว multi-include config ที่ AI สร้าง ให้เช็คสิ่งเหล่านี้:
includeblock ตัวที่สองที่ไม่มีexpose = trueเลย แล้วมีโค้ดที่ referenceinclude.subscription.locals.subscription_id— พิสูจน์ข้างบนว่าexposeคือสิ่งที่ทำให้include.NAMEเป็นค่าที่อ่านได้ ถ้าไม่มี reference นั้นก็ resolve ไม่ได้merge_strategyปล่อยเป็นค่า default (deep merge) บน include ตัวกลางที่ตั้งใจให้มีส่วนร่วมแค่locals—subscription.hclที่ภายหลังเพิ่มremote_stateblock เข้ามาโดยไม่ตั้งใจ จะ deep-merge เข้าทุก unit ทับสิ่งที่ root ตั้งไว้แล้วเงียบ ๆread_terragrunt_config()ถูกเรียกโดยไม่มี accessor.localsเพิ่ม — return value ซ้อนอยู่ใต้.locals(พิสูจน์ข้างบนผ่านโครงสร้างที่เทียบเท่ากัน) โค้ดที่ลืมขั้นตอนนั้นจะได้ null แทนค่าจริง
🐛 หา bug ในโค้ดนี้
include "root" {path = find_in_parent_folders("root.hcl")}
include "subscription" {path = find_in_parent_folders("subscription.hcl")}
terraform {source = "."}
inputs = {subscription_id = include.subscription.locals.subscription_id}เฉลย
ทั้งสอง include block ไม่มี expose = true เลย พิสูจน์ข้างบนว่า expose คือสิ่งที่ทำให้เนื้อหาของ include อ่านได้ผ่าน reference include.NAME — ถ้าไม่มี include.subscription.locals.subscription_id ก็ resolve เป็นอะไรที่ใช้ได้ไม่ได้เลย config นี้จะ fail ตอน evaluate แทนที่จะอ่านค่าผิดแบบเงียบ ๆ ทางแก้คือ version ที่โชว์ข้างบน: เพิ่ม expose = true เข้าทั้งสอง block (และ merge_strategy = "no_merge" เข้าตัวที่ไม่ใช่ root)
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”include กับ find_in_parent_folders คือ copy-paste แบบ dynamic ที่ไม่ขึ้นกับตำแหน่ง — พิสูจน์ข้างบนผ่าน render จริงที่โชว์ว่า unit สามบรรทัดจบลงด้วย remote_state block ที่ resolve ครบ กับ subscription_id input ที่ตัวเองไม่เคยพิมพ์เลย expose คือความต่างระหว่าง “merge เข้ามาเป็น block” (default) กับ “อ่านเป็นค่าได้ด้วย” (include.NAME.FIELD) — พิสูจน์ข้างบน ถ้าไม่มีมัน locals ของ include subscription ก็ไม่มีผลอะไรเลยเมื่อ inputs พยายาม reference มัน
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ทางเลือก | ได้อะไร | เสียอะไร |
|---|---|---|
include ตัวที่สอง (subscription) พร้อม expose + no_merge | share ค่าให้แค่บางส่วนของ tree ค่าเข้าถึงได้ด้วยชื่อ พิสูจน์ข้างบน | ต้อง block เพิ่มอีกหนึ่งต่อ unit ให้สม่ำเสมอ ลืม expose (พิสูจน์ข้างบน) พัง reference ที่อ้างถึงมันเงียบ ๆ |
read_terragrunt_config() แทน include ตัวที่สอง | อ่านไฟล์ Terragrunt ไหนก็ได้โดยไม่ต้องมี include block เลย | return value ซ้อนอยู่ใต้ .locals — พลาด accessor path ผิดได้ง่ายและเงียบ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “include deep-merge ทุกอย่างจากไฟล์เป้าหมายเสมอ” พิสูจน์ข้างบน:
merge_strategy = "no_merge"บน includesubscriptionแปลว่ามีแค่ค่าlocalsของมันที่ถูก expose ไม่ได้ deep-merge เป็น block - “expose = true copy เนื้อหาไฟล์แบบ physical เหมือนที่ generate ทำ” พิสูจน์ข้างบนโดยเทียบกับ
generate(บทก่อนหน้า เขียนbackend.tf/provider.tfจริง):exposeแค่ทำให้ config ที่ parse แล้ว อ่านได้ ข้างใน HCL evaluation เท่านั้น ไม่มีอะไรถูกเขียนลง disk เลย