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

ติดตั้ง Terraform และโครงสร้างโปรเจกต์

ติดตั้ง Terraform ผ่าน version manager แทนการใช้ binary เดียวที่ pin เวอร์ชันตายตัว เขียน Azure resource จริงตัวแรกของคุณ แล้วแบ่งออกเป็นไฟล์หลายไฟล์ตามชื่อที่เป็นธรรมเนียม — ซึ่งจริง ๆ แล้ว Terraform ไม่ได้บังคับสักไฟล์เดียว

ติดตั้ง Terraform (และทำไม version manager ถึงดีกว่า binary เดียว)

หัวข้อที่มีชื่อว่า “ติดตั้ง Terraform (และทำไม version manager ถึงดีกว่า binary เดียว)”

คุณดาวน์โหลด Terraform binary เดียวจาก HashiCorp แล้ววางไว้ใน PATH ได้ วิธีนี้ใช้งานได้ดีสำหรับการทดลองคนเดียว แต่ทันทีที่คุณทำงานมากกว่าหนึ่งโปรเจกต์ วิธีนี้จะเริ่มพัง repository หนึ่งเขียนไว้กับ Terraform 1.5 อีกอันต้องการ feature ของ 1.9 ส่วนอันที่สาม migrate ไป OpenTofu ไปแล้ว การคอย sync binary กลางตัวเดียวให้ตรงกับโปรเจกต์ที่คุณกำลังทำอยู่ตลอดเวลาเป็นงานจุกจิกที่ผิดพลาดได้ง่าย

version manager แก้ปัญหานี้ด้วยการติดตั้งหลายเวอร์ชันไว้เคียงข้างกัน แล้วสลับใช้ตามแต่ละโปรเจกต์ โดยปกติขับเคลื่อนด้วยไฟล์ระบุเวอร์ชันที่ commit เข้า repository tenv เป็นตัวเลือกที่ดีเพราะไม่ได้ผูกติดกับเครื่องมือตัวเดียว จัดการเวอร์ชันของ Terraform, OpenTofu, Terragrunt และ Atmos ได้ทั้งหมดจาก CLI ตัวเดียว ซึ่งสำคัญมากเมื่อทีมของคุณรัน Terragrunt คู่กับ Terraform (จะพูดถึงทีหลังในคอร์สนี้)

Terminal window
# install tenv (macOS, via Homebrew)
brew install tenv
# install and pin a specific Terraform version for the current directory
tenv tf install 1.9.0
tenv tf use 1.9.0
# tenv reads a .terraform-version file (or the terraform block's
# required_version constraint) to pick the right version automatically
terraform version

ไม่ว่าคุณจะใช้วิธีติดตั้งแบบไหน คำสั่งที่ใช้งานกับ terraform binary เองในแต่ละวันจะเหมือนกันทุกประการ — version manager เปลี่ยนแค่วิธีที่ binary นั้นมาอยู่บนเครื่องคุณเท่านั้น

โปรเจกต์ Terraform ก็แค่ directory ที่มีไฟล์ .tf หนึ่งไฟล์หรือมากกว่านั้น ไฟล์ที่เล็กที่สุดที่ใช้งานได้จริงจะประกาศ provider, resource group และ resource หนึ่งตัวข้างใน

terraform {
required_version = ">= 1.11.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.0"
}
}
}
provider "azurerm" {
features {}
}
resource "azurerm_resource_group" "main" {
name = "example-resources"
location = "East US"
}
resource "azurerm_storage_account" "reports" {
name = "acmemonthlyreports001"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
account_tier = "Standard"
account_replication_type = "LRS"
}

นี่คือ configuration ที่สมบูรณ์และ apply ได้จริง ไม่มี virtual network ไม่มี compute ไม่มี prerequisite อะไรนอกจาก credential ของ Azure ใน environment ของคุณ azurerm_storage_account เป็น resource แรกที่ดีด้วยเหตุผลนี้เป๊ะ ๆ — เพราะไม่ต้องพึ่งพาอะไรให้มีอยู่ก่อน นอกจาก resource group ของตัวเอง มีจุดแปลกอันหนึ่งที่ควรรู้ไว้ตั้งแต่ต้น คือ name ของ storage account ต้อง unique ทั่วทั้ง Azure ไม่ใช่แค่ใน subscription หรือ resource group ของคุณ — ใช้ได้แค่ตัวอักษรพิมพ์เล็กกับตัวเลข ห้ามมีขีด (dash) นั่นคือเหตุผลที่โปรเจกต์จริงมักจะ generate suffix แบบสุ่มหรือ hash แทนที่จะ hardcode ชื่ออย่างที่เห็นด้านบน

