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

DRY Configuration with include

include block คือวิธีที่ terragrunt.hcl ของ unit หนึ่งดึงทุกอย่างที่ประกาศไว้ใน root.hcl หรือใน intermediate config file ไหนก็ตามเข้ามาใช้ โดยไม่ต้องพิมพ์ซ้ำสักบรรทัด

vpc/terragrunt.hcl ในบทที่แล้วเปิดด้วย block นี้ ตอนนี้ถึงเวลาอธิบายให้ชัดเจน

prod/us-east-1/vpc/terragrunt.hcl
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 เปลี่ยนไป

include ไม่ใช่กลไก inherit เดียวที่มี และไม่ใช่ตัวเลือกที่ถูกเสมอไป บางครั้งคุณอยากแชร์ค่าที่เล็กกว่าและเจาะจงกว่าลงไปแค่บางส่วนของ tree ไม่ใช่ทั้งหมด ลองนึกภาพไฟล์ region.hcl ตัวกลางที่อยู่ต่ำกว่า root จริงหนึ่งชั้น ครอบคลุมเฉพาะ unit ที่อยู่ใน AWS region หนึ่งเจาะจง

prod/us-east-1/region.hcl
locals {
aws_region = "us-east-1"
}

unit ที่อยู่ใต้ directory ของ region นั้นอ่านไฟล์นั้นด้วย read_terragrunt_config() ผสมกับ find_in_parent_folders() ตัวเดิมที่คุณรู้จักแล้ว

prod/us-east-1/vpc/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
locals {
region_vars = read_terragrunt_config(find_in_parent_folders("region.hcl"))
}
terraform {
source = "git::[email protected]:acme/infrastructure-modules.git//vpc?ref=v1.4.0"
}
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 = { ... } 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"]
root.hcl กับ region.hcl รวมกันเป็น config ของ unit หนึ่งตัว

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 ตัวที่สอง:

root.hcl
remote_state {
backend = "local"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
path = "${get_terragrunt_dir()}/terraform.tfstate"
}
}
prod/us-east-1/region.hcl
locals {
aws_region = "us-east-1"
}
prod/us-east-1/vpc/terragrunt.hcl
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"
}
prod/us-east-1/vpc/main.tf
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 -- plan
STDOUT terraform: region=us-east-1 cidr=10.0.0.0/16

unit ที่อยู่ใต้ 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 ล้วน ๆ)

ทั้งสองกลไกอ่านเนื้อหาของ 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 ทำได้ด้วย

ตอนรีวิว multi-include config ที่ AI สร้าง ให้เช็คสิ่งเหล่านี้:

  • include block ตัวที่สองที่ไม่มี expose = true เลย แล้วมีโค้ดที่อื่นพยายาม reference include.region.locals.aws_region — พิสูจน์ข้างบนว่า expose คือสิ่งที่ทำให้ include.NAME เป็นค่าที่อ่านได้ใน config ของ unit นี้ ถ้าไม่มี reference นั้นก็ resolve ไม่ได้
  • merge_strategy ปล่อยเป็นค่า default (deep merge) บน include ตัวกลางที่ตั้งใจให้มีส่วนร่วมแค่ locals — region.hcl ที่ภายหลังเพิ่ม remote_state หรือ generate block เข้ามา (อาจ copy-paste มาจาก root.hcl ตอน refactor) จะ deep-merge block พวกนั้นเข้า unit ไปด้วย ทับสิ่งที่ include root ตั้งไว้แล้วเงียบ ๆ ขึ้นอยู่กับลำดับ include
  • read_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 ในโค้ดนี้

prod/us-east-1/vpc/terragrunt.hcl
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 ตัวที่สอง)

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

ทางเลือกได้อะไรเสียอะไร
include + find_in_parent_folders แทน relative path ที่ hardcodeรอดจากการที่ unit ถูกย้ายไปอยู่ระดับความลึกอื่น พิสูจน์จากกลไกเอง (ค้นหา ไม่ใช่ path ตายตัว)typo ในชื่อไฟล์เป้าหมาย fail แค่ตอนที่ Terragrunt เดินหาไฟล์นั้นจริง ๆ ไม่มี check ตอน compile
include ตัวที่สอง (region) พร้อม expose + no_mergeshare ค่าให้แค่บางส่วนของ 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 แบบ staticreference ค่า local.*/include.* ที่คำนวณที่อื่นใน config เดียวกันได้ (พิสูจน์ข้างบน: include.region.locals.aws_region)portable น้อยกว่า .tfvars ธรรมดาถ้า config ต้องรันภายใต้ Terraform เปล่า ๆ โดยไม่มี Terragrunt
  • “include deep-merge ทุกอย่างจากไฟล์เป้าหมายเสมอ” พิสูจน์ข้างบน: merge_strategy = "no_merge" บน include region แปลว่ามีแค่ค่า 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 หนึ่งชั้นหรือห้าชั้น — มันค้นหา ไม่ได้นับ
include block ที่มี find_in_parent_folders ทำอะไรจริง ๆ
read_terragrunt_config ใช้ทำอะไรกับไฟล์ตัวกลางอย่าง region.hcl
inputs block ใน terragrunt.hcl ของ unit หนึ่งเทียบเท่ากับอะไร
ทำไม find_in_parent_folders ยังทำงานได้ปกติหลังจาก unit ถูกย้ายให้ลึกขึ้นไปอยู่ใน subdirectory ใหม่
unit หนึ่งมี include สองตัวคือ "root" กับ "region" ไม่มีตัวไหนมี expose = true เลย โค้ดใน unit reference include.region.locals.aws_region เกิดอะไรขึ้นจริง ๆ พิสูจน์ข้างบน
ความต่างจริง ๆ ระหว่างค่าที่ read_terragrunt_config(find_in_parent_folders("region.hcl")) คืนมา กับสิ่งที่ exposed include "region" reference ให้คืออะไร