Terraform 进阶实战——模块化、状态管理、团队协作的完整方案
Terraform 进阶实战——模块化、状态管理、团队协作的完整方案
适读人群:有 Terraform 基础、需要在团队中落地 IaC 的工程师 | 阅读时长:约22分钟 | 核心价值:用模块化解决重复代码,用状态管理解决协作冲突,用 CI/CD 让 IaC 真正规范运行
上一篇讲了 Terraform 的基础,这篇要说说真正在团队里用 Terraform 的时候会遇到的问题。
入门阶段,你自己写,自己执行,很爽。但当团队里有 5 个人,有 3 个环境,有 20 个模块的时候,原来的那套做法会开始出问题。
我亲历了一次这样的"扩张之痛"。
我们团队从 2 个人用 Terraform 扩展到 8 个人的时候,出现了这些问题:同事 A 在测试环境改了某个安全组,同事 B 不知道,在生产环境做了不一致的变更;有人 terraform apply 的时候状态文件正好被别人加了锁,等了 10 分钟无果,强制解锁,结果锁的那个人的 apply 到一半被打断,状态文件损坏了;各个环境的 Terraform 代码大量重复,测试环境改了一个模块,要同步到 3 个环境,经常改漏。
这篇文章讲的就是如何解决这些问题。
模块化:消灭重复代码
什么时候应该提取模块
不是所有代码都要模块化。过度模块化和不模块化一样糟糕。
应该提取模块的信号:
- 同一段代码在多个地方出现(三次以上)
- 一组资源在逻辑上是一个整体,有明确的边界
- 这组资源需要在多个环境以相同方式复用
不该过度模块化:
- 只有一个地方用到的代码不值得模块化
- 为了"整洁"而模块化,结果模块间依赖关系复杂到难以维护
编写一个生产级别的 VPC 模块
modules/
└── vpc/
├── main.tf
├── variables.tf
├── outputs.tf
└── README.mdmodules/vpc/variables.tf:
variable "name" {
description = "VPC 名称前缀"
type = string
}
variable "vpc_cidr" {
description = "VPC CIDR 块"
type = string
default = "10.0.0.0/16"
}
variable "azs" {
description = "可用区列表"
type = list(string)
}
variable "public_subnets" {
description = "公有子网 CIDR 列表"
type = list(string)
default = []
}
variable "private_subnets" {
description = "私有子网 CIDR 列表"
type = list(string)
default = []
}
variable "enable_nat_gateway" {
description = "是否创建 NAT Gateway(生产环境开启,成本较高)"
type = bool
default = false
}
variable "single_nat_gateway" {
description = "是否只创建一个 NAT Gateway(节省成本,但单点故障)"
type = bool
default = true
}
variable "tags" {
description = "额外的标签"
type = map(string)
default = {}
}modules/vpc/main.tf(核心逻辑):
locals {
# 合并默认标签和自定义标签
common_tags = merge(
{ Name = var.name },
var.tags
)
# NAT Gateway 数量:single 模式只创建一个,否则每个 AZ 一个
nat_gateway_count = var.enable_nat_gateway ? (var.single_nat_gateway ? 1 : length(var.azs)) : 0
}
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = local.common_tags
}
resource "aws_internet_gateway" "this" {
count = length(var.public_subnets) > 0 ? 1 : 0
vpc_id = aws_vpc.this.id
tags = merge(local.common_tags, { Name = "${var.name}-igw" })
}
resource "aws_subnet" "public" {
count = length(var.public_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnets[count.index]
availability_zone = var.azs[count.index]
map_public_ip_on_launch = true
tags = merge(local.common_tags, {
Name = "${var.name}-public-${var.azs[count.index]}"
Type = "public"
})
}
resource "aws_subnet" "private" {
count = length(var.private_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.private_subnets[count.index]
availability_zone = var.azs[count.index]
tags = merge(local.common_tags, {
Name = "${var.name}-private-${var.azs[count.index]}"
Type = "private"
})
}
resource "aws_eip" "nat" {
count = local.nat_gateway_count
domain = "vpc"
tags = merge(local.common_tags, { Name = "${var.name}-nat-eip-${count.index}" })
}
resource "aws_nat_gateway" "this" {
count = local.nat_gateway_count
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = merge(local.common_tags, { Name = "${var.name}-nat-gw-${count.index}" })
depends_on = [aws_internet_gateway.this]
}
resource "aws_route_table" "public" {
count = length(var.public_subnets) > 0 ? 1 : 0
vpc_id = aws_vpc.this.id
dynamic "route" {
for_each = length(aws_internet_gateway.this) > 0 ? [1] : []
content {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this[0].id
}
}
tags = merge(local.common_tags, { Name = "${var.name}-public-rt" })
}
resource "aws_route_table_association" "public" {
count = length(var.public_subnets)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public[0].id
}
resource "aws_route_table" "private" {
count = local.nat_gateway_count > 0 ? length(var.private_subnets) : 0
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = var.single_nat_gateway ? aws_nat_gateway.this[0].id : aws_nat_gateway.this[count.index].id
}
tags = merge(local.common_tags, { Name = "${var.name}-private-rt-${count.index}" })
}
resource "aws_route_table_association" "private" {
count = local.nat_gateway_count > 0 ? length(var.private_subnets) : 0
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[count.index].id
}调用模块
# environments/production/main.tf
module "vpc" {
source = "../../modules/vpc"
name = "prod"
vpc_cidr = "10.0.0.0/16"
azs = ["ap-northeast-1a", "ap-northeast-1c"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnets = ["10.0.10.0/24", "10.0.11.0/24"]
enable_nat_gateway = true
single_nat_gateway = false # 生产环境每个 AZ 一个 NAT GW,避免单点故障
tags = {
CostCenter = "engineering"
}
}
# environments/staging/main.tf
module "vpc" {
source = "../../modules/vpc"
name = "staging"
vpc_cidr = "10.1.0.0/16"
azs = ["ap-northeast-1a"]
public_subnets = ["10.1.1.0/24"]
private_subnets = ["10.1.10.0/24"]
enable_nat_gateway = true
single_nat_gateway = true # 测试环境单 NAT GW,节省成本
}踩坑一:模块版本不一致导致环境漂移
我们有段时间模块化做到一半,不同环境引用的模块版本不一样——生产用的老版本,测试用的新版本——结果两个环境的行为不一致,追了半天 bug,最后发现是模块版本差异导致的。
解决方案:模块也要做版本管理。把模块放在独立的 Git 仓库,打 tag:
module "vpc" {
source = "git::https://github.com/company/terraform-modules.git//vpc?ref=v1.2.0"
# ...
}所有环境都明确指定版本,升级时有计划、有测试,不要让各个环境"漂移"。
状态管理:团队协作的基础
状态文件分离策略
一个常见的错误:把所有环境的资源都放在一个 Terraform 配置里,用变量区分。这样所有资源共用一个状态文件,每次 plan/apply 要计算所有资源,速度慢,而且任何操作都有影响全部环境的风险。
正确做法:按环境、按功能模块分离状态:
├── environments/
│ ├── production/
│ │ ├── network/ # 网络层,独立状态
│ │ │ └── main.tf
│ │ ├── data/ # 数据层(RDS、Redis),独立状态
│ │ │ └── main.tf
│ │ └── application/ # 应用层(ECS、ALB),独立状态
│ │ └── main.tf
│ └── staging/
│ └── ...(同样结构)每个目录是一个独立的 Terraform 工作空间,有自己的状态文件:
# environments/production/network/versions.tf
backend "s3" {
key = "production/network/terraform.tfstate"
}
# environments/production/data/versions.tf
backend "s3" {
key = "production/data/terraform.tfstate"
}跨状态文件引用数据,用 terraform_remote_state:
# environments/production/application/main.tf
# 引用网络层输出的 VPC 和子网 ID
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "company-terraform-state"
key = "production/network/terraform.tfstate"
region = "ap-northeast-1"
}
}
resource "aws_ecs_cluster" "main" {
name = "production-cluster"
}
resource "aws_ecs_service" "app" {
name = "my-app"
cluster = aws_ecs_cluster.main.id
# 使用网络层的输出值
network_configuration {
subnets = data.terraform_remote_state.network.outputs.private_subnet_ids
security_groups = [aws_security_group.app.id]
}
}踩坑二:状态文件锁超时后强制解锁引发的灾难
上面我提到过的那次问题——有人强制解锁了另一个人正在 apply 的锁,导致状态文件损坏。
正确处理锁超时的流程:
- 先确认持有锁的人的 apply 是否还在运行(问一下对方,或查看 DynamoDB 里锁的创建时间)
- 如果确认 apply 已经结束(成功或失败),再解锁:
terraform force-unlock LOCK_ID - 永远不要在不确认的情况下 force-unlock
如果状态文件真的损坏了,处理步骤:
- 从 S3 找到上一个版本的状态文件(S3 开启版本控制是必需的!)
- 恢复到上一个版本
- 执行
terraform refresh重新同步真实资源状态 - 对有差异的资源逐一处理
S3 状态桶一定要开版本控制,这是出现问题时的最后一道保险。如果 terraform apply 中途失败,状态文件可能处于不一致状态,有了版本控制,可以回滚到上一个一致的状态版本。另外,状态文件本身也要定期备份(S3 的对象版本控制本身就是备份机制),并且要测试从备份恢复的流程,确保在真正需要的时候能顺利恢复。
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}CI/CD 集成:让 IaC 真正规范运行
手动执行 terraform apply 是不够规范的。你无法保证每个人执行前都运行了最新的代码,也无法追踪谁在什么时候执行了什么变更。
把 Terraform 的执行放进 CI/CD 流程里,是成熟 IaC 实践的必要步骤。有一个细节值得注意:把 Terraform 纳入 CI/CD 之后,要建立配套的"紧急通道"——当 CI 系统本身出问题,或者遇到需要立即处理的生产基础设施问题时,允许特定人员绕过 CI 直接执行 terraform apply,但这个操作必须有完整的记录和事后审计。不能为了流程严格而牺牲紧急情况的响应速度。
GitHub Actions 的 Terraform 流水线
name: Terraform
on:
push:
branches: [main]
paths:
- 'environments/**'
- 'modules/**'
pull_request:
paths:
- 'environments/**'
- 'modules/**'
permissions:
contents: read
pull-requests: write # 需要在 PR 里添加评论
jobs:
terraform-plan:
name: Terraform Plan
runs-on: ubuntu-latest
strategy:
matrix:
environment: [staging, production]
layer: [network, data, application]
defaults:
run:
working-directory: environments/${{ matrix.environment }}/${{ matrix.layer }}
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.7.0"
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-1
- name: Terraform Init
id: init
run: terraform init
- name: Terraform Format Check
id: fmt
run: terraform fmt -check
continue-on-error: true # 格式问题不阻塞,但会记录
- name: Terraform Validate
id: validate
run: terraform validate
- name: Terraform Plan
id: plan
run: |
terraform plan \
-var="environment=${{ matrix.environment }}" \
-out=tfplan \
-no-color 2>&1 | tee plan-output.txt
continue-on-error: true
- name: Comment Plan on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('environments/${{ matrix.environment }}/${{ matrix.layer }}/plan-output.txt', 'utf8');
const maxLen = 65000;
const truncated = plan.length > maxLen ? plan.slice(0, maxLen) + '\n... (truncated)' : plan;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## Terraform Plan: ${{ matrix.environment }}/${{ matrix.layer }}\n\`\`\`\n${truncated}\n\`\`\``
});
terraform-apply:
name: Terraform Apply
needs: terraform-plan
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production # 需要审批
strategy:
max-parallel: 1 # 串行执行,防止状态冲突
matrix:
include:
- environment: staging
layer: network
- environment: staging
layer: data
- environment: staging
layer: application
defaults:
run:
working-directory: environments/${{ matrix.environment }}/${{ matrix.layer }}
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.7.0"
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-1
- run: terraform init
- run: terraform apply -auto-approve -var="environment=${{ matrix.environment }}"踩坑三:PR 里没有 Plan 输出,Review 变成形式
我们一开始的 CI 流程只是验证 Terraform 代码语法,没有在 PR 里展示 plan 的结果。
Review 者看着一堆 .tf 代码,只能猜测这会创建/修改什么资源,无法做有效的 review。
上面的配置里,Comment Plan on PR 步骤会自动把 terraform plan 的输出贴到 PR 的评论里:
## Terraform Plan: staging/network
+ aws_vpc.this will be created
+ id = (known after apply)
+ cidr_block = "10.1.0.0/16"
...
Plan: 8 to add, 0 to change, 0 to destroy.这样 review 者能直观地看到这次 PR 会对基础设施做什么变更,code review 变得有实质意义。
深度解析:Terraform 工作空间(Workspace)vs 目录分离
Terraform 有一个叫 Workspace 的功能,很多新手以为它是用来隔离多个环境的(生产/测试/开发)。这是一个常见的误解,需要澄清。
Workspace 允许在同一套代码里维护多个状态文件,理论上可以用 terraform workspace new staging 来创建测试环境的状态文件,与生产环境的状态文件隔离。
但是,我不推荐用 Workspace 来区分环境。原因如下:
首先,Workspace 共享同一套代码,意味着生产环境和测试环境用完全一样的配置(如果用变量区分,就变得非常复杂)。实际上不同环境往往有真实的配置差异(资源规格、副本数、是否开启 Multi-AZ),这些差异放在一套代码里,很难清晰地表达。
其次,所有 Workspace 的状态文件存储在同一个 S3 bucket 下的不同路径,意味着误操作的风险更高——一不小心 terraform workspace select production && terraform destroy 就是生产灾难。
推荐的做法:不同环境用不同的目录,每个目录是完全独立的 Terraform 根模块,有自己的状态文件。生产环境和测试环境的代码可以不一样,差异清晰可见,风险也完全隔离。
目录分离的额外好处是可以对不同目录设置不同的访问控制。测试环境的 Terraform 配置,任何工程师都可以修改;生产环境的配置,只有资深工程师才能修改,需要两人审批。这种权限分级在 Workspace 模式下很难实现,但在目录模式下,通过 GitHub 的 CODEOWNERS 文件就能轻松配置。
深度解析:Terraform 的 Provider 版本管理
Terraform Provider 的版本管理是一个容易被忽视但很重要的细节。
在 versions.tf 里声明 Provider 版本时,~> 符号的语义需要理解清楚:
version = "~> 5.0" # 允许 5.x 的任意版本,但不升级到 6.x
version = "~> 5.30" # 允许 5.30.x 的任意补丁版本,但不升级到 5.31
version = ">= 5.0, < 6.0" # 明确范围,等价于 ~> 5.0最佳实践:生产环境的 versions.tf 应该锁定到小版本(~> 5.30),避免 Provider 的次要版本升级带来 breaking change。在测试环境可以使用较宽松的约束(~> 5.0),优先发现升级问题。
定期升级 Provider 版本,而不是永远锁定在老版本。Provider 升级往往带来新功能的支持、bug 修复和安全更新。有些 Provider 升级会带来新的资源属性或者改变现有属性的行为,所以升级不应该直接在生产环境做,要先在测试环境验证 plan 输出没有意外的变化,再应用到生产。
可以在 CI 里加一个周期性的"Provider 升级检查"任务:
# 每月自动创建 PR,升级 Provider 版本
terraform-update:
runs-on: ubuntu-latest
schedule:
- cron: '0 9 1 * *' # 每月1日
steps:
- uses: actions/checkout@v4
- name: Update Terraform providers
run: |
terraform init -upgrade
git add .terraform.lock.hcl
git commit -m "chore: update Terraform provider lock file"
continue-on-error: true一些实用技巧
使用 moved 块处理资源重命名
当你需要重命名一个资源(比如 aws_subnet.subnet1 改成 aws_subnet.public),不能直接改名——Terraform 会认为这是"删除旧资源 + 创建新资源",生产环境直接删子网是灾难性的。
用 moved 块可以安全地重命名:
moved {
from = aws_subnet.subnet1
to = aws_subnet.public[0]
}这告诉 Terraform:subnet1 和 public[0] 是同一个资源,只是改了名,不需要销毁重建。
用 terraform console 调试表达式
terraform console 是一个交互式控制台,可以用来调试 HCL 表达式:
$ terraform console
> "prod-${lower("STAGING")}"
"prod-staging"
> length(["a", "b", "c"])
3
> keys({name = "test", env = "prod"})
toset(["env", "name"])调试复杂的 for 表达式或 dynamic 块时非常有用。很多工程师不知道这个工具的存在,遇到 HCL 表达式报错时只能靠猜测修改,非常低效。terraform console 可以让你即时验证表达式的输出,大大提升调试效率。建议把它和 terraform validate、terraform fmt 一起,作为 Terraform 开发的常用调试工具。
深度解析:Terraform 与 Pulumi、AWS CDK 的比较
Terraform 不是 IaC 的唯一选择,近年来 Pulumi 和 AWS CDK 也越来越受到关注。理解它们的差异,有助于做出适合团队的选择。
Terraform(HCL)的优势和劣势
HCL 是 Terraform 专用的声明式语言,学习曲线平缓,可读性高,特别适合没有强烈编程背景的运维工程师。多云支持是 Terraform 的最大优势——通过不同的 Provider,可以用同一套语言管理 AWS、Azure、GCP、K8s、数据库、DNS……的资源。
劣势是 HCL 的编程能力有限,处理复杂逻辑(条件判断、循环、数据处理)比通用编程语言繁琐。很多时候你想做一件"看起来很简单"的事,却要用 count、for_each、dynamic 的组合才能实现,代码难以理解。
Pulumi:用真正的编程语言写 IaC
Pulumi 支持用 Python、TypeScript、Go、C# 等通用编程语言定义基础设施。对于有强烈编程背景的团队,这意味着可以用熟悉的语言特性——函数、类、循环、错误处理——来表达复杂的基础设施逻辑,代码的表达能力要强得多。
劣势是可读性有时候反而更差——一段 Python 脚本的意图不如一段 HCL 直观,需要对代码有一定了解才能理解它做了什么。对于混合了运维和开发背景的团队,学习成本也更高。
AWS CDK:专为 AWS 设计
AWS CDK(Cloud Development Kit)也支持用通用编程语言写 IaC,但专门针对 AWS 优化,底层生成 CloudFormation 模板。如果你的基础设施全部在 AWS 上,CDK 提供了最高层次的抽象——很多常见的架构模式(ALB + ECS、API Gateway + Lambda)有开箱即用的 Construct(类似模块),几行代码就能搭起来。
代价是 AWS 锁定:CDK 只能用于 AWS,如果将来有多云需求,CDK 的代码基本没有复用价值。
我的实际建议
中小团队入门 IaC:用 Terraform,学习资源最多,社区最活跃,遇到问题最容易找到答案。
大型团队、复杂基础设施、团队以开发工程师为主:Pulumi 值得认真评估。不过要注意,Pulumi 的社区和生态相对 Terraform 小很多,遇到问题时可以参考的资料也少,这是一个实际的成本。
全量 AWS 且无多云计划:CDK 是很好的选择,特别是配合 AWS CDK 官方的高层 Constructs,开发速度很快。CDK 的一个独特优势是和 AWS 的深度集成——新的 AWS 服务通常第一时间在 CDK 里有 L2(高层)Construct 支持,比 Terraform 的 Provider 更新更快。
总结
Terraform 的真正威力在规模化使用时才体现出来。模块化解决了代码复用和一致性问题,状态分离解决了团队协作的冲突问题,CI/CD 集成让 IaC 变成可审查、可追踪的工程实践。
这套体系建立起来之后,基础设施的变更就像代码 PR 一样:有人提议,有人 review,有记录,有审批,可回溯。这是把"基础设施"真正纳入工程管理的关键一步。
从实际推行的经验来看,Terraform 进阶实践的最大阻力往往不是技术,而是"改变习惯"。习惯了直接在控制台点按钮的工程师,要求他们每次修改都提 PR、走 CI、等审批,需要有耐心地引导。可以从几件小事开始:第一,在出了问题之后,一起做复盘,算清楚"因为基础设施配置不一致导致的问题,花了多少时间排查";第二,展示 terraform plan 在 PR 评论里的输出,让大家看到"原来 code review 可以这样做";第三,帮团队经历一次"基础设施快速灾难恢复",亲身感受 IaC 的价值。这些具体的体验,比任何道理都更能说服人。当团队真正体验过 IaC 带来的可靠性和效率,就再也不想回到手动管理的时代了——这是最好的团队文化建设方式。
