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

DRY Configuration with include

terragrunt.hcl ของ unit ใช้ include block คู่กับ find_in_parent_folders() เพื่อดึงทุกอย่างที่ root.hcl ประกาศไว้เข้ามา และยังใช้วิธีเดียวกันนี้ซ้อน config ระดับกลางที่เล็กกว่าเข้ามาได้อีก ทำให้ config ที่ share กันไม่ต้องเขียนซ้ำแม้แต่ครั้งเดียว

บทก่อนหน้าโชว์ terragrunt.hcl ของ unit ที่อ้างถึง root.hcl ด้วยสามบรรทัด แต่ยังไม่ได้อธิบายว่าทำงานยังไง กลไกนั้นคือ include block

network/terragrunt.hcl
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("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 ไม่ใช่สิ่งที่ข้ามผ่านไปได้

include ถูกออกแบบมาให้ทำงานเฉพาะทางเดียวคือ inherit root.hcl ที่ share กันตัวเดียวนั้น แต่ config จำนวนไม่น้อยไม่ได้ global ขนาดนั้น บางอย่างใช้แค่กับ unit ของ GCP project เดียว ซึ่งอยู่ต่ำกว่า root ลงมาหนึ่งชั้น สำหรับกรณีนี้ locals block คู่กับ read_terragrunt_config() ทำให้ unit ไหนก็ตามอ่านไฟล์ที่เล็กกว่าและเจาะจงกว่าได้ แล้วดึงค่าเฉพาะที่ต้องการออกมา ต่อจาก layout ด้านบน prod/project.hcl เก็บค่าที่เจาะจงสำหรับ GCP project นั้นตัวเดียว

prod/project.hcl
locals {
project_id = "acme-prod-123456"
region = "us-central1"
}

ทุก unit ใต้ prod/ อ่านไฟล์นั้นด้วยวิธีเดียวกัน

prod/network/terragrunt.hcl
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 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"]
root.hcl, project.hcl ระดับกลาง, และ leaf unit ที่รวมทั้งสองเข้าด้วยกัน

read_terragrunt_config() ข้างบนคือวิธีหนึ่งในการอ่าน project.hcl เทคนิคที่สอง ตรงกว่า คือ include block ตัวที่สอง ใช้ expose = true กับทั้งคู่เพื่อให้เนื้อหาของมันอ่านได้เป็น include.NAME.FIELD และ merge_strategy = "no_merge" บน include ที่ไม่ใช่ root เพื่อให้ project.hcl มีส่วนร่วมแค่ locals ของตัวเอง:

root.hcl
remote_state {
backend = "local"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
path = "${get_terragrunt_dir()}/terraform.tfstate"
}
}
prod/project.hcl
locals {
project_id = "acme-prod-123456"
region = "us-central1"
}
prod/network/terragrunt.hcl
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
}
prod/network/main.tf
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 -- plan
STDOUT terraform: project=acme-prod-123456 region=us-central1

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

prod/network/terragrunt.hcl
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 แทนที่จะอ่านค่าผิดแบบเงียบ ๆ

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

include กับ find_in_parent_folders คือ copy-paste แบบ dynamic ที่ไม่ขึ้นกับตำแหน่ง expose คือความต่างระหว่าง “merge เข้ามาเป็น block” (default) กับ “อ่านเป็นค่าได้ด้วย” (include.NAME.FIELD) — พิสูจน์ข้างบน

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