2024年5月21日
Terraform 團隊協作的兩個基本功:S3 + DynamoDB 遠端 State,以及可重複使用的模組設計
先搞懂 State 是什麼
Terraform 是宣告式的:你描述「想要有一台 EC2、一個 S3 Bucket」,Terraform 負責把現實變成你描述的樣子。要做到這點,它需要知道「現在已經建立了哪些資源」,這份紀錄就是 State(terraform.tfstate)。
每次執行 plan 或 apply,Terraform 會比對三個東西:
- 程式碼:你想要的狀態
- State:Terraform 記得它上次建立了什麼,以及每個資源在雲端上的 ID
- 雲端實際狀態:透過 API 查詢資源目前真正的樣子
State 是 Terraform 認得「程式碼裡的這個 aws_s3_bucket.logs,就是雲端上那個 Bucket」的唯一依據。State 遺失了,Terraform 就不認得任何既有的資源,下一次 apply 會想要重新建立一份;State 損壞了,可能會刪掉不該刪的東西。
本機 State 的問題
預設情況下,State 存在執行 Terraform 的那台電腦上:
- 只有一個人有:同事要修改基礎設施,得先跟你拿 State 檔
- 沒有鎖:兩個人同時 apply,兩邊都讀到舊的 State、各自改了資源、各自寫回 State,後寫的蓋掉先寫的,State 和雲端實際狀態對不起來
- 含有機密:State 會以明文保存資源的屬性,資料庫密碼、存取金鑰都可能出現在裡面,放在個人電腦上或 commit 進 Git 都很危險
- CI 無法執行:Pipeline 拿不到 State,就無法自動化 apply
遠端後端:S3 存 State、DynamoDB 做鎖
AWS 上最常見的遠端後端組合,是 S3 存放 State 檔、DynamoDB 做狀態鎖:
terraform {
backend "s3" {
bucket = "company-terraform-backend"
key = "production/app/terraform.tfstate"
region = "ap-northeast-1"
dynamodb_table = "company-terraform-lock"
encrypt = true
}
}
- S3 Bucket:存放 State。務必開啟版本控制(State 被寫壞時可以回到上一版)、伺服器端加密,並封鎖所有公開存取
- DynamoDB 資料表:以
LockID(字串)為主鍵。執行apply前,Terraform 會在表中寫入一筆以 State 路徑為鍵的紀錄,代表「我正在使用這份 State」;其他人此時執行,會看到鎖被佔用而等待或失敗;執行結束後自動釋放 - 存取權限:用 IAM 限制誰能讀寫哪些 State 路徑,例如開發人員只能操作
development/下的 State,production/只有部署用的 Role 可以寫入
雞生蛋的問題:誰來建立後端?
存放 State 的 Bucket 和鎖表本身也是 AWS 資源,也想用 Terraform 管理,但它們必須在「其他所有 Terraform 專案之前」就存在。常見的做法是做一個獨立的 Bootstrap 專案:先用本機 State 建立 Bucket 與鎖表,建好之後再把這個專案自己的 State 也遷移到剛建立的遠端後端(terraform init -migrate-state)。
環境隔離:每個環境一份 State
開發、測試、正式環境一定要使用不同的 State。如果所有環境共用一份 State,一次在開發環境的誤操作,就可能動到正式環境的資源。
常見的目錄結構:
infrastructure/
├── global/ ← 只需要一份的共用資源:State 後端、IAM Role
├── development/
│ └── main.tf ← backend key = development/…/terraform.tfstate
├── stage/
└── production/
└── main.tf ← backend key = production/…/terraform.tfstate
modules/
└── aws/
├── alb/
├── ec2/
└── s3/
每個環境的目錄有自己的後端設定與 State,彼此完全隔離;資源的寫法則抽成共用的模組,環境之間的差異只在傳入的變數值。
模組設計:讓每個環境都用同一份寫法
如果每個環境各自複製一份 ALB、EC2 的寫法,一開始一模一樣,幾個月後就會各自長出不同的修改,誰也說不清楚正式環境和測試環境到底差在哪。模組讓「怎麼建立一個 ALB」只寫一次:
# production/main.tf
module "web_alb" {
source = "../../modules/aws/alb"
name = "web-prod"
vpc_id = data.aws_vpc.main.id
subnet_ids = data.aws_subnets.public.ids
certificate_arn = var.certificate_arn
enable_access_log = true
}
一個好維護的模組,通常會有這幾個特徵:
輸入有型別、有驗證、有合理的預設值
variable "name" {
description = "ALB 名稱,會作為相關資源的名稱前綴"
type = string
validation {
condition = length(var.name) <= 28
error_message = "name 長度不可超過 28 個字元(ALB 名稱上限為 32)。"
}
}
variable "enable_access_log" {
description = "是否開啟存取日誌"
type = bool
default = false
}
錯誤的參數在 plan 階段就被擋下來,而不是 apply 到一半才收到 AWS API 的錯誤。
輸出其他模組需要的值
output "arn" { value = aws_lb.this.arn }
output "dns_name" { value = aws_lb.this.dns_name }
模組之間透過輸出值串接,例如 CloudFront 模組需要 ALB 的 dns_name 作為來源。
單一職責、適當的抽象層級
模組應該代表一個有意義的元件(一個 ALB 加上它的監聽器與安全群組),而不是把整個環境塞進一個模組,也不要包裝到只剩一個 resource 的薄殼——那樣只是多了一層間接,沒有帶來任何價值。
鎖定版本
terraform {
required_version = ">= 1.5"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
模組放在獨立的 Git Repo 時,使用端以 ?ref=v1.2.0 指定版本,模組升級不會意外影響所有環境。
文件與測試
每個模組附上說明輸入、輸出、使用範例的 README(可以用 terraform-docs 自動產生),並用 Terratest(Go)撰寫整合測試:真的在測試帳號中建立資源、驗證行為、再自動刪除,確保模組修改後仍然能正常運作。
使用情境:建立公司的 Terraform 規範
在房仲公司導入 Terraform 時,主要的工作就是上面這兩件事:
- 以 Bootstrap 專案建立 S3 + DynamoDB 的遠端後端與 IAM Role,所有專案統一使用,State 不再散落在個人電腦上
- 依環境切分目錄,每個環境一份 State,搭配 IAM 權限控制誰能操作正式環境
- 把 ELB、CloudFront、EC2、S3、EKS 等常用資源做成模組,附上文件與 Terratest 整合測試,各團隊以一致的方式建立基礎設施
一句話總結
State 是 Terraform 認得雲端資源的唯一依據,團隊協作時要放在 S3 這類遠端後端,並用 DynamoDB 加鎖避免同時 apply,再開啟版本控制、加密與權限控管;每個環境各自一份 State 確保互不影響,資源的寫法則抽成有型別驗證、有輸出、有版本、有文件和測試的模組,讓所有環境都用同一份程式碼建立。