โปรเจกต์จริงแบ่งไฟล์เดียวนั้นออกเป็นหลายไฟล์ ตามธรรมเนียม

providers.tf
terraform {
required_version = ">= 1.11.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.0"
}
}
}
provider "azurerm" {
features {}
}
variables.tf
variable "storage_account_name" {
description = "Globally unique name of the storage account to create"
type = string
default = "acmemonthlyreports001"
}
main.tf
resource "azurerm_resource_group" "main" {
name = "example-resources"
location = "East US"
}
resource "azurerm_storage_account" "reports" {
name = var.storage_account_name
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
account_tier = "Standard"
account_replication_type = "LRS"
}
outputs.tf
output "storage_account_id" {
description = "Resource ID of the created storage account"
value = azurerm_storage_account.reports.id
}

ชื่อ main.tf, variables.tf, outputs.tf, providers.tf และ versions.tf เป็น ธรรมเนียม ไม่ใช่ข้อบังคับของ Terraform Terraform ไม่ได้ parse ชื่อไฟล์เลยแม้แต่น้อย แต่โหลดไฟล์ .tf ทุกไฟล์ใน directory มารวมกันเป็น configuration เดียว และไม่สนใจว่าคุณจะตั้งชื่อไฟล์ว่า main.tf, zzz.tf หรือ whatever.tf resource ใน outputs.tf กับ variable ที่อ้างถึงใน main.tf ทำงานเหมือนกันทุกประการกับตอนที่ทุกอย่างอยู่ในไฟล์เดียว — Terraform สร้าง dependency graph แบบเดียวกันไม่ว่าจะแบ่งไฟล์แบบไหน การแบ่งตามหน้าที่ (provider, variable, resource, output) มีไว้เพื่อให้คนที่เปิด directory รู้ว่าต้องดูตรงไหน ไม่ใช่เพราะ Terraform ต้องการเส้นแบ่งนั้น

flowchart TB
  subgraph dir["Project directory"]
    main["main.tf"]
    vars["variables.tf"]
    outs["outputs.tf"]
    prov["providers.tf"]
    ver["versions.tf"]
  end
  main --> combined["One combined Terraform configuration"]
  vars --> combined
  outs --> combined
  prov --> combined
  ver --> combined
Terraform โหลดไฟล์ .tf ทุกไฟล์ใน directory มารวมเป็น configuration เดียว

terraform init คือคำสั่งที่เปลี่ยนโปรเจกต์ขั้นต่ำสุดข้างบนให้กลายเป็นสิ่งที่ plan ได้จริง output จริงจาก terraform/provider block เดียวกันเป๊ะข้างบน:

$ terraform init
Initializing provider plugins...
- Finding hashicorp/azurerm versions matching "~> 5.0"...
- Installing hashicorp/azurerm v5.7.0...
- Installed hashicorp/azurerm v5.7.0 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

มีสองสิ่งจริงที่ถาวรเกิดขึ้นจากคำสั่งนั้น: ไดเรกทอรี .terraform ที่เก็บ provider binary ที่ดาวน์โหลดมา และไฟล์ .terraform.lock.hcl — เนื้อหาจริง ตัดให้เหลือแค่ไม่กี่ hash เพื่อความสั้น:

# .terraform.lock.hcl (real, trimmed)
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/azurerm" {
version = "5.7.0"
constraints = "~> 5.0"
hashes = [
"h1:ZWNc2pM6kUJtdekpy3Z3rfbJSw9KgY+qcQRLBJHAUJ0=",
"zh:186b696f83510c6e69a4fc924469179cd39bf3dbb7dbb5d9e3fdd6255413a8c",
"zh:2273d01bb3d3310467e35644c569736462030b41fed91b2a4043ab9cedf9b4",
# ... one zh: hash per published platform (darwin_arm64, linux_amd64, ...)
]
}

