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

Remote Backends and Locking

remote backend เก็บ state ไว้ที่จุดกลางอย่าง S3 แทนที่จะอยู่บน laptop ของคนใดคนหนึ่ง ส่วน locking กันไม่ให้สองคนเขียนทับ state ที่ share กันนั้นพร้อมกัน

โดย default terraform.tfstate จะอยู่บนเครื่องที่รัน apply ซึ่งโอเคสำหรับทดลองคนเดียว แต่พังทันทีเมื่อมีคนที่สองเข้ามาร่วม

  • เพื่อนร่วมทีม A รัน apply แล้วสร้าง VPC local state file บน laptop ของเขารู้เรื่องนี้แล้ว
  • เพื่อนร่วมทีม B บน laptop คนละเครื่อง ไม่เคยเห็น state file นั้นเลย เขารัน plan เทียบกับ local state ที่เก่าหรือว่างเปล่าของตัวเอง แล้วอาจพยายามสร้าง VPC ซ้ำ หรือได้ plan ที่ไม่สมเหตุสมผลเมื่อเทียบกับของจริงที่มีอยู่แล้ว
  • CI/CD pipeline เจอปัญหาเดียวกันแต่หนักกว่า pipeline runner คือ environment ใหม่ที่ใช้แล้วทิ้ง ไม่มี local state file ติดตัวมาเลย จึงต้องการ state ฉบับกลางที่อ่านและเขียนได้ทุกครั้งที่รัน ไม่ใช่ copy ที่บังเอิญไปอยู่บน laptop ของใครสักคน

ทางแก้คือย้าย state ออกจากเครื่องใครเครื่องคนหนึ่ง ไปไว้บน backend กลางที่ทั้งทีมและทุก CI run ชี้ไปที่เดียวกัน

วิธีที่แนะนำในปัจจุบันสำหรับเก็บ Terraform state บน AWS คือ S3 bucket ที่ config เป็น backend พร้อมเปิด native S3 state locking

terraform {
backend "s3" {
bucket = "my-company-terraform-state"
key = "networking/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
}
}

bucket กับ key รวมกันระบุว่า object ไหนใน S3 ที่เก็บ state ของ configuration นี้ — key คือ path ภายใน bucket ดังนั้น configuration ต่างกัน (หรือ Terragrunt unit ต่างกัน) มักใช้ key คนละอันใน bucket เดียวกัน use_lockfile = true คือบรรทัดสำคัญ Terraform ใช้ conditional-write lock file ที่เก็บอยู่คู่กับ state object ตรงใน S3 เลย เพื่อจัดการ locking ไม่ต้องมี infrastructure สำหรับ locking แยกต่างหาก แค่ S3 bucket ก็พอ

คุณจะยังเจอ pattern เก่าใน codebase ที่มีอยู่แล้ว ซึ่งใช้ DynamoDB table แยกสำหรับ locking

# Legacy locking pattern — dynamodb_table is deprecated and scheduled for removal.
# Prefer use_lockfile = true (shown above) in new configurations.
terraform {
backend "s3" {
bucket = "my-company-terraform-state"
key = "networking/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-lock"
}
}

pattern นั้นยังทำงานได้ และไม่ต้องตกใจถ้าคุณรับ codebase ที่ใช้แบบนี้มา แต่ dynamodb_table ตอนนี้เป็น argument ที่ deprecated แล้วบน S3 backend และมีแผนจะลบออกในเวอร์ชันถัดไปของ Terraform configuration ใหม่ควรใช้ use_lockfile = true แทน เพราะทำหน้าที่เดียวกันโดยไม่ต้องสร้าง AWS resource ตัวที่สองมาดูแลและจ่ายเงินเพิ่ม

การเปลี่ยน backend block — ย้ายไป bucket อื่น เปลี่ยน key หรือย้ายจาก local ไป S3 ครั้งแรก — ต้องรัน terraform init ใหม่ และ Terraform มี flag สองตัวให้เลือกต่างกันตามว่าอยากให้ state เก่าตามมาด้วยหรือไม่ คอร์สนี้ไม่มี cloud credential เลย พิสูจน์ข้างล่างจึงเปลี่ยน path ของ local backend แทนการ migrate ไป S3 จริง — prompt และกลไกเดียวกันเป๊ะ ๆ ใช้ได้กับการย้าย local→S3 จริง ต่างกันแค่ชนิด backend

terraform init -migrate-state จะถามว่าจะ copy state เดิมไปที่ backend ใหม่ไหม output จริง ตอนเปลี่ยน path = "state/old.tfstate" เป็น path = "state/new.tfstate" ใน backend "local" block

