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

The State File

state ของ Terraform คือไฟล์ JSON ที่แมป resource แต่ละตัวใน configuration ของคุณเข้ากับ object จริงที่สร้างขึ้น และเป็นสิ่งเดียวที่ทำให้ Terraform รู้ว่าตัวเองจัดการอะไรอยู่

ตอนคุณรัน terraform apply Terraform สร้าง resource ผ่าน API ของ Google Cloud — Compute Engine instance, Cloud Storage bucket, IAM binding สักตัว API ของ GCP ไม่มีแนวคิดเรื่อง “Terraform” เลย ถ้า project หนึ่งมี Compute Engine instance 500 ตัว Compute Engine API ก็บอกไม่ได้ว่าตัวไหนคือตัวที่ Terraform สร้างให้กับ resource "google_compute_instance" "web" block นี้ ตัวไหนที่เพื่อนร่วมทีมสร้างมือใน Console และตัวไหนเป็นของ Terraform configuration อื่นไปเลย

Terraform แก้ปัญหานี้ด้วยการเก็บ record ของตัวเอง ทุกครั้งที่ apply Terraform จะเขียน mapping ระหว่าง resource address ใน configuration กับ ID จริงของ infrastructure ลงไฟล์ state ที่ชื่อ default คือ terraform.tfstate

resource "google_compute_instance" "web" {
name = "web-vm"
machine_type = "e2-medium"
zone = "us-central1-a"
boot_disk {
initialize_params {
image = "debian-cloud/debian-12"
}
}
network_interface {
network = "default"
}
}

หลัง apply ไฟล์ state จะบันทึกประมาณว่า google_compute_instance.web -> projects/my-project-id/zones/us-central1-a/instances/web-vm พร้อม attribute ทุกตัวที่ Compute Engine API ส่งกลับมาสำหรับ instance นั้น — internal IP, self link, network interface และอื่น ๆ ข้อมูลที่ cache ไว้นี้ทำให้ Terraform ไม่ต้องเรียก GCP API แล้ว describe resource ทุกตัวใหม่แค่เพื่อคำนวณ plan เพราะเทียบ configuration กับ state ล่าสุดก่อนได้เลย นี่คือทั้งวิธีตรวจจับว่าอะไรเปลี่ยน และเป็นข้อดีด้าน performance จริง ๆ กับ configuration ขนาดใหญ่ state ยังเป็นที่เก็บ dependency metadata ด้วย ทำให้ Terraform รู้ลำดับที่ต้องสร้าง แก้ไข หรือลบ resource

state file ไม่ได้ผ่านการ sanitize attribute value ใด ๆ ที่ Terraform ได้รับจาก provider — หรือที่คุณตั้งตรงใน configuration — จะไปอยู่ใน terraform.tfstate แบบ plaintext รวมถึงค่าที่เป็น secret ด้วย ตัวอย่างที่เจอบ่อย

resource "google_sql_user" "app" {
name = "app_admin"
instance = google_sql_database_instance.main.name
password = "S3cretPassword!"
}

คุณไม่เคยรัน terraform output กับ password ตัวนั้น และไม่เคย print ใน log แต่ค่านั้นก็ยังไปอยู่ใน terraform.tfstate แบบ plaintext อยู่ดี เพราะ Terraform ต้องจำ attribute ทุกตัวที่ตั้งไว้ เพื่อตรวจ drift ตอน plan ครั้งถัดไป ใครก็ตามที่อ่านไฟล์นั้นได้ก็อ่าน password ได้ด้วย

นี่คือเหตุผลที่ terraform.tfstate ห้าม commit เข้า version control เด็ดขาด ใส่ลง .gitignore ตั้งแต่ commit แรกของโปรเจกต์ใหม่

.gitignore
terraform.tfstate
terraform.tfstate.backup
*.tfstate
*.tfstate.*

บทถัดไปจะพูดถึงการย้าย state ออกจาก laptop ไปไว้บน remote backend ที่ควบคุม access ได้จริง นั่นคือวิธีแก้ความเสี่ยงนี้ที่แท้จริง — .gitignore แค่กันอุบัติเหตุที่เห็นชัดที่สุดเท่านั้น

