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

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 "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 ไหนที่เดาได้อย่างสมเหตุสมผล

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
A module directory called from a root configuration via a module block
อะไรคือสิ่งที่ทำให้ directory ของ Terraform files เป็น module แทนที่จะเป็น root module
ทำไม file layout ของ reusable module ถึงมักเหมือน variables.tf, main.tf และ outputs.tf
ต้นทุนที่แท้จริงของการ over-parameterize module ด้วยการ expose ทุก field ของ resource เป็น variable ตั้งแต่วันแรกคืออะไร
ในตัวอย่าง storage-account module ทำไม name ถึงไม่มี default แต่ account_replication_type มี