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

Module Versioning and Registries

module source ที่ไม่ pin version สามารถ resolve ไปเป็น version ใหม่กว่าเดิมแบบเงียบ ๆ ในครั้งถัดไปที่มีคนรัน init ดังนั้นการ pin version constraint คือวินัยเดียวกับที่คุณใช้กับ provider lock file อยู่แล้ว เพียงแต่เล็งไปที่ module code แทนที่จะเป็น provider code

ทำไม module version ที่ไม่ pin ถึงเป็นความเสี่ยงแบบเงียบ ๆ

หัวข้อที่มีชื่อว่า “ทำไม module version ที่ไม่ pin ถึงเป็นความเสี่ยงแบบเงียบ ๆ”

module ที่มาจาก Registry หรือ Git tag ไม่ได้ถูก freeze ไว้กับที่เหมือน local relative path ถ้า module block ไม่มี version constraint การรัน terraform init -upgrade — หรือแม้แต่ init ใหม่ใน CI pipeline ที่ไม่เคยรันมาก่อน — สามารถดึง release ใหม่กว่าของ module นั้นเข้ามาโดยไม่มีใคร review changelog ก่อน upgrade ที่ดูเหมือนเล็กน้อยสามารถเปลี่ยนชื่อ variable เปลี่ยน default หรือปรับโครงสร้าง resource ที่สร้างขึ้นใหม่ทั้งหมด และไม่มีอะไรแสดงให้เห็นจนกว่า plan หรือ apply จะเริ่มทำงานต่างจากเมื่อวาน

# unpinned - always resolves to the latest version on init -upgrade
module "vnet" {
source = "Azure/avm-res-network-virtualnetwork/azurerm"
}

นี่คือ failure mode เดียวกันเป๊ะกับที่ provider lock file มีไว้เพื่อป้องกัน แค่ขยับขึ้นไปอีกระดับหนึ่ง: แทนที่จะเป็น provider plugin ที่เปลี่ยนไปโดยที่คุณไม่รู้ตัว กลายเป็น implementation ภายในของ module เองที่เปลี่ยนไปโดยที่คุณไม่รู้ตัวแทน

การ pin module จาก Registry หรือ Git tag ด้วย version argument แก้ปัญหานี้ได้ pattern ที่ใช้กันทั่วไปคือ pessimistic constraint operator, ~> หัวข้อนี้ใช้ Azure Verified Modules (AVM) แทน community module เก่าอย่าง Azure/vnet/azurerm ที่ tutorial บางตัวยังอ้างถึง — เช็คตรงกับ GitHub แล้ว repository นั้นมี banner “This repository was archived by the owner” จริง และชี้ผู้ใช้ไปยัง AVM เป็นตัวแทน คอร์สนี้จึงสอน module ที่เป็นปัจจุบันจริง ๆ

module "vnet" {
source = "Azure/avm-res-network-virtualnetwork/azurerm"
version = "~> 0.2"
location = "eastus"
parent_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/acme-rg"
address_space = ["10.0.0.0/16"]
name = "acme-vnet"
enable_telemetry = false
}

output จริงของ terraform init — resolve จริง ไม่มีเลข version ที่กุขึ้นมาเลย — กับ azurerm ~> 5.0 ที่ pin ไว้ของคอร์สนี้เอง

$ terraform init
Initializing modules...
Downloading registry.terraform.io/Azure/avm-res-network-virtualnetwork/azurerm 0.22.2 for vnet...
Initializing provider plugins...
- Finding hashicorp/random versions matching "~> 3.5, ~> 3.6"...
- Finding hashicorp/azurerm versions matching "~> 5.0"...
- Finding azure/azapi versions matching "~> 2.4, ~> 2.12"...
- Finding azure/modtm versions matching "~> 0.3"...
- Installing hashicorp/random v3.9.1...
- Installing hashicorp/azurerm v5.7.0...
- Installing azure/azapi v2.13.0...
- Installing azure/modtm v0.4.0...
$ terraform validate
Success! The configuration is valid.

~> 0.2 resolve เป็น 0.22.2 — release จริงและปัจจุบัน สังเกตสิ่งที่ init ทำให้เองโดยไม่ต้องสั่ง: AVM module นี้ต้องการ azure/azapi (resource ของมัน implement ผ่าน type azapi_resource แบบทั่วไป ไม่ใช่เรียก azurerm_virtual_network ตรง ๆ) และ azure/modtm (telemetry การใช้งานแบบไม่ระบุตัวตน ปิดไว้ตรงนี้ด้วย enable_telemetry = false) เป็น provider เพิ่มเติม — ทั้งสองตัวไม่ได้ถูกประกาศไว้ใน required_providers ของคอร์สนี้เอง แต่ init ดึงเข้ามาให้อัตโนมัติเพราะ manifest ของ module เองต้องการมัน ~> 5.0 แปลว่า “อนุญาต version ใด ๆ ที่มากกว่าหรือเท่ากับ 5.0 จนถึงแต่ไม่รวม 6.0” สำหรับ azurerm provider โดยเฉพาะ ส่วน module version constraint ~> 0.2 ตาม pattern เดียวกันเป๊ะ อีกระดับหนึ่ง สำหรับ release line ของ module เอง

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

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

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

