การติดตั้ง Terraform และโครงสร้างโปรเจกต์
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”ติดตั้ง Terraform ผ่าน version manager แทนที่จะ pin binary เดียวไว้ตายตัว เขียน GCP resource จริงตัวแรก แล้วแยกออกเป็นไฟล์หลายไฟล์ตามชื่อที่เป็นธรรมเนียม — ซึ่งจริง ๆ แล้ว Terraform ไม่ได้บังคับสักไฟล์เดียว
ติดตั้ง Terraform (และทำไม version manager ดีกว่า binary เดียว)
หัวข้อที่มีชื่อว่า “ติดตั้ง Terraform (และทำไม version manager ดีกว่า binary เดียว)”คุณดาวน์โหลด Terraform binary เดียวจาก HashiCorp มาวางไว้ใน PATH ได้ และก็ใช้งานได้ดีถ้าทดลองอยู่คนเดียวโปรเจกต์เดียว แต่พอต้องทำงานมากกว่าหนึ่งโปรเจกต์ วิธีนี้จะเริ่มมีปัญหา repository หนึ่งเขียนไว้กับ Terraform 1.5 อีกอันต้องการ feature ของ 1.9 ส่วนอีกอันย้ายไป OpenTofu แล้ว การคอยสลับ binary กลาง (global) ตัวเดียวให้ตรงกับโปรเจกต์ที่กำลังทำอยู่ตลอดเวลาเป็นงานที่น่าเบื่อและพลาดง่าย
version manager แก้ปัญหานี้ด้วยการติดตั้งหลายเวอร์ชันเก็บไว้คู่กัน แล้วสลับใช้ทีละโปรเจกต์ โดยมักอิงจาก version file ที่ commit ไว้ใน repository tenv เป็นตัวเลือกที่ดีเพราะไม่ได้ผูกกับเครื่องมือตัวเดียว จัดการ Terraform, OpenTofu, Terragrunt และ Atmos ได้จาก CLI ตัวเดียว ซึ่งสำคัญมากเมื่อทีมของคุณใช้ Terragrunt คู่กับ Terraform (จะพูดถึงในคอร์สนี้ตอนหลัง)
# install tenv (macOS, via Homebrew)brew install tenv
# install and pin a specific Terraform version for the current directorytenv tf install 1.9.0tenv tf use 1.9.0
# tenv reads a .terraform-version file (or the terraform block's# required_version constraint) to pick the right version automaticallyterraform versionไม่ว่าจะติดตั้งด้วยวิธีไหน คำสั่งที่ใช้งานกับ terraform binary ในแต่ละวันก็เหมือนกันทุกประการ — version manager เปลี่ยนแค่วิธีที่ binary นั้นมาอยู่บนเครื่องคุณเท่านั้น
โปรเจกต์ GCP จริงขนาดเล็กที่สุด
หัวข้อที่มีชื่อว่า “โปรเจกต์ GCP จริงขนาดเล็กที่สุด”โปรเจกต์ Terraform ก็แค่ directory ที่มีไฟล์ .tf หนึ่งไฟล์หรือมากกว่า ตัวที่เล็กที่สุดแต่ใช้งานได้จริงประกาศ provider กับ resource เดียว
terraform { required_version = ">= 1.11.0"
required_providers { google = { source = "hashicorp/google" version = "~> 8.0" } }}
provider "google" { project = "acme-app" region = "us-central1"}
resource "google_storage_bucket" "reports" { name = "acme-monthly-reports" location = "US"}นี่คือ configuration ที่สมบูรณ์และ apply ได้ทันที ไม่ต้องมี VPC ไม่ต้องมี networking ไม่มีข้อกำหนดล่วงหน้าอะไรเลยนอกจาก GCP credentials ในเครื่อง (Application Default Credentials ตั้งค่าด้วย gcloud auth application-default login) google_storage_bucket เป็น resource แรกที่ดีด้วยเหตุผลนี้เป๊ะ ๆ เพราะไม่ต้องพึ่งอะไรที่ต้องมีอยู่ก่อนเลย
การจัดไฟล์ตามธรรมเนียม
หัวข้อที่มีชื่อว่า “การจัดไฟล์ตามธรรมเนียม”โปรเจกต์จริงจะแยกไฟล์เดียวนั้นออกเป็นหลายไฟล์ ตามธรรมเนียมที่ใช้กัน
terraform { required_version = ">= 1.11.0"
required_providers { google = { source = "hashicorp/google" version = "~> 8.0" } }}
provider "google" { project = "acme-app" region = "us-central1"}variable "bucket_name" { description = "Name of the storage bucket to create" type = string default = "acme-monthly-reports"}resource "google_storage_bucket" "reports" { name = var.bucket_name location = "US"}output "bucket_self_link" { description = "Self link of the created storage bucket" value = google_storage_bucket.reports.self_link}ชื่อ main.tf, variables.tf, outputs.tf, providers.tf และ versions.tf เป็น ธรรมเนียม ไม่ใช่ข้อบังคับของ Terraform Terraform ไม่ได้สนใจชื่อไฟล์เลยแม้แต่น้อย แต่โหลดทุกไฟล์ .tf ใน directory มารวมกันเป็น configuration เดียว และไม่สนว่าคุณจะตั้งชื่อไฟล์ว่า main.tf, zzz.tf หรือ whatever.tf resource ที่อยู่ใน outputs.tf กับ variable ที่อ้างอิงถึงใน main.tf ทำงานเหมือนกันเป๊ะกับตอนที่ทุกอย่างอยู่ในไฟล์เดียว Terraform สร้าง dependency graph แบบเดียวกันเสมอไม่ว่าจะแยกไฟล์แบบไหน การแยกตามหน้าที่ (providers, variables, resources, outputs) มีไว้เพื่อให้คนที่เปิด 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 init, provider install output จริง และ lock file
หัวข้อที่มีชื่อว่า “terraform init, provider install output จริง และ lock file”terraform init คือคำสั่งที่เปลี่ยนโปรเจกต์ขั้นต่ำสุดข้างบนให้กลายเป็นสิ่งที่ plan ได้จริง output จริงจาก terraform/provider block เดียวกันเป๊ะข้างบน:
$ terraform initInitializing provider plugins...- Finding hashicorp/google versions matching "~> 8.0"...- Installing hashicorp/google v8.4.0...- Installed hashicorp/google v8.4.0 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the providerselections it made above. Include this file in your version control repositoryso that Terraform can guarantee to make the same selections by default whenyou 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/google" { version = "8.4.0" constraints = "~> 8.0" hashes = [ "h1:vxMhPGBXNTrgoK8PpiDUHwboHUuiUhb2HPG8pYZpySE=", "zh:0a5eb94e558ae40cc0d992ce1c9bd717decda1a109fd743c5e0872be858f2c7", "zh:1b0c6708fadba06883f56e5de2b7c199450642a1d1af932d09a95983935f10b", # ... one zh: hash per published platform (darwin_arm64, linux_amd64, ...) ]}version คือ version เป๊ะ ๆ ที่ Terraform resolve จาก constraint ~> 8.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 ~> 8.0 เดียวกัน ห่างกันเป็นสัปดาห์ อาจ resolve ไปยัง 8.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/google ~> 8.0 เหมือนกัน การตั้ง environment variable TF_PLUGIN_CACHE_DIR ให้ชี้ไปที่ไดเรกทอรีเดียวที่แชร์กันแก้ปัญหานี้ได้: Terraform ดาวน์โหลด provider version หนึ่งเข้า shared cache นั้นแค่ครั้งเดียว แล้ว init ครั้งถัดไปทุกครั้ง — ในโปรเจกต์ไหนก็ได้บนเครื่องเดียวกัน — จะ link ไปที่มันแทนที่จะดาวน์โหลดซ้ำ
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/google/8.4.0/lrwxr-xr-x darwin_arm64 -> /Users/you/.terraform.d/plugin-cache/registry.terraform.io/hashicorp/google/8.4.0/darwin_arm64โปรเจกต์ที่สองที่ pin constraint hashicorp/google ~> 8.0 เดียวกัน ด้วย TF_PLUGIN_CACHE_DIR เดียวกัน init จะ resolve ได้โดยไม่ต้องดาวน์โหลดใหม่เลย — Terraform เจอ version ที่มีอยู่แล้วใน cache แล้ว link ให้ทันที นี่คือสิ่งที่ CI pipeline และ harness สำหรับ verify ของคอร์สนี้เองใช้เพื่อไม่ต้องดาวน์โหลด provider binary เดิมซ้ำทุกบท
สุขอนามัยวันแรก ๆ ได้แก่ fmt กับ validate
หัวข้อที่มีชื่อว่า “สุขอนามัยวันแรก ๆ ได้แก่ fmt กับ validate”มีสองคำสั่งที่ควรรันก่อน commit ทุกครั้ง และรันบ่อยแค่ไหนก็ไม่มีต้นทุนอะไรเลย
terraform fmt เขียนไฟล์ทับในที่เดิมให้เป็น formatting มาตรฐานของ Terraform เช่น indentation ที่สม่ำเสมอ เครื่องหมาย = ที่จัดตำแหน่งตรงกัน spacing ที่สม่ำเสมอ แก้แค่ style ไม่ได้แก้ logic
terraform fmtterraform validate ตรวจว่า configuration สอดคล้องกันเองภายใน เช่น HCL syntax ที่ถูกต้อง argument ที่ type ตรง และ variable ที่ถูกอ้างอิงถึงมีอยู่จริง จับ typo และความผิดพลาดเชิงโครงสร้างได้ตั้งแต่ก่อนที่จะติดต่อ GCP ด้วยซ้ำ
terraform validateSuccess! The configuration is valid.สิ่งที่ validate ไม่ได้ทำ คือตรวจสอบอะไรกับ GCP API จริง validate ไม่รู้เลยว่าโปรเจกต์ของคุณชน service quota หรือยัง ชื่อ bucket ที่คุณเลือกถูกใช้ไปแล้วทั่วโลกหรือเปล่า หรือ service account ของคุณมีสิทธิ์สร้าง resource นั้นจริงไหม ทั้งหมดนี้จะโผล่มาให้เห็นตอนรัน terraform plan เท่านั้น เพราะเป็นคำสั่งแรกที่ติดต่อกับ GCP จริง ๆ configuration ที่ผ่าน validate ได้อย่างสมบูรณ์ก็ยังพังตอน plan หรือ apply ได้อยู่ดี
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุกอย่างในบทนี้คือการทำให้ input ของโปรเจกต์ reproducible ก่อนที่อะไรจะถูกสร้างเลย: Terraform version (version manager หรือ required_version), provider version พร้อม binary เป๊ะ ๆ (required_providers บวก lock file) และ syntax เอง (fmt/validate) ทั้งสี่อย่างนี้ไม่เคยติดต่อ GCP เลย — ล้วนเป็นเรื่อง local, deterministic และมีไว้เพื่อให้แน่ใจว่าคอนฟิกเดียวกันผลิต plan เดียวกันบนเครื่องไหนก็ได้ ซึ่งเป็นเงื่อนไขเบื้องต้นสำหรับทุกอย่างที่เหลือของคอร์สนี้
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ต้นทุน |
|---|---|---|
Version manager (tenv) แทน binary เดี่ยว global | หลายโปรเจกต์ pin Terraform/Terragrunt version ต่างกันบนเครื่องเดียวได้ สลับอัตโนมัติ | ต้องติดตั้งเครื่องมือเพิ่มอีกตัวและคอยอัปเดตมันเอง |
Commit .terraform.lock.hcl | provider 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 และความผิดพลาดเชิงความสอดคล้องได้ฟรี ก่อน GCP API call ใด ๆ | ไม่มีตัวไหนจับอะไรที่ต้องใช้ GCP state จริงได้ — validate ที่ผ่านสะอาดจำเป็นแต่ไม่พอ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “lock file แค่ทวน version constraint จาก required_providers” พิสูจน์ข้างบนแล้วว่าไม่ใช่:
required_providersมีversion = "~> 8.0"(constraint);.terraform.lock.hclมีversion = "8.4.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_DIRcopy provider binary เข้าแต่ละโปรเจกต์” พิสูจน์ข้างบนแล้วด้วยls -laจริง: ไฟล์ภายใน.terraform/providers/ของโปรเจกต์เองคือ symlink ไปยัง shared cache directory ไม่ใช่สำเนาที่สอง - “
terraform validateผ่านสะอาดแปลว่าterraform applyจะสำเร็จ” พิสูจน์ข้างบนแล้ว (และย้ำด้วยการออกแบบ harness ของคอร์สเอง):validateไม่เคยติดต่อ GCP ดังนั้นชื่อ bucket ที่ถูกใช้แล้ว, permission ของ service account ที่ขาด หรือ quota ที่ชน จะโผล่แค่ตอนplanหรือapplyเท่านั้น - “การแยกโปรเจกต์เป็น main.tf/variables.tf/outputs.tf เปลี่ยนวิธีที่ Terraform ประเมินมัน” ไม่จริง — พิสูจน์ข้างบนแล้วว่า Terraform รวมทุกไฟล์
.tfในไดเรกทอรีเข้าเป็น configuration เดียวไม่ว่าจะชื่ออะไร การแยกมีไว้เพื่อคนอ่านไดเรกทอรีล้วน ๆ
อ่านโค้ดที่ AI สร้างให้
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้างให้”.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 ล้วน ๆ ไม่มี GCP 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 ในโค้ดนี้
.terraform/.terraform.lock.hcl*.tfstate*.tfstate.backupเฉลย
.terraform.lock.hcl ไม่ควร อยู่ใน .gitignore — มันคือ artifact เดียวจาก terraform init ที่มีไว้เพื่อ commit โดยเฉพาะ พิสูจน์ข้างบนแล้ว: มัน pin provider version ที่ resolve จริง (8.4.0 ไม่ใช่ constraint ~> 8.0) บวก checksum สำหรับทุก platform ซึ่งเป็นสิ่งที่ทำให้ terraform init reproducible ข้ามเครื่องและข้ามเวลา การ ignore มันแปลว่า init ของเพื่อนร่วมทีมแต่ละคน และทุก CI run อาจ resolve hashicorp/google version 8.x patch คนละตัวได้อย่างถูกต้องตามกฎ — แบบเงียบ ๆ ไม่มี error เพราะ ~> 8.0 พอใจทั้งคู่อยู่ดี มีแค่ .terraform/ (binary ที่ดาวน์โหลดมา สร้างใหม่ได้จาก lock file โดย init ไหนก็ได้) กับรายการ *.tfstate* เท่านั้นที่ควรอยู่ใน .gitignore ทางแก้คือลบบรรทัด .terraform.lock.hcl ออกไปเฉย ๆ