State File Anatomy
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”terraform.tfstate ไม่ใช่แค่ “JSON ที่แมป resource เข้ากับ ID” — มันคือ เอกสารที่มีเวอร์ชัน มี serial ที่เพิ่มขึ้นทุกครั้งที่เขียน และ lineage UUID ที่บอกว่ามันเป็นของ history ไหน และ terraform state push จริง ปฏิเสธ ด้วย error จริงเจาะจง ไม่ยอมเขียนทับด้วยอะไรที่เก่ากว่าหรือไม่เกี่ยวข้องกัน
schema ระดับบนสุด แบบจริง
หัวข้อที่มีชื่อว่า “schema ระดับบนสุด แบบจริง”ใช้ config graph demo ของโมดูลนี้เอง (random_pet.name, local_file.greeting, terraform_data.after_file) หลัง terraform apply จริง ผลจาก terraform state pull จริง:
$ terraform state pull{ "version": 4, "terraform_version": "1.16.4", "serial": 4, "lineage": "4dd73a2e-c225-ef0e-a8b4-7105ae82d1ca", "outputs": {}, "resources": [ { "mode": "managed", "type": "local_file", "name": "greeting", "provider": "provider[\"registry.terraform.io/hashicorp/local\"]", "instances": [ { "schema_version": 0, "attributes": { "content": "hello firm-krill", "filename": "./greeting.txt", "id": "731418e9e5cf7c65895eca44c56cec7b2335219c", ... }, "sensitive_attributes": [...] } ] }, ... ]}5 field ที่สำคัญกว่าอันอื่น:
| Field | จริง ๆ คืออะไร |
|---|---|
version | เวอร์ชันของ state file format (ปัจจุบันคือ 4 เสถียรมาตั้งแต่ Terraform 0.12) — ไม่ใช่เวอร์ชัน CLI |
terraform_version | เวอร์ชัน CLI ที่เขียนไฟล์นี้ล่าสุด บันทึกไว้เพื่อ diagnostic |
serial | integer ที่เพิ่มขึ้นทุกครั้งที่เขียน state ตัวนี้ ใน lineage เดียวกัน |
lineage | UUID ที่สร้างครั้งเดียวตอน state นี้ถูกสร้างครั้งแรก และไม่เปลี่ยนอีกเลยหลังจากนั้น |
resources[].instances[].schema_version | schema version ต่อ ชนิด resource (พิสูจน์ด้านล่าง: 0 สำหรับ local_file, random_pet, และ terraform_data ที่เวอร์ชันซึ่งคอร์สนี้ pin ไว้) |
serial/lineage: พิสูจน์ด้วยการถูกปฏิเสธจริง ไม่ใช่คำกล่าวอ้าง
หัวข้อที่มีชื่อว่า “serial/lineage: พิสูจน์ด้วยการถูกปฏิเสธจริง ไม่ใช่คำกล่าวอ้าง”serial กับ lineage มีไว้ตอบคำถามเดียวอย่างปลอดภัย: “state ที่กำลังจะเขียนนี้ เป็นความต่อเนื่องจริงของ state ที่อยู่ตอนนี้ไหม หรือมีอะไรผิดพลาดไปแล้ว” terraform state push จริงบังคับใช้ทั้งสองอย่าง และปฏิเสธตรง ๆ แทนที่จะ merge หรือเขียนทับเงียบ ๆ
อย่างแรก push จริงที่ lineage ถูกแก้ เป็น UUID ที่ไม่เกี่ยวข้องกัน:
$ python3 -c "import json; d=json.load(open('state.orig.json')); d['lineage']='00000000-0000-0000-0000-000000000000'; json.dump(d, open('state.badlineage.json','w'))"$ terraform state push state.badlineage.jsonFailed to write state: cannot import state with lineage "00000000-0000-0000-0000-000000000000" over unrelated state with lineage "4dd73a2e-c225-ef0e-a8b4-7105ae82d1ca"อย่างที่สอง push จริงที่ lineage เดียวกัน แต่ serial ถูกย้อนกลับไปเป็น 1 ในขณะที่ backend อยู่ที่ 4 จริง:
$ python3 -c "import json; d=json.load(open('state.orig.json')); d['serial']=1; json.dump(d, open('state.staleserial.json','w'))"$ terraform state push state.staleserial.jsonFailed to write state: cannot import state with serial 1 over newer state with serial 4การปฏิเสธทั้งสองครั้งระบุค่าที่ขัดแย้งกันตรง ๆ นี่คือกลไกจริงที่ปกป้อง remote backend ที่แชร์กันจากการที่สองคน (หรือ local copy ที่ stale) เขียนทับกันเงียบ ๆ — ไม่ใช่แค่ convention ไม่ใช่แค่ lock อย่างเดียว (locking ซึ่งอธิบายใน Remote Backends and Locking หยุดการเขียน พร้อมกัน; serial/lineage หยุดการเขียน ตามลำดับ จาก copy ที่เก่าหรือมาจากที่อื่น ซึ่ง lock เองตรวจจับไม่ได้)
terraform state replace-provider: รันจริง
หัวข้อที่มีชื่อว่า “terraform state replace-provider: รันจริง”เปลี่ยนว่า provider FQN ไหนเป็นเจ้าของ resource ชุดหนึ่งใน state — use case ที่ document ไว้คือย้ายไป provider registry mirror หรือ provider source ที่เปลี่ยนชื่อ โดยไม่แตะ resource เองเลย -help จริงและการรันจริงกับ state ของบทนี้:
$ terraform state replace-provider -helpUsage: terraform [global options] state replace-provider [options] FROM_PROVIDER_FQN TO_PROVIDER_FQN
Replace provider for resources in the Terraform state.
Options: -auto-approve Skip interactive approval. -lock=false Don't hold a state lock during the operation. ...
$ terraform state replace-provider -auto-approve hashicorp/local registry.example.com/hashicorp/localTerraform will perform the following actions: ~ Updating provider: - registry.terraform.io/hashicorp/local + registry.example.com/hashicorp/local
Changing 1 resources: local_file.greeting
Successfully replaced provider for 1 resources.ยืนยันตรง ๆ จาก state ที่ pull มาหลังจากนั้น — มีแค่ provider FQN ของ local_file.greeting ที่เปลี่ยน ไม่มีอะไรอื่นเลย:
$ terraform state pull | python3 -c "import json,sys; print(set(r['provider'] for r in json.load(sys.stdin)['resources']))"{'provider["registry.terraform.io/hashicorp/random"]', 'provider["registry.example.com/hashicorp/local"]', 'provider["terraform.io/builtin/terraform"]'}คำสั่งนี้แก้ state metadata เท่านั้น — ไม่แตะ infrastructure จริงเลย และไม่รัน provider RPC ใด ๆ อีก เป็นแค่การแก้ string provider[...] ที่ state entry ของแต่ละ resource บันทึกไว้
terraform show -json เทียบกับ state ดิบ: เอกสารคนละแบบ
หัวข้อที่มีชื่อว่า “terraform show -json เทียบกับ state ดิบ: เอกสารคนละแบบ”terraform state pull (หรืออ่าน terraform.tfstate ตรง ๆ สำหรับ local backend) ให้เอกสาร state ดิบข้างบน terraform show -json (ไม่มี argument เป็น plan file) ให้เอกสาร คนละแบบ ที่ออกแบบมาเฉพาะ เป็น read-only มาจาก state ปัจจุบัน:
$ terraform show -json | python3 -c "import json,sys; d=json.load(sys.stdin); print(list(d.keys())); print(d['format_version'])"['format_version', 'terraform_version', 'values']1.0state ดิบคือสิ่งที่ Terraform อ่านและเขียนเป็น working record ของตัวเอง (version, serial, lineage, schema_version ต่อ instance, sensitive_attributes, provider FQN) values.root_module.resources[] ของ show -json คือมุมมองแบบแบน เป็นมิตรกับ tooling ของค่า ปัจจุบัน เท่านั้น — ไม่มี serial, ไม่มี lineage, ไม่มี schema version — ออกแบบมาให้ใช้ภายนอก (Plan File and JSON Output ครอบคลุม show -json output ของ plan file ซึ่งมีโครงสร้างต่างกันอีกแบบ เพิ่ม resource_changes เข้ามา)
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”มอง terraform.tfstate เป็น เอกสารที่เขียนได้คนเดียวและมีตัวนับเวอร์ชัน ไม่ใช่ cache: lineage บอกว่า “เป็นของ history เอกสารไหน” serial บอกว่า “อยู่จุดไหนของ history นั้น” และ path การเขียนจริงทุกอัน (apply ปกติ หรือ state push มือ) บังคับใช้ทั้งสองอย่างก่อนรับ copy ใหม่ — พิสูจน์ข้างบนด้วยข้อความปฏิเสธจริงของแต่ละกรณี schema_version คือแนวคิดเดียวกันแต่ลึกลงไปอีกชั้น ต่อ ชนิด resource: มันให้ provider migrate รูปแบบที่เก็บไว้ข้ามเวอร์ชันได้โดยไม่ต้อง reset lineage ทั้งหมด state pull/push คือเอกสารดิบ; show -json (ไม่มี plan argument) คือ projection แยกที่ derive มา เน้น tooling ของค่าปัจจุบันเท่านั้น
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ต้นทุน |
|---|---|---|
บังคับใช้ serial/lineage ทุกครั้งที่ state push | พิสูจน์ข้างบน: state ที่ stale หรือมาจากที่อื่นถูกปฏิเสธตรง ๆ ไม่ merge เงียบ ๆ | การแก้แบบ out-of-band ที่ถูกต้องจริง (เช่น restore จาก backup เก่าโดยตั้งใจ) ต้องหาทางข้าม check นี้เอง ไม่ใช่แค่ push ซ้ำ |
state replace-provider สำหรับเปลี่ยนชื่อแบบ metadata-only | ไม่แตะ resource เลย ไม่รัน provider RPC ใหม่ เร็วแม้กับ state ขนาดใหญ่ | เปลี่ยนแค่ string FQN — ถ้าพฤติกรรม provider จริงต่างกัน (fork จริง ไม่ใช่ mirror) แค่นี้ไม่ migrate รูปร่าง resource ให้ |
show -json เป็น interface อ่านจากภายนอก | เสถียร document ไว้ ไม่ expose รายละเอียดภายในอย่าง serial/lineage ให้ tooling ที่ไม่ควรแตะ | consumer ที่ต้องการรายละเอียดระดับ schema-version ต้องกลับไปใช้ state pull ดิบ ไม่ใช่ show -json |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “แก้ state ที่ ‘ค้าง’ หรือ ‘เสีย’ ได้ด้วยการแก้
serialมือให้ลดลงแล้ว push ใหม่” พิสูจน์ข้างบน:state pushจริงที่มีserialต่ำกว่าที่ backend ถืออยู่ตอนนี้ถูกปฏิเสธตรง ๆ พร้อมระบุตัวเลข serial ทั้งสอง — ไม่ใช่ convention ที่ Terraform แค่แนะนำ แต่บังคับใช้จริง - “
terraform show -jsonกับterraform state pullคืนข้อมูลเดียวกัน แค่ตัวหนึ่งสวยกว่า” พิสูจน์ข้างบน: key ระดับบนของshow -jsonคือformat_version/terraform_version/valuesไม่มีserial/lineage/schema_versionเลย — เป็นเอกสารที่ derive มาคนละแบบจริง ๆ ไม่ใช่ format state ดิบใหม่ - “
state replace-providerre-import หรือ re-apply resource ใหม่” พิสูจน์ข้างบน: output จริงของการรันบอกว่า “Changing 1 resources” แต่มีแค่ stringprovider[...]ใน state ที่เปลี่ยน — ไม่มี plan ไม่มี apply ไม่มี provider RPC เกินกว่าการเขียน state เอง - “
lineageเปลี่ยนไปตามอายุของ state file ตอนมันวิวัฒนาการ” พิสูจน์ข้างบน (และโดยนิยาม):lineageสร้างครั้งเดียวและไม่เปลี่ยนอีกเลย มีแค่serialที่เพิ่มขึ้นlineageจะเปลี่ยนก็ต่อเมื่อเริ่ม history ใหม่จริง ๆ เท่านั้น (เช่นterraform state pushไฟล์ที่ไม่เกี่ยวข้อง ซึ่ง — พิสูจน์ข้างบน — คือกรณีที่ถูกปฏิเสธเป๊ะ ๆ ถ้า push ทับ state ที่มี lineage ต่างกัน)
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- runbook “disaster recovery” หรือ script ที่ AI เขียน step มาแก้
serialในterraform.tfstateมือเพื่อ “unstick” การ push — พิสูจน์ข้างบน นี่คือกรณีที่ Terraform ปฏิเสธตรง ๆ ทางแก้จริงคือterraform force-unlock(สำหรับ lock ที่ค้าง กลไกคนละอันกัน) หรือการ restore state แบบตั้งใจและ document ไว้ ไม่ใช่การแก้serialเลย - โค้ดหรือ doc ที่อ้างว่า
terraform show -json(ไม่มี argument) exposeserial/lineageแล้วสร้าง logic ตรวจ drift อิงกับ field ที่ไม่มีอยู่จริงตรงนั้น — พิสูจน์ข้างบน มีแค่state pullดิบเท่านั้นที่มี ถ้าต้องการทั้ง snapshot ค่าและ version metadata ต้องใช้คู่กับstate pull - migration script ที่เรียก
state replace-providerแล้วสมมติว่า resource เข้ากันได้เต็มที่กับ provider fork ที่ต่างกันจริง (ไม่ใช่ mirror) — พิสูจน์ข้างบน คำสั่งนี้แก้แค่ string FQN ใน state ไม่ validate หรือ migrate อะไรเกี่ยวกับ schema หรือพฤติกรรมจริงของ resource เลย
🐛 หา bug ในโค้ดนี้
# "fix-stuck-state.sh" -- an AI-generated runbook stepterraform state pull > state.jsonpython3 -c "import json; d=json.load(open('state.json')); d['serial']-=1; json.dump(d, open('state.json','w'))"terraform state push state.jsonเฉลย
script นี้ลด serial ลง 1 แล้ว push คงเพื่อ “retry” การเขียนที่ผู้เขียนคิดว่าล้มเหลว พิสูจน์ข้างบนด้วยการรันจริงของบทนี้เอง: การ push serial ที่ ต่ำกว่า ที่ backend ถืออยู่ตอนนี้ถูกปฏิเสธตรง ๆ — "cannot import state with serial 1 over newer state with serial 4" คือรูปแบบจริงของ error ที่ script นี้จะกระตุ้น (ด้วยตัวเลขของมันเอง) serial ของ Terraform ต้องเดินหน้าอัตโนมัติจากการเขียนปกติเท่านั้น ไม่มี workflow ที่รองรับให้ลดมันด้วยมือเลย state ที่ค้างจริง ๆ มักหมายถึง lock ที่ stale ไม่ใช่ปัญหา serial — ทางแก้จริงคือ terraform force-unlock <lock-id> (อธิบายใน Remote Backends and Locking) ไม่ใช่การแก้ JSON ที่ pull มาเลย