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

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/gcs-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/gcs-bucket/variables.tf
variable "bucket_name" {
description = "Name of the GCS bucket to create"
type = string
}
variable "project_id" {
description = "ID of the GCP project that owns the bucket"
type = string
}
modules/gcs-bucket/main.tf
resource "google_storage_bucket" "this" {
name = var.bucket_name
project = var.project_id
location = "US"
}
modules/gcs-bucket/outputs.tf
output "url" {
description = "GS URI of the created bucket"
value = google_storage_bucket.this.url
}

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

# root main.tf
module "logs_bucket" {
source = "./modules/gcs-bucket"
bucket_name = "acme-app-logs"
project_id = "acme-app-prod"
}
output "logs_bucket_url" {
value = module.logs_bucket.url
}

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

มีสองนิสัยเชิงปฏิบัติที่ช่วยให้ 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/gcs-bucket/variables.tf
variable "bucket_name" {
description = "Name of the GCS bucket to create"
type = string
}
variable "project_id" {
description = "ID of the GCP project that owns the bucket"
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 "google" { project = "my-project-id" } block จริงที่ไม่ว่างเปล่า ถูกเรียกด้วย count

# @expect-error Module is incompatible with count, for_each, and depends_on
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "~> 8.0"
}
}
}
provider "google" {
project = "my-project-id"
}
resource "google_storage_bucket" "this" {
name = "acme-example-bucket"
location = "US"
}
# @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 เดียวกันเป๊ะ — และการที่คำใน docs ตัวเก่าไม่ตรงกับเวอร์ชันปัจจุบัน ก็เกิดขึ้นเหมือนกันเป๊ะ — ที่พิสูจน์แล้วในคอร์สพี่น้อง aws และ azurerm ของคอร์สนี้ เพราะนี่คือกฎหลักของภาษา Terraform เอง ไม่ใช่เรื่องเฉพาะของ google provider ทางแก้คือ configuration_aliases module ประกาศว่าต้องการ provider ที่ส่งเข้ามาภายใต้ alias ท้องถิ่น และไม่ config provider เองเลย

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

output จริงของ terraform validate หลังแก้ (plan สำหรับ google_storage_bucket ต้องการ GCP project จริง คอร์สนี้เลย validate แทนที่จะ 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.bucket"]
  end
  subgraph child["Child module: modules/gcs-bucket"]
    vars["variables.tf (inputs)"] --> main["main.tf (resources)"]
    main --> outs["outputs.tf (values exposed)"]
  end
  rmain -->|source = ./modules/gcs-bucket| vars
  outs -->|module.bucket.url| 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 ของตัวเองถึงจะรู้ว่าใช้ GCP project ไหน” พิสูจน์ข้างบน ตรงกันข้ามเลยสำหรับ reusable module — provider block ที่เป็นเจ้าของเองนี่แหละที่ทำให้ count/for_each/depends_on fail ด้วย error จริงที่มีชื่อระบุ project ควรมาจาก 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 เฉพาะของ google provider” พิสูจน์ข้างบนด้วยการเทียบ 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 GCP project 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 {
google = {
source = "hashicorp/google"
version = "~> 8.0"
}
}
}
provider "google" {
project = "acme-prod-project"
}
resource "google_pubsub_topic" "this" {
name = var.topic_name
}
เฉลย

พิสูจน์ข้างบนด้วย terraform init fail จริง — module นี้เป็นเจ้าของ provider "google" 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 acme-prod-project แบบเงียบ ๆ ดังนั้นทุก caller จะได้ GCP project นี้ไม่ว่า provider configuration ของตัวเองจะเป็นอะไร โดยไม่มีทาง override เลย ทางแก้คือ configuration_aliases = [google.this] ใน required_providers อ้างอิง provider = google.this บน resource และไม่มี provider block ในตัว module เลย จากนั้น caller จะส่ง provider จริงผ่าน providers = { google.this = google } ซึ่งพิสูจน์ข้างบนแล้วว่า 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 "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.
error "Module is incompatible with count, for_each, and depends_on" เป็นเรื่องเฉพาะของ google provider หรือเปล่า
ต้นทุนที่แท้จริงของการ over-parameterize module ด้วยการ expose ทุก field ของ resource เป็น variable ตั้งแต่วันแรกคืออะไร
ในตัวอย่าง gcs-bucket module ทำไม bucket_name ถึงไม่มี default แต่ force_destroy มี