Terraform มีคำสั่งสามตัวสำหรับดูข้างใน state โดยไม่ต้องเปิดไฟล์ JSON เอง

Terminal window
# Human-readable dump of the entire current state
terraform show
# List every resource address currently tracked in state
terraform state list
# Show full attribute detail for one specific resource
terraform state show google_compute_instance.web

terraform state list คือวิธีเร็วที่สุดในการตอบคำถามว่า “configuration นี้จัดการอะไรอยู่ตอนนี้” คำสั่งนี้ print address อย่าง google_compute_instance.web หรือ module.network.google_compute_network.main โดยไม่มีรายละเอียดอื่น terraform state show <address> จะลงลึกไป address ใดตัวหนึ่ง แล้ว print attribute ทุกตัวที่ Terraform บันทึกไว้ ซึ่งบ่อยครั้งเร็วกว่าการไปเปิด Google Cloud Console เวลาต้องเช็คค่าที่ Terraform track อยู่

ตัวอย่างข้างล่างนี้ไม่ได้เขียนจากความจำ — มาจาก terraform apply จริงกับ resource สองตัวที่ไม่ต้องใช้ cloud credential เลย: terraform_data (ตัวแทน null_resource ที่ deprecated ไปแล้วของ built-in provider) และ local_file (hashicorp/local) นี่คือ JSON structure ชุดเดียวกับที่ state ของ GCP ใช้ — format ไม่ขึ้นกับ provider ต่างกันแค่สตริง provider กับ type/attributes ของ resource ถ้าเป็น google_* จริง configuration:

main.tf
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
resource "terraform_data" "trigger" {
input = "v1"
}
resource "local_file" "config" {
filename = "${path.module}/generated/app.conf"
content = "listen=8080\ntrigger_id=${terraform_data.trigger.output}\n"
}

หลัง terraform init -backend=false && terraform apply -auto-approve ไฟล์ terraform.tfstate จริงบน disk (ตัด machine path ออกแล้ว)

{
"version": 4,
"terraform_version": "1.16.4",
"serial": 6,
"lineage": "91789eb2-a206-8e38-2571-62323b581164",
"outputs": {},
"resources": [
{
"mode": "managed",
"type": "local_file",
"name": "config",
"provider": "provider[\"registry.terraform.io/hashicorp/local\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"content": "listen=8080\ntrigger_id=v1\n",
"content_sha1": "7551ae2164513c74b5c0665a6b28b6a40399686f",
"filename": "./generated/app.conf",
"id": "7551ae2164513c74b5c0665a6b28b6a40399686f"
},
"dependencies": ["terraform_data.trigger"],
"sensitive_attributes": [[{ "type": "get_attr", "value": "sensitive_content" }]]
}
]
},
{
"mode": "managed",
"type": "terraform_data",
"name": "trigger",
"provider": "provider[\"terraform.io/builtin/terraform\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"id": "7e48645a-6b7e-14c9-d8b3-99d042610145",
"input": { "value": "v1", "type": "string" },
"output": { "value": "v1", "type": "string" }
},
"sensitive_attributes": []
}
]
}
],
"check_results": null
}

