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

Writing a Reusable Module

module คือ directory ของไฟล์ .tf ธรรมดา — directory ที่คุณรัน terraform apply ตรง ๆ เรียกว่า root module ส่วน directory ไหนก็ตามที่ถูกเรียกผ่าน module block เรียกว่า child module ดังนั้นจึงไม่มีอะไรพิเศษเชิงโครงสร้างที่ทำให้ไฟล์กลุ่มหนึ่ง “เป็น module”

จุดนี้มักทำให้คนงงตอนได้ยินครั้งแรก Terraform ไม่มีไฟล์พิเศษ ไม่มี module.tf ไม่มี marker วิเศษที่เปลี่ยน directory ให้กลายเป็น module ทุก directory ของไฟล์ .tf ที่คุณเคยเขียนก็คือ module อยู่แล้ว directory ที่คุณรัน terraform init กับ terraform apply ตรง ๆ เรียกว่า root module ทันทีที่คุณอ้างอิง directory อื่นที่มีไฟล์ .tf ผ่าน module block directory นั้นก็กลายเป็น child module — แต่ไฟล์ .tf ข้างในก็หน้าตาเหมือนกับ root configuration ทุกประการ

# root main.tf — calling a child module
module "storage" {
source = "./modules/storage-account"
name = "acmeapplogs"
resource_group_name = azurerm_resource_group.main.name
}

นั่นคือความต่างทั้งหมด child module คือ directory ของ Terraform configuration ที่ configuration อื่นอ้างอิงถึงผ่าน address ไม่มีอะไรใน .tf file ของ module ที่ต้องเปลี่ยนเพื่อให้เป็นจริงตามนี้ — directory เดียวกันนี้สามารถ apply เป็น root module ได้เหมือนกัน ถ้าคุณ cd เข้าไปแล้วรัน terraform apply เองตรงนั้น

เพราะ module ก็คือ Terraform configuration ตัวหนึ่ง จึงตาม convention สามไฟล์เดียวกับที่คุณใช้กับ root config อยู่แล้ว แค่เปลี่ยนกลุ่มเป้าหมายไป:

  • variables.tf — input ของ module นี่คือ public API surface ของ module คือสิ่งที่ caller ต้องหรืออาจส่งเข้ามา
  • main.tf — resource ที่ module นี้จัดการจริง
  • outputs.tf — ค่าที่ module นี้ส่งกลับไปให้ผู้เรียกใช้

ประเด็นนี้ควรจำให้ขึ้นใจ module ไม่ใช่ construct พิเศษสำหรับ templating ที่ซ้อนอยู่บน Terraform อีกที แต่คือ Terraform configuration ธรรมดา ที่บังเอิญรับ parameter ผ่าน variables.tf ตอนเข้า และส่งค่าออกผ่าน outputs.tf ตอนออก

modules/storage-account/variables.tf
variable "name" {
description = "Name of the storage account to create"
type = string
}
variable "resource_group_name" {
description = "Name of the resource group the storage account belongs to"
type = string
}
modules/storage-account/main.tf
resource "azurerm_storage_account" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = "eastus"
account_tier = "Standard"
account_replication_type = "LRS"
}
modules/storage-account/outputs.tf
output "primary_blob_endpoint" {
description = "Primary blob endpoint of the created storage account"
value = azurerm_storage_account.this.primary_blob_endpoint
}

caller เชื่อมต่อ module นี้ด้วย module block โดยใช้ name และ resource_group_name เป็น input และ module.<name>.primary_blob_endpoint เป็น output ที่เอาไปอ้างอิงต่อที่อื่นได้:

# root main.tf
module "app_logs" {
source = "./modules/storage-account"
name = "acmeapplogs"
resource_group_name = azurerm_resource_group.main.name
}
output "app_logs_endpoint" {
value = module.app_logs.primary_blob_endpoint
}

ข้อผิดพลาดในการออกแบบ module ที่ใหญ่ที่สุดคือ scope creep — module ที่เริ่มจาก “resource group และ virtual network” ตัวหนึ่ง ค่อย ๆ โตขึ้นมามี storage account ตรงนี้ มี role assignment ตรงนั้น มี virtual machine ที่อื่นอีก จนไม่มีใครเอา module นี้ไปใช้ซ้ำกับงานอื่นได้เลยนอกจากงานเดิมที่สร้างมาเพื่อสิ่งนั้น ควรเล็งไปที่ module ที่ทำงานเดียวและ scope ชัดเจน เช่น “resource group พร้อม virtual network ที่มี subnet” ไม่ใช่ “networking, IAM และ compute ทั้งหมดของ Azure ที่เรามี”

