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

Module Composition and Sources

source argument ใน module block บอกว่าโค้ดของ module นั้นอยู่ที่ไหน — local path, public Terraform Registry หรือ Git URL — และ infrastructure จริงถูกสร้างขึ้นด้วยการเอา output ของ module หนึ่งไปต่อเป็น variable ของอีก module ไม่ใช่เขียน configuration แบนราบก้อนเดียว

module block ทุกตัวต้องมี source argument และ Terraform รองรับรูปแบบหลักสามแบบ แบบง่ายที่สุดคือ local relative path ชี้ไปที่ directory ของไฟล์ .tf ที่อยู่ใน repository เดียวกับตัวที่เรียกใช้

module "vpc" {
source = "./modules/vpc"
project_id = var.project_id
network_name = "app-network"
}

local path เหมาะตอนที่ module ยังผูกกับ repository เดียวอยู่ หรือตอนที่คุณกำลังพัฒนาไปพร้อมกับโค้ดที่เรียกใช้ แต่พอ module reuse ได้จริงข้ามหลาย configuration คุณมักอยากเผยแพร่ไว้ที่ที่ทุก caller เข้าถึงได้เองโดยไม่ต้องผูกกับ repository ใดเป็นพิเศษ — ตรงนี้แหละที่ public Terraform Registry กับ Git source เข้ามามีบทบาท

public Terraform Registry เก็บ module จากชุมชนและ vendor ต่าง ๆ ภายใต้ address รูปแบบ namespace/name/provider ซึ่ง terraform init resolve ให้อัตโนมัติโดยไม่ต้องใช้ URL เลย

module "vpc" {
source = "terraform-google-modules/network/google"
version = "~> 18.0"
project_id = var.project_id
network_name = "app-network"
}

Git URL คือรูปแบบที่สาม เหมาะกับ module ส่วนตัวหรือภายในองค์กรที่ไม่ได้เผยแพร่ขึ้น registry ไหนเลย การต่อ ?ref= ท้าย URL คือการ pin source ไว้ที่ tag, branch หรือ commit ที่ระบุ เหมือนกับที่ version pin module จาก Registry

module "vpc" {
source = "git::https://github.com/acme-corp/terraform-modules.git//vpc?ref=v2.3.0"
project_id = var.project_id
network_name = "app-network"
}

สังเกต double slash ก่อนคำว่า vpc ใน Git URL — ทุกอย่างก่อนหน้านั้นคือ repository ที่จะ clone และทุกอย่างหลังจากนั้นคือ subdirectory ภายใน repository นั้นที่ module ตัวจริงอยู่ เรื่องนี้สำคัญเพราะ repository เดียวมักเก็บหลาย module ไว้ด้วยกัน ไม่ใช่แค่ตัวเดียว

infrastructure ที่ซับซ้อนจริง ๆ แทบไม่เคยเป็น configuration แบนราบก้อนเดียว แต่เป็น module เล็ก ๆ ที่ focus ชัดเจนหลายตัวต่อกัน โดย output ของ module หนึ่งกลายเป็น input variable ของ module ถัดไปโดยตรง ลองดู vpc module ที่สร้าง network กับ subnet ของตัวเอง และ compute-instance module ที่ต้องรู้ว่าจะ attach instance เข้ากับ network และ subnetwork ไหน

# Illustrative: modules/vpc/outputs.tf (the vpc module's own resources are omitted here)
output "network_id" {
description = "Self link of the VPC network created by this module"
value = google_compute_network.this.id
}
output "subnet_ids" {
description = "Map of region to subnetwork ID"
value = { for s in google_compute_subnetwork.this : s.region => s.id }
}
modules/compute-instance/variables.tf
variable "network_id" {
description = "ID of the VPC network to attach the instance to"
type = string
}
variable "subnet_id" {
description = "ID of the subnetwork to attach the instance to"
type = string
}

root configuration instantiate module ทั้งสองตัวและส่ง output ของตัวแรกเข้าไปเป็น input ของตัวที่สองตรง ๆ โดยไม่ต้อง copy ID ด้วยมือระหว่างทาง

# root main.tf
module "vpc" {
source = "./modules/vpc"
project_id = var.project_id
network_name = "app-network"
}
module "compute_instance" {
source = "./modules/compute-instance"
project_id = var.project_id
network_id = module.vpc.network_id
subnet_id = module.vpc.subnet_ids["us-central1"]
}

Terraform resolve dependency ตรงนี้ให้อัตโนมัติ เพราะ module.compute_instance อ้างอิงถึง module.vpc.network_id Terraform จึงรู้ว่าต้องสร้าง network ก่อน instance โดยที่คุณไม่ต้องเขียน depends_on เพิ่มเลย

