Terraform 生产级基础设施管理实战:从单文件混乱到模块化架构的完整进化
我花了 3 个月把一套 3000 行的 Terraform 单体配置重构为生产级基础设施即代码架构,部署时间从 15 分钟降到 2 分钟,故障率下降 80%。这篇文章就是那 3 个月的踩坑实录。
背景:单体 Terraform 有多痛?
故事从一个典型场景开始:我们的微服务从 3 个膨胀到 12 个,单体 Terraform 配置也随之爆炸。
当时我的项目结构是这样的:
infrastructure/
├── main.tf # 3000 行,所有资源堆在一起
├── variables.tf # 80 个变量,命名混乱
└── terraform.tfvars # 所有环境的配置混在一个文件
痛点非常具体:
- 部署耗时:
terraform plan要跑 3-5 分钟,因为每次都要计算全部资源 - 安全风险:开发环境的误操作能导致生产环境挂掉(同一个 state 文件)
- 变更不可追溯:没人敢动别人的模块,因为一个改动可能影响整个系统
- 状态文件锁定频繁:多人并发执行时,state 锁冲突是家常便饭
- 环境配置割裂:staging 和生产环境的差异只能靠注释和人的记忆
最惨的一次:我在修复开发环境的 ECS 配置,不小心把生产环境的 ECS 集群也给重建了。因为它们在同一个 state 文件里,terraform apply 扫到了所有资源。
第一步:按环境拆分 State
第一个改造:把生产、预发、开发环境的 state 彻底隔离。
backend "s3" {
bucket = "my-infra-state"
key = "env:${var.environment}/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
关键点:每个环境一个独立 state key。开发同学的误操作再也不可能波及生产。
但这里有个坑——IAM 权限还要配合。我给每个环境的 Terraform 执行角色配置了不同的 S3 路径权限:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-infra-state/env:${environment}/*"
}
]
}
注意: state 拆分后,不同环境之间共享的数据(比如 VPC ID)必须通过 SSM Parameter Store 或 Terraform Data Source 来获取,不能再直接引用。
第二步:按服务模块化
拆分 state 只是第一步。真正质的飞跃来自模块化。
这是我重构后的目录结构:
terraform/
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ ├── staging/
│ │ └── ...
│ └── prod/
│ └── ...
├── modules/
│ ├── vpc/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── ecs-cluster/
│ ├── ecs-service/
│ ├── rds/
│ ├── redis/
│ ├── alb/
│ ├── route53/
│ └── iam/
└── global/
└── iam-base/ # IAM 基础角色,全局共享
每个模块都有清晰的输入输出边界。比如 ECS Service 模块:
# modules/ecs-service/main.tf
variable "service_name" {
description = "服务名称"
type = string
}
variable "container_port" {
type = number
description = "容器监听端口"
validation {
condition = var.container_port > 0 && var.container_port < 65536
error_message = "端口必须在 1-65535 之间。"
}
}
variable "desired_count" {
type = number
default = 2
description = "期望运行实例数"
}
variable "environment" {
type = string
description = "部署环境"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "环境只能是 dev、staging 或 prod。"
}
}
使用模块时,生产环境和开发环境的区别只是一个 .tfvars 文件:
# environments/prod/terraform.tfvars
environment = "prod"
desired_count = 5
instance_type = "r6i.large"
min_capacity = 3
max_capacity = 20
enable_autoscaling = true
# environments/dev/terraform.tfvars
environment = "dev"
desired_count = 1
instance_type = "t3.micro"
min_capacity = 1
max_capacity = 2
enable_autoscaling = false
模块化的好处立竿见影——团队可以并行开发不同模块,代码 review 也变成了真正的 review,而不是扫读 3000 行找差异。
第三步:Remote State 数据共享
模块化之后,不同模块之间需要交换信息。ECS 服务需要知道 ALB 的 ARN,而 ALB 又需要知道 VPC 的 ID。
用 Remote State Data Source 解决:
# 在 ECS 模块中获取 VPC 和 ALB 的信息
data "terraform_remote_state" "vpc" {
backend = "s3"
config = {
bucket = "my-infra-state"
key = "env:${var.environment}/vpc/terraform.tfstate"
region = "us-east-1"
}
}
data "terraform_remote_state" "alb" {
backend = "s3"
config = {
bucket = "my-infra-state"
key = "env:${var.environment}/alb/terraform.tfstate"
region = "us-east-1"
}
}
这里有个坑:远程 state 读取会创建隐式依赖。VPC 模块还没部署的话,ECS 模块的 terraform plan 就会失败。解决方案是定义好部署顺序,并通过 CI 流程强制执行。
第四步:Terraform Workspace vs 多目录
这里我踩过一个很纠结的选择题:用 Terraform Workspace 还是多目录?
最终我选了多目录方案。原因很简单——Workspace 虽然减少了重复代码,但让状态管理变得不透明:
# Workspace 方案:状态文件结构和目录脱节
terraform workspace select prod
terraform apply # 当前是 prod,但目录结构看不出
而且 Workspace 的权限控制更麻烦——所有 Workspace 共享同一个后端的执行角色,无法做到"开发只能操作 dev workspace"的细粒度权限。
多目录 + 独立后端的方案,每个环境的配置都在 git 里清晰可见,CI/CD 也容易对接:
# .github/workflows/terraform-deploy.yml
jobs:
terraform:
strategy:
matrix:
environment: [dev, staging, prod]
steps:
- uses: actions/checkout@v4
- run: |
cd terraform/environments/${{ matrix.environment }}
terraform init
terraform plan
第五步:CI/CD 管道中的 Terraform
我想重点说说把 Terraform 接入 CI/CD 时踩的坑。
Plan 结果需人工确认
我们采用了两阶段 pipeline:
- PR 阶段:自动跑
terraform plan,把结果作为 PR 评论发布 - Merge 阶段:跑
terraform apply,但只有特定角色可以触发
# PR 阶段 - 自动 Plan
- name: Terraform Plan
id: plan
run: |
terraform plan -no-color -out=tfplan 2>&1 | tee plan_output.txt
continue-on-error: true
- name: Post Plan Comment
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('plan_output.txt', 'utf8');
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '## Terraform Plan\n```\n' + plan.slice(0, 60000) + '\n```'
});
注意:
continue-on-error: true很重要——plan 命令本身如果返回非零退出码(比如配置错误),pipeline 不会中断,PR 评论依然能发出去,团队可以直观看到失败原因。
State Lock 超时处理
多人协作时,state 锁冲突是家常便饭。我们在 CI 中加了个小改动就解决了:
terraform {
backend "s3" {
# ...
dynamodb_table = "terraform-state-lock"
max_retries = 5
}
}
同时在 CI 脚本中增加了锁超时提醒:
terraform plan -lock-timeout=60s || {
echo "State 锁获取超时,可能有人正在执行 terraform"
echo "如有必要,运行: terraform force-unlock <LOCK_ID>"
exit 1
}
第六步:State 文件安全与备份
State 文件包含明文敏感信息,这是很多人忽略的。Terraform state 里会有数据库密码、API Key 等——即使你在配置里标记了 sensitive = true。
我们的安全策略:
resource "aws_s3_bucket" "terraform_state" {
bucket = "my-infra-state"
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
这四点组合拳打上之后,state bucket 的安全性基本达标了。加上 DynamoDB 的锁表,就能放心地让团队自由操作了。
踩坑实录 Top 3
坑 1:`terraform destroy` 误执行
某天凌晨,同事在开发环境跑 CI,不小心给 terraform destroy 输了个 yes。开发环境所有资源瞬间蒸发。
解决方案:给生产环境加 prevent_destroy:
resource "aws_s3_bucket" "critical_bucket" {
# ...
lifecycle {
prevent_destroy = true
}
}
同时给 CI 加了一层保护:
if [ "$ENVIRONMENT" = "prod" ] && [ "$1" = "destroy" ]; then
echo "❌ 禁止在 CI 中对生产环境执行 destroy"
exit 1
fi
坑 2:Secrets 明文泄漏
我们曾把 RDS 密码直接写在 terraform.tfvars 里,结果同事不小心把文件贴到了 Slack 上。
解决方案:全面迁移到 AWS Secrets Manager,Terraform 只引用不存储:
data "aws_secretsmanager_secret" "db_password" {
name = "rds-password-${var.environment}"
}
data "aws_secretsmanager_secret_version" "db_password" {
secret_id = data.aws_secretsmanager_secret.db_password.id
}
resource "aws_db_instance" "main" {
# ...
password = data.aws_secretsmanager_secret_version.db_password.secret_string
}
配置文件里再也没有明文密码了。
坑 3:模块版本管理混乱
多个服务引用同一个 VPC 模块,有人改了模块代码,其他服务莫名其妙就挂了。
解决方案:给模块打 tag,通过 Registry 方式引用:
module "vpc" {
source = "git::https://github.com/org/terraform-modules.git//vpc?ref=v1.2.0"
environment = var.environment
vpc_cidr = var.vpc_cidr
}
这样改模块时必须更新版本号,下游服务手动升级才能收到变更。谁都不会被突然改动的模块坑到。
最后的效果
经过两个月的逐步改造,基础设施管理的面貌彻底变了:
| 指标 | 改造前 | 改造后 |
|---|---|---|
terraform plan 时间 |
3-5 分钟 | 15-30 秒 |
| 部署故障次数/月 | 4-5 次 | 0-1 次 |
| 新服务上线时间 | 2-3 天 | 2-3 小时 |
| 团队并行开发能力 | 1-2 人 | 全团队 |
| 安全事件 | 发生过密码泄漏 | 零事件 |
延伸思考
如果你已经完成了上述改造,下一步可以考虑:
- Terragrunt:如果你的模块调用层还有很多重复代码,Terragrunt 的
generate和include能进一步消除样板代码 - Policy as Code:用 Sentinel 或 OPA 在 plan 阶段自动拦截不合规的资源创建(比如禁止所有 public S3 bucket)
- 自定义 Provider:我们后来给内部 CI/CD 工具写了一个自定义 Terraform Provider,把发布流程也纳入了 IaC 管理
基础设施即代码是一条渐进的路,别想一步到位。先拆分 state,再模块化,然后上 CI/CD——每一步都能带来立竿见影的改善。

评论已关闭!