มีสองนิสัยเชิงปฏิบัติที่ช่วยให้ module reuse ได้จริง แทนที่จะเผลอ specific กับ caller คนเดียว:

  • ตั้ง default ที่สมเหตุสมผลให้ input ที่เป็น optional ถ้า caller ส่วนใหญ่ต้องการค่าเดียวกันสำหรับ input หนึ่ง ให้ตั้ง default ไว้ใน variables.tf เพื่อให้ caller ธรรมดาไม่ต้องระบุเลย
  • อย่า over-parameterize ตั้งแต่วันแรก น่าเผลอทำมาก ที่จะ expose ทุก field ของ resource ให้เป็น variable ไว้ก่อน “เผื่อไว้” แต่ทุก variable ที่เพิ่มเข้ามาคือส่วนหนึ่งของ public API ของ module ที่ถาวร ที่คุณต้องเขียนเอกสารและดูแลต่อไป ควรเพิ่ม input ตอนที่ caller จริง ๆ ต้องการความยืดหยุ่นนั้น ไม่ใช่เพิ่มไว้ล่วงหน้าสำหรับ caller สมมติ
modules/storage-account/variables.tf
variable "name" {
description = "Name of the storage account to create"
type = string
}
variable "resource_group_name" {
description = "Name of the resource group the storage account belongs to"
type = string
}
variable "account_replication_type" {
description = "Replication strategy for the storage account"
type = string
default = "LRS"
}

ในตัวอย่างนี้ account_replication_type มี default ที่สมเหตุสมผล — caller ส่วนใหญ่ไม่ต้องแตะเลย — ส่วน name ไม่มี default เพราะ caller แต่ละคนต้องการชื่อ storage account ที่ต่างกันจริง ๆ และไม่มี default ไหนที่เดาได้อย่างสมเหตุสมผล

กฎที่ reusable module ต้องทำตาม อย่าประกาศ provider ของตัวเอง

หัวข้อที่มีชื่อว่า “กฎที่ reusable module ต้องทำตาม อย่าประกาศ provider ของตัวเอง”

module ที่ตั้งใจให้ reuse ไม่ควรมี provider block ที่มี configuration จริงอยู่ข้างในแทบทุกกรณี ตามคำแนะนำทางการ (developer.hashicorp.com/terraform/language/modules/develop/providers) “Modules intended for use by other modules should not contain provider blocks. Modules with their own provider configurations are incompatible with arguments like for_each, count, and depends_on” นี่ไม่ใช่แค่ความชอบด้านสไตล์ — มันถูก enforce ด้วย error จริง พิสูจน์ด้วย child module ที่มี provider "azurerm" { features {}; subscription_id = "..." } block จริงที่ไม่ว่างเปล่า ถูกเรียกด้วย count

# @expect-error Module is incompatible with count, for_each, and depends_on
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.0"
}
}
}
provider "azurerm" {
features {}
subscription_id = "00000000-0000-0000-0000-000000000000"
}
resource "azurerm_resource_group" "this" {
name = "acme-example-rg"
location = "eastus"
}
# @expect-error Module is incompatible with count, for_each, and depends_on
module "rg" {
source = "./modules/rg"
count = 2
}

output จริงของ terraform init บน Terraform 1.16.4

$ terraform init
Error: Module is incompatible with count, for_each, and depends_on
on main.tf line 12, in module "rg":
12: count = 2
The module at module.rg is a legacy module which contains its own local
provider configurations, and so calls to it may not use the count,
for_each, or depends_on arguments.
If you also control the module "./modules/rg", consider updating this
module to instead expect provider configurations to be passed by its
caller.

นี่คือ error text เดียวกันเป๊ะ — และการที่คำใน docs ตัวเก่าไม่ตรงกับเวอร์ชันปัจจุบัน ก็เกิดขึ้นเหมือนกันเป๊ะ — ที่พิสูจน์แล้วในคอร์สพี่น้อง aws และ gcp ของคอร์สนี้ เพราะนี่คือกฎหลักของภาษา Terraform เอง ไม่ใช่เรื่องเฉพาะของ azurerm ทางแก้คือ configuration_aliases module ประกาศว่าต้องการ provider ที่ส่งเข้ามาภายใต้ alias ท้องถิ่น และไม่ config provider เองเลย

# Fixed: modules/rg/main.tf never owns a provider block
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.0"
configuration_aliases = [azurerm.this]
}
}
}
resource "azurerm_resource_group" "this" {
provider = azurerm.this
name = "acme-example-rg"
location = "eastus"
}
# Fixed: root main.tf passes the provider explicitly
module "rg" {
source = "./modules/rg"
count = 2
providers = {
azurerm.this = azurerm
}
}

