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

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 แยกต่อ AWS region, copy แยกต่อลูกค้าสำหรับ B2B deployment ขนาดใหญ่ หรือ preview environment อายุสั้นที่ spin up ต่อ feature branch แล้วถูกทำลายทิ้งเมื่อ pull request merge แต่ละอันมักต่างจากกันตรง ๆ อย่างเป็นรูปธรรม เช่น EC2 instance type เล็กกว่าใน dev, read replica น้อยกว่าใน staging, ปิด autoscaling ใน dev เพื่อประหยัดเงิน และที่สำคัญ prod มักถูกวางไว้ใน AWS account หรือ region ที่แยกออกไปต่างหากจากที่เหลือทั้งหมด เพื่อให้ความผิดพลาดใน 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 ไม่กี่จุด เช่น instance size ตรงนี้ ชื่อ bucket ตรงนั้น ทุกอย่างใช้งานได้ และรู้สึกเร็วดี ไม่ต้องออกแบบ shared module ไม่ต้องคิด abstraction ให้ถูกต้อง แค่สาม folder ของโค้ด .tf ธรรมดา

# Day one: dev/main.tf, staging/main.tf, and prod/main.tf are all this rule, verbatim
resource "aws_security_group_rule" "allow_app_port" {
type = "ingress"
from_port = 8080
to_port = 8080
protocol = "tcp"
cidr_blocks = ["10.0.0.0/16"]
security_group_id = aws_security_group.app.id
}

หกเดือนถัดมา 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 "aws_security_group_rule" "allow_app_port" {
type = "ingress"
from_port = 8080
to_port = 8080
protocol = "tcp"
cidr_blocks = ["10.0.5.0/24"]
security_group_id = aws_security_group.app.id
}

ไม่มีใครทำอะไรร้ายหรือแม้แต่สะเพร่าตรงนี้เลย การแก้ 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 -->|"security rule tightened here only"| prodDrift["prod/ (drifted)"]
  staging -->|"instance size bumped here only"| stagingDrift["staging/ (drifted)"]
  dev -->|"never touched again"| devDrift["dev/ (stale)"]
สาม directory environment ที่ copy-paste มา ค่อย ๆ diverge เงียบ ๆ เมื่อ fix แบบ ad hoc ลงแค่อันเดียว

ทั้งสอง aws_security_group_rule fence ข้างบนเป็น HCL จริงที่ valid — ยืนยันด้วย terraform fmt -check กับทั้งคู่ — drift ที่โชว์ระหว่างสองอันไม่ใช่ความต่างของ syntax เลย เป็นความต่างของค่า (cidr_blocks) เท่านั้น ซึ่งเป็นความต่างเล็ก ๆ ที่มองข้ามได้ง่ายแบบเป๊ะ ๆ ที่สะสมเงียบ ๆ ใน tree ที่ copy-paste มา

ตอนรีวิว environments/ tree ที่ AI สร้าง ให้เช็คสิ่งเหล่านี้:

  • สาม (หรือมากกว่า) environment directory ที่เหมือนกันเป๊ะ byte-ต่อ-byte ยกเว้นค่าไม่กี่ตัวที่ hardcode ไว้ ไม่มี shared module หรือ shared config file ที่ไหนเลย — นี่คือ pattern copy-paste เป๊ะ ๆ ที่บทนี้อธิบาย และเป็นจุดเริ่มต้นที่ AI assistant มักสร้างเวลาถูกขอ “dev, staging, prod setup” เพราะเป็นคำตอบที่มี abstraction น้อยที่สุด ไม่ใช่เพราะเป็นคำตอบที่ดีที่สุด
  • ค่า hardcode (instance type, CIDR block, tag) ที่ต่างกันระหว่าง environment directory โดยไม่มี comment อธิบายว่า ทำไม — พิสูจน์ข้างบน แยกไม่ออกเลยระหว่าง drift โดยไม่ตั้งใจ กับตัวเลือกที่ตั้งใจให้ต่างกันตาม environment หกเดือนต่อมาไม่มีใครบอกได้ว่าอันไหนคืออะไร
  • ไม่มีการพูดถึง strategy แยก .gitignore/state เลยสำหรับ multi-environment tree — ตัวอย่างที่ generate มาจาก single-environment config ที่ copy สามรอบ มักลืมว่า copy แต่ละชุดตอนนี้ต้องมี state ของตัวเอง (เริ่มพูดถึงตั้งแต่บทถัดไป)

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

