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

Module Versioning and Registries

version constraint บน module บอกว่า release ไหนของ Registry module หรือ Git-tagged module ที่ Terraform ได้รับอนุญาตให้ resolve ด้วยเหตุผลเดียวกับที่ version constraint ของ provider สำคัญ — ถ้าไม่มี terraform init -upgrade ธรรมดา ๆ ครั้งหนึ่งอาจดึง module release ใหม่ที่มี breaking change เข้ามาโดยไม่มีใคร review ก่อนเลย

นี่คือปัญหาเดียวกับที่ provider dependency lock file แก้ไข แค่ขยับขึ้นมาอีกชั้นหนึ่ง เมื่อ module block ดึง source มาจาก public Registry หรือ Git tag โดยไม่มี version constraint Terraform จะ resolve release ล่าสุดที่มีอยู่ทุกครั้งที่ init รันแบบ cold — clone ใหม่ใน CI, laptop เครื่องใหม่, cache ที่ถูกสร้างใหม่ ถ้าคนเขียน module ปล่อย breaking change มาใน release ใหม่นั้น — variable ที่เปลี่ยนชื่อ, default ที่ต่างไป, resource ที่ตอนนี้ต้อง replace แทนที่จะ update — การเปลี่ยนนั้นจะถูกดึงเข้ามาแล้ว apply โดยไม่มีใครในทีมได้อ่าน changelog ของ module นั้นก่อนเลย

module "vpc" {
source = "terraform-google-modules/network/google"
# no version constraint — always resolves to the latest release
project_id = var.project_id
network_name = "app-network"
}

การ pin version เปลี่ยนเรื่องนี้ให้เป็นการตัดสินใจที่จงใจและ review ได้ แทนที่จะเป็นสิ่งที่เกิดขึ้นเองโดยบังเอิญ

module "vpc" {
source = "terraform-google-modules/network/google"
version = "~> 18.0"
project_id = var.project_id
network_name = "app-network"
}

~> เรียกว่า pessimistic constraint operator ความหมายคืออนุญาตให้ upgrade ได้ แต่เฉพาะ upgrade ที่ไม่น่าจะทำให้อะไรพัง version = "~> 18.0" อนุญาตให้ Terraform resolve release ในสาย 18.x ตัวไหนก็ได้ — 18.1.0, 18.4.2, 18.12.0 — เพราะตาม semantic versioning ตัวเหล่านี้ควร backward compatible กัน แต่บล็อก 19.0.0 เอาไว้เด็ดขาด เพราะ major version bump คือจุดที่คนเขียน module ได้รับอนุญาตให้ใส่ breaking change เข้ามา

operator ตัวเดียวกันนี้ใช้ได้ละเอียดกว่านั้นอีกระดับ version = "~> 18.2.0" อนุญาต patch release อย่าง 18.2.1 กับ 18.2.7 แต่บล็อก 18.3.0 — มีประโยชน์ตอนที่คุณอยากกัน minor version bump ไว้ก่อนจนกว่าจะได้ review ก่อน ~> 18.0 ที่อนุญาตทั้งสาย 18.x คือ default ทั่วไปของทีมส่วนใหญ่ ส่วนรูปแบบที่แคบกว่าอย่าง ~> 18.2.0 เหมาะกับ module ที่เคยโดน minor version เปลี่ยน behavior มาก่อน

การ upgrade ข้ามขอบเขตที่ถูกบล็อกไว้ก็ยังทำได้ในคำสั่งเดียวเมื่อคุณพร้อม — แก้ version constraint ใน .tf file แล้วรัน terraform init -upgrade — constraint แค่ทำให้แน่ใจว่า upgrade นั้นเป็นสิ่งที่คนตัดสินใจทำเอง ไม่ใช่สิ่งที่เกิดขึ้นเงียบ ๆ ตอน init ครั้งถัดไป

พิสูจน์ ~> 18.0 ด้วยของจริง: terraform-google-modules/network/google และ argument subnets ที่มันต้องการจริง ๆ

หัวข้อที่มีชื่อว่า “พิสูจน์ ~> 18.0 ด้วยของจริง: terraform-google-modules/network/google และ argument subnets ที่มันต้องการจริง ๆ”

terraform-google-modules/network/google เป็น module จริงและปัจจุบัน — ~> 18.0 resolve ได้จริง แต่ v18 ต้องการ argument ที่ตัวอย่างข้างบนไม่เคยแสดงเลย คือ subnets output จริงของ terraform validate พิสูจน์ว่าการไม่ใส่มันเป็น error จริง ไม่ใช่แค่เรื่องสไตล์

