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

Module Composition and Sources

source argument บน module block บอก Terraform ว่าจะไปเอา code ของ module นั้นมาจากไหน และ infrastructure จริงที่ไม่ trivial ถูกสร้างขึ้นด้วยการประกอบ module เล็ก ๆ หลายตัวเข้าด้วยกัน — output ของ module หนึ่งถูกส่งตรงเข้าไปเป็น input variable ของอีก module

ทุก module block ต้องมี source และมีสามแบบหลักที่คุณจะเจอบ่อยมาก local relative path ชี้ไปที่ directory ภายใน repository ของคุณเอง public Terraform Registry resolve address สั้น ๆ แบบ <namespace>/<name>/<provider> ไปยัง module ที่ publish และมี version แล้ว Git URL ชี้ตรงไปที่ repository โดย pin ไปที่ tag, branch หรือ commit ที่ต้องการได้ด้วย ?ref=

# local relative path
module "storage" {
source = "./modules/storage-account"
}
# public Terraform Registry
module "vnet" {
source = "Azure/vnet/azurerm"
version = "~> 5.0"
}
# Git URL pinned to a specific tag
module "vnet_from_git" {
source = "git::https://github.com/acme/terraform-modules.git//vnet?ref=v1.4.0"
}

ทั้งสามแบบเป็นแค่คำตอบต่างกันของคำถามเดียวกัน: Terraform ไปเอาไฟล์ .tf ของ child module นี้จากที่ไหน ไม่มีอะไรในวิธีที่คุณเขียน variables.tf, main.tf หรือ outputs.tf ภายใน module ที่ต้องเปลี่ยน ไม่ว่า caller จะใช้ source แบบไหน

ประกอบ module เข้าด้วยกัน: output หนึ่งกลายเป็น input ของอีกตัว

หัวข้อที่มีชื่อว่า “ประกอบ module เข้าด้วยกัน: output หนึ่งกลายเป็น input ของอีกตัว”

configuration แบบ flat ตัวเดียวที่มี resource เป็นร้อยอ่านยากและเอาไปใช้ซ้ำไม่ได้เลย infrastructure จริงถูกสร้างจาก module เล็ก ๆ ที่ focus เฉพาะเรื่อง แล้วต่อกันผ่าน output และ input ของแต่ละตัว module vnet expose identifier ที่ module อื่นต้องใช้:

# Illustrative: modules/vnet/outputs.tf (the vnet module's own resources are omitted here)
output "vnet_id" {
description = "ID of the created virtual network"
value = azurerm_virtual_network.this.id
}
output "subnet_ids" {
description = "IDs of the subnets"
value = azurerm_subnet.this[*].id
}

module virtual-machine ประกาศ input ที่ต้องใช้เพื่อ launch เข้าไปใน network นั้น:

modules/virtual-machine/variables.tf
variable "vnet_id" {
description = "ID of the virtual network to launch the VM into"
type = string
}
variable "subnet_id" {
description = "ID of the subnet to launch the VM into"
type = string
}

root configuration คือส่วนที่ต่อทั้งสอง module เข้าด้วยกันจริง ๆ โดยส่ง output ของ module หนึ่งตรงเข้าไปเป็น input ของอีก module:

# root main.tf
module "vnet" {
source = "./modules/vnet"
address_space = "10.0.0.0/16"
}
module "web_server" {
source = "./modules/virtual-machine"
vnet_id = module.vnet.vnet_id
subnet_id = module.vnet.subnet_ids[0]
}

ไม่มี module ไหนต้องรู้ว่าอีกตัวมีอยู่ module vnet ไม่รู้เลยว่าจะมี module virtual-machine มา consume output ของตัวเอง และ module virtual-machine ก็ไม่รู้ว่า vnet_id ที่ได้รับมาจาก module หรือเป็นแค่ string ที่ hardcode ไว้ root configuration คือจุดเดียวที่รู้จักทั้งสอง module และเชื่อมทั้งสองเข้าด้วยกัน

