The Multi-Environment Problem
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”setup จริงของ infrastructure ทุกอันต้องมีอย่างน้อย dev, staging, prod เป็น environment ที่แยกจากกันและ isolate กัน แล้วการเอา logic เดียวกันของ Terraform มาใช้ซ้ำในทุก environment โดยไม่ปล่อยให้ configuration แต่ละอัน drift ออกจากกันเงียบ ๆ คือปัญหาที่ Terraform ไม่ได้แก้ให้อัตโนมัติ
environment มีมากกว่าที่คิด
หัวข้อที่มีชื่อว่า “environment มีมากกว่าที่คิด”ตัวอย่าง 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 ที่หลุดออกไปจะไปได้ไกลแค่ไหน
ทางแก้แบบไร้เดียงสา: copy-paste directory ต่อ environment
หัวข้อที่มีชื่อว่า “ทางแก้แบบไร้เดียงสา: copy-paste directory ต่อ environment”สัญชาตญาณแรกที่แทบทุกคนใช้คือสร้าง environment เดียวให้ทำงานได้ก่อน แล้ว copy ทั้ง directory ไปเป็น environment ที่เหลือ
# Three near-identical copies, created once by handtree environmentsenvironments├── 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, verbatimresource "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 ตาม
ความตึงที่ module นี้จะพาไปดู
หัวข้อที่มีชื่อว่า “ความตึงที่ module นี้จะพาไปดู”ปัญหาลึก ๆ คือการ 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)"]