Remote Backends and Locking
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”remote backend เก็บ state ไว้ในที่ที่แชร์กันได้อย่าง Azure Storage แทนที่จะอยู่บน laptop ของคนเดียว และ locking ก็ป้องกันไม่ให้สองคนเขียน state ที่แชร์กันนั้นพร้อมกัน
ทำไม local state ถึงใช้กับทีมไม่ได้
หัวข้อที่มีชื่อว่า “ทำไม local state ถึงใช้กับทีมไม่ได้”โดย default terraform.tfstate อยู่บนเครื่องที่คุณรัน apply นั่นแหละ ซึ่งใช้ได้ดีตอนทำคนเดียว แต่พังทันทีที่มีคนที่สองเข้ามา
- เพื่อนร่วมทีม A รัน
applyแล้วสร้าง virtual network ไฟล์ state บน laptop ของเขาก็รู้เรื่องนี้แล้ว - เพื่อนร่วมทีม B บน laptop คนละเครื่อง ไม่เคยเห็นไฟล์ state นั้นเลย เขารัน
planเทียบกับ local state ของตัวเองที่เก่าหรือว่างเปล่า แล้วก็จะพยายามสร้าง virtual network ซ้ำ หรือไม่ก็ได้ plan ที่ไม่สมเหตุสมผลเทียบกับของจริงที่มีอยู่ - CI/CD pipeline เจอปัญหาเดียวกันแต่หนักกว่า — pipeline runner คือ environment ใหม่ที่ใช้แล้วทิ้ง ไม่มี local state file อยู่เลย จึงต้องการ state ชุดเดียวที่เป็น canonical ให้อ่านและเขียนทุกครั้งที่รัน ไม่ใช่สำเนาที่บังเอิญอยู่บน laptop ใครสักคน
ทางแก้คือย้าย state ออกจากเครื่องใดเครื่องหนึ่ง ไปไว้บน shared backend ที่ทั้งทีมและทุก CI run ชี้ไปที่เดียวกัน
Config azurerm backend
หัวข้อที่มีชื่อว่า “Config azurerm backend”วิธีมาตรฐานในการเก็บ Terraform state บน Azure คือใช้ Azure Storage container เป็น backend
terraform { backend "azurerm" { resource_group_name = "tfstate-rg" storage_account_name = "acmetfstate001" container_name = "tfstate" key = "networking.terraform.tfstate" }}resource_group_name กับ storage_account_name ระบุว่า storage account ไหนเก็บ state ไว้ container_name คือ blob container ใน account นั้น และ key คือชื่อ blob ภายใน container — configuration ที่ต่างกัน (หรือ Terragrunt unit ที่ต่างกัน) มักใช้ key คนละตัวใน container เดียวกัน ทั้งสี่ argument นี้จำเป็นทั้งหมด เพื่อให้ backend รู้ว่า blob ตัวไหนเก็บ state ของ configuration นี้อยู่
authentication argument สองตัวสำคัญมากกับวิธีที่ Terraform คุยกับ storage account นั้นจริง ๆ แทนที่จะใช้ legacy account-key access
terraform { backend "azurerm" { resource_group_name = "tfstate-rg" storage_account_name = "acmetfstate001" container_name = "tfstate" key = "networking.terraform.tfstate" use_azuread_auth = true use_oidc = true }}use_azuread_auth = true authenticate เข้า data plane ของ storage account ด้วย Microsoft Entra ID แทน shared account key หรือ SAS token use_oidc = true เพิ่ม OpenID Connect / workload identity federation เข้าไปอีกชั้น — ไม่มี secret อายุยืนให้ rotate เลย ซึ่งเป็นเหตุผลที่ documentation ของ backend เอง label ว่า “(Recommended)” สำหรับ CI/CD โดยเฉพาะ GitHub Actions กับ Azure DevOps
Locking ติดมาในตัว ไม่ต้องต่อเพิ่ม
หัวข้อที่มีชื่อว่า “Locking ติดมาในตัว ไม่ต้องต่อเพิ่ม”นี่คือจุดที่ต่างจาก cloud อื่นจริง ๆ state locking ของ azurerm backend ติดมากับวิธีทำงานของ Azure Blob Storage เอง ไม่ต้อง config อะไรเพิ่มเลย ตอน Terraform เขียน state ผ่าน backend นี้ จะขอ blob lease บน state blob ก่อนเขียน — เป็นฟีเจอร์ native ของ Azure Blob Storage ที่ให้สิทธิ์เขียนแบบ exclusive กับคนที่ถือ lease อยู่ พอ apply ตัวที่สองพยายามขอ lease ตัวเดียวกันตอนที่ lease ยังถูกถืออยู่ ก็ขอไม่ได้ เลยต้องรอหรือ fail แบบ clean แทนที่จะไปเขียนทับ run แรก
ไม่มี lock table แยกที่ต้องสร้าง ไม่มี argument เพิ่มที่ต้องตั้ง และไม่มี Azure resource เพิ่มที่ต้อง provision แค่เพื่อ locking เลย core argument สี่ตัวใน block ข้างบนคือทั้งหมดที่ azurerm backend ต้องการ locking มาฟรีทันทีที่ state ถูกเก็บใน Azure Storage เรื่องนี้ควรพูดให้ชัดเพราะเรียบง่ายกว่า backend ของ cloud อื่นบางเจ้าที่ผ่านมา ซึ่ง locking เคยต้องมี resource แยกไว้เก็บ lock record ต่างหาก
Migrate backend init -migrate-state กับ init -reconfigure ต่างกันยังไง
หัวข้อที่มีชื่อว่า “Migrate backend init -migrate-state กับ init -reconfigure ต่างกันยังไง”การเปลี่ยน backend block — ย้ายไป storage account อื่น เปลี่ยน key หรือย้ายจาก local ไป azurerm ครั้งแรก — ต้องรัน terraform init ใหม่ และ Terraform มี flag สองตัวให้เลือกต่างกันตามว่าอยากให้ state เก่าตามมาด้วยหรือไม่ คอร์สนี้ไม่มี cloud credential เลย พิสูจน์ข้างล่างจึงเปลี่ยน path ของ local backend แทนการ migrate ไป Azure Storage จริง — prompt และกลไกเดียวกันเป๊ะ ๆ ใช้ได้กับการย้าย local→azurerm จริง ต่างกันแค่ชนิด backend
terraform init -migrate-state จะถามว่าจะ copy state เดิมไปที่ backend ใหม่ไหม output จริง ตอนเปลี่ยน path = "state/old.tfstate" เป็น path = "state/new.tfstate" ใน backend "local" block
$ terraform init -migrate-stateInitializing the backend...Backend configuration changed!
Terraform has detected that the configuration specified for the backendhas 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 automaticallyuse 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 -reconfigureInitializing the backend...
Successfully configured the backend "local"! Terraform will automaticallyuse 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” เลือกผิดตัวกับ storage account จริง จะทำให้ state เก่าถูกทิ้งไว้ไม่ได้ migrate หรือแย่กว่านั้น ทำให้ Terraform คิดว่า virtual network ที่รันอยู่ต้องสร้างใหม่ตั้งแต่ต้น
Partial backend configuration ด้วย -backend-config
หัวข้อที่มีชื่อว่า “Partial backend configuration ด้วย -backend-config”backend block ไม่จำเป็นต้อง hardcode ทุก argument — ปล่อยว่างไว้แล้วใส่ค่าตอน init (จากไฟล์ หรือ -backend-config="key=value" ทีละตัว) ทำให้ค่าเฉพาะ environment ไม่ต้องอยู่ในไฟล์ .tf ที่ commit
terraform { backend "azurerm" {}}# backend.hcl (not committed, or generated by CI per environment)resource_group_name = "tfstate-rg"storage_account_name = "acmetfstate001"container_name = "tfstate"key = "networking.terraform.tfstate"terraform init -backend-config=backend.hcloutput จริงกับ local backend พิสูจน์ว่า flag ถูกยอมรับและ merge เข้าไป (path ถูกส่งแบบเดียวกับที่ argument ของ azurerm จะถูกส่งให้)
$ terraform init -backend-config=backend.hclInitializing the backend...
Successfully configured the backend "local"! Terraform will automaticallyuse this backend unless the backend configuration changes.นี่คือกลไกเดียวกับที่ CI pipeline ใช้ชี้ configuration เดียวกันไปที่ storage account หรือ key คนละอันต่อ environment (dev.hcl, prod.hcl) โดยไม่ต้อง duplicate ไฟล์ .tf
terraform force-unlock ทางออกแบบ manual
หัวข้อที่มีชื่อว่า “terraform force-unlock ทางออกแบบ manual”ถ้า apply ถูก kill กลางคัน (laptop sleep, CI job ถูก cancel) blob lease ที่มันถือไว้อาจค้างอยู่โดยไม่มีอะไรมาปล่อยมันแล้ว terraform force-unlock <LOCK_ID> ลบ lock นั้นด้วยมือ output จริงจาก -help ของ Terraform 1.16.4
$ terraform force-unlock -helpUsage: 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 ไม่ได้จะ print lock ID นั้น (พร้อมว่าใครถืออยู่ และตอนไหน) ใน error message ของตัวเอง แล้ว ID นั้นแหละที่เอาไปวางใน force-unlock lock ที่ค้างจริง ๆ ไม่สามารถจำลองแบบ offline ได้ในคอร์สนี้ — local backend ที่ harness ใช้ไม่มี lock ให้ค้างแบบที่ blob lease ที่ถืออยู่ทำได้ (-help text ข้างบนบอกตรง ๆ ว่า “Local state files cannot be unlocked by another process”) ปฏิบัติกับ force-unlock เป็นทางเลือกสุดท้ายสำหรับ lock ที่คุณ verify แล้วว่าค้างจริง (process ที่ถือ lease หายไปจริง ๆ — เช็คก่อน เช่นว่า CI run นั้นถูก cancel จริงไหม) ไม่ใช่ reflex สำหรับ “apply ของฉันช้าไปหน่อย”
Terragrunt remote_state generate backend.tf ที่ไม่ต้องเขียนมือ
หัวข้อที่มีชื่อว่า “Terragrunt remote_state generate backend.tf ที่ไม่ต้องเขียนมือ”remote_state block ของ Terragrunt ใน root.hcl generate backend block แบบข้างบนให้ทุก unit ที่ include มัน โดย resolve key ต่อ unit ด้วย path_relative_to_include() อัตโนมัติ
remote_state { backend = "azurerm" generate = { path = "backend.tf" if_exists = "overwrite" } config = { resource_group_name = "terraform-state-rg" storage_account_name = "mycompanytfstate" container_name = "tfstate" key = "${path_relative_to_include()}/terraform.tfstate" use_azuread_auth = true }}include "root" { path = find_in_parent_folders("root.hcl")}
terraform { source = "."}รัน terragrunt run -- init -backend=false ใน unit vnet generate backend.tf จริง — ทุก key จาก config ไปอยู่ตรง ๆ และ key resolve เป็น vnet/terraform.tfstate
# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEaterraform { backend "azurerm" { container_name = "tfstate" key = "vnet/terraform.tfstate" resource_group_name = "terraform-state-rg" storage_account_name = "mycompanytfstate" use_azuread_auth = true }}ทุก argument ใน config map ถูก emit ตรง ๆ — Terragrunt ไม่สร้าง storage account หรือ container ให้อัตโนมัติแบบที่มันสร้าง GCS bucket จาก project/location ได้ (คู่เทียบฝั่ง GCP ใน module-versioning-and-registries.mdx พิสูจน์ความต่างนี้ตรง ๆ) — resource group, storage account, และ container ต้องมีอยู่แล้วล่วงหน้า นี่คือกลไกที่ทำให้ไม่ต้องเขียน azurerm backend block มือทีละ environment — remote_state block เดียวใน root config แล้วทุก unit ข้างล่างได้ backend ที่ถูกต้องและ scope เฉพาะตัวเองมาให้เอง
Locking ป้องกันอะไรจริง ๆ
หัวข้อที่มีชื่อว่า “Locking ป้องกันอะไรจริง ๆ”ลองนึกภาพว่าไม่มี locking คนสองคน หรือคนหนึ่งกับ CI run หนึ่ง รัน terraform apply เข้าไปที่ state เดียวกันบน Azure Storage ในเวลาใกล้เคียงกัน ทั้งคู่อ่าน state ปัจจุบัน ทั้งคู่คำนวณ plan และทั้งคู่เริ่มเขียนผลลัพธ์กลับไปที่ state blob เดียวกัน แล้วแต่จังหวะเวลา การเขียนครั้งที่สองอาจเขียนทับครั้งแรก — ทำให้การเปลี่ยนแปลงที่ run แรกบันทึกไว้หายไปเงียบ ๆ ทั้งที่ infrastructure ของ run แรกถูกเปลี่ยนไปจริง ๆ แล้ว หรือไม่ก็ state file อาจเสียหายกลางคัน
lock เปลี่ยน race แบบนั้นให้กลายเป็นคิว apply ตัวแรกที่เริ่มจะได้ blob lease ไป ส่วน apply ตัวที่สองก็ต้องรอให้ lease ถูกปล่อย หรือไม่ก็ fail ทันทีด้วย error ที่ชัดเจน แล้วแต่ flag ที่ใช้ ไม่ว่าทางไหน run ที่สองก็ไม่มีทางเขียนทับ state ของ run แรกในระหว่างที่ run แรกยังทำงานอยู่
# A run that finds the state already locked fails fast with a clear message,# rather than corrupting the state fileterraform apply# Error: Error acquiring the state lockflowchart LR eng1["Engineer A: terraform apply"] -->|acquires blob lease| storage["Azure Storage: state blob"] eng2["CI run: terraform apply"] -->|blocked, waits or fails| storage storage -->|lease released after apply| eng2
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุกกลไกในบทนี้มีไว้ตอบคำถามเดียวอย่างตรงไปตรงมา “ใครชนะตอนเขียน และผู้แพ้รู้ตัวไหม” remote backend ตอบว่า “อะไรคือฉบับจริงหนึ่งเดียว” ไม่ให้ laptop ใครเป็นฉบับนั้น locking ตอบว่า “เกิดอะไรขึ้นตอนสองการเขียนชนกัน” โดยเปลี่ยน race ให้เป็นคิวแทนที่จะเป็นการสุ่ม — คำตอบของ Azure บังเอิญฟรี (blob lease เป็นส่วนหนึ่งของ storage เองอยู่แล้ว ไม่ใช่ของต่อเพิ่ม) -migrate-state กับ -reconfigure ตอบว่า “record เก่าตามมาด้วยไหม” ตอน backend location เปลี่ยน force-unlock มีอยู่เพราะกลไกคิวเองก็ติดขัดได้ (lease holder ที่จะไม่มีวันปล่อยแล้ว) — มันคือการ override คิวด้วยมือ ไม่ใช่ตัวแทนของคิว
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ได้อะไร | เสียอะไร |
|---|---|---|
| blob-lease locking ที่ติดมากับ azurerm backend | ไม่ต้อง provision resource เพิ่มเลย ไม่ต้องจ่ายเงินหรือดูแล sync (พิสูจน์ข้างบน ต้องการแค่ core argument สี่ตัว) | ไม่มี resource audit trail แยกแบบที่ dedicated lock table อาจมี access log ของตัวเอง |
use_azuread_auth + use_oidc แทน shared account key | ไม่มี secret อายุยืนให้ rotate หรือรั่ว docs ของ backend เอง label OIDC ว่า “(Recommended)” สำหรับ CI | ต้อง setup federated identity / Entra ID ล่วงหน้า แทนที่จะ copy-paste account key |
-migrate-state แทน -reconfigure ตอน backend location เปลี่ยน | state เก่าตามมาด้วย — พิสูจน์ข้างบน resource ยังถูก track อยู่หลังจากนั้น | ต้องตอบ prompt แบบ interactive (หรือ -input=false บวกความระมัดระวัง) — พลาดกดตอบ “no” แล้วเสียการเชื่อมกับ infrastructure ที่มีอยู่ได้ง่าย |
Terragrunt remote_state generate backend.tf | block เดียวใน root.hcl scope state path ให้ทุก unit ถูกต้องอัตโนมัติ (พิสูจน์ข้างบนด้วย output จริงที่ generate) | ไฟล์ที่ generate ไม่ควรแก้มือ — แก้แล้วจะถูกทับใน run ถัดไป (if_exists = "overwrite"); storage account/container ต้องมีอยู่แล้วล่วงหน้า ไม่เหมือน GCP ที่ auto-create bucket ได้ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
terraform init -reconfigureเป็นแค่ชื่อที่ปลอดภัยกว่าของ-migrate-state” พิสูจน์ข้างบน-reconfigureไม่ถามและไม่ copy state เลยplanครั้งถัดไปแสดง resource ที่ track ไว้เป็น “to create” อีกครั้ง เพราะ backend location ใหม่ไม่เคยถูกเติมข้อมูล - “lock ค้างเป็นเรื่องปกติ และ
force-unlockเป็นคำสั่งประจำวัน” พิสูจน์ข้างบนจาก-helptext จริง มันคือทางออกฉุกเฉินสำหรับ lock ที่ verify แล้วว่า holder หายไปจริง ไม่ใช่ default response สำหรับ apply ที่ช้าหรือกำลังทำงานอยู่ - “Azure state locking ต้องใช้ lock file หรือ table แยกต่างหาก เหมือน S3 ที่ใช้ DynamoDB” พิสูจน์จาก docs ของ azurerm backend เองที่ quote ไว้ใน
the-state-file.mdxมัน lock ด้วยการเอา blob lease บน state blob ตรง ๆ ไม่มี lock resource แยกให้ provision - “
remote_stateblock ของ Terragrunt auto-create storage account กับ container ให้ได้แบบที่มัน auto-create GCS bucket ได้” พิสูจน์ข้างบนbackend.tfที่ generate มีแค่สี่ config key ตรง ๆ — resource group, storage account, และ container ต้องมีอยู่แล้วก่อนinitจะสำเร็จกับของจริงได้
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ตอน review backend configuration หรือ migration script ที่ generate มา เช็คสิ่งเหล่านี้
- backend block ที่ยังใช้ shared storage account key หรือ SAS token ทั้งที่
use_azuread_auth/use_oidcจะลบ secret อายุยืนออกไปได้เลย — พิสูจน์ข้างบน สองตัวนี้ถูก document และแนะนำเพื่อเลี่ยงการ rotate secret โดยเฉพาะ 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ที่ตั้งใจ ไม่งั้น script เสี่ยง copy state ไปผิดที่แบบเงียบ ๆ force-unlockถูกเรียกด้วย lock ID ที่คิดขึ้นเองหรือ copy มาจาก tutorial แทนที่จะเป็น ID ที่ print ออกมาจาก error “state is locked” จริงของ run นั้นresource_group_name/storage_account_name/container_name/keyhardcode ไว้ใน module ที่ตั้งใจ reuse ข้าม environment แทนที่จะเป็นbackend "azurerm" {}เปล่า ที่เติมด้วย-backend-configต่อ environment (หรือใน Terragrunt คือremote_stateblock เดียวที่ generate ให้ถูกต้องต่อ unit)
🐛 หา bug ในโค้ดนี้
# CI "cleanup" step run before every apply, to avoid ever seeing a locked-state errorterraform 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” ด้วยตัวมันเอง แต่การปล่อย blob lease ที่กำลังถูกถืออยู่จริง — โดย teammate ที่ apply อยู่ หรือ CI run ที่รันพร้อมกัน — แบบไม่ดูอะไร คือการเอาการป้องกันเดียวที่ state locking ให้ไว้ออกไป บรรทัดถัดมา apply จะแข่งตรง ๆ กับสิ่งที่ถือ lease นั้นอยู่ ซึ่งคือ scenario การเขียนพังที่ locking มีไว้ป้องกันเป๊ะ ๆ การใช้ force-unlock ที่ถูกต้องคือ reactive และทำด้วยมือ อ่าน error “state is locked” จริงที่ระบุ ID มา verify แยกว่า holder หายไปจริง แล้วค่อย unlock ID นั้นตัวเดียว — ไม่ใช่ step ล่วงหน้าใน script ที่รันไม่ว่าจะมีอะไร lock อยู่จริงหรือไม่