pattern ที่พบบ่อยอย่าง “virtual network ที่มี public/private subnet” ถูกแก้ปัญหาไปแล้วหลายครั้ง tutorial เก่า ๆ มักชี้ไปที่ community module ขององค์กร Azure เช่น Azure/vnet/azurerm, Azure/naming/azurerm เป็นจุดเริ่มต้น แต่พอเช็ค registry page ของแต่ละตัวตรง ๆ ทั้งสองตัวถูก archive แล้ว พร้อม banner ที่ชี้ผู้ใช้ไปยังตัวแทน คำตอบปัจจุบันของ public Terraform Registry สำหรับ pattern ทั่วไปของ Azure คือโปรแกรม Azure Verified Modules (AVM) แทน — ความร่วมมือของ Microsoft/HashiCorp ไม่ใช่ community project — และ module-versioning-and-registries.mdx มีตัวอย่างการเรียก AVM module จริงที่ validate แล้วพร้อม version constraint แบบลึก

ถึงอย่างนั้น “ดูแลอย่างต่อเนื่อง” ก็ไม่เหมือนกับ “เชื่อได้แบบไม่ต้องดู” ก่อนจะเอา Registry module ตัวไหนมาสร้าง production infrastructure — รวมถึง AVM ด้วย — ควรอ่านก่อนว่า module สร้างอะไรจริง ๆ resource, default value และ role assignment ใด ๆ ที่ provision ให้โดยอัตโนมัติ Registry module ก็ยังเป็น code ของคนอื่นที่รันด้วย credential ของ Azure ที่เป็นของคุณ

ทุกอย่างข้างบนต่อ module เข้าด้วยกัน ภายใน configuration เดียว ที่ root มีทั้ง module.vnet และ module.web_server ใน terraform apply ครั้งเดียวกัน infrastructure จริงมักถูกแยกเป็น configuration ต่างหากจริง ๆ ที่มี state ของตัวเอง — configuration networking ของทีม platform ที่ apply ตามตารางของตัวเอง configuration ของทีม application ที่ apply แยกต่างหาก terraform_remote_state คือวิธีเชื่อมทั้งสองแบบ native ไม่ต้องใช้ Terragrunt เลย เป็น data source ที่อ่าน output ของ configuration อื่นตรง ๆ จาก state ของมัน ไม่ว่า state นั้นจะอยู่บน backend ไหนก็ตาม

# Illustrative: network/main.tf — a separate configuration, applied on its own
resource "azurerm_resource_group" "main" {
name = "acme-network-rg"
location = "eastus"
}
resource "azurerm_virtual_network" "this" {
name = "acme-vnet"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
address_space = ["10.0.0.0/16"]
}
output "vnet_id" {
value = azurerm_virtual_network.this.id
}
# Illustrative: app/main.tf — a different configuration, reading network's state
data "terraform_remote_state" "network" {
backend = "azurerm"
config = {
resource_group_name = "tfstate-rg"
storage_account_name = "acmetfstate001"
container_name = "tfstate"
key = "network.terraform.tfstate"
}
}
resource "azurerm_network_interface" "web" {
name = "web-nic"
resource_group_name = "acme-app-rg"
location = "eastus"
ip_configuration {
name = "internal"
subnet_id = data.terraform_remote_state.network.outputs.vnet_id
private_ip_address_allocation = "Dynamic"
}
}
resource "azurerm_linux_virtual_machine" "web" {
name = "web-vm"
# ...
network_interface_ids = [
azurerm_network_interface.web.id,
]
}

พิสูจน์จริงโดยไม่มี cloud credential เลย — configuration สอง config ที่ใช้ local backend แทน network กับ app โดย terraform_remote_state อ่าน state file จริงข้ามขอบเขตกัน

$ cd network && terraform apply -auto-approve
Outputs:
vnet_id = "vnet-0123456789abcdef0"
$ cd ../app && terraform plan
+ content = <<-EOT
vnet_id=vnet-0123456789abcdef0
EOT

output ของ plan แสดงค่าจริงที่อ่านมาจาก state file ของ network — data.terraform_remote_state.network.outputs.vnet_id resolve ออกมาเป็น string เดียวกันเป๊ะกับที่ apply ของ network เองสร้างไว้ โดยไม่มี dependency ตรงระหว่าง source code ของทั้งสอง configuration เลย ไม่มี configuration ไหน import อีกตัว app แค่รู้ shape ของ output ของ network และรู้ว่า state ของมันอยู่ที่ไหน