version คือ version เป๊ะ ๆ ที่ Terraform resolve จาก constraint ~> 5.0 — ไม่ใช่ตัว constraint เอง hashes คือ checksum list ที่ครอบคลุมทุก platform ที่ provider เผยแพร่ binary ไว้ ไม่ใช่แค่ platform ของคุณ นี่คือสิ่งที่ทำให้ terraform init บนเครื่อง Linux ของเพื่อนร่วมทีม (หรือ Linux CI runner) ตรวจสอบ provider release เดียวกันเป๊ะ ที่คุณ resolve ไว้บน macOS ได้ ไบต์ต่อไบต์ โดยไม่ต้อง resolve version constraint ใหม่ commit .terraform.lock.hcl เข้า version control ถ้าไม่มีไฟล์นี้ การรัน terraform init สองครั้งกับ constraint ~> 5.0 เดียวกัน ห่างกันเป็นสัปดาห์ อาจ resolve ไปยัง 5.x patch release คนละตัวได้อย่างถูกต้องตามกฎ — lock file คือสิ่งที่ทำให้ provider version reproducible ข้ามเครื่องและข้ามเวลา หน้าที่เดียวกับที่ package-lock.json หรือ Cargo.lock ทำใน ecosystem อื่น ห้าม commit .terraform/ ซึ่งเป็นไดเรกทอรีที่ provider binary เองถูกดาวน์โหลดเข้าไป — นั่นคือ local cache ที่ init สร้างใหม่ได้จาก lock file บนเครื่องไหนก็ได้

TF_PLUGIN_CACHE_DIR: ดาวน์โหลดครั้งเดียว ใช้ได้หลายโปรเจกต์

หัวข้อที่มีชื่อว่า “TF_PLUGIN_CACHE_DIR: ดาวน์โหลดครั้งเดียว ใช้ได้หลายโปรเจกต์”

โดย default แต่ละโปรเจกต์จะมีไดเรกทอรี .terraform/providers/ ของตัวเองที่เก็บสำเนาเต็มของทุก provider binary ที่ใช้ — ไม่มีปัญหาถ้ามีโปรเจกต์เดียว แต่สิ้นเปลือง (และช้า) เมื่อคุณมี repository เป็นสิบตัวที่ pin hashicorp/azurerm ~> 5.0 เหมือนกัน การตั้ง environment variable TF_PLUGIN_CACHE_DIR ให้ชี้ไปที่ไดเรกทอรีเดียวที่แชร์กันแก้ปัญหานี้ได้: Terraform ดาวน์โหลด provider version หนึ่งเข้า shared cache นั้นแค่ครั้งเดียว แล้ว init ครั้งถัดไปทุกครั้ง — ในโปรเจกต์ไหนก็ได้บนเครื่องเดียวกัน — จะ link ไปที่มันแทนที่จะดาวน์โหลดซ้ำ

Terminal window
export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"
mkdir -p "$TF_PLUGIN_CACHE_DIR"
terraform init

พิสูจน์ว่านี่คือ link จริง ไม่ใช่สำเนาที่สอง — หลัง init ที่ตั้ง TF_PLUGIN_CACHE_DIR ไว้ provider binary ภายใน .terraform/providers/ ของโปรเจกต์เอง คือ symlink ไปยัง shared cache directory:

$ ls -la .terraform/providers/registry.terraform.io/hashicorp/azurerm/5.7.0/
lrwxr-xr-x darwin_arm64 -> /Users/you/.terraform.d/plugin-cache/registry.terraform.io/hashicorp/azurerm/5.7.0/darwin_arm64

โปรเจกต์ที่สองที่ pin constraint hashicorp/azurerm ~> 5.0 เดียวกัน ด้วย TF_PLUGIN_CACHE_DIR เดียวกัน init จะ resolve ได้โดยไม่ต้องดาวน์โหลดใหม่เลย — Terraform เจอ version ที่มีอยู่แล้วใน cache แล้ว link ให้ทันที นี่คือสิ่งที่ CI pipeline และ harness สำหรับ verify ของคอร์สนี้เองใช้เพื่อไม่ต้องดาวน์โหลด provider binary เดิมซ้ำทุกบท

มีสองคำสั่งที่ควรรันก่อน commit ทุกครั้ง และไม่มีต้นทุนอะไรเลยแม้จะรันบ่อยแค่ไหน

terraform fmt เขียนไฟล์ใหม่ในที่เดิมให้เป็น format มาตรฐานของ Terraform — indentation สม่ำเสมอ เครื่องหมาย = จัดแนวตรงกัน spacing สม่ำเสมอ แก้แค่ style ไม่แก้ logic

Terminal window
terraform fmt

terraform validate ตรวจสอบว่า configuration สอดคล้องกันเองภายใน คือ HCL syntax ถูกต้อง argument มี type ตรงกัน variable ที่ reference ถึงมีอยู่จริง จับ typo และความผิดพลาดเชิงโครงสร้างได้ก่อนที่คุณจะติดต่อ Azure ด้วยซ้ำ

Terminal window
terraform validate
Success! The configuration is valid.