$ terraform init -migrate-state
Initializing the backend...
Backend configuration changed!
Terraform has detected that the configuration specified for the backend
has changed. Terraform will now check for existing state in the backends.
Do you want to copy existing state to the new backend?
Pre-existing state was found while migrating the previous "local" backend to the
newly configured "local" backend. No existing state was found in the newly
configured "local" backend. Do you want to copy this state to the new "local"
backend? Enter "yes" to copy and "no" to start with an empty state.
Enter a value: yes
Successfully configured the backend "local"! Terraform will automatically
use this backend unless the backend configuration changes.

ตอบ yes แล้ว resource ถูก copy ไปที่ location ใหม่จริง ๆ — state/new.tfstate มี resource local_file ตัวเดียวกับที่ path เก่ามี terraform init -reconfigure ข้าม prompt นั้นไปเลยและ ไม่ copy state มาด้วย — พิสูจน์ เปลี่ยน path อีกครั้งแล้วรัน -reconfigure แทน

$ terraform init -reconfigure
Initializing the backend...
Successfully configured the backend "local"! Terraform will automatically
use this backend unless the backend configuration changes.

ไม่มีคำถาม migration เลย plan ครั้งถัดไปพิสูจน์ความต่าง — Terraform เชื่อว่า resource ไม่มีอยู่แล้ว เพราะ backend location ใหม่ไม่เคยถูกเติมข้อมูล

$ terraform plan
# local_file.config will be created
+ resource "local_file" "config" {
+ content = "listen=8080\n"
+ filename = "./generated/app.conf"
}
Plan: 1 to add, 0 to change, 0 to destroy.

-reconfigure เหมาะกับ “เริ่มสะอาดที่ location ใหม่นี้ ไม่เอา state เก่า” (หรือ backend เก่าหายไป/เข้าไม่ถึงแล้ว) ส่วน -migrate-state เหมาะกับ “นี่คือ infrastructure เดิม แค่ย้ายที่เก็บ state record” เลือกผิดตัวกับ S3 bucket จริง จะทำให้ state เก่าถูกทิ้งไว้ไม่ได้ migrate หรือแย่กว่านั้น ทำให้ Terraform คิดว่า VPC ที่รันอยู่ต้องสร้างใหม่ตั้งแต่ต้น

backend block ไม่จำเป็นต้อง hardcode ทุก argument — ปล่อยว่างไว้แล้วใส่ค่าตอน init (จากไฟล์ หรือ -backend-config="key=value" ทีละตัว) ทำให้ secret กับค่าที่ต่างกันตาม environment ไม่ต้องอยู่ในไฟล์ .tf ที่ commit

terraform {
backend "s3" {}
}
# backend.hcl (not committed, or generated by CI per environment)
bucket = "my-company-terraform-state"
key = "networking/terraform.tfstate"
region = "us-east-1"
Terminal window
terraform init -backend-config=backend.hcl

output จริงกับ local backend พิสูจน์ว่า flag ถูกยอมรับและ merge เข้าไป (path ถูกส่งแบบเดียวกับที่ bucket/key/region จะถูกส่งให้ S3)

$ terraform init -backend-config=backend.hcl
Initializing the backend...
Successfully configured the backend "local"! Terraform will automatically
use this backend unless the backend configuration changes.

นี่คือกลไกเดียวกับที่ CI pipeline ใช้ชี้ configuration เดียวกันไปที่ S3 key คนละอันต่อ environment (dev.hcl, prod.hcl) โดยไม่ต้อง duplicate ไฟล์ .tf

ถ้า apply ถูก kill กลางคัน (laptop sleep, CI job ถูก cancel) lock ที่มันถือไว้อาจค้างอยู่โดยไม่มีอะไรมาปล่อยมันแล้ว terraform force-unlock <LOCK_ID> ลบ lock นั้นด้วยมือ output จริงจาก -help ของ Terraform 1.16.4

$ terraform force-unlock -help
Usage: terraform [global options] force-unlock LOCK_ID
Manually unlock the state for the defined configuration.
This will not modify your infrastructure. This command removes the lock on the
state for the current workspace. The behavior of this lock is dependent
on the backend being used. Local state files cannot be unlocked by another
process.
Options:
-force Don't ask for input for unlock confirmation.