การเขียน vpc module เองตั้งแต่ศูนย์เป็นแบบฝึกหัดที่ดี แต่สำหรับงาน production มักไม่ใช่การใช้เวลาที่คุ้มค่าที่สุด องค์กร terraform-google-modules บน public Registry ดูแล module อย่าง terraform-google-modules/network/google ที่จัดการรายละเอียดยิบย่อยของ GCP networking ไว้แล้ว — custom subnet range, secondary range สำหรับ GKE, Shared VPC, Cloud NAT — ผ่านการใช้งานจริงจากชุมชนจำนวนมากที่เจอ edge case เดียวกับที่คุณจะเจอเองในที่สุด

แต่ module จาก Registry ก็ยังเป็นโค้ดของคนอื่นที่รันใน project ของคุณด้วย credential ของคุณ ก่อนจะพึ่งพาใน production ควรอ่านให้รู้ว่า module สร้างอะไรจริง ๆ — main.tf, ค่า default, IAM role หรือ API ที่เปิดใช้แทนคุณ module จาก Registry ที่ดูแลดีคือ productivity lever จริง ไม่ใช่ตัวแทนของการเข้าใจว่าอะไรถูก deploy เข้า GCP project ของคุณ

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

# network/main.tf — a separate configuration, applied on its own
resource "google_compute_network" "this" {
name = "app-network"
auto_create_subnetworks = false
}
output "network_id" {
value = google_compute_network.this.id
}
# app/main.tf — a different configuration, reading network's state
data "terraform_remote_state" "network" {
backend = "gcs"
config = {
bucket = "my-company-terraform-state"
prefix = "network/state"
}
}
resource "google_compute_instance" "web" {
name = "web-vm"
machine_type = "e2-medium"
zone = "us-central1-a"
boot_disk {
initialize_params {
image = "debian-cloud/debian-12"
}
}
network_interface {
network = data.terraform_remote_state.network.outputs.network_id
}
}

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

$ cd network && terraform apply -auto-approve
Outputs:
network_id = "projects/my-project/global/networks/app-network"
$ cd ../app && terraform plan
+ content = <<-EOT
network_id=projects/my-project/global/networks/app-network
EOT

output ของ plan แสดงค่าจริงที่อ่านมาจาก state file ของ network — data.terraform_remote_state.network.outputs.network_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 = google_compute_instance.web
to = module.compute_instance.google_compute_instance.this
}
module "compute_instance" {
source = "./modules/compute-instance"
# ...
}

พิสูจน์จริง ใช้ 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 Compute Engine resource จริง — ตราบใดที่ moved block เดินทางไปพร้อมกับ refactor ใน commit เดียวกัน

flowchart LR
  subgraph root["Root module"]
    rvpc["module.vpc"]
    rci["module.compute_instance"]
  end
  subgraph vpcmod["Child module: modules/vpc"]
    vout1["output: network_id"]
    vout2["output: subnet_ids"]
  end
  subgraph cimod["Child module: modules/compute-instance"]
    cin1["variable: network_id"]
    cin2["variable: subnet_id"]
  end
  rvpc --> vout1
  rvpc --> vout2
  vout1 -->|module.vpc.network_id| rci
  vout2 -->|module.vpc.subnet_ids| rci
  rci --> cin1
  rci --> cin2
root configuration ส่ง output ของ vpc module เข้าไปเป็น input ของ compute-instance module

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 พร้อมกัน — vpc เปลี่ยนนิดเดียวอาจขยาย 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
  • “version constraint ของ Registry module รับประกันว่า argument ของ module ไม่เปลี่ยนระหว่าง minor version” ไม่ได้พิสูจน์หรืออ้างแบบนี้ในบทนี้ — ~> ยังยอม patch/minor update ภายใน constraint ได้ (พูดถึงลึกใน module-versioning-and-registries.mdx รวมถึง required argument ที่เปลี่ยนจริงใน terraform-google-modules/network/google v18) ดังนั้นการ pin version แค่ลดขอบเขตของ drift ไม่ได้กำจัดมันทั้งหมด

ตอน 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 ที่ pin ไว้โดยไม่มี version constraint เลย ซึ่ง module-versioning-and-registries.mdx พูดถึงเป็นความเสี่ยงจริงที่พิสูจน์ได้

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

# app/main.tf — refactored so the instance now launches from a module,
# migrated from a bare resource block in the same commit
module "compute_instance" {
source = "./modules/compute-instance"
network_id = data.terraform_remote_state.network.outputs.network_id
}
เฉลย

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

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

network_id = "projects/my-project/global/networks/app-network"

$ cd ../app && terraform plan
      + content = <<-EOT
            network_id=projects/my-project/global/networks/app-network
        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.
query parameter ref บน Git module source ทำหน้าที่อะไร
เมื่อไหร่ที่การเลือกใช้ module จาก public Terraform Registry อย่าง terraform-google-modules/network/google เหมาะสมที่สุด