provider "registry.terraform.io/hashicorp/azurerm" {
version = "5.7.0"
constraints = "~> 5.0"
...
}

ไม่มี module entry เลยที่ไหนในไฟล์นั้น — มีแต่ provider (azurerm, random, azapi, modtm ทั้งที่ดึงเข้ามาตรง ๆ หรือดึงมาโดย module เอง) version ของ module ที่ resolve แล้วไปอยู่ที่ .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":"vnet","Source":"registry.terraform.io/Azure/avm-res-network-virtualnetwork/azurerm","Version":"0.22.2","Dir":".terraform/modules/vnet"}, ...]}

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

ไม่ใช่ทุก module ที่ควรอยู่บน public Registry module ที่ encode networking layout, naming convention หรือ compliance requirement เฉพาะขององค์กรหนึ่งไม่ควรถูก publish ให้คนทั้งโลกเห็น สำหรับกรณีแบบนี้ องค์กรมักใช้ private Git repository หรือ private module registry — private registry ของ HCP Terraform หรือของที่ self-hosted เอง — เพื่อให้ module ยังมี version และค้นหาได้ภายในองค์กร โดยไม่ต้องกลายเป็น public

# private Git repository
module "internal_networking" {
source = "git::ssh://[email protected]/acme-corp/terraform-modules.git//networking?ref=v2.1.0"
}
# private module registry (e.g. HCP Terraform)
module "internal_networking" {
source = "app.terraform.io/acme-corp/networking/azurerm"
version = "~> 2.1"
}

ถ้าคุณต้องการ publish module ออกสู่สาธารณะ public Terraform Registry ต้องการ repository naming convention เฉพาะแบบนี้: terraform-<PROVIDER>-<NAME> AVM เองก็ตาม convention นี้เป๊ะ — module ที่พิสูจน์ตลอดบทนี้อยู่ใน GitHub repository ที่ชื่อ terraform-azurerm-avm-res-network-virtualnetwork ตรง ๆ Registry parse ชื่อนี้เพื่อดูว่า module เล็งไปที่ provider ไหนและควรเรียกว่าอะไร ดังนั้น repository ที่ไม่ตาม pattern นี้จะ publish ไม่สำเร็จ

flowchart TB
  subgraph pinned["Pinned version"]
    p1["version = ~> 5.0"] --> p2["terraform init always resolves within 5.x"]
  end
  subgraph unpinned["Unpinned version"]
    u1["no version constraint"] --> u2["terraform init -upgrade may resolve 6.0"]
    u2 --> u3["breaking changes applied without changelog review"]
  end
module ที่ pin version ไว้ เทียบกับตัวที่ไม่ pin ซึ่ง resolve ไปเป็น release ใหม่กว่าแบบเงียบ ๆ ได้

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 และพิสูจน์ข้างบน รูปร่างของ constraint เองก็สำคัญกว่าที่ดูภายนอก ~> 0.2 กับ ~> 0.2.0 ไม่ใช่คำสัญญาเดียวกันเลยสำหรับ module ที่ยังไม่ถึง 1.0

