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

Production Checklist

Terraform กับ Terragrunt setup ที่พร้อมสำหรับ production บน Azure ไม่ใช่ feature เดียว แต่เป็นผลรวมของทุก practice ที่คอร์สนี้คุยไปแล้ว — remote state, version ที่ pin ไว้, plan ที่ review แล้ว, secret ที่ป้องกันไว้, static analysis กับ policy gate, unit ที่เรียงลำดับด้วย dependency, และ test จริง — ผูกเข้าด้วยกันเป็น repository เดียวที่ขับเคลื่อนด้วย CI

แต่ละข้อข้างล่างโยงกลับไปที่ failure mode จริงที่บทเรียนหนึ่งบทในคอร์สนี้ถูกสร้างขึ้นมาเพื่อแก้:

  • remote azurerm backend พร้อม blob-lease locking ตั้งแต่วันแรก local state แปลว่า disk ของ laptop เครื่องเดียวคือสำเนาความจริงเดียวของ Azure footprint ทั้งหมด และ engineer สองคนรัน apply พร้อมกันทำให้มันเสียหายได้ — เหตุผลทั้งหมดที่ module State Management มีอยู่
  • provider version pin ไว้ผ่าน .terraform.lock.hcl, module version pin เป็น tag หรือ version constraint provider หรือ module ที่ไม่ pin resolve ไป major version ใหม่แบบเงียบ ๆ ได้ตอน init รันครั้งถัดไป เปลี่ยน behavior ที่ไม่มีใครขอกลางการเปลี่ยนแปลงที่ไม่เกี่ยวกันเลย
  • plan review ใน CI ก่อน apply ทุกครั้ง เฝ้าดู resource replacement ที่ไม่คาดคิดโดยเฉพาะ -/+ ใน plan แปลว่า destroy-แล้ว-สร้างใหม่บน Azure resource จริงที่ stateful — review plan แบบ code diff ธรรมดาพลาดบรรทัดที่สำคัญที่สุดพอดี
  • ค่า sensitive อยู่นอก Terraform variable และ state ในที่ที่มี secrets-manager data source ให้ใช้ พร้อม Azure RBAC จำกัดคนอ่าน state container เข้มงวด sensitive = true แค่ redact CLI output การป้องกันจริงคือจำกัด read access ของ backend และลดสิ่งที่ต้องพิมพ์ลง variable ตั้งแต่แรก
  • tflint กับ security scanner และ policy as code ถ้าทำได้ wired เข้า CI static analysis จับ HCL ที่รู้อยู่แล้วว่าไม่ดีในไม่กี่วินาทีก่อน plan จะรันด้วยซ้ำ policy as code (Sentinel หรือ OPA) ประเมิน plan ที่คำนวณจริงเทียบ rule ขององค์กรที่มีความหมายก็ต่อเมื่อมี change จริงอยู่แล้วเท่านั้น
  • Terragrunt dependency block ขับเคลื่อนลำดับ rollout หลาย unit การแยก infrastructure เป็น unit อิสระ (vnet, vm, database) ปลอดภัยก็ต่อเมื่อ unit ที่ต้องการ output ของอีก unit — subnet ID, resource group name — ประกาศ dependency นั้นตรง ๆ ให้ Terragrunt apply ตามลำดับที่ถูกต้องแทนที่จะพึ่งคนจำ
  • terraform test กับ .tftest.hcl coverage สำหรับ module ที่ทำอะไรไม่ธรรมดา validate เช็ค syntax และ plan เช็คว่า Azure รับ request แต่ไม่มีตัวไหนยืนยันว่า behavior จริงของ module ถูกต้อง — นั่นคือหน้าที่ของ terraform test
  • lifecycle.prevent_destroy บน resource ที่แทนที่ไม่ได้จริง ๆ พิสูจน์ข้างล่างด้วยการปฏิเสธจริง — guardrail ที่เปลี่ยน “config change จะทำลายข้อมูล production” ให้เป็น error ตอน plan time แบบดัง ๆ แทนที่จะ apply สำเร็จแบบเงียบ ๆ
  • precondition block ที่ validate สมมติฐานก่อนจะกลายเป็น resource พิสูจน์ข้างล่างทั้งกรณีผ่านจริงและ fail จริง — error ตอน plan time ที่บอก input ที่ผิดจริง แทนที่จะเป็น provider error ลึกลับกลาง apply
  • force-unlock runbook ที่ซ้อมมาแล้ว, เข้าใจ blob-lease locking, และนิสัย backup state ที่ทำจริง พิสูจน์ข้างล่าง รวมถึงเซอร์ไพรส์จริงเกี่ยวกับสิ่งที่ force-unlock ทำได้และทำไม่ได้กับ state คนละแบบ

