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

Dependencies Between Units

dependency block ทำให้ unit หนึ่งอ่าน output ของอีก unit ข้าม state boundary ที่แยกทั้งสองออกจากกันได้ และ Terragrunt ก็ใช้ block เดียวกันนี้คำนวณลำดับที่ unit ต่าง ๆ ต้องถูก apply

ใน module Modules คุณเห็น output-to-input composition แล้ว output ของ module หนึ่งป้อนตรงเข้า input argument ของอีก module ภายใน main.tf เดียว evaluate เป็น Terraform plan เดียวกับ state ไฟล์เดียว วิธีนั้นทำงานได้เพราะทั้งสอง module อยู่ใน apply เดียวกัน

Terragrunt unit ตั้งใจทำลาย assumption นั้นทิ้ง unit vpc กับ unit ec2 ต่างมี terragrunt.hcl ของตัวเองและ state ไฟล์แยกของตัวเอง apply กันอย่างอิสระ module.vpc.outputs.vpc_id ที่อ้างอิงตรง ๆ แบบตอนอยู่ใน Terraform config เดียว จากมุมมองของ unit ec2 reference นั้นไม่มีอยู่จริงเลย เพราะไม่มี config ไฟล์ที่ share กันให้ reference นั้นอยู่ข้างใน ต้องมีอะไรสักอย่างอ่านข้าม state boundary นั้นตรง ๆ และสิ่งนั้นคือ dependency block

ec2/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "git::[email protected]:acme/infrastructure-modules.git//ec2?ref=v1.4.0"
}
dependency "vpc" {
config_path = "../vpc"
mock_outputs = {
vpc_id = "mock-vpc-id"
}
mock_outputs_allowed_terraform_commands = ["plan"]
}
inputs = {
vpc_id = dependency.vpc.outputs.vpc_id
}

config_path ชี้ไปที่ directory ของอีก unit หนึ่ง ไม่ใช่ Terraform module แต่เป็น Terragrunt unit ก่อนรัน Terragrunt จะรัน terraform output กับ state จริงของ unit นั้น แล้ว expose ทุก output ที่เจอเป็น dependency.vpc.outputs.NAME ให้ใช้ได้ทุกที่ใน config ของ unit นี้ รวมถึงข้างใน inputs ด้วย

dependency.vpc.outputs.vpc_id จะมีค่าจริงก็ต่อเมื่อ unit vpc ถูก apply ไปแล้วอย่างน้อยหนึ่งครั้ง มีสองสถานการณ์ที่ทำลาย assumption นี้ คือตอนตั้ง environment ใหม่เอี่ยมครั้งแรก ที่ยังไม่มีอะไรถูก apply เลย และตอน CI validate pull request ที่แก้แค่ไฟล์ของ unit ec2 ซึ่งไม่มีใครอยากให้ plan fail แค่เพราะ unit อื่นที่ไม่เกี่ยวข้องยังไม่ถูก apply ใน environment นั้น

mock_outputs มาปิด gap นี้พอดี โดยให้ค่า placeholder ที่ Terragrunt สลับใช้แทนทุกครั้งที่ output จริงยังไม่มี และ mock_outputs_allowed_terraform_commands จำกัดว่า command ไหนถึง fallback ไปใช้ค่านั้นได้ ["plan"] ข้างบนแปลว่า plan บน ec2 สำเร็จได้ด้วย vpc_id = "mock-vpc-id" แม้ยังไม่มี state จริงของ vpc เลย ส่วน apply ยังคงปฏิเสธไม่ยอมรันกับค่าปลอม และบังคับให้ dependency จริงถูก apply ก่อนเสมอ

Terraform เองก็คำนวณลำดับการรันภายใน state เดียวอยู่แล้ว คือ dependency graph ระหว่าง resource แต่ละตัว dependency block ส่งข้อมูลแบบเดียวกันนี้ให้ Terragrunt ในระดับที่สูงขึ้นมาอีกชั้น คือระหว่าง unit ทั้งก้อน เพราะ ec2 ประกาศ dependency ไปที่ vpc Terragrunt ก็รู้ว่าต้อง apply vpc ก่อน ขยายแบบนี้ไปทั่ว tree ที่มีหลายสิบ unit จริง ๆ Terragrunt จะสร้างกราฟเต็มรูปแบบจากทุก dependency block ที่เจอ แล้วเดินตามลำดับที่ถูกต้องเวลามี command ไหนก็ตามที่แตะมากกว่าหนึ่ง unit พร้อมกัน ซึ่งจะพูดถึงเต็ม ๆ ในบทถัดไป โดยไม่ต้องมีใครจำหรือ maintain ลำดับนั้นด้วยมือเอง

