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

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 "bucket" {
source = "./modules/s3-bucket"
bucket_name = "acme-app-logs"
}

นั่นคือความต่างทั้งหมด 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/s3-bucket/variables.tf
variable "bucket_name" {
description = "Name of the S3 bucket to create"
type = string
}
modules/s3-bucket/main.tf
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
}
modules/s3-bucket/outputs.tf
output "arn" {
description = "ARN of the created S3 bucket"
value = aws_s3_bucket.this.arn
}

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

# root main.tf
module "logs_bucket" {
source = "./modules/s3-bucket"
bucket_name = "acme-app-logs"
}
output "logs_bucket_arn" {
value = module.logs_bucket.arn
}

ข้อผิดพลาดในการออกแบบ module ที่ใหญ่ที่สุดคือ scope creep — module ที่เริ่มจาก “VPC” ตัวหนึ่ง ค่อย ๆ โตขึ้นมามี IAM role ตรงนี้ มี EC2 instance ตรงนั้น มี Lambda function ที่อื่นอีก จนไม่มีใครเอา module นี้ไปใช้ซ้ำกับงานอื่นได้เลยนอกจากงานเดิมที่สร้างมาเพื่อสิ่งนั้น ควรเล็งไปที่ module ที่ทำงานเดียวและ scope ชัดเจน เช่น “VPC ที่มี public/private subnet” ไม่ใช่ “networking, IAM และ compute ทั้งหมดของเรา”

มีสองนิสัยเชิงปฏิบัติที่ช่วยให้ 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/s3-bucket/variables.tf
variable "bucket_name" {
description = "Name of the S3 bucket to create"
type = string
}
variable "force_destroy" {
description = "Allow bucket to be destroyed even if it still contains objects"
type = bool
default = false
}

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

flowchart LR
  subgraph root["Root module"]
    rmain["main.tf calls module.bucket"]
  end
  subgraph child["Child module: modules/s3-bucket"]
    vars["variables.tf (inputs)"] --> main["main.tf (resources)"]
    main --> outs["outputs.tf (values exposed)"]
  end
  rmain -->|source = ./modules/s3-bucket| vars
  outs -->|module.bucket.arn| 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 ตั้งแต่วันแรกคืออะไร
ในตัวอย่าง s3-bucket module ทำไม bucket_name ถึงไม่มี default แต่ force_destroy มี