field ระดับบนสุดแต่ละตัวมีความหมายเฉพาะ

  • version — เวอร์ชันของ format ไฟล์ state (เป็น 4 มาตั้งแต่ Terraform 0.12 — ไม่เกี่ยวกับเวอร์ชันของตัว Terraform binary)
  • terraform_version — เวอร์ชัน CLI จริงที่เขียนไฟล์นี้ครั้งล่าสุด
  • serial — ตัวนับที่เพิ่มขึ้นทุกครั้งที่เขียน state ไฟล์ state สองไฟล์ที่มี lineage เดียวกันจะถูกเทียบกันด้วย serial เพื่อดูว่าไฟล์ไหนใหม่กว่า
  • lineage — UUID ที่ generate ครั้งเดียวตอนสร้าง state ให้ working directory ครั้งแรก มันไม่เปลี่ยนตลอดอายุของ state นั้น และเป็นวิธีที่ Terraform แยก “state เดียวกันแต่เป็นก็อปปี้เก่ากว่า” ออกจาก “state คนละไฟล์กันที่หน้าตาคล้ายกันเฉย ๆ”
  • resources[] — หนึ่ง entry ต่อ resource block (ไม่ใช่ต่อ instance — count/for_each ใช้ entry เดียวกันแต่มีหลาย instances[]) แต่ละตัวมี mode, type, name, provider, และ instances ถ้าเป็น google_compute_instance จริง type จะเป็น "google_compute_instance" และ provider จะเป็น provider["registry.terraform.io/hashicorp/google"]
  • instances[].attributes — attribute ทุกตัวที่ provider ส่งกลับมาหลัง create/read ข้อมูลชุดเดียวกับที่ terraform state show print ออกมา
  • instances[].dependencies — resource address ที่ configuration ของ instance นี้อ้างถึง (พิสูจน์ข้างบน: local_file.config depend on terraform_data.trigger เพราะ content ของมัน interpolate terraform_data.trigger.output) นี่คือ dependency metadata ที่พูดถึงข้างบนเป๊ะ ๆ — เป็นเหตุผลที่ destroy รื้อ resource ตามลำดับที่ถูกต้องได้โดยไม่ต้องคำนวณ dependency ใหม่ตั้งแต่ต้น

terraform show -json ข้อมูลชุดเดียวกัน แต่ provider-agnostic และเสถียร

หัวข้อที่มีชื่อว่า “terraform show -json ข้อมูลชุดเดียวกัน แต่ provider-agnostic และเสถียร”

terraform state show ข้างบนเป็น format ที่อ่านง่ายสำหรับคน terraform show -json คือ machine-readable contract ที่ tool อื่น (Sentinel, OPA/conftest, custom CI check) ต้อง parse — มัน document ไว้ชัดเจน มี version (format_version) และไม่เปลี่ยนรูปแบบระหว่างเวอร์ชัน provider เหมือน internal ของ raw state บางครั้งเป็น output จริง (ตัดทอน) จาก apply เดียวกัน

{
"format_version": "1.0",
"terraform_version": "1.16.4",
"values": {
"root_module": {
"resources": [
{
"address": "local_file.config",
"mode": "managed",
"type": "local_file",
"name": "config",
"provider_name": "registry.terraform.io/hashicorp/local",
"values": {
"content": "listen=8080\ntrigger_id=v1\n",
"id": "7551ae2164513c74b5c0665a6b28b6a40399686f"
},
"sensitive_values": { "sensitive_content": true },
"depends_on": ["terraform_data.trigger"]
}
]
}
}
}

drift-detection-and-import.mdx และ sensitive-data-and-team-workflows.mdx ต่างก็กลับมาใช้ show -json เพราะ proof ของ -detailed-exitcode และ sensitive_values map ทั้งคู่อยู่ใน structure นี้เป๊ะ ๆ

ทำไมการแก้ state ด้วยมือถึงพัง lineage กับ serial ถูกบังคับใช้จริง ไม่ใช่แค่ตกแต่ง

หัวข้อที่มีชื่อว่า “ทำไมการแก้ state ด้วยมือถึงพัง lineage กับ serial ถูกบังคับใช้จริง ไม่ใช่แค่ตกแต่ง”

terraform state push <file> อัพโหลด state file ในเครื่องไปแทนที่สิ่งที่ backend ถืออยู่ตอนนี้ — และมันบังคับตรวจ lineage กับ serial ก่อนจะยอมทำแบบนั้น เพื่อกันไม่ให้คุณเอา state เก่าหรือไม่เกี่ยวข้องไปทับ state ที่ใหม่กว่า พิสูจน์ เอา state จริงข้างบน เปลี่ยน lineage เป็น UUID สุ่ม แล้ว push กลับ

$ terraform state push tampered.tfstate
Failed to write state: cannot import state with lineage "00000000-0000-0000-0000-000000000000" over unrelated state with lineage "91789eb2-a206-8e38-2571-62323b581164"

state เดียวกัน แต่ตั้ง serial ให้ย้อนกลับแทน

$ terraform state push tampered2.tfstate
Failed to write state: cannot import state with serial 1 over newer state with serial 6