นี่คือกลไกที่ Terragrunt dependency block (อยู่ใน terragrunt-fundamentals/dependencies-between-units.mdx) สร้างต่อยอดขึ้นมาและมาแทนที่เป็นส่วนใหญ่สำหรับ setup multi-environment — terraform_remote_state คือตัวที่ทำงานกับ Terraform ธรรมดา ไม่ต้องมี Terragrunt เลย ซึ่งเป็นเหตุผลที่มันยังโผล่บ่อยมากใน codebase ที่มีมาก่อน Terragrunt หรือที่ตั้งใจไม่ใช้ Terragrunt เลย

composition ไม่ได้หยุดนิ่ง — resource ที่เริ่มอยู่ที่ root มักถูกดึงเข้าไปใน module ทีหลัง ทันทีที่ caller ตัวที่สองต้องการสิ่งเดียวกัน ถ้าปล่อยไว้เฉย ๆ refactor แบบนี้ Terraform จะอ่านว่า “address เก่าหายไป มี address ใหม่โผล่มา” ซึ่ง plan เป็น destroy-แล้ว-create moved block ที่ข้ามขอบเขต module บอก Terraform ว่านี่คือ object จริงตัวเดียวกัน

# root main.tf
moved {
from = azurerm_linux_virtual_machine.web
to = module.web_server.azurerm_linux_virtual_machine.this
}
module "web_server" {
source = "./modules/virtual-machine"
# ...
}

พิสูจน์จริง ใช้ pattern local_file แบบไม่มี credential ที่คอร์สนี้ใช้ตลอดสำหรับกลไก state — resource ที่เคยอยู่ที่ root ถูกดึงเข้า module โดยมี moved ข้ามขอบเขต

$ terraform plan
local_file.config: Refreshing state... [id=5d54098470458257525a27f6f0b6de89e278569d]
Terraform will perform the following actions:
# local_file.config has moved to module.config.local_file.this
resource "local_file" "this" {
id = "5d54098470458257525a27f6f0b6de89e278569d"
# (10 unchanged attributes hidden)
}
Plan: 0 to add, 0 to change, 0 to destroy.

Plan: 0 to add, 0 to change, 0 to destroy คือหลักฐานที่สำคัญ การดึง resource เข้า module ซึ่งเป็นหนึ่งใน refactor ที่พบบ่อยที่สุดใน configuration ที่กำลังโต ไม่จำเป็นต้องแปลว่าต้อง recreate Azure resource จริง — ตราบใดที่ moved block เดินทางไปพร้อมกับ refactor ใน commit เดียวกัน

flowchart LR
  subgraph root["Root configuration"]
    vnetmod["module vnet"]
    vmmod["module web_server"]
  end
  subgraph vnet["Child module: vnet"]
    vnetout1["output vnet_id"]
    vnetout2["output subnet_ids"]
  end
  subgraph vm["Child module: virtual-machine"]
    vmin1["var.vnet_id"]
    vmin2["var.subnet_id"]
  end
  vnetmod --> vnetout1
  vnetmod --> vnetout2
  vnetout1 -->|module.vnet.vnet_id| vmin1
  vnetout2 -->|module.vnet.subnet_ids[0]| vmin2
  vmin1 --> vmmod
  vmin2 --> vmmod
output ของ vnet module ถูกส่งเข้าไปเป็น input ของ virtual-machine module จาก root configuration

