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

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

กฎที่ 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 "aws" { region = "us-east-1" } block จริงที่ไม่ว่างเปล่า ถูกเรียกด้วย count

# @expect-error Module is incompatible with count, for_each, and depends_on
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "this" {
bucket = "acme-example-bucket"
}
# @expect-error Module is incompatible with count, for_each, and depends_on
module "bucket" {
source = "./modules/bucket"
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 "bucket":
12: count = 2
The module at module.bucket 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/bucket", consider updating this
module to instead expect provider configurations to be passed by its
caller.

สังเกตคำที่ใช้เป๊ะ ๆ นี่คือ error text จริงของเวอร์ชัน (1.16.4) ที่ pin ไว้ ซึ่งต่างจากคำว่า “Module does not support count” ที่หน้า docs ของ HashiCorp เองยังแสดงจากรุ่นเก่ากว่า — เนื้อหาที่ quote error ของ Terraform ควรถูก re-verify กับเวอร์ชันที่ pin ไว้เสมอ แทนที่จะเชื่อจากความจำหรือ doc snippet เก่า ซึ่งคือช่องว่างที่การพิสูจน์นี้ปิดพอดี ทางแก้คือ configuration_aliases module ประกาศว่าต้องการ provider ที่ส่งเข้ามาภายใต้ alias ท้องถิ่น และไม่ config provider เองเลย

# Fixed: modules/bucket/main.tf never owns a provider block
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
configuration_aliases = [aws.this]
}
}
}
resource "aws_s3_bucket" "this" {
provider = aws.this
bucket = "acme-example-bucket"
}
# Fixed: root main.tf passes the provider explicitly
module "bucket" {
source = "./modules/bucket"
count = 2
providers = {
aws.this = aws
}
}

output จริงของ terraform plan หลังแก้ ด้วย mock AWS credential (ไม่ต้องมี account จริง)

$ terraform plan
Plan: 2 to add, 0 to change, 0 to destroy.

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.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
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 ของตัวเองถึงจะรู้ว่าใช้ AWS account หรือ region ไหน” พิสูจน์ข้างบน ตรงกันข้ามเลยสำหรับ reusable module — provider block ที่เป็นเจ้าของเองนี่แหละที่ทำให้ count/for_each/depends_on fail ด้วย error จริงที่มีชื่อระบุ provider ควรมาจาก 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”
  • “provider block ในตัว module จะทำให้เกิด error นี้เสมอ แม้จะเป็นตัวเปล่า ๆ” ไม่เชิง — พิสูจน์ด้วยการเทียบกันจาก harness run ของคอร์สนี้ provider "aws" {} แบบ legacy ที่ว่างเปล่า “proxy” block ให้แค่ deprecation warning และยัง plan สำเร็จ ต้องเป็น provider block ที่ config จริง (มี argument จริง) เท่านั้นที่ทำให้เกิด hard error
  • “เชื่อคำ 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 AWS region, ค่าเฉพาะ account หรือ 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 {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "us-west-2"
}
resource "aws_sqs_queue" "this" {
name = var.queue_name
}
เฉลย

พิสูจน์ข้างบนด้วย terraform init fail จริง — module นี้เป็นเจ้าของ provider "aws" 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 us-west-2 แบบเงียบ ๆ ดังนั้นทุก caller จะได้ region นี้ไม่ว่า provider configuration ของตัวเองจะเป็นอะไร โดยไม่มีทาง override เลย ทางแก้คือ configuration_aliases = [aws.this] ใน required_providers อ้างอิง provider = aws.this บน resource และไม่มี provider block ในตัว module เลย จากนั้น caller จะส่ง provider จริงผ่าน providers = { aws.this = aws } ซึ่งพิสูจน์ข้างบนแล้วว่า plan สำเร็จสะอาดแม้จะมี 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 "bucket":
  12:   count  = 2

The module at module.bucket 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.
module มี provider "aws" {} block เปล่า ๆ ไม่มี argument เลยอยู่ข้างใน แล้วถูกเรียกด้วย count = 2 เกิดอะไรขึ้น พิสูจน์ข้างบนโดยเทียบกับกรณีที่ config เต็ม
ต้นทุนที่แท้จริงของการ over-parameterize module ด้วยการ expose ทุก field ของ resource เป็น variable ตั้งแต่วันแรกคืออะไร
ในตัวอย่าง s3-bucket module ทำไม bucket_name ถึงไม่มี default แต่ force_destroy มี