flowchart LR
  vpcstate["vpc unit state (applied separately)"] --> vpcout["vpc outputs: vpc_id"]
  vpcout --> dep["ec2 terragrunt.hcl: dependency vpc block"]
  dep --> ecin["ec2 inputs: vpc_id = dependency.vpc.outputs.vpc_id"]
  mock["mock_outputs: vpc_id = mock-vpc-id"] -.->|lets plan succeed before vpc is applied| dep
dependency block ส่ง output ของ vpc เข้าไปเป็น inputs ของ ec2

นี่คือ Terragrunt 1.1.0 tree จริง ใช้ local backend (ไม่ต้องมี AWS เพื่อพิสูจน์ กลไก — vpc output string vpc_id แบบเดียวกับที่ aws_vpc.this.id จะทำ):

root.hcl
remote_state {
backend = "local"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
path = "${get_terragrunt_dir()}/terraform.tfstate"
}
}
vpc/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "."
}
vpc/main.tf
terraform {
required_providers {
local = { source = "hashicorp/local", version = "~> 2.5" }
}
}
resource "local_file" "vpc" {
filename = "${path.module}/vpc-id.txt"
content = "vpc-0123456789abcdef0\n"
}
output "vpc_id" {
value = "vpc-0123456789abcdef0"
}

ec2/terragrunt.hcl คือ dependency block เดียวกันเป๊ะกับที่โชว์ข้างบน (config_path = "../vpc", mock_outputs_allowed_terraform_commands = ["plan"]) module ของมัน:

ec2/main.tf
terraform {
required_providers {
local = { source = "hashicorp/local", version = "~> 2.5" }
}
}
variable "vpc_id" { type = string }
resource "local_file" "ec2" {
filename = "${path.module}/ec2-vpc-ref.txt"
content = "launched into ${var.vpc_id}\n"
}

ก่อนที่ vpc จะเคย apply เลย รัน terragrunt run --all -- plan จากบนสุดของ tree:

$ terragrunt run --all --non-interactive -- plan
INFO The following units will be run, starting with dependencies and then their dependents:
╰── vpc
╰── ec2
WARN [ec2] Config .../vpc/terragrunt.hcl is a dependency of .../ec2/terragrunt.hcl
that has no outputs, but mock outputs provided and returning those in
dependency output.
STDOUT [ec2] terraform: # local_file.ec2 will be created
STDOUT [ec2] terraform: + content = <<-EOT
launched into mock-vpc-id
EOT
❯❯ Run Summary 2 units 2s
Succeeded 2

พิสูจน์สองเรื่องพร้อมกัน: ordering log (“starting with dependencies and then their dependents”) list vpc ก่อน ec2 มาจาก dependency "vpc" block ล้วน ๆ — ไม่มีอะไรใน config ของ unit ไหนบอกว่า “รันฉันทีหลัง” เลย และเนื้อหา plan เอง launched into mock-vpc-id คือค่า mock_outputs literal พิสูจน์ว่า mock_outputs_allowed_terraform_commands = ["plan"] คือตัวที่ทำให้สำเร็จได้โดยไม่มี state จริงของ vpc อยู่บน disk เลย

ตอนนี้ apply vpc จริง (ยัง offline เต็มรูปแบบ — local_file ไม่มี cloud) แล้วรัน plan ของ ec2 ใหม่ โดยไม่แก้ config อะไรเลย:

$ terragrunt run --non-interactive -- apply -auto-approve # inside vpc/
Outputs:
vpc_id = "vpc-0123456789abcdef0"
$ terragrunt run --non-interactive -- plan # inside ec2/, unchanged config
STDOUT terraform: + content = <<-EOT
launched into vpc-0123456789abcdef0
EOT

