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

DRY Configuration with include

include block ร่วมกับ find_in_parent_folders() ทำให้ unit ดึงทุกอย่างที่ shared config file กำหนดไว้มาใช้ได้เลย และ locals ร่วมกับ read_terragrunt_config() ก็ขยายแนวคิดเดียวกันนี้ไปยัง intermediate file ที่อยู่ระหว่าง root กับ unit ได้อีกกี่ชั้นก็ได้

บทก่อนหน้าแสดงให้เห็นว่า root.hcl ประกาศ remote_state และ provider generate block ไว้ครั้งเดียวที่ด้านบนสุดของ tree ส่วน unit จะเข้าถึงทั้งสองอย่างนี้ได้โดยไม่ต้อง copy บรรทัดใดเลย ผ่าน include block

vnet/terragrunt.hcl
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

root.hcl ไม่ใช่ที่เดียวที่ shared configuration จะอยู่ได้ tree ขนาดใหญ่มักมีชั้นกลางอีกชั้นด้วย เช่น ไฟล์ subscription.hcl หนึ่งไฟล์ต่อหนึ่ง Azure subscription วางอยู่เหนือทุก unit ที่อยู่ภายใต้ subscription นั้น เก็บค่าที่เจาะจงสำหรับ subscription นั้นโดยเฉพาะ

subscriptions/sub-networking/subscription.hcl
locals {
subscription_id = "11111111-1111-1111-1111-111111111111"
environment = "networking"
}

unit ที่อยู่ภายใต้ subscription นั้นอ่านไฟล์นี้ด้วย read_terragrunt_config() ห่อไว้ใน locals block ของตัวเอง

subscriptions/sub-networking/vnet/terragrunt.hcl
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 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"]
root.hcl, intermediate subscription.hcl และ unit ที่รวมทั้งสองระดับเข้าด้วยกัน

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

root.hcl
remote_state {
backend = "local"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
path = "${get_terragrunt_dir()}/terraform.tfstate"
}
}
subscriptions/sub-networking/subscription.hcl
locals {
subscription_id = "11111111-1111-1111-1111-111111111111"
environment = "networking"
}
subscriptions/sub-networking/vnet/terragrunt.hcl
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"]
}
subscriptions/sub-networking/vnet/main.tf
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 -- plan
STDOUT terraform: subscription=11111111-1111-1111-1111-111111111111 address_space=10.1.0.0/16

unit ที่อยู่ใต้ sub-compute/ directory อื่นที่มี subscription.hcl ของตัวเองประกาศ subscription_id ต่างออกไป จะ resolve เป็นค่านั้นแทน — root.hcl เดียวกัน โค้ด unit เดียวกัน แค่ subscription.hcl ต่างกันต่อ subtree

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

  • include block ตัวที่สองที่ไม่มี expose = true เลย แล้วมีโค้ดที่ reference include.subscription.locals.subscription_id — พิสูจน์ข้างบนว่า expose คือสิ่งที่ทำให้ include.NAME เป็นค่าที่อ่านได้ ถ้าไม่มี reference นั้นก็ resolve ไม่ได้
  • merge_strategy ปล่อยเป็นค่า default (deep merge) บน include ตัวกลางที่ตั้งใจให้มีส่วนร่วมแค่ locals — subscription.hcl ที่ภายหลังเพิ่ม remote_state block เข้ามาโดยไม่ตั้งใจ จะ deep-merge เข้าทุก unit ทับสิ่งที่ root ตั้งไว้แล้วเงียบ ๆ
  • read_terragrunt_config() ถูกเรียกโดยไม่มี accessor .locals เพิ่ม — return value ซ้อนอยู่ใต้ .locals (พิสูจน์ข้างบนผ่านโครงสร้างที่เทียบเท่ากัน) โค้ดที่ลืมขั้นตอนนั้นจะได้ null แทนค่าจริง

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

subscriptions/sub-networking/vnet/terragrunt.hcl
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)

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 มัน

ทางเลือกได้อะไรเสียอะไร
include ตัวที่สอง (subscription) พร้อม expose + no_mergeshare ค่าให้แค่บางส่วนของ tree ค่าเข้าถึงได้ด้วยชื่อ พิสูจน์ข้างบนต้อง block เพิ่มอีกหนึ่งต่อ unit ให้สม่ำเสมอ ลืม expose (พิสูจน์ข้างบน) พัง reference ที่อ้างถึงมันเงียบ ๆ
read_terragrunt_config() แทน include ตัวที่สองอ่านไฟล์ Terragrunt ไหนก็ได้โดยไม่ต้องมี include block เลยreturn value ซ้อนอยู่ใต้ .locals — พลาด accessor path ผิดได้ง่ายและเงียบ
  • “include deep-merge ทุกอย่างจากไฟล์เป้าหมายเสมอ” พิสูจน์ข้างบน: merge_strategy = "no_merge" บน include subscription แปลว่ามีแค่ค่า locals ของมันที่ถูก expose ไม่ได้ deep-merge เป็น block
  • “expose = true copy เนื้อหาไฟล์แบบ physical เหมือนที่ generate ทำ” พิสูจน์ข้างบนโดยเทียบกับ generate (บทก่อนหน้า เขียน backend.tf/provider.tf จริง): expose แค่ทำให้ config ที่ parse แล้ว อ่านได้ ข้างใน HCL evaluation เท่านั้น ไม่มีอะไรถูกเขียนลง disk เลย
include block ร่วมกับ find_in_parent_folders ทำหน้าที่อะไร
read_terragrunt_config ใช้ทำอะไรกับ intermediate file อย่าง subscription.hcl
inputs block ใน terragrunt.hcl ของ unit เทียบเท่ากับอะไร
ทำไม tree หนึ่งจึงอาจใช้ intermediate subscription.hcl เพิ่มเติมจาก root.hcl
unit หนึ่งมี include สองตัวคือ "root" กับ "subscription" ไม่มีตัวไหนมี expose = true เลย โค้ด reference include.subscription.locals.subscription_id เกิดอะไรขึ้น พิสูจน์ข้างบน
terragrunt render จริงโชว์อะไรสำหรับ inputs.subscription_id เมื่อ unit ใช้ include "subscription" { expose = true, merge_strategy = "no_merge" }