สิ่งที่ validate ไม่ทำ คือตรวจสอบอะไรกับ Azure API จริง validate ไม่รู้เลยว่า subscription ของคุณชน service quota หรือเปล่า ชื่อ storage account ที่คุณเลือกถูกใช้ไปแล้วทั่วโลกหรือไม่ หรือ credential ของคุณมีสิทธิ์สร้างจริงหรือเปล่า — ทั้งหมดนี้จะโผล่มาให้เห็นตอนที่คุณรัน terraform plan เท่านั้น เพราะเป็นคำสั่งแรกที่ติดต่อกับ Azure จริง ๆ configuration หนึ่งอาจผ่าน validate ได้อย่างไม่มีปัญหา แต่ยังพังตอน plan หรือ apply ได้

ทุกอย่างในบทนี้คือการทำให้ input ของโปรเจกต์ reproducible ก่อนที่อะไรจะถูกสร้างเลย: Terraform version (version manager หรือ required_version), provider version พร้อม binary เป๊ะ ๆ (required_providers บวก lock file) และ syntax เอง (fmt/validate) ทั้งสี่อย่างนี้ไม่เคยติดต่อ Azure เลย — ล้วนเป็นเรื่อง local, deterministic และมีไว้เพื่อให้แน่ใจว่าคอนฟิกเดียวกันผลิต plan เดียวกันบนเครื่องไหนก็ได้ ซึ่งเป็นเงื่อนไขเบื้องต้นสำหรับทุกอย่างที่เหลือของคอร์สนี้

ตัวเลือกข้อดีต้นทุน
Version manager (tenv) แทน binary เดี่ยว globalหลายโปรเจกต์ pin Terraform/Terragrunt version ต่างกันบนเครื่องเดียวได้ สลับอัตโนมัติต้องติดตั้งเครื่องมือเพิ่มอีกตัวและคอยอัปเดตมันเอง
Commit .terraform.lock.hclprovider version reproducible ข้ามเครื่องและเวลา พิสูจน์ข้างบนด้วย lock file จริงที่มี hashes ครอบคลุมทุก platformต้อง regenerate (terraform init -upgrade) อย่างตั้งใจเมื่อต้องการ bump version จริง ๆ ไม่ track “latest” แบบเงียบ ๆ
TF_PLUGIN_CACHE_DIRดาวน์โหลดครั้งเดียวแทนสำเนาต่อโปรเจกต์ พิสูจน์ข้างบนด้วย symlink จริงไดเรกทอรีที่แชร์และแก้ไขได้ข้ามโปรเจกต์ — cache entry ที่เสียกระทบทุกโปรเจกต์ที่ชี้ไปที่มัน ไม่ใช่แค่ตัวเดียว
terraform fmt/validate ก่อน commit ทุกครั้งจับ syntax และความผิดพลาดเชิงความสอดคล้องได้ฟรี ก่อน Azure API call ใด ๆไม่มีตัวไหนจับอะไรที่ต้องใช้ Azure state จริงได้ — validate ที่ผ่านสะอาดจำเป็นแต่ไม่พอ
  • “lock file แค่ทวน version constraint จาก required_providers” พิสูจน์ข้างบนแล้วว่าไม่ใช่: required_providers มี version = "~> 5.0" (constraint); .terraform.lock.hcl มี version = "5.7.0" (version เป๊ะ ๆ ตัวเดียวที่ resolve จริง) บวก hash list เต็มสำหรับทุก platform ไม่ใช่แค่ของคุณ
  • “.terraform/ (provider binary ที่ดาวน์โหลดมา) ควร commit คู่กับ lock file” ไม่ควร — พิสูจน์ข้างบนแล้วว่ามันถูกสร้างใหม่โดย init บนเครื่องไหนก็ได้จาก lock file และเมื่อตั้ง TF_PLUGIN_CACHE_DIR มันก็ไม่ใช่สำเนาจริงด้วยซ้ำ แค่ symlink เข้า shared cache
  • “TF_PLUGIN_CACHE_DIR copy provider binary เข้าแต่ละโปรเจกต์” พิสูจน์ข้างบนแล้วด้วย ls -la จริง: ไฟล์ภายใน .terraform/providers/ ของโปรเจกต์เองคือ symlink ไปยัง shared cache directory ไม่ใช่สำเนาที่สอง
  • “terraform validate ผ่านสะอาดแปลว่า terraform apply จะสำเร็จ” พิสูจน์ข้างบนแล้ว (และย้ำด้วยการออกแบบ harness ของคอร์สเอง): validate ไม่เคยติดต่อ Azure ดังนั้นชื่อ storage account ที่ถูกใช้แล้ว, permission ที่ขาด หรือ quota ที่ชน จะโผล่แค่ตอน plan หรือ apply เท่านั้น
  • “การแยกโปรเจกต์เป็น main.tf/variables.tf/outputs.tf เปลี่ยนวิธีที่ Terraform ประเมินมัน” ไม่จริง — พิสูจน์ข้างบนแล้วว่า Terraform รวมทุกไฟล์ .tf ในไดเรกทอรีเข้าเป็น configuration เดียวไม่ว่าจะชื่ออะไร การแยกมีไว้เพื่อคนอ่านไดเรกทอรีล้วน ๆ
  • .gitignore ที่ exclude .terraform.lock.hcl (บางทีรวมอยู่ใน pattern ignore แบบ .terraform* ทั่วไป) — พิสูจน์ข้างบนแล้วว่านี่คือไฟล์เดียวใน .terraform* ที่ควร commit เสมอ มีแค่ไดเรกทอรี .terraform/ เองเท่านั้นที่ควร ignore
  • script setup ที่ generate มาซึ่งลบและรัน terraform init ใหม่ทุกครั้งที่ CI รัน โดยไม่ตั้ง TF_PLUGIN_CACHE_DIR — พิสูจน์ข้างบนแล้วว่านี่ดาวน์โหลด provider binary เดิมซ้ำทุกครั้งแทนที่จะใช้ shared cache
  • โค้ดหรือเอกสารที่อ้างว่า terraform validate รับประกันว่า apply จะสำเร็จ — พิสูจน์ข้างบนแล้วว่ามันเป็นแค่ syntax/consistency check แบบ local ล้วน ๆ ไม่มี Azure API call เลย
  • โปรเจกต์ที่ generate มาโดยยัด resource ทุกตัวไว้ใน main.tf เดียวโดยไม่มี variables.tf/outputs.tf แยกเลย — ไม่ผิด (Terraform ไม่สนใจ) แต่ควรเตือนว่าเป็นการถอยหลังด้านความอ่านง่ายเมื่อคอนฟิกโตเกินกว่า resource หยิบมือเดียว
  • terraform init -upgrade ที่ใช้เป็นประจำ (เช่นใน cron job หรือทุก CI run) แทนที่จะใช้เฉพาะตอนต้องการ bump version จริง ๆ — นี่ทำลาย reproducibility ที่ lock file มีไว้ให้ ทำให้ provider version ค่อย ๆ เบี่ยงไปแบบเงียบ ๆ ตามเวลา

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

