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

Terraform Workspaces

Terraform workspace ทำให้ configuration เดียวเก็บ state file แยกกันได้หลายอัน แต่ละอันมีชื่อของตัวเอง เป็นวิธีถูก ๆ ที่จำลอง dev, staging, prod แต่ workspace ไม่ได้เปลี่ยนอะไรเกี่ยวกับ backend, provider, หรือ Azure subscription ที่ configuration นั้นคุยด้วยจริง ๆ เลย

terraform workspace คือชุดคำสั่งในตัวสำหรับจัดการ state ที่มีชื่อ ภายใน working directory เดียว สร้าง workspace หนึ่งอันต่อ environment แล้ว Terraform จะเก็บ state file แยกกันโดยสมบูรณ์ให้แต่ละอัน

Terminal window
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
terraform workspace list
# dev
# staging
# * prod
terraform workspace select dev

workspace ที่ active อยู่คือตัวชี้เฉย ๆ ว่า workspace ไหนถูกเลือกอยู่จะเป็นตัวกำหนดว่า plan หรือ apply ครั้งถัดไปจะอ่านและเขียน state file ไหน ถ้าใช้ local backend แบบ default Terraform จะเก็บไว้ใต้ terraform.tfstate.d แยก subdirectory ต่อ workspace หนึ่งอัน

Terminal window
tree terraform.tfstate.d
terraform.tfstate.d
├── dev
│ └── terraform.tfstate
├── staging
│ └── terraform.tfstate
└── prod
└── terraform.tfstate

ถ้าใช้ remote backend อย่าง azurerm แนวคิดเดียวกันนี้ยังใช้ได้ Terraform จะแยก namespace ของ state blob ต่อ workspace แทนที่จะแยกต่อ local directory แต่ผลลัพธ์เหมือนกันทุกประการ คือ dev มองไม่เห็นและเขียนทับ resource ของ prod ไม่ได้ เพราะอยู่คนละ state กันโดยสิ้นเชิง

Terraform เปิดชื่อของ workspace ที่กำลัง select อยู่ให้ใช้ผ่านค่า built-in ชื่อ terraform.workspace ซึ่ง interpolate ได้ทุกที่ที่ต้องการ string การใช้งานที่พบบ่อยที่สุดคือเลือกขนาดหรือชื่อต่อ environment จาก lookup map

locals {
vm_size = {
dev = "Standard_B1s"
staging = "Standard_B2s"
prod = "Standard_D2s_v5"
}
}
resource "azurerm_linux_virtual_machine" "app" {
name = "app-${terraform.workspace}"
resource_group_name = azurerm_resource_group.app.name
location = azurerm_resource_group.app.location
size = local.vm_size[terraform.workspace]
admin_username = "azureuser"
# ...
}

select dev แล้ว VM จะได้ชื่อ app-dev และขนาด Standard_B1s select prod ไฟล์ .tf เดียวกันนี้จะได้ app-prod ขนาด Standard_D2s_v5 โดยไม่มีโค้ดซ้ำที่ไหนเลย นี่คือจุดขายของ workspace คือมีชุด .tf เดียว มีที่แก้ bug จุดเดียว และความต่างต่อ environment ขับเคลื่อนด้วย workspace ไหนกำลัง active อยู่ล้วน ๆ

นี่คือจุดที่พลาดง่าย terraform workspace new สร้างแค่ state file ใหม่เท่านั้น ไม่ได้สร้าง backend configuration ใหม่ ไม่ได้สร้าง provider configuration ใหม่ และไม่ได้สร้าง Azure subscription ใหม่ ทั้งหมดนั้นอยู่ใน .tf code เดียวกัน แชร์กันทุกตัวอักษรข้ามทุก workspace

terraform {
backend "azurerm" {
resource_group_name = "tfstate-rg"
storage_account_name = "tfstateacct001"
container_name = "tfstate"
key = "app.terraform.tfstate"
}
}
provider "azurerm" {
features {}
subscription_id = "00000000-0000-0000-0000-000000000000"
}

ทุก workspace ทั้ง dev, staging, และ prod อ่านเขียนผ่าน backend block เดียวกันนั้น และ authenticate ผ่าน provider block เดียวกันนั้น กับ subscription_id เดียวกันนั้น ไม่มี credential ที่ scope ต่อ workspace ไม่มี subscription ที่ scope ต่อ workspace และไม่มี copy ของ .tf code ที่ scope ต่อ workspace สิ่งเดียวที่ต่างกันระหว่าง workspace คือ state file ไหนถูกแตะ กับอะไรก็ตามที่เลือก key ไว้ด้วย terraform.workspace

นั่นทำให้ workspace เข้ากันไม่ดีกับ environment ที่ต้องการ Azure subscription, region, หรือ credential ที่แยกกันจริง ๆ และสร้างความเสี่ยงจริงที่เฉพาะเจาะจงขึ้นมาอันหนึ่ง คือไม่มีอะไรใน tooling หยุดใครสักคนไม่ให้อยู่บน prod workspace ทั้งที่คิดว่ายังอยู่บน dev แล้ว apply การเปลี่ยนแปลงที่ตั้งใจไว้สำหรับ environment เล็ก ๆ ตรงเข้าไปที่ production ได้ เพราะ backend, provider, และ subscription เบื้องหลังไม่เคยเปลี่ยนเลยสักนิด

