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

The Multi-Environment Problem

setup จริงของ infrastructure ทุกอันต้องมีอย่างน้อย dev, staging, prod เป็น environment ที่แยกจากกันและ isolate กัน แล้วการเอา logic เดียวกันของ Terraform มาใช้ซ้ำในทุก environment โดยไม่ปล่อยให้ configuration แต่ละอัน drift ออกจากกันเงียบ ๆ คือปัญหาที่ Terraform ไม่ได้แก้ให้อัตโนมัติ

ตัวอย่าง Terraform ง่าย ๆ ทั่วไปมักจัดการแค่ environment เดียว แต่ setup จริงไม่เคยเป็นแบบนั้น อย่างน้อยต้องมี

  • dev ราคาถูก ทิ้งได้ พังได้อย่างปลอดภัย
  • staging จำลอง prod ให้ใกล้เคียงที่สุด ใช้ดักปัญหาก่อนจะปล่อยจริง
  • prod ตัวที่รับ traffic จริงและลูกค้าจริง

หลายทีมมี subdivision มากกว่านั้นอีก เช่น copy แยกต่อ Azure region, copy แยกต่อลูกค้าสำหรับ B2B deployment ขนาดใหญ่ หรือ preview environment อายุสั้นที่ spin up ต่อ feature branch แล้วถูกทำลายทิ้งเมื่อ pull request merge แต่ละอันมักต่างจากกันตรง ๆ อย่างเป็นรูปธรรม เช่น VM size เล็กกว่าใน dev, replica น้อยกว่าใน staging, ปิด autoscaling ใน dev เพื่อประหยัดเงิน และที่สำคัญ prod มักถูกวางไว้ใน resource group ของตัวเอง หรือแม้แต่ subscription ของตัวเอง แยกออกไปต่างหากจากที่เหลือทั้งหมด เพื่อให้ความผิดพลาดใน dev ไปไม่ถึงอะไรที่สำคัญ จุดหลังนี้เรียกว่า blast-radius isolation คือการจำกัดว่าความผิดพลาดจาก apply หรือ credential ที่หลุดออกไปจะไปได้ไกลแค่ไหน

สัญชาตญาณแรกที่แทบทุกคนใช้คือสร้าง environment เดียวให้ทำงานได้ก่อน แล้ว copy ทั้ง directory ไปเป็น environment ที่เหลือ

Terminal window
# Three near-identical copies, created once by hand
tree environments
environments
├── dev
└── main.tf
├── staging
└── main.tf
└── prod
└── main.tf

วันแรก ทั้งสามไฟล์เหมือนกันทุกตัวอักษร ยกเว้นค่า hardcode ไม่กี่จุด เช่น VM size ตรงนี้ ชื่อ resource group ตรงนั้น ทุกอย่างใช้งานได้ และรู้สึกเร็วดี ไม่ต้องออกแบบ shared module ไม่ต้องคิด abstraction ให้ถูกต้อง แค่สาม folder ของโค้ด .tf ธรรมดา

# Day one: dev/main.tf, staging/main.tf, and prod/main.tf are all this rule, verbatim
resource "azurerm_network_security_rule" "allow_app_port" {
name = "allow-app-port"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "8080"
source_address_prefix = "10.0.0.0/16"
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.app.name
network_security_group_name = azurerm_network_security_group.app.name
}

หกเดือนถัดมา security review flag ว่า rule นั้นใน prod หลวมเกินไป แล้วมีคนไปแก้ให้แน่นขึ้นตรงใน prod/main.tf โดยตรง

# Six months later: prod/main.tf was patched during a security review.
# staging/main.tf and dev/main.tf still say 10.0.0.0/16 — nobody remembered to port the fix.
resource "azurerm_network_security_rule" "allow_app_port" {
name = "allow-app-port"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "8080"
source_address_prefix = "10.0.5.0/24"
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.app.name
network_security_group_name = azurerm_network_security_group.app.name
}

ไม่มีใครทำอะไรร้ายหรือแม้แต่สะเพร่าตรงนี้เลย การแก้ rule ตรงที่ security review กำลังมองอยู่คือเรื่องสมเหตุสมผลในตอนนั้น แต่ตอนนี้สาม directory กลายเป็นสาม configuration ที่ต่างกันแบบเล็ก ๆ และไม่มีเอกสารบันทึกไว้เลยว่าต่างกันตรงไหน สั่ง diff ระหว่าง dev/ กับ prod/ หกเดือนต่อมาจะเจอความต่างเล็ก ๆ เป็นสิบจุด และไม่มีใครจำได้จากความทรงจำว่าจุดไหนตั้งใจให้ต่าง จุดไหนแค่ลืม port fix ตาม

ปัญหาลึก ๆ คือการ copy-paste directory ทั้ง environment optimize เพื่อ isolation คือแต่ละ environment ไม่กระทบกันจริง ๆ เพราะเป็นไฟล์คนละชุดกันเลย แต่แลกมาด้วยการ reuse ที่หายไปทั้งหมด bug fix ทุกตัว, tag ที่เพิ่มเข้าไปเพื่อ track cost ทุกอัน, module version ที่ bump ทุกครั้ง ต้องไปทำซ้ำมือใน copy ทุกชุด และการทำซ้ำมือคือขั้นตอนแบบที่มักจะเงียบ ๆ หยุดเกิดขึ้นไปเอง

ที่เหลือของ module นี้คือการแก้ความตึงนั้น จะ reuse logic เดียวกันข้ามทุก environment ยังไง (ให้มีจุดเดียวสำหรับแก้ bug ไม่ใช่สามหรือห้าจุด) พร้อมกับยังคงเก็บ state, credential, และ blast radius ของแต่ละ environment ให้ isolate จากกันเต็มที่ Terraform เองมีคำตอบบางส่วนให้สองแบบ คือ workspace กับ directory-per-environment pattern และแต่ละแบบ ตามที่สองบทถัดไปจะแสดงให้เห็น แก้ปัญหาได้แค่ครึ่งเดียว แล้วเปิดช่องโหว่ให้อีกครึ่งที่เหลือ

flowchart LR
  origin["Working config, copied once"] -->|copy-paste| dev["dev/ (copy)"]
  origin -->|copy-paste| staging["staging/ (copy)"]
  origin -->|copy-paste| prod["prod/ (copy)"]
  prod -->|"NSG rule tightened here only"| prodDrift["prod/ (drifted)"]
  staging -->|"VM size bumped here only"| stagingDrift["staging/ (drifted)"]
  dev -->|"never touched again"| devDrift["dev/ (stale)"]
Three copy-pasted environment directories quietly diverge as ad hoc fixes land in only one of them
การ copy-paste directory ของ environment ทั้งชุดต่อหนึ่ง environment มีปัญหาอะไรเป็นการเฉพาะ
สอง goal ที่ขัดแย้งกันซึ่งทุกวิธี multi-environment ต้องหาสมดุลคืออะไร
ทำไมปัญหา copy-paste-ต่อ-environment ถึงมีแนวโน้มแย่ลงเรื่อย ๆ ตามเวลา แทนที่จะคงที่
blast-radius isolation ใน multi-environment setup หมายถึงอะไร