composition ในบทนี้มีสองรูปแบบที่ต่างกันมาก และการแยกให้ออกสำคัญมาก module block ประกอบกันภายใน configuration เดียวคือการต่อสายแบบ compile-time-ish — root configuration เห็น graph เต็มของทั้งสอง module และ plan พร้อมกันใน apply ครั้งเดียว terraform_remote_state ประกอบข้าม configuration คือการต่อสายแบบ runtime ต่อ snapshot — app อ่านสิ่งที่ network เขียนลง state ล่าสุด ซึ่งอาจ stale ทันทีที่ network เปลี่ยนอีกครั้ง โดยไม่มีการ re-plan อัตโนมัติฝั่ง app ทั้งสองแบบใช้ได้จริงทั้งคู่ ความต่างคือ blast radius กับความ stale ไม่ใช่ความถูกต้อง moved ข้ามขอบเขต module คือชิ้นที่สาม มันคือสิ่งที่ทำให้คุณ migrate ระหว่าง สองรูปแบบนี้ได้ (ดึง resource ที่ root เข้า module หรือกลับกัน) โดยไม่ให้ Terraform อ่าน refactor นี้เป็นการเปลี่ยนแปลง infrastructure จริง

ทางเลือกได้อะไรเสียอะไร
ประกอบด้วย module (apply ครั้งเดียว)root config เห็นภาพเต็ม plan เดียวแสดงผลกระทบทั้งหมดทุก module ใน graph ถูก plan พร้อมกัน — vnet เปลี่ยนนิดเดียวอาจขยาย plan ให้กว้างไปทุกอย่างที่ต่อกับมัน
terraform_remote_state ข้าม configlifecycle apply แยกกันจริง — network เปลี่ยนไม่บังคับให้ app plan ตามอ่าน snapshot ไม่ใช่ state สด — พิสูจน์ข้างบน app เห็นแค่สิ่งที่ network apply ล่าสุดเขียนไว้ อาจ drift จนกว่าจะ re-plan
moved ข้ามขอบเขต modulerefactor resource เข้า (หรือออกจาก) module โดยไม่ destroy/recreate — พิสูจน์ข้างบน 0 to add, 0 to change, 0 to destroyต้องเขียนและ commit ใน change เดียวกับ refactor เป๊ะ ไม่งั้นช่วงเวลาระหว่างนั้น plan เป็น destroy/recreate จริง
  • “terraform_remote_state ให้การเชื่อมต่อแบบ live กับ state ปัจจุบันของ configuration อื่น” พิสูจน์ข้างบน มันอ่านสิ่งที่ถูกเขียนลง state object ล่าสุด — ถ้า network เปลี่ยนและ apply ใหม่หลังจาก app plan ไปแล้ว plan ครั้งถัดไปของ app (ไม่ใช่ครั้งที่รันไปแล้ว) เท่านั้นที่จะเห็นค่าใหม่
  • “ต้องมี Terragrunt ถึงจะประกอบ Terraform configuration แยกกันได้” พิสูจน์ข้างบน terraform_remote_state คือ data source ของ Terraform ธรรมดา ไม่ต้องมี Terragrunt เลย — มันมีมาก่อน dependency block ของ Terragrunt เองด้วยซ้ำ และยังโผล่บ่อยมากใน codebase ที่ไม่ใช้ Terragrunt เลย
  • “การดึง resource เข้า module เป็นเรื่อง disruptive โดยธรรมชาติ ต้อง destroy แล้ว recreate เสมอ” พิสูจน์ข้างบนด้วยตัวอย่าง local_file จริง ด้วย moved block ที่ข้ามขอบเขต module refactor แบบเดียวกันเป๊ะ plan ออกมาเป็น 0 to add, 0 to change, 0 to destroy
  • “Azure/vnet/azurerm กับ Azure/naming/azurerm ยังเป็นจุดเริ่มต้นมาตรฐานสำหรับ module networking ของ Azure” เช็คตรง ๆ กับ GitHub แล้วทั้งสอง repository มี banner “This repository was archived by the owner” จริง (พ.ย. 2024 และ ก.ย. 2026 ตามลำดับ) และชี้ผู้ใช้ไปยัง Azure Verified Modules — คอร์สหรือ tutorial ที่ยังสอนสองตัวนี้เป็นคำตอบปัจจุบันกำลังอ้างอิง guidance ที่เลิกใช้แล้ว