flowchart LR
  config["One Terraform config, one backend block"] -->|terraform workspace new dev| devWs["dev workspace"]
  config -->|terraform workspace new staging| stagingWs["staging workspace"]
  config -->|terraform workspace new prod| prodWs["prod workspace"]
  devWs --> devState["dev state file"]
  stagingWs --> stagingState["staging state file"]
  prodWs --> prodState["prod state file"]
  devWs -.-> shared["Same subscription_id, same backend, same .tf code"]
  stagingWs -.-> shared
  prodWs -.-> shared
Terraform config เดียวและ backend เดียว สาม workspace สาม state file แต่ที่เหลือแชร์กันหมดรวมถึง Azure subscription

พิสูจน์ได้แบบ offline ด้วย local_file แทนที่ azurerm_linux_virtual_machine (กลไก terraform.workspace เดียวกัน):

main.tf
locals {
vm_size = {
default = "Standard_B1s"
dev = "Standard_B1s"
staging = "Standard_B2s"
prod = "Standard_D2s_v3"
}
}
resource "local_file" "app" {
filename = "${path.module}/app-${terraform.workspace}.txt"
content = "workspace=${terraform.workspace} size=${local.vm_size[terraform.workspace]}\n"
}

apply จริงใน default แล้ว terraform workspace new staging และ apply อีกครั้งด้วย config เดียวกัน:

$ terraform apply -auto-approve # default workspace
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform workspace new staging
$ terraform apply -auto-approve # staging workspace, same .tf files
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

ไฟล์ที่ generate มาสองไฟล์พิสูจน์ว่า terraform.workspace สลับจริงตาม workspace มาจาก resource block เดียวกัน:

$ cat app-default.txt
workspace=default size=Standard_B1s
$ cat app-staging.txt
workspace=staging size=Standard_B2s

state layout จริงบน disk — รูปร่างจริงของ terraform.tfstate.d/WORKSPACE/:

$ find . -iname "*.tfstate*" -not -path "./.terraform/*"
./terraform.tfstate
./terraform.tfstate.d
./terraform.tfstate.d/staging/terraform.tfstate

default คือกรณีพิเศษหนึ่งเดียว — state ของมันอยู่ที่ path terraform.tfstate ธรรมดา ตำแหน่งเดียวกับที่ state จะอยู่ถ้าไม่เคยใช้ workspace เลย workspace อื่นทุกตัวได้ subdirectory terraform.tfstate.d/ terraform workspace list ยืนยันว่ามีทั้งคู่จริง:

$ terraform workspace list
default
* staging
  • lookup map local.vm_size ที่ขาด key ของ workspace ที่ใช้งานจริงอยู่ — terraform.workspace evaluate เป็น string อะไรก็ตามที่ active อยู่ การ index map ด้วย key ที่ไม่มี คือ error จริง ไม่ใช่ fallback เงียบ ๆ
  • CI script ไหนก็ตามที่รัน terraform apply โดยไม่เช็ค terraform workspace show ก่อน — workspace selection คือ state ที่อยู่ใน process นั้น ๆ ไม่เกี่ยวอะไรกับ branch หรือ environment ที่ CI เข้าใจว่ากำลัง deploy

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

locals {
vm_size = {
dev = "Standard_B1s"
staging = "Standard_B2s"
}
}
resource "azurerm_linux_virtual_machine" "app" {
size = local.vm_size[terraform.workspace]
}
เฉลย

lookup map มีแค่ key dev กับ staging เท่านั้น พิสูจน์ข้างบน terraform.workspace evaluate เป็น workspace อะไรก็ตามที่ active จริง ทันทีที่มีใครสร้างและสลับไป workspace prod local.vm_size[terraform.workspace] จะ fail ด้วย error จริง “key does not exist” ตอน plan

workspace คือ pointer ไม่ใช่ boundary — พิสูจน์ข้างบน terraform.tfstate.d/NAME/ คือกลไกทั้งหมด เป็นแค่ subdirectory ที่ Terraform เลือกจาก string ตัวเดียว อ่านกับไฟล์ .tf ชุดเดียวและ backend/provider/subscription_id เดียวที่ไม่เปลี่ยน

ทางเลือกได้อะไรเสียอะไร
WorkspaceDRY จริง ๆ — resource block เดียว พิสูจน์ข้างบนว่าให้ค่าต่างกันต่อ workspaceไม่ isolate backend/provider/subscription_id เลย พิสูจน์ข้างบน สิ่งเดียวที่แยก environment คือชื่อไหน active อยู่ตอนนั้น
  • “แต่ละ workspace มี subscription_id หรือ backend bucket ของตัวเองได้” พิสูจน์ข้างบน: ทุก workspace อ่าน backend และ provider configuration เดียวกันเป๊ะ
  • “default workspace ถูกเก็บแบบเดียวกับ workspace อื่นทุกตัว” พิสูจน์ข้างบนด้วย find จริง: state ของ default ไม่มี subdirectory terraform.tfstate.d/ เลย
การสร้าง Terraform workspace ใหม่ isolate อะไรจาก workspace อื่นจริง ๆ
อะไรที่แชร์เหมือนกันทุกประการข้ามทุก workspace ใน configuration เดียวกัน
terraform.workspace มักถูกใช้ทำอะไร
ความเสี่ยงจริงที่เฉพาะเจาะจงซึ่ง Terraform workspace สร้างขึ้นมาคืออะไร
state file ของ default workspace มี path จริงบน disk ต่างจาก workspace อื่นทุกตัวตรงไหน ตามที่พิสูจน์ข้างบน
local.vm_size map มีแค่ key dev กับ staging แต่มีคนสร้างและสลับไป workspace prod ด้วย config เดียวกันนี้ เกิดอะไรขึ้น พิสูจน์ข้างบน