module "vpc" {
source = "terraform-google-modules/network/google"
version = "~> 18.0"
project_id = "acme-app-prod"
network_name = "app-network"
}
$ terraform validate
Error: Missing required argument
on main.tf line 10, in module "vpc":
10: module "vpc" {
The argument "subnets" is required, but no definition was found.

subnets ไม่ใช่ optional ใน v18 แม้แต่กับ VPC เล็ก ๆ นี่คือการเรียกที่แก้แล้วและ validate ผ่านจริง

module "vpc" {
source = "terraform-google-modules/network/google"
version = "~> 18.0"
project_id = "acme-app-prod"
network_name = "app-network"
subnets = [
{
subnet_name = "app-subnet-us-central1"
subnet_ip = "10.0.1.0/24"
subnet_region = "us-central1"
}
]
}
$ terraform init
...
$ terraform validate
Success! The configuration is valid.

~> 18.0 resolve เป็น 18.3.0 จริงและปัจจุบัน — หลักฐานว่า module นี้ยัง maintain อยู่ ไม่ใช่ตัวอย่างเก่าที่ไม่ตรงกับความจริงอีกต่อไปที่คอร์สหนึ่งอาจเผลอพาต่อมาเรื่อย ๆ การเปลี่ยน required argument แบบนี้คือสิ่งที่ ~> ปกป้องคุณจากการข้ามไปแบบ ไม่มีใคร review ตรงขอบเขต major version (ในที่นี้คือ 17.x ไป 18.x) — แต่มันไม่ได้ปกป้องคุณจากตัวอย่างเก่าที่ copy-paste มาแล้วไม่เคย update ให้ตรงกับ version ที่ pin ไว้จริง

version ที่ resolve แล้วจริง ๆ อยู่ที่ไหน — ไม่ใช่ .terraform.lock.hcl

หัวข้อที่มีชื่อว่า “version ที่ resolve แล้วจริง ๆ อยู่ที่ไหน — ไม่ใช่ .terraform.lock.hcl”

นี่คือรายละเอียดที่ควรแก้ไขให้ชัดเจน เพราะเผลอสันนิษฐานผิดได้ง่ายมากถ้าเทียบเคียงกับ provider version ของ module ที่ resolve แล้วไม่เคยถูกบันทึกใน .terraform.lock.hcl เลย ไฟล์นั้นมีไว้สำหรับ provider เท่านั้น พิสูจน์ — version ของ module ที่ resolve แล้วจาก init ข้างบนไปอยู่ที่ .terraform/modules/modules.json แทน ซึ่ง terraform init regenerate ใหม่ทุกครั้งและไม่ได้ตั้งใจให้ commit เข้า version control (มันอยู่ใน directory .terraform/ ที่ gitignore ไว้ คู่กับ module source ที่ดาวน์โหลดมาเอง)

$ cat .terraform/modules/modules.json
{"Modules":[{"Key":"","Source":"","Dir":"."},{"Key":"vpc","Source":"registry.terraform.io/terraform-google-modules/network/google","Version":"18.3.0","Dir":".terraform/modules/vpc"}, ...]}

ไม่มี module entry เลยที่ไหนใน .terraform.lock.hcl — มีแค่ provider block เท่านั้น นั่นแปลว่า version = "~> 18.0" ในไฟล์ .tf ของคุณคือบันทึกถาวรและ commit ได้ ตัวเดียว ที่บอกว่า version ไหนของ module ที่ยอมรับได้ — ต่างจาก provider ที่ไม่มี lock file แยกต่างหากมา pin version ที่ resolve แล้วไว้สำหรับครั้งถัดไป ทุก init re-resolve constraint นั้นใหม่กับสิ่งที่ Registry publish ไว้ ณ ตอนนั้นเสมอ ซึ่งเป็นเหตุผลเป๊ะ ๆ ว่าทำไม constraint เอง ไม่ใช่ lock file ต่างหาก คือสิ่งที่ปกป้องคุณจากการกระโดดไป 19.0 โดยไม่มีใคร review

ไม่ใช่ทุก module ที่ควรอยู่บน public Registry module ที่ encode security policy, naming convention หรือการเชื่อมต่อ service ภายในของบริษัทหนึ่งไม่มีเหตุผลที่ต้อง public และอาจไม่ควร public ด้วยซ้ำ Terraform รองรับ source mechanic แบบเดียวกันสำหรับกรณีนี้ — private Git repository (มักผ่าน SSH เพื่อให้ init authenticate ด้วย credential ที่มีอยู่แล้ว) หรือ private module registry เช่น private registry ของ HCP Terraform หรือ registry ที่ host เองภายในองค์กร

module "internal_service" {
source = "git::ssh://[email protected]/acme-corp/terraform-modules.git//internal-service?ref=v1.4.0"
project_id = var.project_id
}

ถ้าคุณอยาก publish module ของตัวเองขึ้น public Terraform Registry Registry ต้องการ pattern ชื่อ repository แบบเฉพาะ คือ terraform-<PROVIDER>-<NAME> เช่น terraform-google-vpc สำหรับ module ที่ target provider google Registry จะ parse ชื่อนี้ตรง ๆ เป็น address ของ module (namespace/name/provider) ดังนั้น repository ที่ตั้งชื่อแบบอื่นจะไม่ถูกดึงเข้ามาเลย

flowchart LR
  subgraph pinned["Pinned: version = ~> 18.0"]
    p1["terraform init today"] --> p2["Resolves 18.4.2 (latest 18.x)"]
    p2 --> p3["terraform init -upgrade later"]
    p3 --> p4["Still resolves within 18.x, blocks 19.0.0"]
  end
  subgraph unpinned["Unpinned: no version constraint"]
    u1["terraform init today"] --> u2["Resolves 18.4.2 (latest overall)"]
    u2 --> u3["terraform init tomorrow"]
    u3 --> u4["Silently resolves 19.0.0 with breaking changes"]
  end
module source ที่ pin version ไว้ที่ tag เทียบกับ source ที่ไม่ pin

version ที่ resolve แล้วของ provider ถูก pin สองชั้น ชั้นแรกหลวม ๆ ด้วย version constraint ของคุณ ชั้นที่สองเป๊ะ ๆ ด้วย .terraform.lock.hcl version ที่ resolve แล้วของ module ถูก pin แค่ชั้นเดียว — ด้วย version constraint ของคุณเท่านั้น พิสูจน์ข้างบนด้วยการที่ .terraform.lock.hcl ไม่มี module entry เลยจริง ๆ ความไม่สมมาตรนี้แปลว่า constraint string ในไฟล์ .tf ของคุณแบกน้ำหนักมากกว่า string เดียวกันของ provider เพราะไม่มี safety net ชั้นที่สองที่ commit ไว้คอยจับการ resolve ที่เซอร์ไพรส์แบบที่ lock file ทำให้ provider และ ~> ปกป้องคุณแค่จากการกระโดด major version แบบ ไม่มีใคร review เท่านั้น — พิสูจน์ข้างบน มันไม่ได้ช่วยอะไรเลยถ้าตัวอย่างในคอร์ส (หรือ module call ของคุณเอง) ไม่เคย update ให้ตรงกับ required argument ที่ version ที่ pin ไว้ต้องการจริง ๆ

ทางเลือกได้อะไรเสียอะไร
version = "~> 18.0" บน Registry moduleอนุญาต patch/minor fix จริง (พิสูจน์ข้างบน resolve เป็น 18.3.0) ในขณะที่กันการกระโดดไป 19.0 โดยไม่มี reviewยังต้อง re-validate กับ version ที่ resolve แล้วจริง ๆ — พิสูจน์ข้างบน ~> 18.0 เพียงอย่างเดียวไม่จับว่า subnets กลายเป็น required แล้ว
ไม่มี lock file สำหรับ module (ต่างจาก provider)มีไฟล์ที่ generate ให้ sync น้อยลงหนึ่งไฟล์ .terraform/modules/ regenerate สะอาดทุก initversion constraint string คือบันทึกที่ commit ได้เพียงตัวเดียวของ version ของ module ที่ยอมรับได้ — พิสูจน์ข้างบน ไม่มี .terraform.lock.hcl entry มา back up
private Git repo หรือ private registry สำหรับ module ภายในองค์กรinfrastructure detail เฉพาะของบริษัทไม่มีวัน publicต้องมีวินัย versioning ของตัวเอง (Git tag จริง, private registry จริง) แทนที่จะพึ่ง convention ของ public Registry
  • “version ที่ resolve แล้วของ module ถูกบันทึกใน .terraform.lock.hcl เหมือนกับของ provider” พิสูจน์ข้างบน init จริงกับ terraform-google-modules/network/google ได้ .terraform.lock.hcl ที่มีแค่ provider block — ไม่มี module entry เลยที่ไหน version ของ module ที่ resolve แล้วไปอยู่ที่ .terraform/modules/modules.json แทน ซึ่งไม่ได้ตั้งใจให้ commit
  • “ถ้า version constraint ของ module ถูกต้อง โค้ดตัวอย่างที่เคยใช้ได้ก็จะยังใช้ได้อยู่” พิสูจน์ข้างบนด้วย error จริง subnets กลายเป็น required argument ที่จุดหนึ่งในสาย 18.x และการเรียกแบบ project_id/network_name เท่านั้นที่อาจเคย validate ผ่านกับ release เก่า ก็ fail กับ 18.3.0 ปัจจุบันด้วย “Missing required argument”
  • “เพราะ module ไม่มี lock file เลยไม่ค่อยสำคัญเท่าไหร่” พิสูจน์ข้างบนจากความไม่สมมาตรนั้นเอง เพราะไม่มี safety net ชั้นที่สองที่ commit ไว้สำหรับ module แบบที่ provider มี constraint string จึง สำคัญกว่า สำหรับ module ไม่ใช่น้อยกว่า

ตอน review module source ที่ generate มา เช็คสิ่งเหล่านี้

  • module block จาก Registry หรือ Git tag ที่ไม่มี version (หรือ ?ref=) argument เลย — พิสูจน์ข้างบน นี่คือความเสี่ยงเรื่อง unpinned version ที่บทนี้เปิดไว้ตั้งแต่ต้น
  • terraform-google-modules/network/google ที่เรียกโดยไม่มี subnets argument — พิสูจน์ข้างบน นี่คือ error จริงและปัจจุบันตอน validate กับ ~> 18.0 ไม่ใช่สมมติฐาน
  • comment หรือ commit message ที่อ้างว่า version ที่ resolve แล้วของ module ถูก “lock” หรือ “pin ไว้ใน lock file” — พิสูจน์ข้างบน คำกล่าวอ้างนั้นจริงแค่สำหรับ provider เท่านั้น version ที่ resolve แล้วจริงของ module อยู่ใน .terraform/modules/modules.json ที่ gitignore ไว้
  • module เฉพาะของบริษัทที่ private ถูก publish ด้วยชื่อทั่วไปที่ไม่ตาม terraform-<PROVIDER>-<NAME> — สิ่งนี้ทำให้ auto-detection ของ Registry สำหรับ module public พัง และควรระวังแม้กับ private registry ที่ naming discipline เดียวกันช่วยให้ module ค้นหาเจอ

🐛 หา bug ในโค้ดนี้

module "vpc" {
source = "terraform-google-modules/network/google"
version = "~> 18.0"
project_id = "acme-app-prod"
network_name = "app-network"
}
เฉลย

พิสูจน์ข้างบนด้วย error จริงของ terraform validate terraform-google-modules/network/google ต้องการ argument subnets จริงตามสาย ~> 18.0 ที่ pin ไว้ — “The argument `subnets` is required, but no definition was found” นี่คือสิ่งที่ version constraint ที่ถูกต้อง ไม่ ปกป้อง ~> 18.0 resolve เป็น 18.3.0 จริงและปัจจุบันได้ถูกต้อง constraint ทำหน้าที่ของมันแล้ว แต่ตัว module call เองไม่เคยถูก update ให้ตรงกับสิ่งที่ version นั้นต้องการจริง ๆ ทางแก้คือเพิ่ม subnets list จริง — พิสูจน์ข้างบนแล้วว่า validate ผ่านสะอาดทันทีที่มี subnet block อย่างน้อยหนึ่งตัว (subnet_name, subnet_ip, subnet_region)

ความเสี่ยงที่เกิดจาก module version constraint ที่ไม่ได้ pin ไว้คืออะไร
constraint version = ~> 18.0 อนุญาตอะไรและบล็อกอะไร
error จริงนี้เกิดกับ module ที่ pin ด้วย constraint ~> 18.0 ที่ถูกต้อง มันพิสูจน์อะไร
$ terraform validate
Error: Missing required argument

  on main.tf line 10, in module "vpc":
  10: module "vpc" {

The argument "subnets" is required, but no definition was found.
version ที่ resolve แล้ว 18.3.0 ถูกบันทึกไว้ที่ไหนจริง ๆ พิสูจน์ข้างบน
$ terraform init
Downloading registry.terraform.io/terraform-google-modules/network/google 18.3.0 for vpc...
...
$ cat .terraform.lock.hcl
provider "registry.terraform.io/hashicorp/google" {
  version = "8.4.0"
  ...
}
ทำไมองค์กรหนึ่งอาจเลือกใช้ private Git repository หรือ private registry สำหรับ module แทน public Terraform Registry
public Terraform Registry ต้องการ pattern ชื่อ repository แบบไหนสำหรับ module ที่จะ publish