ec2/terragrunt.hcl เดิม dependency block เดิม — เนื้อหา plan สลับจากค่า mock ไปเป็นค่า terraform output จริงทันทีที่ vpc มี state จริง โดยฝั่ง ec2 ไม่ต้องแก้อะไรเลย นี่คือกลไกทั้งหมดที่ dependency.vpc.outputs.vpc_id ทำงานอยู่บน: Terragrunt shell out ไปที่ terraform output -json กับ state ของ vpc แล้ว fallback ไปใช้ mock_outputs ก็ต่อเมื่อผลลัพธ์นั้นว่างเปล่า (และ command ปัจจุบันอยู่ใน mock_outputs_allowed_terraform_commands)

error จริงเมื่อ dependency ไม่มี output และ command ไม่อยู่ใน allow-list

หัวข้อที่มีชื่อว่า “error จริงเมื่อ dependency ไม่มี output และ command ไม่อยู่ใน allow-list”

apply ถูกตั้งใจเอาออกจาก mock_outputs_allowed_terraform_commands = ["plan"] ข้างบน รัน terragrunt apply กับ ec2 ก่อนที่ vpc จะถูก apply — error จริงของ Terragrunt 1.1.0 ไม่ได้ paraphrase:

$ terragrunt apply --non-interactive # ec2/, before vpc has been applied, apply not in the allow-list
ERROR resolving dependency "vpc" outputs: .../vpc/terragrunt.hcl is a dependency of
.../ec2/terragrunt.hcl but detected no outputs. Either the target module has
not been applied yet, or the module has no outputs.
If this dependency is accessed before the outputs are ready (which can happen
during the planning phase of an unapplied stack), consider using mock_outputs:
...
If you do not require outputs from your dependency, consider using the
dependencies block instead:
https://docs.terragrunt.com/reference/config-blocks-and-attributes/#dependencies

นี่คือ guardrail ที่ mock_outputs_allowed_terraform_commands มีไว้เพื่อบังคับใช้: plan ไปต่อได้ด้วย placeholder แต่ apply ถูกปฏิเสธตรง ๆ แทนที่จะ apply ec2 เงียบ ๆ ไปกับ vpc_id ปลอม — พฤติกรรมเดียวกับที่อธิบายไว้ก่อนหน้านี้ในบท ตอนนี้เห็นเป็น error text จริงของ Terragrunt แทนที่จะเป็นแค่คำกล่าวอ้าง

skip_outputs = true บน dependency block แปลว่า “ไม่ต้องเรียก terraform output เลย ใช้ mock_outputs เสมอถ้าตั้งไว้” พิสูจน์ — unit ec2 เดิม vpc มี state จริงที่ apply ไปแล้วจากข้างบน เพิ่ม skip_outputs = true เข้า dependency "vpc" block ไม่แก้อะไรอื่น:

$ terragrunt run --non-interactive -- plan # skip_outputs = true, vpc already applied for real
STDOUT terraform: + content = <<-EOT
launched into mock-vpc-id
EOT

แม้ output จริงของ vpc จะอยู่บน disk ตรงนั้นเลย (vpc_id = "vpc-0123456789abcdef0" พิสูจน์ข้างบน) skip_outputs ก็ข้ามการอ่านไปเลยทั้งหมด mock ชนะ นี่คือพฤติกรรมที่เอกสารระบุไว้สำหรับสถานการณ์เดียวเป๊ะ ๆ คือ CI ที่ตั้ง remote_state.disable_init ไว้ ที่ไม่อยากให้ Terragrunt พยายามอ่าน backend ไหนของ dependency เลย ทั้งจริงหรือ fallback เป็น mock ก็ตาม — อยากได้ mock แบบไม่มีเงื่อนไข ทุกครั้ง

dependencies (พหูพจน์) เทียบ dependency (เอกพจน์): แค่ลำดับ เทียบกับ มี output

หัวข้อที่มีชื่อว่า “dependencies (พหูพจน์) เทียบ dependency (เอกพจน์): แค่ลำดับ เทียบกับ มี output”

dependencies block คือกลไกเก่ากว่า คนละตัว ที่มีผลแค่กับลำดับของ run --all เท่านั้น — ไม่ expose output เลย:

dependencies {
paths = ["../vpc", "../rds"]
}

