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

Installing Terraform and Project Structure

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

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

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

คุณดาวน์โหลด Terraform binary ตัวเดียวจาก HashiCorp แล้ววางไว้ใน PATH ได้ และวิธีนี้ก็ใช้งานได้ดีถ้าทำ experiment คนเดียว แต่พอทำงานมากกว่าหนึ่งโปรเจกต์ วิธีนี้จะเริ่มมีปัญหา repository หนึ่งเขียนไว้สำหรับ Terraform 1.5 อีกอันต้องใช้ฟีเจอร์ของ 1.11 และอีกอันก็ migrate ไปใช้ OpenTofu แล้ว การคอยสลับ 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.16.2
tenv tf use 1.16.2
# 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 คือไดเรกทอรีที่มีไฟล์ .tf หนึ่งไฟล์หรือมากกว่านั้น ไฟล์ที่เล็กที่สุดแต่ใช้งานได้จริงคือการประกาศ provider กับ resource เดียว

terraform {
required_version = ">= 1.11.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "reports" {
bucket = "acme-monthly-reports"
}

นั่นคือ configuration ที่สมบูรณ์และ apply ได้จริง ไม่มี VPC ไม่มีเรื่อง networking ไม่มี prerequisite อะไรนอกจาก AWS credential ใน environment ของคุณ aws_s3_bucket เป็น resource แรกที่ดีด้วยเหตุผลนี้เป๊ะ ๆ คือไม่ต้องพึ่งอะไรให้มีอยู่ก่อน

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

providers.tf
terraform {
required_version = ">= 1.11.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
variables.tf
variable "bucket_name" {
description = "Name of the S3 bucket to create"
type = string
default = "acme-monthly-reports"
}
main.tf
resource "aws_s3_bucket" "reports" {
bucket = var.bucket_name
}
outputs.tf
output "bucket_arn" {
description = "ARN of the created S3 bucket"
value = aws_s3_bucket.reports.arn
}

ชื่อ main.tf, variables.tf, outputs.tf, providers.tf และ versions.tf เป็น convention ไม่ใช่ข้อบังคับของ Terraform Terraform ไม่ได้สนใจชื่อไฟล์เลยแม้แต่น้อย แต่โหลดทุกไฟล์ .tf ในไดเรกทอรี รวมเข้าเป็น configuration เดียว และไม่สนว่าคุณตั้งชื่อไฟล์ว่า main.tf, zzz.tf หรือ whatever.tf resource ใน outputs.tf กับ variable ที่อ้างอิงใน main.tf ทำงานเหมือนกันทุกอย่างกับตอนที่ทุกอย่างอยู่ในไฟล์เดียว Terraform สร้าง dependency graph เดียวกันไม่ว่าจะแยกไฟล์แบบไหน การแยกตาม concern (provider, variable, resource, output) มีไว้เพื่อให้คนที่เปิดไดเรกทอรีรู้ว่าต้องไปดูตรงไหนเท่านั้น ไม่ใช่เพราะ 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 ในไดเรกทอรีรวมเป็น configuration เดียว

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

$ terraform init
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 6.0"...
- Installing hashicorp/aws v6.66.0...
- Installed hashicorp/aws v6.66.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/aws" {
version = "6.66.0"
constraints = "~> 6.0"
hashes = [
"h1:OnLj4nhqJnEcUzyyRKUjp1FgWG00Y8maikJEYSf9Zjw=",
"zh:156fe7164a3d26ef6b35734c43e99fb198df90575ed897d1182b8e930b8cd52",
"zh:1af52b22b35be00f8d16e3ebebff9fa699ec4db2ef69e6032ba5c536f80c03d",
# ... one zh: hash per published platform (darwin_arm64, linux_amd64, ...)
]
}

version คือ version เป๊ะ ๆ ที่ Terraform resolve จาก constraint ~> 6.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 ~> 6.0 เดียวกัน ห่างกันเป็นสัปดาห์ อาจ resolve ไปยัง 6.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/aws ~> 6.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/aws/6.66.0/
lrwxr-xr-x darwin_arm64 -> /Users/you/.terraform.d/plugin-cache/registry.terraform.io/hashicorp/aws/6.66.0/darwin_arm64

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

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

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

Terminal window
terraform fmt

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

Terminal window
terraform validate
Success! The configuration is valid.

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

ทุกอย่างในบทนี้คือการทำให้ input ของโปรเจกต์ reproducible ก่อนที่อะไรจะถูกสร้างเลย: Terraform version (version manager หรือ required_version), provider version พร้อม binary เป๊ะ ๆ (required_providers บวก lock file) และ syntax เอง (fmt/validate) ทั้งสี่อย่างนี้ไม่เคยติดต่อ AWS เลย — ล้วนเป็นเรื่อง 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 และความผิดพลาดเชิงความสอดคล้องได้ฟรี ก่อน AWS API call ใด ๆไม่มีตัวไหนจับอะไรที่ต้องใช้ AWS state จริงได้ — validate ที่ผ่านสะอาดจำเป็นแต่ไม่พอ
  • “lock file แค่ทวน version constraint จาก required_providers” พิสูจน์ข้างบนแล้วว่าไม่ใช่: required_providers มี version = "~> 6.0" (constraint); .terraform.lock.hcl มี version = "6.66.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 ไม่เคยติดต่อ AWS ดังนั้นชื่อ S3 bucket ที่ถูกใช้แล้ว, permission IAM ที่ขาด หรือ 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 ล้วน ๆ ไม่มี AWS 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 จริง (6.66.0 ไม่ใช่ constraint ~> 6.0) บวก checksum สำหรับทุก platform ซึ่งเป็นสิ่งที่ทำให้ terraform init reproducible ข้ามเครื่องและข้ามเวลา การ ignore มันแปลว่า init ของเพื่อนร่วมทีมแต่ละคน และทุก CI run อาจ resolve hashicorp/aws version 6.x patch คนละตัวได้อย่างถูกต้องตามกฎ — แบบเงียบ ๆ ไม่มี error เพราะ ~> 6.0 พอใจทั้งคู่อยู่ดี มีแค่ .terraform/ (binary ที่ดาวน์โหลดมา สร้างใหม่ได้จาก lock file โดย init ไหนก็ได้) กับรายการ *.tfstate* เท่านั้นที่ควรอยู่ใน .gitignore ทางแก้คือลบบรรทัด .terraform.lock.hcl ออกไปเฉย ๆ

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