LOCK_ID ไม่ใช่อะไรที่คุณคิดขึ้นเอง — ตอน lock ถูกถืออยู่แล้ว run ถัดไป ที่ acquire lock ไม่ได้จะ print lock ID นั้น (พร้อมว่าใครถืออยู่ และตอนไหน) ใน error message ของตัวเอง แล้ว ID นั้นแหละที่เอาไปวางใน force-unlock lock ที่ค้างจริง ๆ ไม่สามารถจำลองแบบ offline ได้ในคอร์สนี้ — local backend ที่ harness ใช้ไม่มี lock ให้ค้างแบบที่ .tflock object ของ S3 หรือ blob lease ที่ถืออยู่ทำได้ (-help text ข้างบนบอกตรง ๆ ว่า “Local state files cannot be unlocked by another process”) ปฏิบัติกับ force-unlock เป็นทางเลือกสุดท้ายสำหรับ lock ที่คุณ verify แล้วว่าค้างจริง (process ที่ถือมันหายไปจริง ๆ — เช็คก่อน เช่นว่า CI run นั้นถูก cancel จริงไหม) ไม่ใช่ reflex สำหรับ “apply ของฉันช้าไปหน่อย”

remote_state block ของ Terragrunt ใน root.hcl generate backend block แบบข้างบนให้ทุก unit ที่ include มัน โดย resolve key ต่อ unit ด้วย path_relative_to_include() อัตโนมัติ

root.hcl
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite"
}
config = {
bucket = "my-company-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true
}
}
vpc/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "."
}

รัน terragrunt run -- init -backend=false ใน unit vpc generate backend.tf จริง — ทุก key จาก config ไปอยู่ตรง ๆ และ key resolve เป็น vpc/terraform.tfstate

# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa
terraform {
backend "s3" {
bucket = "my-company-terraform-state"
encrypt = true
key = "vpc/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
}
}

ทุก argument ใน config map ถูก emit ตรง ๆ — ไม่มีขั้น auto-create แบบที่ GCP ที่ project/location trigger การสร้าง bucket (คู่เทียบฝั่ง GCP ใน module-versioning-and-registries.mdx พิสูจน์ความต่างนี้ตรง ๆ) นี่คือกลไกที่ทำให้ไม่ต้องเขียน backend "s3" {} block มือทีละ environment — remote_state block เดียวใน root config แล้วทุก unit ข้างล่างได้ backend ที่ถูกต้องและ scope เฉพาะตัวเองมาให้เอง

ลองนึกภาพว่าไม่มี locking เลย สองคน หรือคนกับ CI run รัน terraform apply เทียบกับ state บน S3 เดียวกันในเวลาที่ใกล้กันมาก ทั้งคู่อ่าน state ปัจจุบัน ทั้งคู่คำนวณ plan และทั้งคู่เริ่มเขียนผลกลับไปที่ state object เดียวกัน ขึ้นอยู่กับ timing การเขียนครั้งที่สองอาจเขียนทับครั้งแรก — ทำให้การเปลี่ยนแปลงที่ record ไว้จาก run แรกหายไปเงียบ ๆ ทั้งที่ infrastructure ของ run แรกถูกเปลี่ยนจริงไปแล้ว หรือ state file อาจ corrupt ระหว่างเขียน

lock เปลี่ยน race นี้ให้กลายเป็นคิว apply ตัวแรกที่เริ่มจะได้ lock ไป apply ตัวที่สองจะรอให้ lock ถูกปล่อย หรือ fail ทันทีพร้อม error ที่ชัดเจน ขึ้นอยู่กับ flag ที่ใช้ ไม่ว่าทางไหน run ตัวที่สองก็ไม่มีทางเขียนทับ state ของ run แรกในระหว่างที่ run แรกยังทำงานอยู่

Terminal window
# A run that finds the state already locked fails fast with a clear message,
# rather than corrupting the state file
terraform apply
# Error: Error acquiring the state lock
flowchart LR
  eng1["Engineer A: terraform apply"] -->|acquires lock| s3["S3 state + lock file"]
  eng2["CI run: terraform apply"] -->|blocked, waits or fails| s3
  s3 -->|lock released after apply| eng2
สอง apply แข่งกันเข้าถึง state ที่ backed ด้วย S3 เดียวกัน lock ให้ตัวหนึ่งผ่านและให้อีกตัวรอ