ไม่มี dependencies.vpc.outputs อยู่ที่ไหนเลย — block นี้คือ list ของ path เปล่า ๆ ที่หน้าที่เดียวคือบอก run --all ว่า “apply พวกนี้ก่อน” ไม่มี output-passing, mock_outputs, หรือ per-command allow-list แบบ dependency เลย ใช้ dependencies แค่ตอนที่ unit ต้องการลำดับแต่ไม่มีค่า config จริงที่ต้องอ่านจากอีก unit เลย ทันทีที่ต้องการค่าจริงแม้แค่ค่าเดียวจากอีก unit นั่นต้องเป็น dependency block ที่มีชื่อแทน — dependencies ไม่มี dependency.NAME.outputs.OUTPUT ให้อ่านเลย

ตอนรีวิว dependency block ที่ AI สร้าง ให้เช็คสิ่งเหล่านี้:

  • mock_outputs_allowed_terraform_commands ที่มี "apply" รวมอยู่ด้วย — พิสูจน์ข้างบนว่า guardrail เฉพาะนี้มีไว้เพื่อหยุด apply ไม่ให้รันกับข้อมูลปลอม การเพิ่ม "apply" เข้า allow-list ทำลาย guardrail นั้นทั้งหมดแบบเงียบ ๆ
  • mock_outputs ที่มีอยู่แต่ไม่มี mock_outputs_allowed_terraform_commands เลย — mock ไม่ถูกใช้เลย (allow-list default เป็นค่าว่าง ไม่ใช่ “ใช้เสมอ”) ดังนั้น plan ครั้งแรกบน environment ใหม่ล้วนจะ fail ด้วย error “detected no outputs” เป๊ะ ๆ แบบที่พิสูจน์ข้างบน ทั้งที่ mock_outputs block ที่ไม่ถูกใช้ดูเหมือนควรจะป้องกันสิ่งนั้นได้
  • dependencies { paths = [...] } block ที่โค้ดข้างในพยายามอ่าน dependencies.vpc.something — พิสูจน์ข้างบนว่า attribute นั้นไม่มีอยู่บน dependencies block เลย มีแค่ dependency (เอกพจน์) เท่านั้นที่ expose .outputs
  • skip_outputs = true คู่กับไม่มี mock_outputs เลย — พิสูจน์จาก quote เอกสารข้างบน outputs จะกลายเป็น empty map ในกรณีนั้น แล้วโค้ดที่ reference key เฉพาะข้างในจะ fail ตอน evaluate ไม่ใช่ด้วย message ชัด ๆ ว่า “คุณลืม mock_outputs”

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

ec2/terragrunt.hcl
dependency "vpc" {
config_path = "../vpc"
mock_outputs = {
vpc_id = "mock-vpc-id"
}
mock_outputs_allowed_terraform_commands = ["plan", "apply"]
}
inputs = {
vpc_id = dependency.vpc.outputs.vpc_id
}
เฉลย

บั๊กคือ "apply" ที่อยู่ใน mock_outputs_allowed_terraform_commands ควบคู่กับ "plan" พิสูจน์ข้างบนว่าการจำกัด list เหลือแค่ ["plan"] คือตัวที่ทำให้ apply ปฏิเสธไม่รันด้วย error จริง จนกว่า vpc จะถูก apply จริง ๆ — ถ้ามี "apply" รวมอยู่ terragrunt apply บน ec2 จะสำเร็จเงียบ ๆ โดยใช้ vpc_id = "mock-vpc-id" สร้าง infrastructure จริงที่ต่อเข้ากับค่า placeholder แทนที่จะเป็น VPC จริง ทางแก้คือ config เดียวกับที่โชว์ไว้ก่อนหน้าในบทนี้เป๊ะ: เอา apply ออกจาก allow-list เพื่อให้ไปเจอ error “detected no outputs” แล้วบังคับให้ vpc ถูก apply ก่อน

dependency block คือตัวแทนของ cross-module reference ที่ Terraform ให้ฟรีภายใน state เดียว — พิสูจน์ข้างบนว่ามันทำงานด้วยการ shell out ไปเรียก terraform output -json กับอีก unit จริง ๆ แล้วเอาผลลัพธ์มาแทนที่ก่อนที่ plan/apply ของ unit นี้จะเริ่มด้วยซ้ำ ไม่มีอะไร share หรือ link กันในระดับ Terraform เลย พอถึงตอนที่ Terraform เองรัน dependency.vpc.outputs.vpc_id ถูก resolve เป็น string ธรรมดาและถูก bake เข้าไปใน inputs เรียบร้อยแล้ว mock_outputs มีอยู่เพราะ terraform output call นั้นอาจว่างเปล่า และ Terragrunt ต้องการคำตอบที่ชัดเจน scope ตาม command ว่า “ทำอะไรต่อ” แทนที่จะ fail ตลอดหรือเดาตลอด