ทั้งสองคือ error จริงจาก Terraform 1.16.4 ไม่ได้จำลอง นี่คือเหตุผลที่การแก้ terraform.tfstate ด้วย text editor ตรง ๆ อันตราย แม้จะตั้งใจแก้แค่ field เดียว เว้นแต่คุณจะเพิ่ม serial ให้ถูกต้องด้วย (และไม่แตะ lineage เลย) consistency check ของ Terraform เองจะปฏิเสธการเขียนทันทีตอนคุณพยายาม push กลับ — และถ้าคุณข้าม check นี้ไปด้วยการเขียนทับ backend object ตรง ๆ (ข้าม state push ไปเลย เช่น copy ไฟล์ทับ GCS object ด้วยมือ) ก็ไม่มีอะไรหยุดคุณได้ ซึ่งนี่คือ failure mode ตัวจริง state เสียหายแบบเงียบ ไม่มี error เลย จะรู้ตัวอีกทีก็ตอน plan ครั้งถัดไป ที่ความเข้าใจของ Terraform ไม่ตรงกับ GCP resource ที่ deploy จริงอีกต่อไป terraform state mv, import, และ rm ถูกสร้างมาเพื่อไม่ให้คุณต้องเปิด JSON ด้วยมือเลย — มันอัพเดต serial ให้ถูกต้องในตัวเป็นส่วนหนึ่งของ operation เดียวกัน

คอร์สนี้ใช้ local backend สำหรับ proof ทุกอันข้างบน (ไม่ต้องใช้ credential) แต่ production state บน GCP อยู่บน gcs backend ตาม documentation ของ GCS backend เอง

“Stores the state as an object in a configurable prefix in a pre-existing bucket on Google Cloud Storage (GCS).” — developer.hashicorp.com/terraform/language/backend/gcs

ดังนั้น state JSON ที่แสดงข้างบนคือสิ่งที่ไปอยู่ใน GCS object นั้นเป๊ะ ๆ — shape เดียวกันของ version/serial/lineage/resources ไม่มีอะไรเฉพาะ GCP เพิ่มเข้ามา argument prefix ควบคุมว่า object นั้น (และ object ของแต่ละ workspace) อยู่ตรงไหนใต้ bucket — ตาม docs “named states for workspaces are stored in an object called <prefix>/<name>.tfstate” gcs backend documentation ยืนยันว่า locking รองรับ แต่ต่างจากหน้า S3 backend (ที่บอกชัดว่าเป็น object <key>.tflock) หรือหน้า azurerm (ที่ระบุกลไก blob lease) หน้า docs ของ gcs ไม่ได้ document กลไก locking ที่แน่ชัดไว้ในตัวเนื้อหาเอง — บอกตรง ๆ แบบนี้ดีกว่าเดาสุ่ม implementation detail ที่ไม่มีหน้า HashiCorp ไหนยืนยัน สิ่งที่ docs ยืนยันได้ ไม่ต้อง provision lock resource แยก (table, bucket เพิ่ม) — locking เป็นคุณสมบัติของ backend กับ bucket เดียวกันนั่นเอง remote-backends-and-locking.mdx พูดถึง backend block เต็ม ๆ, migration, และ force-unlock

flowchart LR
  cfg["Terraform config (.tf files)"] --> apply["terraform apply"]
  apply --> gcp["Real GCP resources"]
  gcp -->|attributes written back| state["terraform.tfstate"]
  cfg -->|next run| plan["terraform plan"]
  state -->|read for comparison| plan
ทั้ง config และ resource จริงบน GCP ป้อนข้อมูลเข้า state file ที่ plan ครั้งถัดไปอ่าน

