2023 年我们做一个跨境电商 系统跑在 AWS 上 主要服务部署在 us-east-1 us-west-2 两个 region 还有一组数据库副本在 eu-central-1。一开始基础设施都是工程师手点 AWS 控制台搞出来的 EC2 RDS VPC ELB 一个个配。这种方式一开始还能撑 但半年后我们陆续踩了一堆坑。第一种最让我傻眼 某次 us-east-1 故障 我们想紧急在 us-west-2 启一套 完全一致的备份环境 却发现配置全靠回忆 安全组规则漏了 5 条 RDS 参数对不上 ELB 监听器配置错 整整 4 小时才把备用环境拉起来。第二种最难缠 工程师 A 在测试环境改了一个 IAM policy 上线时漏带到生产 生产权限不一致跑了 3 天才被发现。第三种最离谱 某天有个新人手抖在生产 console 删了一个 RDS instance 没有 MFA delete 没有 termination protection 数据 backup 还在但恢复也花了 6 小时。第四种最致命 我们的 EC2 spot instance 自动扩缩容靠 user data 脚本 一台机器手动改了一行 cron 上线没同步给模板 后来扩容拉起的新机器都没那条 cron 任务静默丢失。第五种最莫名其妙 同一段 Terraform 在两个工程师机器上 plan 出来差异巨大 排查发现是 provider 版本一个 3.x 一个 5.x 行为差异导致 plan 漂移。我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为基础设施代码化就是写写 Terraform 把资源声明一下就够了 可这个认知是错的真正能扛业务的 IaC 是一个 状态后端 加 模块化设计 加 多环境隔离 加 plan-apply 流水线 加 漂移检测 加 secret 管理 加 团队协作规范 的整套工程方法论 任何一环没做都可能在某次生产变更里造成配置漂移服务中断或安全漏洞本文从头梳理 Terraform 与 Pulumi 的工程化要点 state 怎么管 模块怎么拆 多环境怎么隔离 CI 流水线怎么搭 漂移怎么检测 secret 怎么注入 以及一些把 IaC 做扎实要避开的工程坑
问题背景:为什么 Terraform 不是写写就完事
很多人对 IaC 的认知是 把资源 declarative 写出来 terraform apply 就完事 但生产里你会发现 多人协作时 state 冲突 多环境 dev/staging/prod 配置错乱 secret 不小心进了 git 历史 模块重复定义 升级 provider 行为突变。问题的根源在于:
- state 是 IaC 的真相源:state 文件存哪 怎么 lock 怎么备份 决定了多人协作能不能不打架 必须用 remote backend。
- 模块化是工程化基础:每个 VPC ELB RDS 都重新写一遍迟早翻车 必须抽 module 让 dev/staging/prod 复用同一份。
- 多环境隔离不是文件夹:同一份代码不同 var 文件最容易踩坑 必须 workspace 或目录级隔离 每个环境独立 state。
- plan 必须人工 review:apply 之前 plan diff 必须有人看 否则改错一个参数可能删数据库。
- 漂移会发生:总有人手点 console 改东西 必须定期 drift detection 否则 state 与现实越差越远。
- secret 不能进 state:Terraform state 里会明文存 RDS 密码这类敏感字段 必须配合 Vault 或 SSM 注入 不要硬编码。
一 Remote State 与锁:多人协作的根本
Terraform 默认 state 存本地 terraform.tfstate 文件 多人协作时这是致命问题。一定要用 remote backend 主流选择是 S3 + DynamoDB lock 或者 Terraform Cloud。S3 存 state DynamoDB 做 lock 这样两个人同时 apply 时一个会被 lock 住等待。
# backend.tf 远程 state 与锁配置
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.30"
}
}
backend "s3" {
bucket = "mycompany-tfstate-prod"
key = "platform/network/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
kms_key_id = "alias/tfstate"
}
}
# S3 bucket 配置 必须开启版本化与加密
resource "aws_s3_bucket" "tfstate" {
bucket = "mycompany-tfstate-prod"
}
resource "aws_s3_bucket_versioning" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.tfstate.arn
}
}
}
# DynamoDB 锁表
resource "aws_dynamodb_table" "tf_lock" {
name = "terraform-state-lock"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
state 远程化的关键点 第一是 S3 bucket 必须开版本化 这样 state 误删也能恢复 第二是必须加密 因为 state 里包含敏感配置 第三是 DynamoDB lock 防止并发 apply 否则 state 损坏。我们公司的规范是 任何一个 state bucket 都属于 platform team 应用 team 不直接接触只能通过 module 调用 这样 state 操作的权限被严格控制。
二 模块化:Terraform Module 工程实践
不要在每个项目重复写 VPC 写 RDS 写 ELB。要把这些常用资源抽成 module 一份代码多个环境复用。module 的设计要清晰 输入是变量 输出是 attributes 内部封装实现细节 调用者不用关心。
# modules/rds-postgres/main.tf
variable "name" {
type = string
}
variable "engine_version" {
type = string
default = "15.4"
}
variable "instance_class" {
type = string
default = "db.r6g.large"
}
variable "allocated_storage" {
type = number
default = 100
}
variable "subnet_ids" {
type = list(string)
}
variable "vpc_security_group_ids" {
type = list(string)
}
variable "backup_retention_days" {
type = number
default = 7
}
variable "deletion_protection" {
type = bool
default = true
}
resource "aws_db_subnet_group" "this" {
name = "${var.name}-subnet-group"
subnet_ids = var.subnet_ids
}
resource "aws_db_instance" "this" {
identifier = var.name
engine = "postgres"
engine_version = var.engine_version
instance_class = var.instance_class
allocated_storage = var.allocated_storage
storage_encrypted = true
db_subnet_group_name = aws_db_subnet_group.this.name
vpc_security_group_ids = var.vpc_security_group_ids
backup_retention_period = var.backup_retention_days
deletion_protection = var.deletion_protection
skip_final_snapshot = false
final_snapshot_identifier = "${var.name}-final-${formatdate("YYYYMMDD", timestamp())}"
username = "appuser"
manage_master_user_password = true # 自动用 Secrets Manager 管密码
tags = {
ManagedBy = "terraform"
Module = "rds-postgres"
}
}
output "endpoint" {
value = aws_db_instance.this.endpoint
}
output "master_secret_arn" {
value = aws_db_instance.this.master_user_secret[0].secret_arn
}
调用端就是简单的引用 不同环境用不同变量值 prod 用大规格 t 系列在 dev 上撑撑就行 backup_retention_days deletion_protection 这些安全属性根据环境敏感度调整。
# environments/prod/main.tf
module "orders_db" {
source = "../../modules/rds-postgres"
name = "orders-prod"
engine_version = "15.4"
instance_class = "db.r6g.2xlarge"
allocated_storage = 500
subnet_ids = module.vpc.private_subnet_ids
vpc_security_group_ids = [aws_security_group.rds.id]
backup_retention_days = 14
deletion_protection = true
}
staging 环境调用同一个 module 但传不同的参数 这就是模块化的核心价值 改 module 一次 所有环境同步生效 不用每个环境重写。
# environments/staging/main.tf
module "orders_db" {
source = "../../modules/rds-postgres"
name = "orders-staging"
engine_version = "15.4"
instance_class = "db.t4g.medium"
allocated_storage = 50
subnet_ids = module.vpc.private_subnet_ids
vpc_security_group_ids = [aws_security_group.rds.id]
backup_retention_days = 1
deletion_protection = false
}
module 设计的原则是 单一职责 一个 module 干一件事 不要做 一个 module 包整套微服务 这样的胖 module 第二是 变量默认值要安全 deletion_protection 默认 true 让用户主动 opt-out。我们的 module 都 push 到内部 Terraform Registry 版本化管理 应用 team 用 source = "tfregistry.mycompany.com/modules/rds-postgres/aws" version = "1.4.2" 像引用 npm 包一样引用。
三 多环境隔离:目录还是 Workspace
Terraform 多环境管理有两种主流方案 一是 workspace 同一份代码不同 workspace 共享 backend 不同 state key 二是目录隔离 每个环境一个目录 各自独立 backend 各自独立 state。两种各有优缺点。
# 方案一 workspace
# 一份代码 多个 workspace
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
terraform workspace select prod
terraform apply -var-file=prod.tfvars
# 代码里用 terraform.workspace 引用当前 workspace
# locals.tf
locals {
env = terraform.workspace
instance_class = {
dev = "db.t4g.micro"
staging = "db.t4g.medium"
prod = "db.r6g.2xlarge"
}[local.env]
}
方案二是目录隔离 每个环境一个独立目录 各自 backend 各自 state 互不影响。结构如下 environments/dev environments/staging environments/prod 各放 main.tf backend.tf terraform.tfvars 共享部分放在顶层 modules 目录里。
# 方案二 目录隔离 推荐生产用
# environments/
# dev/
# main.tf
# backend.tf
# terraform.tfvars
# staging/
# main.tf
# backend.tf
# terraform.tfvars
# prod/
# main.tf
# backend.tf
# terraform.tfvars
# modules/
# vpc/
# rds-postgres/
# eks/
# 每个环境独立 backend state key
# environments/prod/backend.tf
terraform {
backend "s3" {
bucket = "mycompany-tfstate-prod"
key = "envs/prod/platform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-lock"
}
}
生产强烈推荐目录隔离 不要 workspace workspace 看起来省事但有几个致命问题 同一份代码改一行所有环境都受影响 prod 的 state key 与 dev 在一个 backend 误操作风险高 跨环境的 IAM 权限不好分。目录隔离虽然代码有冗余 但环境之间完全独立 prod 的 apply 工程师可以连 dev backend 的权限都没有 安全性提升一个数量级。
四 plan-apply 流水线:CI/CD 集成
生产环境不能让工程师在本机 apply。必须把 plan-apply 接入 CI/CD 流水线 PR 提交时自动 plan 评论到 PR 上 merge 后 CI 自动 apply 这样所有变更都有完整审计。
# .github/workflows/terraform.yml
name: terraform
on:
pull_request:
paths: ['environments/**', 'modules/**']
push:
branches: [main]
paths: ['environments/**', 'modules/**']
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
plan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
strategy:
matrix:
env: [dev, staging, prod]
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::1234567890:role/terraform-plan
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.0
- name: Terraform Plan
working-directory: environments/${{ matrix.env }}
run: |
terraform init
terraform plan -no-color -out=tfplan
terraform show -no-color tfplan > plan.txt
- name: Comment Plan on PR
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('environments/${{ matrix.env }}/plan.txt', 'utf8');
const body = `### Terraform Plan for ${{ matrix.env }}\n\`\`\`\n${plan.substring(0, 60000)}\n\`\`\``;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body,
});
apply:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production # 需要 manual approval
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::1234567890:role/terraform-apply
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
- name: Terraform Apply
working-directory: environments/prod
run: |
terraform init
terraform apply -auto-approve
流水线最关键的设计是 plan 和 apply 用不同的 IAM role plan 只需要 read 权限 apply 需要 write 权限 这样 PR 阶段即使有漏洞也无法搞坏生产。GitHub Environments 加上 required reviewers 让生产 apply 必须有人 manual approve 这是最后一道防线。
[mermaid]flowchart TD
A[工程师改 Terraform] --> B[创建 PR]
B --> C[CI 自动 plan]
C --> D[plan diff 评论到 PR]
D --> E[同事 review]
E -->|批准| F[merge 到 main]
F --> G[CI apply 流程]
G --> H{prod 环境}
H -->|是| I[manual approval]
H -->|否| J[直接 apply]
I --> K[apply 执行]
J --> K
K --> L[Slack 通知]
L --> M[漂移检测定时任务]
五 漂移检测:state 与现实一致性
就算所有人都走 IaC 流程 总有紧急情况会有人手点 console 比如线上着火时改个 security group 应急。这种变更如果不回写到 Terraform 下次 apply 就会把它改回去 引发二次故障。必须有漂移检测机制 定期跑 terraform plan 看有没有 diff 有就报警。
#!/bin/bash
# scripts/drift-detect.sh 每天定时跑
set -euo pipefail
ENVS=("dev" "staging" "prod")
SLACK_WEBHOOK="${SLACK_WEBHOOK_URL}"
for env in "${ENVS[@]}"; do
cd "environments/$env"
terraform init -input=false >/dev/null
if ! terraform plan -detailed-exitcode -no-color -out=tfplan 2>&1 | tee plan.log; then
EXIT=$?
if [ "$EXIT" = "2" ]; then
# exit 2 表示有 diff 但 plan 成功
DIFF=$(terraform show -no-color tfplan | head -200)
curl -X POST "$SLACK_WEBHOOK" -H 'Content-Type: application/json' \
-d "{\"text\": \":warning: Terraform drift detected in $env\n\`\`\`$DIFF\`\`\`\"}"
else
curl -X POST "$SLACK_WEBHOOK" -H 'Content-Type: application/json' \
-d "{\"text\": \":x: Terraform plan failed in $env\"}"
exit 1
fi
fi
cd - >/dev/null
done
漂移检测要做的不只是发现 还要有明确的处理流程 发现漂移后第一步是确认 这个变更是合理的吗 是的话回写到 Terraform 不是的话立刻 apply 恢复。我们公司的 SRE 团队每周一开周会过一遍漂移列表 这个机制让生产基础设施真正可信。
六 IaC 的工程坑:那些文档里学不到的
讲完原理来说几个真实生产里踩过的坑。第一个坑是 provider 版本不锁 团队成员各自电脑上 provider 版本不同 plan 出来内容不同 必须在 required_providers 里精确锁版本 同时 commit lock 文件 .terraform.lock.hcl 到 git。第二个坑是 RDS 这类有状态资源的 replace 风险 改了某个不可变属性 比如 engine 大版本 Terraform 默认会先 destroy 再 create 数据全没 必须在 lifecycle 块加 prevent_destroy 与 create_before_destroy。第三个坑是 IAM policy 拼错 JSON Terraform 会报错 但 IAM 拼错的语义 比如 Resource arn 写错 不报错也不生效 必须有单元测试用 terraform-compliance 这类工具校验。第四个坑是 import 历史资源 老资源是手点出来的 想纳入 IaC 必须 terraform import 命令一个一个导入 量大时极其痛苦 建议用 terraformer 或 cloudformation-to-terraform 这类工具批量导入。第五个坑是 跨账号 cross-account resource 引用 默认 Terraform 只管单账号 跨账号资源比如同一个 R53 hosted zone 给多个账号用 必须用 multiple provider alias 或者 data source 跨账号引用 这个是经常翻车的地方。
关键概念速查
| 概念 | 含义 | 工程价值 |
|---|---|---|
| Remote State | state 存远端 | 多人协作基础 |
| State Lock | DynamoDB 加锁 | 防并发损坏 |
| Module | 可复用资源组合 | 代码工程化 |
| Workspace | 同代码多 state | 不推荐生产 |
| 目录隔离 | 每环境独立目录 | 生产推荐 |
| plan-apply | CI 流水线 | 变更可审计 |
| 漂移检测 | state 与现实比对 | 避免手点污染 |
| prevent_destroy | lifecycle 保护 | 有状态资源必加 |
| Secrets Manager | 密码自动管理 | 避免明文进 state |
| provider 版本锁 | required_providers | plan 一致性 |
避坑清单
- state 必须用 S3 backend + DynamoDB lock 不要本地 state 多人协作会冲突。
- S3 bucket 必须开版本化 加密 这样 state 误删能恢复 敏感信息不裸奔。
- 重复资源必须抽 module dev/staging/prod 复用同一份代码不同变量。
- 多环境用目录隔离不用 workspace prod 与 dev backend 完全独立才安全。
- plan-apply 必须接 CI plan 评论到 PR review 通过 merge 后自动 apply。
- prod apply 加 manual approval 不要让自动化无人值守改生产。
- 定期跑漂移检测脚本 发现手点变更立即处理 不要任由 state 漂移。
- 有状态资源 RDS 这类必须 lifecycle prevent_destroy create_before_destroy 双保险。
- provider 必须 required_providers 精确锁版本 lock 文件 commit 到 git。
- 跨账号资源用 provider alias 与 data source 不要硬编码 ARN。
总结
IaC 这事 很多人的直觉是 写 Terraform terraform apply 就完事了 这其实是把 我会写 resource 块 和 我能在生产用 Terraform 扛住多人协作多环境变更 混为一谈。前者是会用 DSL 后者是懂基础设施工程。中间隔着的是 state 管理 模块化设计 多环境隔离 CI 流水线 漂移检测 secret 管理 整整一套工程方法论。
从原型到生产 你需要做的事远不止 写几个 resource。你要懂 backend 怎么配 要会拆 module 要选目录隔离 要搭 plan-apply 流水线 要做漂移检测 要管 provider 版本 要处理有状态资源 要做跨账号引用。每一项单独看都不复杂 但它们组合在一起 才是一个能扛业务规模的 IaC 体系。少任何一项 都可能在某次变更里把生产搞炸 而你的 state 与现实已经对不上 恢复都困难。
我经常用一个比喻来理解 IaC 它有点像建筑工程的施工图。Terraform 代码是图纸 state 是施工记录 module 是预制构件 plan 是开工前会审 apply 是真正动土 漂移检测是定期巡检看实际建筑跟图纸是否一致。你不能因为有了图纸就觉得建筑不会出问题 还要管图纸有没有版本控制 施工记录是否完整 预制构件是否合规 开工前是否会审 巡检是否定期 这才是一整套建筑工程。少了任何一项 房子要么建错要么建着建着发现已经偏离设计 拆都不好拆。
这套架构最难的地方在于 它的复杂度在小项目时几乎完全暴露不了。你一个人写 Terraform 部署一个简单服务 一切都很顺 觉得 IaC 真好用 把控制台那套东西全淘汰了。但真正多团队协作 多环境 多账号 多 region 复杂业务 你才发现 99% 的复杂度都在 那 1% 的协作 case 里 state 冲突 漂移 误删 跨账号 provider 版本不一致。建议任何想做 IaC 的团队 上线前一定要做一次 全员演练 故意制造 state 冲突 故意手点改 console 故意触发漂移 看团队能不能从容处理 千万别等真正故障来教你 那时候生产环境可能已经停服几个小时了。
—— 别看了 · 2026