The Directory-per-Environment Pattern
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”แทนที่จะใช้ configuration เดียวสลับไปมาระหว่าง workspace, directory-per-environment pattern ให้ dev, staging, และ prod แต่ละอันมี directory จริงของตัวเอง มี backend และ provider block ของตัวเอง ชี้ไปที่ Azure resource group ของตัวเองหรือแม้แต่ subscription ของตัวเอง โดยที่ทุกอันเรียก module ที่แชร์กันตัวเดียวสำหรับ resource จริง
แยกจริง: หนึ่ง directory ต่อหนึ่ง environment
หัวข้อที่มีชื่อว่า “แยกจริง: หนึ่ง directory ต่อหนึ่ง environment”รูปร่างของ pattern นี้คือชุด environment directory บาง ๆ อยู่ข้าง ๆ module ที่แชร์กันตัวเดียว
tree environments modulesenvironments├── dev│ ├── backend.tf│ └── main.tf├── staging│ ├── backend.tf│ └── main.tf└── prod ├── backend.tf └── main.tfmodules└── app ├── main.tf ├── variables.tf └── outputs.tfdev กับ staging คือเคสที่เบากว่า คือแยก resource group แต่ subscription เดียวกัน backend.tf ในแต่ละอันกำหนด state key ของตัวเองและ subscription_id ของตัวเอง
terraform { backend "azurerm" { resource_group_name = "tfstate-rg" storage_account_name = "tfstateacct001" container_name = "tfstate" key = "dev.terraform.tfstate" }}
provider "azurerm" { features {} subscription_id = "11111111-1111-1111-1111-111111111111"}terraform { backend "azurerm" { resource_group_name = "tfstate-rg" storage_account_name = "tfstateacct001" container_name = "tfstate" key = "staging.terraform.tfstate" }}
provider "azurerm" { features {} subscription_id = "11111111-1111-1111-1111-111111111111"}prod คือเคสที่แข็งกว่า สงวนไว้สำหรับ environment ที่ต้องการแยกแบบแข็งจริง ๆ คือ subscription_id ที่ต่างกันไปเลย ทำให้ความผิดพลาดใน dev หรือ staging แม้แต่ authenticate เข้าไปที่ resource ของ prod ก็ยังทำไม่ได้ ไม่ต้องพูดถึงการเข้าไปแก้ไข
terraform { backend "azurerm" { resource_group_name = "tfstate-rg" storage_account_name = "tfstateacct001" container_name = "tfstate" key = "prod.terraform.tfstate" }}
provider "azurerm" { features {} subscription_id = "22222222-2222-2222-2222-222222222222"}main.tf ของแต่ละ environment เรียก module ที่แชร์กันตัวเดียวกัน ต่างกันแค่ input value ที่ส่งเข้าไป
module "app" { source = "../../modules/app"
resource_group_name = "app-dev-rg" location = "eastus" vm_size = "Standard_B1s" instance_count = 1}module "app" { source = "../../modules/app"
resource_group_name = "app-prod-rg" location = "eastus" vm_size = "Standard_D2s_v5" instance_count = 3}สิ่งที่ pattern นี้แก้ได้จริง
หัวข้อที่มีชื่อว่า “สิ่งที่ pattern นี้แก้ได้จริง”นี่คือ isolation จริง ไม่ใช่แค่ naming convention แบบที่ workspace ให้มา dev กับ staging แต่ละอันตกอยู่ใน resource group ของตัวเอง ทำให้ terraform destroy ที่พลาดใน environment หนึ่งเข้าไปแตะ resource ใน resource group ของอีก environment ไม่ได้ และแต่ละอันมี RBAC scope ของตัวเอง prod ไปไกลกว่านั้นอีก คือนั่งอยู่ใน subscription ของตัวเอง ที่เป็นเส้นแบ่งที่แข็งกว่าของ Azure คือ billing แยก, quota default แยก, และชุด credential ที่ต่างกันจริง ๆ ที่ต้องใช้แม้แต่จะแตะให้ได้ ต่างจาก workspace ตรงที่ไม่มี provider block ที่แชร์กันอยู่ที่ไหนเลยที่จะปล่อยให้ terraform apply ที่พลาดข้ามไปถึงอีก environment ได้ Terraform run ของ directory dev ไม่มี credential path ไปยัง subscription ของ prod เลย
duplication ที่กลับมาใหม่
หัวข้อที่มีชื่อว่า “duplication ที่กลับมาใหม่”ลองดู backend.tf สามไฟล์ข้างบนใกล้ ๆ ทั้งสามเหมือนกันทุกโครงสร้าง คือ backend type เดียวกัน, storage account เดียวกัน, container เดียวกัน ต่างกันแค่สองบรรทัดพอดี คือ key กับ subscription_id เรื่องเดียวกันเกิดกับ module "app" block ใน main.tf ทุกไฟล์ คือ argument source ถูก copy-paste แบบตัวอักษรเป๊ะ ๆ เข้าไปในทุก environment และถ้า module นั้นย้ายที่ไปวันหนึ่ง หรือถูก pin ด้วย version ทุก environment directory ต้องแก้แบบเดียวกันด้วยมือ นี่คือภาพสะท้อนกลับของปัญหา workspace workspace แก้ duplication ได้ด้วยการแชร์ทุกอย่างและ isolate แทบไม่ได้อะไรเลย ส่วน directory-per-environment แก้ isolation ได้ด้วยการแยกทุกอย่าง รวมถึง boilerplate ที่ไม่เคยต้องต่างกันเลยด้วย
flowchart LR devDir["environments/dev"] -->|same subscription, own resource group| subA["Subscription A"] stagingDir["environments/staging"] -->|same subscription, own resource group| subA prodDir["environments/prod"] -->|separate subscription| subB["Subscription B, prod only"] devDir -->|module source = ../../modules/app| sharedModule["modules/app, shared"] stagingDir -->|module source = ../../modules/app| sharedModule prodDir -->|module source = ../../modules/app| sharedModule devDir -.-> dup["backend.tf and provider block nearly identical in every environment"] stagingDir -.-> dup prodDir -.-> dup