prevent_destroy กับ precondition เป็น lifecycle sub-block ทั้งคู่ ไม่ใช่กลไกที่ไม่เกี่ยวกัน — แต่คุ้มกันคนละจังหวะ prevent_destroy = true บล็อก action เฉพาะ (destroy) แบบไม่มีเงื่อนไข ส่วน precondition บล็อก action ไหนก็ได้ เมื่อสมมติฐานเกี่ยวกับ configuration กลายเป็นเท็จ พิสูจน์จริง โดยใช้ local_file แทน resource ไหนก็ได้ที่ความผิดพลาดอาจทำลายได้ (กลไกเป็นของ Terraform-core เหมือนกันไม่ว่า provider ไหน):

main.tf
resource "local_file" "critical" {
filename = "${path.module}/generated/critical.conf"
content = "important=true\n"
lifecycle {
prevent_destroy = true
}
}
$ terraform destroy -input=false -auto-approve
...
Plan: 0 to add, 0 to change, 1 to destroy.
Error: Instance cannot be destroyed
on main.tf line 3:
3: resource "local_file" "critical" {
Resource local_file.critical has lifecycle.prevent_destroy set, but the
plan calls for this resource to be destroyed. To avoid this error and
continue with the plan, either disable lifecycle.prevent_destroy or reduce
the scope of the plan using the -target option.

ปฏิเสธจริง exit code จริงเป็น 1 — ไม่มี flag ไหน bypass ได้นอกจากลบ lifecycle block เอง ซึ่งเป็น diff ที่ review ได้ในตัวมันเอง precondition validate สมมติฐานเกี่ยวกับ input ก่อนที่ Terraform จะ commit เข้า resource เลย:

variable "environment" {
type = string
default = "prod"
}
resource "local_file" "config" {
filename = "${path.module}/generated/app.conf"
content = "env=${var.environment}\n"
lifecycle {
precondition {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be one of dev, staging, prod — got \"${var.environment}\"."
}
}
}
$ terraform plan -var="environment=bogus"
...
Error: Resource precondition failed
on main.tf line 21, in resource "local_file" "config":
21: condition = contains(["dev", "staging", "prod"], var.environment)
├────────────────
│ var.environment is "bogus"
environment must be one of dev, staging, prod — got "bogus".

fail จริง และสำเร็จจริงเมื่อ environment ถูกต้อง (exit 0 plan ไปต่อ) — พิสูจน์ทั้งสองฝั่งของ condition เดียวกัน ไม่ใช่แค่ฝั่ง fail message บอกค่าที่ผิดจริงกับ rule จริง ซึ่งคือประเด็นทั้งหมด: fail ตอน plan time ที่ reviewer จัดการได้ทันที ไม่ใช่ provider-level error ลึกลงไปใน apply

remote-backends-and-locking.mdx พิสูจน์กลไก locking จริงของ Azure ไปแล้ว: azurerm backend เอา blob lease บน state blob ก่อนจะเขียน เป็น native feature ของ Azure Blob Storage ที่ grant exclusive write access — ไม่มี lock table หรือ resource แยกต่างหากต้อง provision ต่างจาก DynamoDB-based locking แบบเก่าของ S3 บทเดียวกันนั้นก็พิสูจน์ไปแล้วกับ local backend ของคอร์สนี้ ตรงกับ fact ของ runbook ที่ checklist ข้อนี้พูดถึงเป๊ะ ๆ: terraform force-unlock --help บอกตรง ๆ ว่า "Local state files cannot be unlocked by another process." reproduce ที่นี่กับ lock ที่ active จริง (time_sleep ถือ state lock ไว้ 15 วินาทีระหว่างที่ apply แบบ background รันอยู่):

$ cat .terraform.tfstate.lock.info
{"ID":"7e6d8bc3-3128-3765-f72c-65d99f8b0afb","Operation":"OperationTypeApply", ...}
$ terraform force-unlock -force 7e6d8bc3-3128-3765-f72c-65d99f8b0afb
Failed to unlock state: LocalState not locked
$ terraform force-unlock -force 00000000-0000-0000-0000-000000000000
Failed to unlock state: LocalState not locked

แม้แต่ lock ID ที่ ถูกต้อง กับ lock ที่ active จริง ก็ fail เหมือนกัน กับ blob lease จริง force-unlock ทำงานตามที่ remote-backends-and-locking.mdx อธิบายไว้จริง: มันลบ lock ที่ apply ที่ถูก kill ทิ้งไว้ โดยใช้ lock ID ที่ error message ของ Azure เองรายงานเมื่อ run ที่สองขอ lease ไม่ได้ กับ local state กลไกนี้ใช้ไม่ได้เลย — lock ของ local backend เป็น OS-level flock ผูกกับ process ที่ถือมันไว้ ไม่ใช่ named lock ที่ backend เป็นตัวกลาง บทเรียนของ runbook เป็นจริงทั้งสองกรณี: force-unlock ต้องการ lock ที่ค้างจริง ๆ ยืนยันได้อย่างอิสระ (process ที่ถือมันหายไปจริง) ไม่ใช่ reflex สำหรับ “apply ของฉันช้าอยู่”

กลไก backup ที่เป็นจริงมีสองแบบ ควรแยกให้ออก แบบแรกคืออัตโนมัติ: backend เขียน terraform.tfstate.backup ทุกครั้งที่มี state-changing operation เก็บ state ก่อนหน้า ไว้ — พิสูจน์ด้วยการ apply config change สองครั้งแล้ว diff ไฟล์ทั้งสอง:

$ cat terraform.tfstate.backup | grep content
"content": "listen=8080\n",
$ cat terraform.tfstate | grep content
"content": "listen=9090\n",

แบบที่สองคือตั้งใจทำเอง: terraform state pull > backup-$(date +%Y%m%d-%H%M%S).tfstate ก่อน operation ไหนก็ตามที่ operator กังวล (state rm ด้วยมือ, import, การ upgrade provider major version) — snapshot จริงที่มี timestamp แยกต่างหากจากไฟล์ .backup อัตโนมัติที่มีอยู่ the-state-file.mdx พิสูจน์ไปแล้วว่า state push enforce lineage/serial ก่อนจะรับการ restore ซึ่งคือ safety net ที่ทำให้ backup แบบนี้ใช้ได้จริง

Terragrunt dependency block คือสิ่งที่ให้ unit หนึ่ง consume output จริงของอีก unit ได้โดยไม่ต้องรู้ internal implementation ของกันและกันเลย tree สอง unit จริงที่สร้างได้แบบ offline เต็มรูปแบบ — sql ต้องการ subnet ที่ vnet สร้าง:

# root.hcl — local backend, so this whole tree validates/renders fully offline
remote_state {
backend = "local"
config = {
path = "${get_parent_terragrunt_dir()}/${path_relative_to_include()}/terraform.tfstate"
}
}
generate "provider" {
path = "provider.tf"
if_exists = "overwrite"
contents = <<EOF
provider "azurerm" {
features {}
subscription_id = "00000000-0000-0000-0000-000000000000"
}
EOF
}
generate "backend" {
path = "backend.tf"
if_exists = "overwrite"
contents = <<EOF
terraform {
backend "local" {}
}
EOF
}
vnet/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "../modules/vnet"
}
modules/vnet/main.tf
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.0"
}
}
}
resource "azurerm_resource_group" "main" {
name = "app-rg"
location = "West Europe"
}
resource "azurerm_virtual_network" "this" {
name = "app-vnet"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
address_space = ["10.0.0.0/16"]
}
resource "azurerm_subnet" "private" {
name = "private"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = ["10.0.1.0/24"]
}
output "vnet_id" {
value = azurerm_virtual_network.this.id
}
output "subnet_id" {
value = azurerm_subnet.private.id
}
output "resource_group_name" {
value = azurerm_resource_group.main.name
}
sql/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "../modules/sql"
}
dependency "vnet" {
config_path = "../vnet"
mock_outputs = {
subnet_id = "/subscriptions/00000000-0000-0000-0000-000000000000/mock-subnet"
resource_group_name = "mock-rg"
}
mock_outputs_allowed_terraform_commands = ["plan"]
}
inputs = {
subnet_id = dependency.vnet.outputs.subnet_id
resource_group_name = dependency.vnet.outputs.resource_group_name
}
# modules/sql/terraform.tfvars — harness fixture only, so the raw `terraform
# plan` proof for modules/sql can attempt to run standalone; a real deployment
# gets these values from Terragrunt's dependency block instead.
subnet_id = "mock-subnet-id"
resource_group_name = "mock-rg"
modules/sql/main.tf
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.0"
}
}
}
variable "subnet_id" {
type = string
}
variable "resource_group_name" {
type = string
}
resource "azurerm_postgresql_flexible_server" "main" {
name = "app-db"
resource_group_name = var.resource_group_name
location = "West Europe"
version = "15"
sku_name = "GP_Standard_D2s_v3"
administrator_login = "appadmin"
administrator_password_wo = "mock"
administrator_password_wo_version = 1
}

