Writing a Reusable Module
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”module คือ directory ของไฟล์ .tf ธรรมดา — directory ที่คุณรัน terraform apply ตรง ๆ เรียกว่า root module ส่วน directory ไหนก็ตามที่ถูกเรียกผ่าน module block เรียกว่า child module ดังนั้นจึงไม่มีอะไรพิเศษเชิงโครงสร้างที่ทำให้ไฟล์กลุ่มหนึ่ง “เป็น module”
ไม่มี keyword module — มีแค่วิธีใช้งาน
หัวข้อที่มีชื่อว่า “ไม่มี keyword 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 modulemodule "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 เองตรงนั้น
ทำไม file layout ของ module ถึงเหมือน root config
หัวข้อที่มีชื่อว่า “ทำไม file layout ของ module ถึงเหมือน root config”เพราะ 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 ตอนออก
variable "bucket_name" { description = "Name of the S3 bucket to create" type = string}resource "aws_s3_bucket" "this" { bucket = var.bucket_name}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.tfmodule "logs_bucket" { source = "./modules/s3-bucket" bucket_name = "acme-app-logs"}
output "logs_bucket_arn" { value = module.logs_bucket.arn}ออกแบบ module ให้มี responsibility เดียวที่ชัดเจน
หัวข้อที่มีชื่อว่า “ออกแบบ module ให้มี responsibility เดียวที่ชัดเจน”ข้อผิดพลาดในการออกแบบ 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 สมมติ
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_onterraform { 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_onmodule "bucket" { source = "./modules/bucket" count = 2}output จริงของ terraform init บน Terraform 1.16.4
$ terraform initError: 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 localprovider 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 thismodule to instead expect provider configurations to be passed by itscaller.สังเกตคำที่ใช้เป๊ะ ๆ นี่คือ 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 blockterraform { 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 explicitlymodule "bucket" { source = "./modules/bucket" count = 2 providers = { aws.this = aws }}output จริงของ terraform plan หลังแก้ ด้วย mock AWS credential (ไม่ต้องมี account จริง)
$ terraform planPlan: 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 Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”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 เดียวที่ชัดเจนต่อ module | reuse ได้กับหลาย caller โดยไม่ต้องแก้ไข | มี module ให้ต้องนำทางมากขึ้น caller อาจต้องเชื่อม module หลายตัวเข้าด้วยกัน (อยู่ใน module-composition-and-sources.mdx) |
| default ที่สมเหตุสมผลบน optional input | caller ธรรมดาต้องระบุแค่ input ที่ required | default อาจซ่อนการตัดสินใจจริงที่ 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_onfail ด้วย error จริงที่มีชื่อระบุ provider ควรมาจาก caller - “ข้อจำกัดเรื่อง count/for_each กับ module ที่มี provider เป็นแค่ lint สไตล์ ไม่ใช่ hard error” พิสูจน์ข้างบนด้วย
terraform initfail จริงบน 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 ไว้เสมอ
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ตอน review module ที่ generate มาซึ่งตั้งใจให้ reuse ได้ เช็คสิ่งเหล่านี้
providerblock ที่มี 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 ในโค้ดนี้
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