ทางเลือกได้อะไรเสียอะไร
dependency block แทนการใช้ Terraform state เดียวร่วมกันstate แยกอิสระต่อ unit (บทก่อนหน้า) run --all ยังได้ลำดับที่ถูกต้อง พิสูจน์ข้างบนมี terraform output shell-out เพิ่มต่อ dependency ต่อ run — ต้นทุนจริงที่วัดได้เมื่อมีหลายสิบ unit พูดถึงใน --parallelism บทถัดไป
mock_outputs scope ด้วย mock_outputs_allowed_terraform_commandsplan/CI บน environment ที่ apply ไปแค่บางส่วนไม่ fail แข็ง — พิสูจน์ข้างบนพิสูจน์ข้างบนเหมือนกัน: ตั้ง allow-list ผิด (รวม apply เข้าไป) แลกจาก fail ที่ดังและถูกต้อง เป็น fail เงียบ ๆ ที่ผิด
skip_outputsmock ที่ deterministic ไม่มีการอ่าน backend เลย มีประโยชน์ตอนตั้ง remote_state.disable_init ใน CIพิสูจน์ข้างบน: มันข้าม state จริงแม้จะมีอยู่ mock เก่าอาจบัง drift จริงใน plan ที่ควรจับมันได้
dependencies (path เท่านั้น) แทน dependencyง่ายกว่าเมื่อ unit ต้องการแค่ลำดับ ไม่ต้องการค่าไม่มี .outputs ให้อ่านเลย ทันทีที่ต้องการค่าจริงแม้แค่ค่าเดียว ต้องเปลี่ยนเป็น dependency block
  • “dependency block link state ของสอง unit เข้าด้วยกัน Terraform เห็นทั้งคู่พร้อมกัน” พิสูจน์ข้างบน: Terraform ไม่เห็น vpc เลยตอน plan ec2 Terragrunt resolve dependency.vpc.outputs.vpc_id เป็น string ธรรมดา ก่อน เรียก Terraform ด้วยซ้ำ ผ่าน terraform output call แยกกับ state ของ vpc เอง
  • “mock_outputs ถูกใช้อัตโนมัติทุกครั้งที่ state จริงของ dependency หายไป” พิสูจน์ข้างบนด้วย error จริงของ apply แบบ “detected no outputs”: mock ถูกใช้แค่กับ command ที่ list ไว้ใน mock_outputs_allowed_terraform_commands ตรง ๆ เท่านั้น list ว่างหรือไม่มี list เลยแปลว่า mock ไม่ถูกใช้เลย แล้ว command นั้น fail แทน
  • “skip_outputs แปลว่า ‘ข้าม mock ไป ใช้ของจริง’” ตรงข้ามเลย พิสูจน์ข้างบน: skip_outputs = true แปลว่าใช้ mock_outputs เสมอ (ไม่เรียก terraform output เลย) แม้ state จริงจะมีอยู่ก็ตาม
  • “dependencies กับ dependency คือ block เดียวกัน สะกดได้สองแบบ” พิสูจน์ข้างบน: dependencies { paths = [...] } มีผลแค่กับลำดับ run --all และไม่ expose อะไรเลย dependency "name" { ... } (เอกพจน์ มี label) ต่างหากที่ expose .outputs ทั้งสองใช้แทนกันไม่ได้
ทำไม output-to-input module composition จาก module Modules ถึงไม่พอสำหรับ Terragrunt unit สองตัว
mock_outputs ให้อะไร
Terragrunt คำนวณลำดับ apply ที่ถูกต้องข้าม unit ที่พึ่งพากันหลายตัวได้ยังไง
mock_outputs_allowed_terraform_commands ควบคุมอะไร
state จริงของ dependency มีอยู่บน disk พร้อม output จริงแล้ว แต่ terragrunt.hcl ของมันก็ตั้ง skip_outputs = true ไว้ด้วย unit ที่อ่านจะเห็นค่าไหนจริง ๆ ตอน plan
ความต่างจริง ๆ ระหว่าง dependencies { paths = [...] } block กับ dependency "name" { ... } block คืออะไร