terragrunt hcl validate กับ terragrunt render จริงกับ tree ตัวนี้เป๊ะ ๆ สำเร็จทั้งคู่แบบ offline (ไม่ต้องมี Azure authentication เลย) terragrunt run --all plan คือคำสั่งเดียวใน chain นี้ที่ต้องมี Azure session จริง — พิสูจน์สม่ำเสมอตลอดทั้งคอร์สนี้ (ci-cd-for-infrastructure.mdx, policy-as-code-and-static-analysis.mdx, และ secrets-and-credential-management.mdx ของบทนี้เอง) และ reproduce ที่นี่กับ tree ตัวนี้เป๊ะ ๆ:

$ terragrunt run --all plan --non-interactive
...
Error: unable to build authorizer for Resource Manager API: could not
configure AzureCli Authorizer: could not parse Azure CLI version: launching
Azure CLI: exec: "az": executable file not found in $PATH
with provider["registry.terraform.io/hashicorp/azurerm"],
on provider.tf line 2, in provider "azurerm":
2: provider "azurerm" {

นี่คือเพดานที่ซื่อสัตย์ที่สุดสำหรับ azurerm ใน environment แบบ offline ของคอร์สนี้: การเชื่อมต่อ DAG (dependency, mock_outputs, include/generate) พิสูจน์จริงและ offline แล้ว ส่วน plan จริงกับ Azure resource จริงต้องมี Azure credential จริงที่คอร์สนี้ไม่มี ข้อจำกัดเดียวกับที่ azurerm เจอทุกที่ในคอร์สนี้ Terragrunt ยังอ่าน dependency block และหาลำดับ apply ที่ถูกต้องเองได้ทันทีที่มี credential จริง: terragrunt run --all apply apply vnet ก่อน อ่าน output subnet_id จริงเมื่อมันมีอยู่แล้ว แล้วค่อยรัน sql ด้วยค่าจริงนั้นเป็น input ไม่มีใครต้อง hardcode ลำดับหรือจำว่าต้อง apply unit ไหนก่อนด้วยมือเลย mock_outputs block สำคัญเฉพาะกับ plan: มันทำให้ plan ของ sql รันได้สะอาดแม้ vnet จะยังไม่เคยถูก apply เลย โดยใช้ placeholder value แทนที่จะ fail เพราะ output จริงยังไม่มีอยู่

เอาทุกข้อมารวมกันแล้ว production Terragrunt repository บน Azure จะหน้าตาแบบนี้: root.hcl หนึ่งตัวที่ด้านบนประกาศ remote_state backend ที่แชร์กันกับ provider generate block, unit directory จำนวนหนึ่งข้างล่างมัน — vnet/, vm/ (หรือ aks/), และ sql/ คือชุดที่สมจริงและน้อยที่สุด — แต่ละอันมี terragrunt.hcl ของตัวเอง wire dependency block ที่ต้องการจริง ๆ และ CI pipeline ที่ authenticate ผ่าน OIDC federated credential รัน terragrunt run --all plan ทุก pull request และ terragrunt run --all apply แค่หลัง merge เข้า main

name: terraform
on:
pull_request:
paths:
- 'infra/**'
push:
branches:
- main
paths:
- 'infra/**'
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
static-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: terraform-linters/setup-tflint@v4
- run: tflint --init && tflint --recursive
- name: Run checkov
uses: bridgecrewio/[email protected]
with:
directory: infra
plan:
needs: static-analysis
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- uses: hashicorp/setup-terraform@v4
- run: terragrunt run --all plan -no-color | tee plan.txt
- run: gh pr comment ${{ github.event.pull_request.number }} --body-file plan.txt
env:
GH_TOKEN: ${{ github.token }}
apply:
needs: static-analysis
if: github.event_name == 'push'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v7
- uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- uses: hashicorp/setup-terraform@v4
- run: terragrunt run --all apply --non-interactive

ไม่มีอะไรใหม่เลยใน pipeline นั้น ณ จุดนี้ของคอร์ส — ทุก stage คือบทเรียนที่ทำมาแล้ว ประกอบกันเป็น repository เดียว: vnet, vm/aks, และ sql เป็น unit อิสระใต้ root.hcl เดียวกัน dependency block ให้ลำดับจริงระหว่างกันกับ Terragrunt tflint กับ checkov fail เร็วก่อนจะพยายาม plan ด้วยซ้ำ OIDC แทนที่ client secret ที่เก็บไว้ plan ที่ review แล้ว gate ทุก apply และ apply เองสงวนไว้แค่หลัง merge หลัง approval gate รันโดย pipeline ไม่ใช่ laptop

flowchart TB
  root["root.hcl (remote_state + provider generate)"] --> vnet["vnet unit"]
  root --> sql["sql unit"]
  vnet -->|dependency block: subnet_id, resource_group_name| sql
  pr["Pull request"] -->|OIDC auth| planj["terragrunt run --all plan"]
  planj -->|reviewed and merged| applyj["terragrunt run --all apply"]
  vnet -.-> planj
  sql -.-> planj
root.hcl กับ unit ที่เรียงลำดับด้วย dependency, deploy ทั้งหมดผ่าน CI pipeline ที่ authenticate ผ่าน OIDC และ gate apply หลัง plan ที่ review แล้ว

ทุกข้อใน checklist นี้คือ guardrail กันไว้จาก failure mode ที่เจาะจง ระบุชื่อได้ prevent_destroy กับ precondition (พิสูจน์ข้างบนด้วยการปฏิเสธจริง) คุ้มกันจังหวะที่ plan ถูกคำนวณ Blob-lease locking กับ force-unlock (พิสูจน์ข้างบน cross-reference จาก proof จริงของ remote-backends-and-locking.mdx เอง) คุ้มกันจังหวะที่ writer สองตัวแข่งกัน และจังหวะที่ lock อยู่นานกว่า process ที่ถือมัน state backup คุ้มกันจังหวะที่มีอะไรผิดพลาดไปแล้วและ “restore state ก่อนหน้า” ต้องเป็น action ที่ซ้อมมาแล้ว และ DAG ของ dependency block (พิสูจน์ข้างบนแบบ offline ผ่าน hcl validate/render จริง พร้อมเพดาน Azure-auth ที่ระบุตรง ๆ) คุ้มกันจังหวะที่ rollout หลาย unit ต้องเกิดตามลำดับที่ถูกต้องโดยไม่ต้องพึ่งความจำของใครเลย ไม่มีข้อไหนเป็นของแต่งเสริม แต่ละข้อปิด gap ที่เจาะจงและพิสูจน์แล้วหนึ่งข้อ และบทนี้บอกตรง ๆ ว่า offline proof สำหรับ azurerm จบตรงไหนจริง ๆ

ทางเลือกได้อะไรเสียอะไร
lifecycle.prevent_destroy บน resource สำคัญพิสูจน์ข้างบน: destroy หรือ forced replacement ถูกปฏิเสธตรง ๆต้องเอาออกอย่างตั้งใจ (diff ที่ review ได้) ก่อน destroy ที่ตั้งใจจริง ๆ จะไปต่อได้
precondition blockพิสูจน์ข้างบน: input ที่ผิด fail ตอน plan time บอกปัญหาจริงเป็นอีกสิ่งที่ต้องคอยตามให้ทันเมื่อ constraint จริงของ module เปลี่ยนไป
Blob-lease locking (built-in ใน azurerm backend)พิสูจน์ใน remote-backends-and-locking.mdx: ไม่ต้อง provision resource เพิ่มเลย ต่างจาก lock table แยกไม่มี resource สำหรับ audit trail แยกต่างหากแบบที่ lock table เฉพาะอาจมี access log ของตัวเอง
backup แบบ state pull ที่ตั้งใจทำก่อน operation เสี่ยงsnapshot ที่ operator คุมเอง แยกจาก .tfstate.backup อัตโนมัติที่มีอยู่เป็นอีกขั้นตอนที่ต้องจำก่อน state rm/import/provider upgrade
พิสูจน์ DAG ของ dependency block ผ่าน hcl validate/render แทน live run --all planพิสูจน์ได้จริงแบบ offline สำหรับ azurerm เพราะ plan เองทำไม่ได้ไม่ได้ทดสอบการคำนวณ plan จริง — แค่การเชื่อมต่อ ไม่ใช่สิ่งที่ plan จริงจะแสดง
  • “prevent_destroy หยุด change ทุกแบบกับ resource” พิสูจน์ข้างบน: มันบล็อกแค่ destroy โดยเฉพาะ — in-place update ที่ไม่ต้อง destroy resource ไปต่อได้ปกติ
  • “Azure state locking ต้องมี lock resource แยกต่างหาก แบบที่ S3 ใช้ DynamoDB” พิสูจน์ใน remote-backends-and-locking.mdx: azurerm backend lock ด้วยการเอา blob lease ตรงบน state blob เลย ไม่มี resource แยกต้อง provision
  • “force-unlock คือทางแก้ที่ถูกต้องสำหรับ Terraform run ที่ค้างไหนก็ได้” พิสูจน์ข้างบนและใน remote-backends-and-locking.mdx: กับ local state force-unlock release lock ที่ process อื่นถืออยู่ไม่ได้เลย terraform force-unlock --help บอกตรง ๆ กับ blob lease จริง มันคือ recovery tool ที่มี document ไว้ แต่แค่สำหรับ lock ที่ยืนยันได้ว่าค้างจริง ๆ เท่านั้น
  • “bridgecrewio/checkov-action@master ก็โอเคตราบใดที่ action นั้น trust ได้” พิสูจน์ใน policy-as-code-and-static-analysis.mdx: trust ได้วันนี้ไม่ได้บอกอะไรเลยว่าพรุ่งนี้อะไรจะอยู่บน master — pin ข้างบนเป็น tag จริงปัจจุบัน (v12.1347.0) แทน

เวลา review Terraform/Terragrunt setup บน Azure ที่ AI เขียนแล้วบอกว่า “production-ready” ให้เช็ค:

  • resource ที่ stateful (database, Key Vault, อะไรก็ตามที่เก็บข้อมูล) ที่ไม่มี lifecycle.prevent_destroy เลย — พิสูจน์ข้างบน นี่คือ guardrail ที่เปลี่ยน accidental destroy ให้เป็น plan ที่ถูกปฏิเสธแบบดัง ๆ แทนที่จะเงียบและร้ายแรง
  • runbook หรือ comment ที่ claim ว่า terraform force-unlock <id> “ใช้ได้เลย” กับ backend ไหนก็ตามที่ใช้อยู่ โดยไม่แยก local state กับ blob lease จริง — พิสูจน์ข้างบน claim นั้นผิดสำหรับ local state โดยเฉพาะ
  • uses: bridgecrewio/checkov-action@master (หรือ action ไหนก็ตามที่ pin ด้วยชื่อ branch) แทนที่จะเป็น release tag จริง — พิสูจน์ข้างบนและใน policy-as-code-and-static-analysis.mdx ความเสี่ยงแบบ reproducibility กับ supply-chain จริง
  • dependency block ที่ reference output ของ unit ต้นทางโดยไม่มี mock_outputs/mock_outputs_allowed_terraform_commands — พิสูจน์ข้างบน terragrunt run --all plan ต้องมีสองอย่างนี้ถึงจะ plan unit ปลายทางได้ก่อนที่ unit ต้นทางจะถูก apply จริง
  • module source ที่ pin ไว้เป็นชื่อ branch แทนที่จะเป็น tag หรือ commit SHA — ความเสี่ยง unpinned-dependency แบบเดียวกับที่ checklist นี้ flag ไว้กับ provider version แค่ขยับลงมาหนึ่งชั้น directory

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

resource "azurerm_postgresql_flexible_server" "main" {
name = "app-db-prod"
resource_group_name = "app-rg"
location = "West Europe"
version = "15"
sku_name = "GP_Standard_D2s_v3"
administrator_login = "appadmin"
administrator_password_wo = var.db_password
administrator_password_wo_version = 1
}
เฉลย

resource database สำหรับ production ตัวนี้ไม่มี lifecycle { prevent_destroy = true } block เลย พิสูจน์ข้างบน block นั้นคือสิ่งที่เปลี่ยน plan ที่จะทำลาย resource นี้ให้เป็น error ที่ถูกปฏิเสธแบบดัง ๆ — ถ้าไม่มี config change ที่บังคับ replacement หรือ terraform destroy ที่พลาดไปโดน workspace ผิดตัว จะไปต่อตามแผนแบบเงียบ ๆ ไม่มี confirmation เพิ่มนอกจาก prompt ปกติของ apply การจัดการ password แบบ write-only (administrator_password_wo ที่ใช้ถูกต้องแล้วตรงนี้) ปกป้อง ค่า secret มันไม่ได้ปกป้องอะไรเลยเกี่ยวกับ ตัว resource เอง ที่ถูกทำลาย — เป็น guardrail สองอันแยกกันจากสองบทเรียนแยกกัน มีอันหนึ่งไม่ได้แปลว่ามีอีกอันด้วย ทางแก้คือ lifecycle block ที่พิสูจน์ข้างบน ตั้งใจมีไว้บน resource ที่แทนที่ไม่ได้จริง ๆ

ทำไม production Terraform setup ถึงต้องมี remote azurerm backend พร้อม locking ตั้งแต่วันแรก แทนที่จะเป็น local state
ทำไม plan ที่ review ใน CI ถึงต้องเฝ้าดู resource replacement ที่ไม่คาดคิดโดยเฉพาะ
Terragrunt dependency block ให้อะไรที่การ apply unit ตามลำดับด้วยมือไม่ให้
ทำไมค่า sensitive ควรอยู่นอก Terraform variable ให้มากที่สุดเท่าที่ทำได้ แม้จะมี remote backend ที่ป้องกันไว้แล้วก็ตาม
error จริงที่พิสูจน์แล้วนี้แสดงอะไรเกี่ยวกับ lifecycle.prevent_destroy?
$ terraform destroy -input=false -auto-approve
...
Error: Instance cannot be destroyed

Resource local_file.critical has lifecycle.prevent_destroy set, but the
plan calls for this resource to be destroyed.
blob lease ถูกถือไว้จริง (พิสูจน์ด้วย .terraform.tfstate.lock.info จริง) แล้ว force-unlock ถูกรันด้วย lock ID ที่ถูกต้องกับ local state เกิดอะไรขึ้น และทำไมมันไม่ขัดแย้งกับสิ่งที่ force-unlock ทำกับ blob lease จริง?