ทุกกลไกในบทนี้มีไว้ตอบคำถามเดียวอย่างตรงไปตรงมา “ใครชนะตอนเขียน และผู้แพ้รู้ตัวไหม” remote backend ตอบว่า “อะไรคือฉบับจริงหนึ่งเดียว” ไม่ให้ laptop ใครเป็นฉบับนั้น locking ตอบว่า “เกิดอะไรขึ้นตอนสองการเขียนชนกัน” โดยเปลี่ยน race ให้เป็นคิวแทนที่จะเป็นการสุ่ม -migrate-state กับ -reconfigure ตอบว่า “record เก่าตามมาด้วยไหม” ตอน backend location เปลี่ยน force-unlock มีอยู่เพราะกลไกคิวเองก็ติดขัดได้ (lock holder ที่จะไม่มีวันปล่อยแล้ว) — มันคือการ override คิวด้วยมือ ไม่ใช่ตัวแทนของคิว ซึ่งเป็นเหตุผลที่มันต้องการ lock ID จริง ไม่ใช่ flag “unlock ทุกอย่างเลย”

ทางเลือกได้อะไรเสียอะไร
use_lockfile = true แทน dynamodb_tableAWS resource น้อยลงหนึ่งตัวที่ต้องสร้าง จ่ายเงิน และดูแลให้ sync กับ state bucket (พิสูจน์ข้างบน locking อยู่ใน S3 เอง)config dynamodb_table แบบเก่าต้อง migrate อย่างตั้งใจ ปล่อยทิ้งไว้เฉย ๆ ไม่ได้
-migrate-state แทน -reconfigure ตอน backend location เปลี่ยนstate เก่าตามมาด้วย — พิสูจน์ข้างบน resource ยังถูก track อยู่หลังจากนั้นต้องตอบ prompt แบบ interactive (หรือ -input=false บวกความระมัดระวัง) — พลาดกดตอบ “no” แล้วเสียการเชื่อมกับ infrastructure ที่มีอยู่ได้ง่าย
Partial backend config (-backend-config)ค่าเฉพาะ environment กับ secret ไม่ต้องอยู่ในไฟล์ .tf ที่ commitbackend block เพียว ๆ อ่านแยกไม่บอกอีกต่อไปว่า state จริงอยู่ bucket/key ไหน — ต้องไปหาไฟล์ -backend-config หรือ CI step ด้วย
Terragrunt remote_state generate backend.tfblock เดียวใน root.hcl scope state path ให้ทุก unit ถูกต้องอัตโนมัติ (พิสูจน์ข้างบนด้วย output จริงที่ generate)ไฟล์ที่ generate ไม่ควรแก้มือ — แก้แล้วจะถูกทับใน run ถัดไป (if_exists = "overwrite")
  • “terraform init -reconfigure เป็นแค่ชื่อที่ปลอดภัยกว่าของ -migrate-state” พิสูจน์ข้างบน -reconfigure ไม่ถามและไม่ copy state เลย plan ครั้งถัดไปแสดง resource ที่ track ไว้เป็น “to create” อีกครั้ง เพราะ backend location ใหม่ไม่เคยถูกเติมข้อมูล
  • “lock ค้างเป็นเรื่องปกติ และ force-unlock เป็นคำสั่งประจำวัน” พิสูจน์ข้างบนจาก -help text จริง มันคือทางออกฉุกเฉินสำหรับ lock ที่ verify แล้วว่า holder หายไปจริง ไม่ใช่ default response สำหรับ apply ที่ช้าหรือกำลังทำงานอยู่
  • “dynamodb_table ยังต้อง config เพื่อให้ S3 locking ทำงานในปี 2026” พิสูจน์ข้างบน use_lockfile = true ที่เพิ่มมาสำหรับ S3 native locking ใน Terraform 1.10.0 ตาม CHANGELOG ไม่ต้องมี DynamoDB table เลย — dynamodb_table คือทางที่ deprecated แล้ว
  • “backend.tf ที่ Terragrunt generate เป็นแค่ copy-paste convenience — เขียนไฟล์เดียวกันด้วยมือก็ถูกต้องเหมือนกัน” พิสูจน์ข้างบน ไฟล์ที่ generate resolve path_relative_to_include() ต่อ unit อัตโนมัติ เขียนมือหมายถึงต้อง derive และพิมพ์ key ที่ถูกต้องไม่ซ้ำกันเองทุก unit โดยไม่มี check ว่าสอง unit ชน key กันหรือไม่