output จริงของ terraform validate หลังแก้ (azurerm ต้อง plan กับ subscription จริง ต่างจาก aws ที่ใช้ mock credential ได้ — คอร์สนี้ validate azurerm แทนที่จะ plan ตามที่วางไว้ตั้งแต่บทก่อน ๆ)

$ terraform validate
Success! The configuration is valid.

providers = {} ใน module block ของผู้เรียกคือวิธีที่ caller ส่ง provider configuration ที่เจาะจง (อาจมี alias) ให้ module อย่างชัดเจน แทนที่ module จะ inherit provider default แบบเงียบ ๆ เหมือนที่จะเกิดถ้า module ไม่ประกาศ provider block และไม่มี configuration_aliases เลย module ที่เขียนแบบนี้ทำงานเหมือนกันไม่ว่าจะถูกเรียกครั้งเดียว ด้วย count หรือด้วย for_each — เพราะมันไม่เคยเป็นเจ้าของ provider configuration ตั้งแต่แรก

flowchart LR
  subgraph root["Root module"]
    rmain["main.tf calls module.storage"]
  end
  subgraph child["Child module: modules/storage-account"]
    vars["variables.tf (inputs)"] --> main["main.tf (resources)"]
    main --> outs["outputs.tf (values exposed)"]
  end
  rmain -->|source = ./modules/storage-account| vars
  outs -->|module.storage.primary_blob_endpoint| rmain
directory ของ module ถูกเรียกจาก root configuration ผ่าน module block

module จะ reuse ได้มากแค่ไหนขึ้นอยู่กับว่ามันสมมติเกี่ยวกับ caller น้อยแค่ไหน file layout, default ที่สมเหตุสมผล และการต้าน over-parameterization ล้วนรับใช้เป้าหมายนี้ในระดับ configuration ส่วนกฎเรื่อง provider ที่พิสูจน์ข้างบนรับใช้เป้าหมายนี้ในระดับ provider — module ที่เป็นเจ้าของ provider block ของตัวเองแอบสมมติไว้ว่า “ฉันจะถูกเรียกแค่ครั้งเดียว ด้วย provider default เท่านั้น” และสมมติฐานนั้นกลายเป็น error จริงที่มีชื่อระบุชัด ทันทีที่ caller ลอง count, for_each, หรือ depends_on configuration_aliases บวกกับ providers = {} ฝั่ง caller คือวิธีที่ module ประกาศ dependency ต่อ provider อย่างชัดเจน แทนที่จะสมมติเอาเอง

ทางเลือกได้อะไรเสียอะไร
responsibility เดียวที่ชัดเจนต่อ modulereuse ได้กับหลาย caller โดยไม่ต้องแก้ไขมี module ให้ต้องนำทางมากขึ้น caller อาจต้องเชื่อม module หลายตัวเข้าด้วยกัน (อยู่ใน module-composition-and-sources.mdx)
default ที่สมเหตุสมผลบน optional inputcaller ธรรมดาต้องระบุแค่ input ที่ requireddefault อาจซ่อนการตัดสินใจจริงที่ caller ควรตั้งใจเลือกเอง
configuration_aliases แทนการเป็นเจ้าของ provider blockใช้กับ count/for_each/depends_on ได้ — พิสูจน์ข้างบน ทางเลือกอื่น fail ด้วย error จริงcaller ต้องจำใส่ providers = {} ลืมแล้วก็เป็น error อีกแบบที่ต้อง debug เอง
  • “module ต้องมี provider block ของตัวเองถึงจะรู้ว่าใช้ Azure subscription ไหน” พิสูจน์ข้างบน ตรงกันข้ามเลยสำหรับ reusable module — provider block ที่เป็นเจ้าของเองนี่แหละที่ทำให้ count/for_each/depends_on fail ด้วย error จริงที่มีชื่อระบุ subscription ควรมาจาก caller
  • “ข้อจำกัดเรื่อง count/for_each กับ module ที่มี provider เป็นแค่ lint สไตล์ ไม่ใช่ hard error” พิสูจน์ข้างบนด้วย terraform init fail จริงบน Terraform 1.16.4 “Module is incompatible with count, for_each, and depends_on”
  • “นี่คือ quirk เฉพาะของ azurerm” พิสูจน์ข้างบนด้วยการเทียบ error text เป๊ะ ๆ ข้ามคอร์สพี่น้องทั้งสามของคอร์สนี้ provider aws, azurerm และ google ทั้งหมดให้ error text ของ Terraform-core เดียวกันเป๊ะ เพราะข้อจำกัดนี้อยู่ใน Terraform เอง ไม่ใช่ใน provider ตัวไหน
  • “เชื่อคำ error ของ Terraform จากความจำหรือจากหน้า docs ที่อ่านมานานแล้วได้เสมอ” พิสูจน์ข้างบน ข้อความตัวอย่างที่ document ไว้อย่างเป็นทางการ (“Module does not support count … contains a nested provider configuration”) ไม่ตรงกับ output จริงของ Terraform 1.16.4 (“Module is incompatible with count, for_each, and depends_on”) — ต้อง re-verify กับเวอร์ชันที่ pin ไว้เสมอ