ตอน review การประกอบ module หรือการต่อสายข้าม configuration ที่ generate มา เช็คสิ่งเหล่านี้

  • terraform_remote_state data source ที่สันนิษฐานว่าสะท้อน state ปัจจุบัน ของ configuration อื่นเสมอ — พิสูจน์ข้างบน มันสะท้อนแค่ apply ล่าสุดของ configuration นั้น ไม่ใช่การอ่านแบบ live
  • resource ที่ถูกย้ายเข้า module ในโค้ดที่ generate มา โดยไม่มี moved block คู่กัน — พิสูจน์ข้างบน สิ่งนี้อ่านเป็น destroy-แล้ว-create ถ้าไม่มี block นั้น ความเสี่ยงเดียวกับที่ state-management/drift-detection-and-import.mdx พูดถึงสำหรับ refactor ภายใน configuration เดียวกัน
  • สอง Terraform configuration ที่แก้ resource จริงตัวเดียวกันผ่านคนละทาง (ตัวหนึ่งผ่าน module อีกตัวผ่าน terraform_remote_state บวก resource block) แทนที่จะให้ตัวหนึ่งเป็นเจ้าของและอีกตัวแค่อ่าน output
  • Registry module source ที่ชี้ไปที่ Azure/vnet/azurerm หรือ Azure/naming/azurerm — เช็คข้างบนแล้วว่าทั้งสองถูก archive คำตอบปัจจุบันคือ Azure Verified Module ที่มีตัวอย่างจริง validate แล้วใน module-versioning-and-registries.mdx

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

# app/main.tf — refactored so the VM now launches from a module,
# migrated from a bare resource block in the same commit
module "web_server" {
source = "./modules/virtual-machine"
subnet_id = data.terraform_remote_state.network.outputs.subnet_ids[0]
}
เฉลย

พิสูจน์ข้างบนด้วย plan จริง (local_file.config -> module.config.local_file.this) การดึง resource เข้า module โดยไม่มี moved { from = ... to = ... } block คู่กันเลย ไม่ได้อ่านเป็น refactor สำหรับ Terraform เลย — resource ที่เคยเป็น azurerm_linux_virtual_machine.web ที่ root หายไป และ module.web_server.azurerm_linux_virtual_machine.this โผล่มาเป็นตัวใหม่ ซึ่ง plan ออกมาเป็น deallocate แล้วลบ VM ที่รันอยู่ provision ตัวใหม่ สำหรับอะไรก็ตามที่รับ traffic ผ่าน VM นั้น นั่นคือ downtime จริงและ VM resource ID ใหม่ ทั้งที่ผู้เขียนตั้งใจแค่จะจัดระเบียบโค้ด ทางแก้คือ moved block ใน commit เดียวกับ refactor — พิสูจน์ข้างบนแล้วว่าให้ Plan: 0 to add, 0 to change, 0 to destroy พร้อม note has moved to ที่ข้ามขอบเขต module แทน

บทเรียนนี้อธิบาย module source สามแบบไหนบ้าง
ในตัวอย่างการประกอบ vnet กับ virtual-machine module virtual-machine module ได้รับ subnet ที่ต้อง launch เข้าไปอย่างไร
output จริงนี้พิสูจน์อะไรเกี่ยวกับวิธีที่ terraform_remote_state อ่าน state ของ configuration อื่น
$ cd network && terraform apply -auto-approve
Outputs:

vnet_id = "vnet-0123456789abcdef0"

$ cd ../app && terraform plan
      + content = <<-EOT
            vnet_id=vnet-0123456789abcdef0
        EOT
HCL จริงตัวไหนที่ข้ามขอบเขต module ทำให้เกิด output นี้ตอน resource ถูกดึงเข้า module
$ terraform plan
local_file.config: Refreshing state...
  # local_file.config has moved to module.config.local_file.this
    resource "local_file" "this" {
        id = "5d54098470458257525a27f6f0b6de89e278569d"
        # (10 unchanged attributes hidden)
    }
Plan: 0 to add, 0 to change, 0 to destroy.
module vnet ในตัวอย่างนี้ต้องรู้หรือไม่ว่าจะมี virtual-machine module มา consume output ของตัวเอง
Azure/vnet/azurerm กับ Azure/naming/azurerm ยังเป็นจุดเริ่มต้นปัจจุบันที่ maintain อยู่สำหรับ Registry module ของ Azure หรือเปล่า