ตอน review backend configuration หรือ migration script ที่ generate มา เช็คสิ่งเหล่านี้

  • ใส่ dynamodb_table เข้าไปใน S3 backend block ใหม่ “เพราะ S3 locking ทำงานแบบนี้” — พิสูจน์ข้างบน argument นั้น deprecated แล้ว use_lockfile = true คือกลไกปัจจุบันที่ไม่ต้องมี AWS resource ตัวที่สอง
  • terraform init -reconfigure ถูกใช้ใน runbook step ที่ตั้งใจจะรักษา state เดิมไว้ — พิสูจน์ข้างบน -reconfigure ไม่ migrate state เลย flag ที่ถูกต้องสำหรับ intent นั้นคือ -migrate-state
  • CI script ที่ pipe yes เข้า terraform init ทุกครั้งแบบไม่มีเงื่อนไขเพื่อเลี่ยง interactive prompt รวมถึง prompt “copy existing state?” ของ -migrate-state — คำตอบนั้นต้องเป็น yes ที่ตั้งใจ (environment นี้เป็นการเปลี่ยน location จริง) ไม่งั้น script เสี่ยง copy state ไปผิดที่แบบเงียบ ๆ
  • force-unlock ถูกเรียกด้วย lock ID ที่คิดขึ้นเองหรือ copy มาจาก tutorial แทนที่จะเป็น ID ที่ print ออกมาจาก error “state is locked” จริงของ run นั้น
  • bucket/key/region hardcode ไว้ใน module ที่ตั้งใจ reuse ข้าม environment แทนที่จะเป็น backend "s3" {} เปล่า ที่เติมด้วย -backend-config ต่อ environment (หรือใน Terragrunt คือ remote_state block เดียวที่ generate ให้ถูกต้องต่อ unit)

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

# CI "cleanup" step run before every apply, to avoid ever seeing a locked-state error
terraform force-unlock -force $(cat last-known-lock-id.txt)
terraform apply -auto-approve
เฉลย

บรรทัดนี้รัน force-unlock แบบไม่มีเงื่อนไขก่อน apply ทุกครั้ง โดยใช้ lock ID ที่ cache มาจาก run ก่อนหน้า ไม่ใช่ ID ของ lock ที่มีอยู่จริงตอนนี้ พิสูจน์ข้างบน force-unlock “will not modify your infrastructure” ด้วยตัวมันเอง แต่การปล่อย lock ที่กำลังถูกถืออยู่จริง — โดย teammate ที่ apply อยู่ หรือ CI run ที่รันพร้อมกัน — แบบไม่ดูอะไร คือการเอาการป้องกันเดียวที่ state locking ให้ไว้ออกไป บรรทัดถัดมา apply จะแข่งตรง ๆ กับสิ่งที่ถือ lock นั้นอยู่ ซึ่งคือ scenario การเขียนพังที่ locking มีไว้ป้องกันเป๊ะ ๆ การใช้ force-unlock ที่ถูกต้องคือ reactive และทำด้วยมือ อ่าน error “state is locked” จริงที่ระบุ ID มา verify แยกว่า holder หายไปจริง แล้วค่อย unlock ID นั้นตัวเดียว — ไม่ใช่ step ล่วงหน้าใน script ที่รันไม่ว่าจะมีอะไร lock อยู่จริงหรือไม่

ทำไม local terraform.tfstate ถึงพังลงทันทีที่มีเพื่อนร่วมทีมคนที่สองเริ่มรัน Terraform
use_lockfile = true บน S3 backend ให้อะไร และทำไมตอนนี้ถึงเป็นที่นิยมกว่า DynamoDB table
ไม่มี migration prompt ปรากฏเลย terraform plan ครั้งถัดไปแสดงอะไร พิสูจน์ข้างบน
$ terraform init -reconfigure
Initializing the backend...

Successfully configured the backend "local"! Terraform will automatically
use this backend unless the backend configuration changes.
เวลาที่ถูกต้องในการรัน force-unlock คือเมื่อไหร่ ตาม help text จริงนี้และ lock ID ที่มันต้องการ
$ terraform force-unlock -help
...
  This will not modify your infrastructure. This command removes the lock on the
  state for the current workspace. ... Local state files cannot be unlocked by
  another process.
root.hcl มี remote_state block ที่ตั้ง key = "${path_relative_to_include()}/terraform.tfstate" โดยไม่มี config เพิ่มเติมต่อ unit เลย จากที่พิสูจน์ด้านบนด้วย terragrunt run -- init -backend=false ใน unit vpc key ไหนไปอยู่ใน backend.tf ที่ generate
ทำไม Terragrunt remote_state block เดียวใน root.hcl ที่ generate backend.tf ถึง scale ได้ดีกว่าทุก unit เขียน backend "s3" block เอง