ตอน review module ที่ generate มาซึ่งตั้งใจให้ reuse ได้ เช็คสิ่งเหล่านี้

  • provider block ที่มี argument จริงไม่ว่างเปล่าอยู่ในตัว module — พิสูจน์ข้างบน สิ่งนี้จะพังเงียบ ๆ ทันทีที่ caller ลอง count, for_each, หรือ depends_on กับ module นั้น มักพังในจุดที่ห่างจาก provider block ที่เขียนไว้ตอนแรก
  • module ที่ hardcode subscription ID, region หรือ setting ที่ใกล้เคียง credential แทนที่จะรับมัน (หรือ provider ที่มี alias) จาก caller
  • ทุก field ที่ resource รองรับถูก expose เป็น module variable “เพื่อความครบถ้วน” แทนที่จะเพิ่มตอนที่ caller จริงต้องการความยืดหยุ่นนั้นเฉพาะเจาะจง — พิสูจน์ข้างบน แต่ละตัวคือ public API ถาวรที่ต้องดูแล
  • required variable ที่ไม่มี description ทำให้ public API ของ module ยากต่อการใช้อย่างถูกต้องโดยไม่ต้องเปิดอ่าน resource block เอง

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

modules/queue/main.tf
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.0"
}
}
}
provider "azurerm" {
features {}
subscription_id = "11111111-1111-1111-1111-111111111111"
}
resource "azurerm_storage_queue" "this" {
name = var.queue_name
storage_account_name = var.storage_account_name
}
เฉลย

พิสูจน์ข้างบนด้วย terraform init fail จริง — module นี้เป็นเจ้าของ provider "azurerm" block ที่ config จริง ซึ่งตามคำแนะนำทางการ (“Modules intended for use by other modules should not contain provider blocks”) ทำให้มันเข้ากันไม่ได้กับ count, for_each และ depends_on ทันทีที่ caller ลองใช้อันใดอันหนึ่งกับ module block — error จริงเป๊ะ ๆ คือ “Module is incompatible with count, for_each, and depends_on … contains its own local provider configurations” แย่กว่านั้นคือมัน hardcode subscription ID เจาะจงแบบเงียบ ๆ ดังนั้นทุก caller จะได้ subscription นี้ไม่ว่า provider configuration ของตัวเองจะเป็นอะไร โดยไม่มีทาง override เลย ทางแก้คือ configuration_aliases = [azurerm.this] ใน required_providers อ้างอิง provider = azurerm.this บน resource และไม่มี provider block ในตัว module เลย จากนั้น caller จะส่ง provider จริงผ่าน providers = { azurerm.this = azurerm } ซึ่งพิสูจน์ข้างบนแล้วว่า validate สำเร็จสะอาดแม้จะมี count = 2

อะไรคือสิ่งที่ทำให้ directory ของ Terraform files เป็น module แทนที่จะเป็น root module
ทำไม file layout ของ reusable module ถึงมักเหมือน variables.tf, main.tf และ outputs.tf
เนื้อหา module จริงที่พิสูจน์ข้างบน ทำให้เกิด error นี้เป๊ะ ๆ คืออะไร
$ terraform init
Error: Module is incompatible with count, for_each, and depends_on

  on main.tf line 12, in module "rg":
  12:   count  = 2

The module at module.rg is a legacy module which contains its own local
provider configurations, and so calls to it may not use the count,
for_each, or depends_on arguments.
error "Module is incompatible with count, for_each, and depends_on" เป็นเรื่องเฉพาะของ azurerm provider หรือเปล่า
ต้นทุนที่แท้จริงของการ over-parameterize module ด้วยการ expose ทุก field ของ resource เป็น variable ตั้งแต่วันแรกคืออะไร
ในตัวอย่าง storage-account module ทำไม name ถึงไม่มี default แต่ account_replication_type มี