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

Remote Backends and Locking

remote backend เก็บ state ไว้ในที่ที่แชร์กันได้อย่าง Azure Storage แทนที่จะอยู่บน laptop ของคนเดียว และ locking ก็ป้องกันไม่ให้สองคนเขียน 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 ชี้ไปที่เดียวกัน

วิธีมาตรฐานในการเก็บ 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

นี่คือจุดที่ต่างจาก 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 ต่างหาก

การเปลี่ยน 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-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” เลือกผิดตัวกับ storage account จริง จะทำให้ state เก่าถูกทิ้งไว้ไม่ได้ migrate หรือแย่กว่านั้น ทำให้ Terraform คิดว่า virtual network ที่รันอยู่ต้องสร้างใหม่ตั้งแต่ต้น

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"
Terminal window
terraform init -backend-config=backend.hcl

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

$ 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 เดียวกันไปที่ storage account หรือ key คนละอันต่อ environment (dev.hcl, prod.hcl) โดยไม่ต้อง duplicate ไฟล์ .tf

ถ้า apply ถูก kill กลางคัน (laptop sleep, CI job ถูก cancel) blob lease ที่มันถือไว้อาจค้างอยู่โดยไม่มีอะไรมาปล่อยมันแล้ว 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 ไม่ได้จะ 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 ของฉันช้าไปหน่อย”

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

root.hcl
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
}
}
vnet/terragrunt.hcl
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: nIlQXj57tbuaRZEa
terraform {
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 คนสองคน หรือคนหนึ่งกับ 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 แรกยังทำงานอยู่

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 blob lease| storage["Azure Storage: state blob"]
  eng2["CI run: terraform apply"] -->|blocked, waits or fails| storage
  storage -->|lease released after apply| eng2
สอง apply แข่งกันเข้าถึง state ที่ backed ด้วย Azure Storage เดียวกัน blob lease ในตัวให้ตัวหนึ่งผ่านและให้อีกตัวรอ

ทุกกลไกในบทนี้มีไว้ตอบคำถามเดียวอย่างตรงไปตรงมา “ใครชนะตอนเขียน และผู้แพ้รู้ตัวไหม” 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.tfblock เดียวใน 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 เป็นคำสั่งประจำวัน” พิสูจน์ข้างบนจาก -help text จริง มันคือทางออกฉุกเฉินสำหรับ 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_state block ของ Terragrunt auto-create storage account กับ container ให้ได้แบบที่มัน auto-create GCS bucket ได้” พิสูจน์ข้างบน backend.tf ที่ generate มีแค่สี่ config key ตรง ๆ — resource group, storage account, และ container ต้องมีอยู่แล้วก่อน init จะสำเร็จกับของจริงได้

ตอน 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/key hardcode ไว้ใน module ที่ตั้งใจ reuse ข้าม environment แทนที่จะเป็น backend "azurerm" {} เปล่า ที่เติมด้วย -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” ด้วยตัวมันเอง แต่การปล่อย 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 อยู่จริงหรือไม่

ทำไม local terraform.tfstate ถึงพังทันทีที่เพื่อนร่วมทีมคนที่สองเริ่มรัน Terraform
ต้อง config resource หรือ argument เพิ่มอะไรใน azurerm backend เพื่อเปิดใช้ state locking
ไม่มี 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 ของ Terragrunt มี remote_state block ที่ backend = "azurerm" และ config map มี resource_group_name, storage_account_name, container_name, key backend.tf ที่ generate จริงพิสูจน์ข้างบนมีอะไร และมันไม่ทำอะไร
ทำไม use_oidc = true ถึงสำคัญกับ CI pipeline ที่ใช้ azurerm backend ตาม documentation ของ backend