ทางเลือกได้อะไรเสียอะไร
version = "~> 0.2" (สองส่วน) บน Registry moduleตาม release จริงอัตโนมัติภายในสาย major เดียวกัน — พิสูจน์ข้างบน resolve เป็น 0.22.2 จริงและปัจจุบันสำหรับ module ที่ยังไม่ถึง 1.0 “สาย major เดียวกัน” แปลว่าทั้งหมดของ 0.x — พิสูจน์ข้างบน กว้างกว่ารูปแบบสามส่วนมาก และยังอาจได้ interface ที่ต่างกันจริงในแต่ละ release
version = "~> 0.2.0" (สามส่วน)ล็อกไว้แค่ patch update ภายใน 0.2.x — พิสูจน์ข้างบน resolve เป็น 0.2.4 ที่เก่ากว่ามากพลาด fix และ feature จริงที่ publish ใน minor release ใหม่กว่าของ 0.x สำหรับ module ที่ยังพัฒนาอยู่และไม่ถึง 1.0 นี่อาจแปลว่าติดอยู่กับ release เก่าตลอดไป
ไม่มี 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
  • “Azure/vnet/azurerm ยังเป็นจุดเริ่มต้นมาตรฐานสำหรับ module networking ของ Azure” เช็คตรง ๆ กับ GitHub repository นั้นมี banner “This repository was archived by the owner” จริง และชี้ผู้ใช้ไปยัง Azure Verified Modules — พิสูจน์ข้างบน Azure/avm-res-network-virtualnetwork/azurerm คือคำตอบปัจจุบันที่ยัง maintain อยู่ และมัน resolve กับ validate ผ่านจริงกับ azurerm ~> 5.0 ที่ pin ไว้ของคอร์สนี้
  • “version ที่ resolve แล้วของ module ถูกบันทึกใน .terraform.lock.hcl เหมือนกับของ provider” พิสูจน์ข้างบน init จริงกับ AVM vnet module ได้ .terraform.lock.hcl ที่มีแค่ provider block เท่านั้น — ไม่มี module entry เลยที่ไหน version ของ module ที่ resolve แล้วไปอยู่ที่ .terraform/modules/modules.json แทน ซึ่งไม่ได้ตั้งใจให้ commit
  • “~> 0.2 กับ ~> 0.2.0 แปลว่าประมาณเดียวกัน แค่ความละเอียดต่างกัน” พิสูจน์ข้างบนด้วยการรันทั้งสองแบบกับ Registry จริง ~> 0.2 resolve เป็น 0.22.2 (release 0.x ไหนก็ได้ที่ >= 0.2) ในขณะที่ ~> 0.2.0 resolve เป็น 0.2.4 (patch release ภายใน 0.2.x เท่านั้น) — สำหรับ module ที่ยังไม่ถึง 1.0 การตัด segment ที่สามออกเปลี่ยนว่า component ไหนที่ pessimistic operator ได้รับอนุญาตให้เพิ่ม ไม่ใช่แค่ความแน่นของการ pin
  • “เพราะ 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 ที่บทนี้เปิดไว้ตั้งแต่ต้น
  • module block ที่ยัง source จาก Azure/vnet/azurerm หรือ Azure/naming/azurerm — เช็คข้างบนแล้วว่าทั้งสองถูก archive คำตอบปัจจุบันคือ Azure Verified Module
  • constraint แบบสองส่วน ~> (เช่น ~> 0.2) ที่ใช้กับ module ที่ยังไม่ถึง 1.0 โดยดูเหมือนตั้งใจ pin ให้แน่นในสาย minor — พิสูจน์ข้างบน สิ่งนี้จริง ๆ อนุญาตช่วงกว้างกว่าที่ผู้เขียนน่าจะตั้งใจมาก ~> 0.2.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> — พิสูจน์ข้างบน แม้แต่ AVM เองก็ตาม convention นี้เป๊ะ

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

module "vnet" {
source = "Azure/vnet/azurerm"
version = "~> 5.0"
resource_group_name = azurerm_resource_group.main.name
vnet_name = "acme-app-vnet"
address_space = ["10.0.0.0/16"]
}
เฉลย

พิสูจน์ข้างบนด้วยการดึงข้อมูลจริงจาก GitHub repository Azure/vnet/azurerm มี banner “This repository was archived by the owner” จริง และ README ชี้ผู้ใช้ไปยัง Azure Verified Modules เป็นตัวแทน — module นี้ไม่ได้รับ update, security fix หรืองาน compatibility กับ azurerm provider เวอร์ชันใหม่เลย ไม่ว่า version constraint จะเขียนยังไง module call ที่ generate มาถูกต้องตามทุก convention ในบทนี้ (มี ~> constraint จริง, Registry address จริง) ก็ยังชี้ไปที่ทางตันได้ถ้า address เองเก่าไปแล้ว ทางแก้คือ module ที่พิสูจน์ตลอดบทนี้แทน source = "Azure/avm-res-network-virtualnetwork/azurerm", version = "~> 0.2" พร้อม interface ของ AVM (parent_id, address_space, name, enable_telemetry) แทนที่จะเป็นของ module เก่าที่ archive แล้ว (resource_group_name, vnet_name) — พิสูจน์ข้างบนแล้วว่า init และ validate ผ่านสะอาดกับ azurerm ~> 5.0 ที่ pin ไว้ของคอร์สนี้เอง

การปล่อย Registry หรือ Git-tag module source ไว้โดยไม่มี version constraint สร้างความเสี่ยงอะไร
Azure/vnet/azurerm ยังเป็นจุดเริ่มต้นปัจจุบันที่ maintain อยู่สำหรับ module networking ของ Azure หรือเปล่า
version ที่ resolve แล้ว 0.22.2 ถูกบันทึกไว้ที่ไหนจริง ๆ พิสูจน์ข้างบน
$ terraform init
Downloading registry.terraform.io/Azure/avm-res-network-virtualnetwork/azurerm 0.22.2 for vnet...
...
$ cat .terraform.lock.hcl
provider "registry.terraform.io/hashicorp/azurerm" {
  version = "5.7.0"
  ...
}
ผลลัพธ์จริงคู่นี้พิสูจน์อะไรเกี่ยวกับ ~> 0.2 เทียบกับ ~> 0.2.0 สำหรับ module ที่ยังไม่ถึง 1.0
$ terraform init  # version = "~> 0.2"
Downloading ... 0.22.2 for vnet...
$ terraform init  # version = "~> 0.2.0"
Downloading ... 0.2.4 for vnet...
ทำไมองค์กรอาจใช้ private Git repository หรือ private module registry แทน public Terraform Registry สำหรับ module หนึ่ง
public Terraform Registry ต้องการ repository naming convention แบบไหนสำหรับการ publish module ของตัวเอง