state คือความจำเดียวของ Terraform เกี่ยวกับความจริง — ไม่ใช่ cache ที่ทิ้งได้ปลอดภัย และไม่ใช่ log ที่ append เพิ่มด้วยมือได้ปลอดภัย ทุกอย่างในบทนี้มาจากข้อเท็จจริงเดียว GCP API ไม่รู้จัก Terraform เลย ทุกบิตของ “สร้างอะไรไปแล้ว หน้าตาเป็นยังไง” ต้องอยู่ที่ที่ Terraform ควบคุมได้เต็มที่ lineage กับ serial ไม่ใช่ metadata ให้คนอ่านเฉย ๆ — มันคือกลไกที่ทำให้ state push เป็น operation ที่ปลอดภัยและถูกตรวจสอบ แทนที่จะเป็นการเขียนทับแบบไม่ดูอะไร ปฏิบัติกับ state file เหมือนกับ transaction log ของ database คุณอ่านมันด้วย tool ที่สร้างมาเพื่ออ่าน (state show, show -json, state pull) และแก้มันด้วยคำสั่งที่สร้างมาเพื่อแก้ (state mv, import, rm) — ไม่ใช่ text editor เด็ดขาด

ทางเลือกได้อะไรเสียอะไร
cache attribute ทุกตัวไว้ใน stateplan เทียบกับข้อมูล cache แทนที่จะ describe resource ทุกตัวใหม่จาก GCP API — ได้ performance จริงเมื่อ scale ใหญ่attribute ทุกตัว รวม secret อยู่แบบ plaintext บน disk หรือใน GCS object (พิสูจน์ข้างบน)
consistency check lineage/serial บน state pushstate เก่าหรือไม่เกี่ยวข้องเขียนทับ state ปัจจุบันแบบเงียบ ๆ ไม่ได้ (พิสูจน์ข้างบนสองครั้ง)ป้องกันแค่ทาง state push — การเขียนทับ GCS object ตรง ๆ ข้าม check นี้ได้เลย ไม่มี error
terraform show -json เป็น machine format ที่เสถียรtool (policy check, CI, custom script) พึ่ง schema ที่ document ไว้ มี version แทนที่จะ parse output ที่อ่านง่ายสำหรับคนมี representation ของข้อมูลชุดเดียวกันสองแบบให้จำ raw terraform.tfstate กับ show -json ไม่ใช่ shape เดียวกัน
  • “state เก็บแค่สิ่งที่ใส่ใน output block” พิสูจน์ข้างบน attribute เต็มชุดของ local_file.config — รวมค่าที่มาจากการคำนวณที่ไม่เคย output เลย — ถูกเขียนลง resources[].instances[].attributes ไม่ว่าจะมี output block หรือไม่
  • “แก้ terraform.tfstate ด้วยมือได้ปลอดภัย ถ้าระวังพอ” พิสูจน์ข้างบน state push ปฏิเสธการเปลี่ยน lineage และ serial ที่ย้อนกลับด้วย error จริง ความอันตรายจริง ๆ อยู่ที่กรณีที่ error นั้นไม่ครอบคลุม — การเขียนทับ GCS object ตรง ๆ ที่ไม่มี error หรือ warning ใด ๆ
  • “serial คือจำนวน resource หรือ version number ที่เราตั้งเองได้” มันคือตัวนับการเขียนภายในที่ Terraform เพิ่มเอง คุณไม่เคยตั้งมันเอง และการตั้งเป็นค่าที่ไม่ใช่ “สูงกว่าค่าปัจจุบัน” คือสิ่งที่ state push ปฏิเสธในพิสูจน์ข้างบน
  • “argument prefix ของ gcs backend ใช้สร้าง bucket อัตโนมัติเหมือน project/location” remote-backends-and-locking.mdx พิสูจน์ด้วย terragrunt render output จริงว่า project กับ location ถูกใช้สร้าง bucket อัตโนมัติ ส่วน prefix มีผลแค่ path ของ object ข้างในเท่านั้น — docs ที่ quote ข้างบน (“named states for workspaces are stored in an object called <prefix>/<name>.tfstate”) ยืนยันว่า prefix เป็นแค่ path ไม่ใช่ input สำหรับสร้าง bucket

