HCL, Provider และ Resource
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”configuration ของ Terraform ทุกส่วนคือ block เขียนด้วย HCL — terraform, provider, resource และ data คือสี่ตัวที่คุณจะเขียนบ่อยที่สุด รวมกันแล้วสี่ตัวนี้บอก Terraform ว่าต้องโหลด plugin อะไร คุยกับ AWS ยังไง ต้องสร้างอะไร และต้องอ่านอะไร
HCL block syntax
หัวข้อที่มีชื่อว่า “HCL block syntax”HCL (HashiCorp Configuration Language) สร้างจาก block รูปแบบทั่วไปคือ block type ตามด้วย label ที่ครอบด้วยเครื่องหมายคำพูดศูนย์ตัวขึ้นไป แล้วปิดท้ายด้วย body ของ argument ใน {}
block_type "label1" "label2" { argument = value}อย่าง resource block มี label สองตัว ส่วน provider block มี label ตัวเดียว comment ใช้ # หรือ // สำหรับบรรทัดเดียว และ /* */ สำหรับหลายบรรทัด string รองรับ interpolation ด้วย ${...} แต่ถ้าอ้างอิงค่าอื่นตรง ๆ (bucket = aws_s3_bucket.reports.id) ไม่ต้องครอบด้วย ${} — syntax แบบนั้นใช้เฉพาะตอนอยู่ข้างใน string ที่ยาวกว่านั้น
# A single-line comment.// Also a single-line comment.
resource "aws_s3_bucket" "reports" { bucket = "acme-monthly-reports-${var.environment}" # interpolation inside a string}terraform block กับ provider block
หัวข้อที่มีชื่อว่า “terraform block กับ provider block”terraform block คือการตั้งค่า Terraform เอง ว่า Terraform เวอร์ชันไหนรัน configuration นี้ได้ และต้องใช้ provider อะไรบ้าง รวมถึงแต่ละ provider มาจากไหนและเวอร์ชันไหนที่รับได้
terraform { required_version = ">= 1.11.0"
required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } }}source คือ registry address ของ provider (hashicorp/aws ชี้ไปที่ AWS provider ตัวจริงบน Terraform Registry) ส่วน version เป็น constraint ไม่ใช่การ pin ตายตัว — ~> 6.0 อนุญาตทุก release ในสาย 6.x
จากนั้น provider block ตั้งค่า instance หนึ่งของ provider ที่ประกาศไว้ด้านบน สำหรับ AWS ตรงนี้คือที่อยู่ของ region (และในโปรเจกต์จริง ๆ ก็มีเรื่องอย่าง assumed role ด้วย)
provider "aws" { region = "us-east-1"}resource block: สอง label ที่มีความหมาย
หัวข้อที่มีชื่อว่า “resource block: สอง label ที่มีความหมาย”resource block คือวิธีบอก Terraform ว่า “สิ่งนี้ควรมีอยู่” resource block มี label สองตัวเสมอ
resource "aws_instance" "web" { ami = "ami-0c55b159cbfafe1f0" instance_type = "t3.micro"}- label แรก
aws_instanceคือ type ของ resource ถูกกำหนดโดย provider และเป็นตัวตัดสินว่า argument ไหนใช้ได้ และ AWS API ตัวไหนที่ Terraform จะเรียก - label ที่สอง
webคือ name ที่คุณเลือกเอง มีความหมายแค่ภายใน configuration นี้เท่านั้น
รวมกันแล้วทั้งสองตัวคือ address ของ resource นั้น คือ aws_instance.web ที่ใช้อ้างอิง attribute ของ resource นี้จากที่ไหนก็ได้ใน configuration ของคุณ เช่น aws_instance.web.id หรือ aws_instance.web.public_ip
data block: อ่านโดยไม่เป็นเจ้าของ
หัวข้อที่มีชื่อว่า “data block: อ่านโดยไม่เป็นเจ้าของ”data block อ่านข้อมูลของสิ่งที่มีอยู่แล้ว คือ infrastructure ที่ configuration นี้ไม่ได้จัดการและจะไม่มีวันสร้าง อัปเดต หรือลบ นี่คือ read-only lookup ของ Terraform
data "aws_ami" "latest" { most_recent = true owners = ["amazon"]
filter { name = "name" values = ["al2023-ami-*-x86_64"] }}
resource "aws_instance" "web" { ami = data.aws_ami.latest.id instance_type = "t3.micro"}data.aws_ami.latest.id อ่านเหมือน resource address เป๊ะ ๆ แค่มี data. นำหน้า — label type กับ name ทำงานแบบเดียวกัน แต่ไม่มีอะไรจาก data block ที่จะโผล่เป็น create, update หรือ destroy ใน plan เลย เพราะมีหน้าที่อ่านอย่างเดียว
Dependency graph
หัวข้อที่มีชื่อว่า “Dependency graph”Terraform ไม่ได้ apply configuration ของคุณเรียงจากบนลงล่างตามที่เขียนไว้ แต่สร้าง DAG (directed acyclic graph) ของทุก resource และ data source แล้วใช้กราฟนั้นหาลำดับที่ถูกต้อง — พร้อมทั้งดูว่า resource ตัวไหนเป็นอิสระจากกันพอที่จะสร้าง พร้อมกัน ได้
edge ในกราฟนั้นมาจากสองที่
- Implicit dependency เมื่อไหร่ที่ argument ของ resource หนึ่งอ้างอิง attribute ของอีก resource หนึ่ง (เช่น
aws_instance.webอ้างอิงdata.aws_ami.latest.idหรือ subnet อ้างอิง ID ของ VPC) Terraform จะสรุปเองว่า resource ที่ถูกอ้างอิงต้องมีอยู่ก่อน - Explicit dependency คือ
depends_onบน resource ใช้ตอนที่ resource หนึ่งขึ้นกับอีกตัวจริง ๆ แต่ความสัมพันธ์นั้นไม่โผล่ผ่าน attribute reference ไหนเลย (เช่น IAM policy ที่ต้องมีอยู่ก่อนแอปเริ่มพึ่งพา โดยไม่มี argument เชื่อมกันตรง ๆ)
resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16"}
resource "aws_subnet" "app" { vpc_id = aws_vpc.main.id # implicit dependency on aws_vpc.main cidr_block = "10.0.1.0/24"}
resource "aws_instance" "web" { subnet_id = aws_subnet.app.id # implicit dependency on aws_subnet.app ami = data.aws_ami.latest.id instance_type = "t3.micro"}flowchart LR vpc["aws_vpc.main"] --> subnet["aws_subnet.app"] --> instance["aws_instance.web"] bucket["aws_s3_bucket.logs (unrelated, applies in parallel)"]
lifecycle block: เปลี่ยนพฤติกรรม default ของ Terraform
หัวข้อที่มีชื่อว่า “lifecycle block: เปลี่ยนพฤติกรรม default ของ Terraform”ทุก resource รับ lifecycle block เป็นตัวเลือกได้ ซึ่งเปลี่ยนวิธีที่ Terraform plan การเปลี่ยนแปลงสำหรับ resource นั้นตัวเดียว โดยไม่เกี่ยวกับส่วนอื่นใน configuration argument สี่ตัวครอบคลุมความต้องการจริงแทบทั้งหมด
prevent_destroy เปลี่ยนการ destroy ที่ plan ไว้ให้กลายเป็น error หนัก ๆ แทนที่จะปล่อยให้ดำเนินต่อ — ราวกั้นความปลอดภัยสำหรับ resource ที่ต้องไม่ถูกลบโดยไม่ตั้งใจเด็ดขาด:
resource "local_file" "protected" { filename = "${path.module}/protected.txt" content = var.content
lifecycle { prevent_destroy = true }}Error จริงจาก terraform plan -destroy กับบล็อกเดียวกันเป๊ะข้างบน:
$ terraform plan -destroyPlan: 0 to add, 0 to change, 1 to destroy.╷│ Error: Instance cannot be destroyed││ on main.tf line 10:│ 10: resource "local_file" "protected" {││ Resource local_file.protected has lifecycle.prevent_destroy set, but the│ plan calls for this resource to be destroyed. To avoid this error and│ continue with the plan, either disable lifecycle.prevent_destroy or reduce│ the scope of the plan using the -target option.╵สังเกตว่า Terraform ยังคำนวณ plan อยู่ (Plan: 0 to add, 0 to change, 1 to destroy พิมพ์ก่อน) — prevent_destroy ถูกเช็คและหยุดการทำงานหลังจาก plan ตัดสินใจแล้วว่าต้อง destroy เท่านั้น ไม่ใช่ก่อนหน้านั้น
create_before_destroy สลับลำดับ replace default ปกติแล้วการ replace resource คือ destroy-แล้ว-create (-/+ ใน plan); เมื่อตั้งค่านี้ Terraform จะสร้างตัวแทนก่อน แล้วค่อย destroy ตัวเก่าหลังจากตัวใหม่มีอยู่แล้ว — จำเป็นมากสำหรับอะไรก็ตามที่ช่วงเวลา “ไม่มีทั้งคู่” ยอมรับไม่ได้ อย่าง aws_instance ที่อยู่หลัง load balancer
resource "local_file" "app" { filename = "${path.module}/app-${var.content}.txt" content = var.content
lifecycle { create_before_destroy = true }}Plan จริงหลังเปลี่ยน var.content (ซึ่งบังคับให้ filename เปลี่ยนตามไปด้วย และ filename บังคับให้ replace):
$ terraform plan # local_file.app must be replaced+/- resource "local_file" "app" { ~ content = "v1" -> "v2" # forces replacement ~ filename = "./app-v1.txt" -> "./app-v2.txt" # forces replacement # (2 unchanged attributes hidden) }Plan: 1 to add, 0 to change, 1 to destroy.สัญลักษณ์คือตัวบ่งชี้: +/- (create แล้ว destroy) แทน -/+ default (destroy แล้ว create) ที่เห็นทั่วทั้งคอร์สนี้ create_before_destroy เปลี่ยนแค่สัญลักษณ์ลำดับนั้น ไม่เปลี่ยนจำนวนบรรทัดของ plan เลย
ignore_changes บอก Terraform ให้เลิกมองการเปลี่ยนแปลงของ argument เฉพาะเป็น drift — มีประโยชน์เมื่อบางอย่างนอก Terraform (autoscaler, คนแก้ tag ใน console) คาดว่าจะเปลี่ยนค่า และการยืนยันค่าเดิมซ้ำทุก apply จะไปขัดกับมัน
resource "terraform_data" "app" { input = { tags = { managed_by = "terraform" } } lifecycle { ignore_changes = [input] }}Plan จริงหลังเปลี่ยน input ใน configuration เอง:
$ terraform planNo changes. Your infrastructure matches the configuration.ถ้าไม่มี ignore_changes = [input] การแก้คอนฟิกแบบเดียวกันนั้นจะ plan update จริง แต่มีมันแล้ว Terraform ไม่เทียบ input ปัจจุบันกับค่าคอนฟิกใหม่สำหรับ argument นั้นเลยด้วยซ้ำ — การเปลี่ยนแปลงมองไม่เห็นสำหรับ plan ไม่ใช่แค่ปล่อยให้เกิดครั้งเดียวแล้วค่อย track ใหม่
replace_triggered_by (อธิบายลึกพร้อมพิสูจน์จริงในบท Count and For Each) บังคับให้ resource ถูก replace ทุกครั้งที่ค่าที่อ้างอิงเปลี่ยน แม้ argument ของ resource เองจะไม่เปลี่ยนเลย — ปัญหาตรงข้ามกับ ignore_changes: แทนที่จะซ่อนการเปลี่ยนแปลง มันสร้างการ replace ขึ้นมาจากการเปลี่ยนแปลงที่ปกติมองไม่เห็นสำหรับ resource นี้
terraform graph output จริง
หัวข้อที่มีชื่อว่า “terraform graph output จริง”terraform graph พิมพ์ dependency graph จริงที่ Terraform คำนวณ — ไม่ใช่คำอธิบาย แต่เป็น DOT output จริงที่ GraphViz (หรือเครื่องมืออ่าน DOT ไหนก็ได้) render ได้ตรง ๆ รันโดยไม่มี -type จะได้ resource-level summary graph เป็น default ซึ่งอ่านด้วยตาเปล่าง่ายที่สุด:
$ terraform graphdigraph G { rankdir = "RL"; node [shape = rect, fontname = "sans-serif"]; "data.aws_ami.latest" [label="data.aws_ami.latest"]; "aws_instance.web" [label="aws_instance.web"]; "aws_s3_bucket.logs" [label="aws_s3_bucket.logs"]; "aws_subnet.app" [label="aws_subnet.app"]; "aws_vpc.main" [label="aws_vpc.main"]; "aws_instance.web" -> "data.aws_ami.latest"; "aws_instance.web" -> "aws_subnet.app"; "aws_subnet.app" -> "aws_vpc.main";}นั่นคือกราฟจริงของ chain aws_vpc.main → aws_subnet.app → aws_instance.web ที่แสดงไว้ก่อนหน้าในบทนี้ บวกกับ aws_s3_bucket.logs ที่ไม่เกี่ยวข้องกัน — อ่านตาม rankdir = "RL" (ขวาไปซ้าย) edge A -> B หมายถึง “A ขึ้นกับ B” ตรงกับเหตุผลเรื่อง implicit dependency จากหัวข้อก่อนหน้าเป๊ะ: aws_instance.web ขึ้นกับทั้ง data.aws_ami.latest และ aws_subnet.app และ aws_subnet.app ขึ้นกับ aws_vpc.main aws_s3_bucket.logs ไม่มี edge เข้าหรือออกจากอะไรเลย ซึ่งเป็นเหตุผลที่ Terraform สร้างมันพร้อมกับ chain ของ VPC ได้อย่างอิสระ
flowchart RL instance["aws_instance.web"] --> ami["data.aws_ami.latest"] instance --> subnet["aws_subnet.app"] subnet --> vpc["aws_vpc.main"] bucket["aws_s3_bucket.logs (no edges: parallel)"]
Provider protocol: เกิดอะไรขึ้นจริง ๆ ตอน terraform init
หัวข้อที่มีชื่อว่า “Provider protocol: เกิดอะไรขึ้นจริง ๆ ตอน terraform init”ทุก provider — รวมถึง hashicorp/aws — เป็น binary แยกต่างหากที่มี version เป็นของตัวเอง ที่ Terraform Core ดาวน์โหลดแล้วรันเป็น subprocess คุยกันผ่าน gRPC ด้วย protocol ตายตัว (protocol version 5 หรือ 6 แล้วแต่ SDK ของ provider) นี่คือเหตุผลที่ terraform init ต้องดาวน์โหลด binary เฉพาะต่อ OS/architecture แทนที่ Terraform จะ “รู้” วิธีคุยกับ AWS, Azure หรือ GCP เองมาตั้งแต่ต้น — provider plugin protocol, plugin handshake และเหตุผลที่ provider crash แล้วเกิด log แบบเฉพาะเจาะจง จะอธิบายลึกพร้อม trace จริงของ TF_LOG=TRACE ตอน provider เริ่มทำงาน ใน Provider Plugin Protocol ในโมดูล Terraform Internals
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุกอย่างในบทนี้มีหน้าที่เดียว: ช่วยให้ Terraform สร้าง plan ที่ถูกต้องก่อนที่มันจะแตะ AWS เลย resource/data block และการอ้างอิงของมันสร้าง dependency graph; การ override ด้วย lifecycle คือข้อยกเว้นที่ซ้อนอยู่บนพฤติกรรมปกติของกราฟนั้นสำหรับ resource ทีละตัว (หยุดหนัก ๆ, สลับลำดับ, จุดบอดที่ตั้งใจ, edge เพิ่ม) — ไม่มีตัวไหนเปลี่ยนหน้าตาของกราฟเลย เปลี่ยนแค่สิ่งที่ Terraform ทำเมื่อไปถึง node นั้น ตัว provider เองเป็น process แยกที่ Terraform Core คุยด้วยผ่าน protocol ตายตัว ซึ่งเป็นเหตุผลที่ “provider” กับ “Terraform” มี version อิสระจากกันได้ — required_providers pin ตัวหนึ่ง required_version pin อีกตัว
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ต้นทุน |
|---|---|---|
prevent_destroy | หยุดหนัก ๆ ป้องกัน destroy โดยไม่ตั้งใจ พิสูจน์ข้างบนด้วย error จริงก่อน API call ใด ๆ | บล็อกทุกการ destroy รวมถึงที่ตั้งใจด้วย — ต้องลบออกจากโค้ด (ไม่ใช่แค่ -target เลี่ยง) เพื่อรื้อ resource จริง ๆ |
create_before_destroy | ไม่มีช่วง “ไม่มีทั้งคู่” ตอน replace พิสูจน์ข้างบนผ่านสัญลักษณ์ลำดับ +/- | ต้องมีสองชุดอยู่ร่วมกันชั่วขณะ — ปัญหาสำหรับอะไรที่มี uniqueness constraint (ชื่อตายตัว, พอร์ตที่จองไว้) นอกจาก argument นั้นจะเปลี่ยนด้วย |
ignore_changes | หยุด Terraform สู้กับการเปลี่ยนแปลงนอกระบบ พิสูจน์ข้างบนด้วย “No changes” จริงแม้แก้คอนฟิก | ค่าจริงของ argument ที่ถูก ignore ใน console/API เบี่ยงห่างจากที่อยู่ใน .tf ได้ไม่จำกัด โดย plan ไม่พูดถึงเลย |
replace_triggered_by | ทำให้ dependency ที่ปกติมองไม่เห็นบังคับ replace จริงได้ อย่างตั้งใจ | ต้นทุนเดียวกับ forced replacement ใด ๆ: destroy + recreate ทั้งหมด ไม่ใช่ update บางส่วน |
terraform graph (default ไม่มี -type) | สะอาด เป็น summary ระดับ resource — ตัวที่ควรอ่านด้วยตาเปล่า พิสูจน์ข้างบน | ไม่มี provider node, internal (expand) และ action เฉพาะ plan ที่ -type=plan แสดง — ไม่ใช่ภาพเต็มที่ Terraform เดินจริง |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
prevent_destroyหยุด Terraform ไม่ให้คำนวณ destroy plan เลยด้วยซ้ำ” พิสูจน์ข้างบนแล้วว่าไม่ใช่: บรรทัดสรุป plan (Plan: 0 to add, 0 to change, 1 to destroy) พิมพ์ ก่อน error —prevent_destroyดักการทำงานหลังจาก plan เสร็จ ไม่ใช่ก่อน - “
create_before_destroyหลีกเลี่ยงการเปลี่ยนจำนวนบรรทัดของ plan diff” มันเปลี่ยนแค่สัญลักษณ์ (+/-กับ-/+) พิสูจน์ข้างบน ไม่เปลี่ยนจำนวนบรรทัด —1 to add, 0 to change, 1 to destroyเหมือนกันทั้งคู่ - “
ignore_changesแค่ ignore drift ครั้งแรก แล้วก็ resync” พิสูจน์ข้างบนแล้วว่าไม่ใช่การข้ามครั้งเดียว — ทุกplanถัดไปยังมองว่า argument นั้นมองไม่เห็นต่อไปเรื่อย ๆ จนกว่าlifecycleblock เองจะถูกแก้ - “Terraform apply resource ตามลำดับที่เขียนในไฟล์” พิสูจน์ข้างบนด้วย
terraform graphจริง: ลำดับมาจาก dependency graph ทั้งหมด ไม่ใช่ตำแหน่งในไฟล์ —aws_s3_bucket.logsที่เขียนไว้ท้ายสุด ไม่มี edge เลยและ apply ก่อนได้ - “provider เป็นส่วนหนึ่งของ Terraform binary compile ติดมาด้วย” มันเป็น binary ที่ดาวน์โหลดแยกต่างหาก รันเป็น subprocess ผ่าน gRPC — อธิบายพร้อม trace จริงในโมดูล Terraform Internals นี่คือเหตุผลที่
terraform initต้องใช้ network access สำหรับ provider ที่ Terraform เองไม่ได้มากับตัว
อ่านโค้ดที่ AI สร้างให้
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้างให้”lifecycle { prevent_destroy = true }ที่ใส่ในทุก resource “เพื่อความปลอดภัย” รวมถึงตัวที่จำเป็นต้องถูก destroy จริงเป็นส่วนหนึ่งของการทำงานปกติ (resource ชั่วคราว, อะไรก็ตามที่อยู่หลังcount/for_eachที่หดตัวจริง ๆ) — พิสูจน์ข้างบนแล้วว่านี่เปลี่ยน destroy ที่ตั้งใจให้เป็น error หนัก ๆ ที่ต้องแก้ด้วยการแก้โค้ด ไม่ใช่คำเตือนเบา ๆcreate_before_destroy = trueที่ใส่ใน resource ที่มี uniqueness constraint บน argument ที่ไม่ได้เปลี่ยน (nameตายตัว, static IP ที่จองไว้) — ตัวแทนจะสร้างไม่สำเร็จเพราะตัวเก่าที่ยังไม่ถูก destroy ยังถือชื่อนั้นอยู่ignore_changes = [tags](หรือแย่กว่านั้นignore_changes = all) ที่ copy มาจากตัวอย่างโดยไม่เช็คว่า tags ของ resource นี้ ควรถูกจัดการโดย Terraform ไหม — พิสูจน์ข้างบนแล้วว่านี่ซ่อน drift แบบเงียบและตลอดไป ไม่ใช่แค่ครั้งเดียวdepends_onที่หายไปเมื่อ comment อ้างว่า “X ต้องมีก่อน Y” แต่ไม่มี argument reference ไหนเข้ารหัสสิ่งนั้นจริง — พิสูจน์ข้างบนแล้วว่า implicit dependency ต้องการ attribute reference จริง comment เฉย ๆ ไม่เข้ากราฟเลย- โค้ดที่อ้างพฤติกรรมของ provider version เฉพาะโดยไม่เช็ค
required_providers— หัวข้อ provider protocol ข้างบนเตือนว่า “Terraform” กับ “AWS provider” เป็นซอฟต์แวร์สอง version อิสระที่คุยกันผ่าน wire protocol ตายตัว
🐛 หา bug ในโค้ดนี้
resource "aws_db_instance" "primary" {identifier = "app-db"engine = "postgres"instance_class = "db.t3.micro"username = "app_admin"password = var.db_password
lifecycle { create_before_destroy = true}}เฉลย
identifier ไม่ได้แสดงว่าเปลี่ยนตรงนี้ แต่ไม่จำเป็นต้องเปลี่ยนด้วย — aws_db_instance.identifier ต้อง unique ต่อ AWS account/region ดังนั้นถ้ามีการเปลี่ยนแปลงใด ๆ กับ resource นี้ที่บังคับให้ replace (เช่น engine เปลี่ยน ซึ่งเป็น ForceNew argument จริงของ resource นี้) create_before_destroy จะพยายามสร้าง app-db ตัวใหม่ก่อน destroy ตัวเก่า — และ AWS จะปฏิเสธการสร้างทันที เพราะ RDS instance identifier ต้อง unique และ app-db ตัวเก่ายังอยู่ อาการที่เห็นคือความล้มเหลวจริงตอน apply (ไม่ใช่ plan เพราะ plan ขอให้ AWS จอง identifier ไว้ล่วงหน้าไม่ได้) ประมาณ “DBInstanceAlreadyExists” ทางแก้คือถอด create_before_destroy ออกจาก resource นี้ (ยอมรับช่วง destroy-แล้ว-create) หรือทำให้ identifier เองเปลี่ยนทุกครั้งที่ replace (เช่น ใส่ suffix จาก random_id) เพื่อให้ชื่อใหม่กับเก่าไม่ชนกันตอนที่ทั้งคู่ยังอยู่