# Generated "multi-environment setup" — three near-identical files
# environments/dev/main.tf, environments/staging/main.tf, environments/prod/main.tf
resource "aws_instance" "web" {
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
tags = {
Environment = "prod"
}
}
เฉลย

resource block นี้เหมือนกันเป๊ะ ถูก generate ออกมาให้ทั้งสาม directory — รวมทั้ง dev/main.tf และ staging/main.tf ที่ hardcode Environment = "prod" ไว้ใน tags ด้วย นี่คือ artifact แบบ copy-paste ที่สมจริง block นี้ถูกเขียนครั้งเดียวสำหรับ prod แล้ว copy ไปอีกสอง directory โดยไม่มีใครอัปเดตค่าเดียวที่ควรต่างกันตาม environment cost report กับ resource filter ทุกตัวที่ key ด้วย tag นั้นตอนนี้ misattribute spend ของ dev กับ staging ไปเป็น prod เงียบ ๆ และไม่มีอะไรใน Terraform เองจับได้เลย — config นี้เป็น HCL ที่ valid สมบูรณ์แบบและ apply ผ่านสะอาดทั้งสามที่

การ copy-paste directory คือการ optimize เพื่อคุณสมบัติหนึ่ง (isolation) โดยแลกกับอีกอัน (single source of truth) — ไม่มีของฟรีตรงนี้ มีแค่ trade ที่คุณเลือกทำอย่างตั้งใจ หรือสะดุดเข้าไปโดยไม่รู้ตัว ทุกเทคนิคที่ module นี้จะพูดถึงต่อจากนี้ (workspace, directory-per-environment พร้อม shared module, Terragrunt) จริง ๆ ก็แค่จุดต่างกันบนเส้น isolation-versus-reuse เส้นเดียวกัน ไม่ใช่วิธีหนีจาก trade-off นั้นไปได้ทั้งหมด

ทางเลือกได้อะไรเสียอะไร
copy-paste directory ต่อ environmentisolation เต็มที่ตั้งแต่วันแรก ไม่ต้อง design หรือเรียนรู้ abstraction เลยทุก fix ต้องเอาไปใส่มือทุกที่ พิสูจน์ข้างบนว่า drift เงียบ ๆ ได้ภายในหกเดือน
shared config เดียวไม่มีการแยก environment เลยDRY สูงสุด มีจุดเดียวที่แก้อะไรก็ได้ไม่มี isolation เลย ความผิดพลาดใน “environment” หนึ่ง (ตัวแปร, workspace) กระทบทุกอันได้
  • “drift ระหว่าง environment copy แปลว่ามีใครสักคนประมาท” พิสูจน์ข้างบน: การแก้ prod เป็นสิ่งที่สมเหตุสมผลและถูกต้องในตอนนั้น ปัญหาอยู่ที่โครงสร้าง — ไม่มีอะไรใน tree ที่ copy-paste มา propagate fix ไปเอง ไม่ว่าใครจะระวังแค่ไหน
  • “พอ copy environment เสร็จแล้วใช้งานได้ ความเสี่ยงก็จบแล้ว” ความเสี่ยงสะสมทบกันไปทุกครั้งที่มีการแก้ต่อจากนั้น พิสูจน์ข้างบน — ไม่ใช่ต้นทุนครั้งเดียวที่จ่ายตอน setup แต่เป็นภาษีต่อเนื่องที่โตขึ้นตามอายุและจำนวนครั้งที่แก้ tree นั้น
การ copy-paste directory ของ environment ทั้งชุดต่อหนึ่ง environment มีปัญหาอะไรเป็นการเฉพาะ
สอง goal ที่ขัดแย้งกันซึ่งทุกวิธี multi-environment ต้องหาสมดุลคืออะไร
ทำไมปัญหา copy-paste-ต่อ-environment ถึงมีแนวโน้มแย่ลงเรื่อย ๆ ตามเวลา แทนที่จะคงที่
blast-radius isolation ใน multi-environment setup หมายถึงอะไร
ตัวอย่าง "dev/staging/prod" ที่ generate มา มี aws_instance block เดียวกันเป๊ะ copy เข้าไปทั้งสาม directory พร้อม tags = { Environment = "prod" } hardcode ไว้ทุกชุด ผลที่เกิดขึ้นจริงคืออะไร พิสูจน์ข้างบน