ตอน review Terraform ที่ generate มาแล้วแตะ state ตรง ๆ เช็คสิ่งเหล่านี้

  • script ที่เปิด terraform.tfstate ด้วย sed/jq -i/text editor เพื่อ “แก้” ค่าเดียว ณ ที่นั้น แล้วหวังให้ apply ครั้งถัดไป pick up ไป — พิสูจน์ข้างบน อย่างดีก็โดน state push ปฏิเสธด้วย check lineage/serial อย่างแย่ backend object เสียหายแบบเงียบ ๆ ถ้า check ถูกข้ามไป
  • .gitignore ไม่มี *.tfstate* เลย หรือมีแค่ terraform.tfstate แต่ขาด terraform.tfstate.backup กับ *.tfstate.* (workspace variant)
  • โค้ดหรือ runbook ที่คิดว่า serial เป็นเลขที่ตั้งเองได้ปลอดภัย (“ตั้ง serial เป็น 1 เพื่อ reset”) — มันคือตัวนับการเขียนแบบ monotonic ที่ backend บังคับใช้ ไม่ใช่ field ให้เลือกเลขเอง
  • custom drift-checking script ที่อ่าน terraform.tfstate ตรง ๆ ด้วย local path ที่ hardcode ไว้ แทนที่จะใช้ terraform state pull ซึ่งพังทันทีที่โปรเจกต์เปลี่ยนไปใช้ remote gcs backend
  • อ้างเป็นข้อเท็จจริงว่า GCS implement locking ภายในยังไงเป๊ะ ๆ — พิสูจน์ข้างบน docs ของ HashiCorp ที่ fetch มายืนยันแค่ว่ารองรับ locking โดยไม่ document กลไก บอกช่องว่างนี้ตรง ๆ ดีกว่าเดา internal ที่ฟังดูสมเหตุสมผล

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

#!/usr/bin/env bash
# "quick fix" for a wrong machine_type recorded in state
sed -i '' 's/"e2-medium"/"e2-standard-4"/' terraform.tfstate
terraform state push terraform.tfstate
เฉลย

มีปัญหาจริงสองอย่าง พิสูจน์ข้างบน อย่างแรก state push บังคับตรวจ lineage/serial — แต่ sed -i ตรงนี้ไม่ได้เปลี่ยนทั้งสอง field เลย push อาจสำเร็จได้จริง ๆ ซึ่งคือกับดัก Terraform เชื่อว่า instance มี machine_type เป็น e2-standard-4 ไปแล้ว ทั้งที่ GCP ยังรัน instance แบบ e2-medium อยู่ ไม่มีการเรียก Compute Engine API มา verify เลย plan ครั้งถัดไปจะไม่แสดงการเปลี่ยนแปลงใด ๆ (state ตอนนี้ “เห็นด้วย” กับค่าที่แก้ไปแบบผิด ๆ แล้ว) drift ระหว่าง state ที่แก้กับ instance จริงจะไม่ถูกจับได้เลยจนกว่าอย่างอื่นจะทำให้เห็น วิธีแก้ที่ถูกต้องเมื่อ “state ไม่ตรงกับความจริง” ไม่ใช่การแก้มือเด็ดขาด — คือ terraform apply (ถ้า config ควรชนะ), terraform plan -refresh-only (เพื่อดูว่าอะไรเปลี่ยน), หรือ terraform import/state rm (พูดถึงใน drift-detection-and-import.mdx) ทั้งหมดนี้ผ่านกลไก consistency ของ Terraform เอง ไม่ใช่การข้ามมันไป

ทำไม Terraform ถึงต้องมี state file แทนที่จะ query cloud provider API ทุกครั้ง
database password ถูกตั้งเป็น resource attribute แต่ไม่เคย print ด้วย terraform output ค่านั้นยังไปอยู่แบบ plaintext ที่ไหนได้ไหม
state file ที่แก้มือแบบนี้ ถูก push ด้วย terraform state push tampered.tfstate กับ backend ที่ตอนนี้อยู่ที่ serial 6, lineage 91789eb2-... จะเกิดอะไรขึ้น
{
  "serial": 1,
  "lineage": "00000000-0000-0000-0000-000000000000"
}
field dependencies บน state resource instance บันทึกอะไรจริง ๆ พิสูจน์จากตัวอย่าง local_file.config / terraform_data.trigger ข้างบน
terraform state list กับ terraform state show ต่างกันยังไง
ตาม gcs backend documentation ที่ quote ข้างบน argument prefix ควบคุมอะไรจริง ๆ