.gitignore
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.backup
เฉลย

.terraform.lock.hcl ไม่ควร อยู่ใน .gitignore — มันคือ artifact เดียวจาก terraform init ที่มีไว้เพื่อ commit โดยเฉพาะ พิสูจน์ข้างบนแล้ว: มัน pin provider version ที่ resolve จริง (5.7.0 ไม่ใช่ constraint ~> 5.0) บวก checksum สำหรับทุก platform ซึ่งเป็นสิ่งที่ทำให้ terraform init reproducible ข้ามเครื่องและข้ามเวลา การ ignore มันแปลว่า init ของเพื่อนร่วมทีมแต่ละคน และทุก CI run อาจ resolve hashicorp/azurerm version 5.x patch คนละตัวได้อย่างถูกต้องตามกฎ — แบบเงียบ ๆ ไม่มี error เพราะ ~> 5.0 พอใจทั้งคู่อยู่ดี มีแค่ .terraform/ (binary ที่ดาวน์โหลดมา สร้างใหม่ได้จาก lock file โดย init ไหนก็ได้) กับรายการ *.tfstate* เท่านั้นที่ควรอยู่ใน .gitignore ทางแก้คือลบบรรทัด .terraform.lock.hcl ออกไปเฉย ๆ

Terraform สนใจไหมว่า resource ถูกประกาศใน main.tf หรือไฟล์อื่น
terraform fmt ทำอะไร
terraform validate ตรวจสอบอะไร
ทำไมการผ่าน terraform validate ถึงไม่การันตีว่าจะไม่เจอปัญหาฝั่ง Azure จริง
ความต่างระหว่าง version ใน required_providers กับ version ใน .terraform.lock.hcl คืออะไร
ไฟล์ไหนควร commit เข้า version control
เมื่อตั้ง TF_PLUGIN_CACHE_DIR แล้ว provider binary ภายใน .terraform/providers/ ของโปรเจกต์เองมีเนื้อหาเป็นอะไรจริง ๆ