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

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

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

คุณดาวน์โหลด 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 (จะพูดถึงในคอร์สนี้ตอนหลัง)

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 เดียว

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 แรกที่ดีด้วยเหตุผลนี้เป๊ะ ๆ เพราะไม่ต้องพึ่งอะไรที่ต้องมีอยู่ก่อนเลย

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

providers.tf
terraform {
required_version = ">= 1.11.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 8.0"
}
}
}
provider "google" {
project = "acme-app"
region = "us-central1"
}
variables.tf
variable "bucket_name" {
description = "Name of the storage bucket to create"
type = string
default = "acme-monthly-reports"
}
main.tf
resource "google_storage_bucket" "reports" {
name = var.bucket_name
location = "US"
}
outputs.tf
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 โหลดทุกไฟล์ .tf ใน directory มารวมเป็น configuration เดียว

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

$ terraform init
Initializing 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 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/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 ไปที่มันแทนที่จะดาวน์โหลดซ้ำ

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/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 เดิมซ้ำทุกบท

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

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

Terminal window
terraform fmt

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

Terminal window
terraform validate
Success! The configuration is valid.

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

ทุกอย่างในบทนี้คือการทำให้ input ของโปรเจกต์ reproducible ก่อนที่อะไรจะถูกสร้างเลย: Terraform version (version manager หรือ required_version), provider version พร้อม binary เป๊ะ ๆ (required_providers บวก lock file) และ syntax เอง (fmt/validate) ทั้งสี่อย่างนี้ไม่เคยติดต่อ GCP เลย — ล้วนเป็นเรื่อง 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 และความผิดพลาดเชิงความสอดคล้องได้ฟรี ก่อน 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_DIR copy 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 เดียวไม่ว่าจะชื่ออะไร การแยกมีไว้เพื่อคนอ่านไดเรกทอรีล้วน ๆ
  • .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 ในโค้ดนี้

.gitignore
.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 ออกไปเฉย ๆ

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