Terraform 基础设施版本控制与 CI/CD 工作流
来源:哇哈哈12138 | 发布日期:2026-05-26
从 HCL 配置到远程状态管理,从模块化拆分到 CI/CD 集成——经历过多云项目的手动操作之痛,才真正理解基础设施必须用代码管起来。本文整理了生产环境用 Terraform 做基础设施版本控制的完整工作流,每个环节都有具体的配置示例和踩坑记录。
一、用 HCL 定义基础设施状态
Terraform 使用 HCL(HashiCorp Configuration Language)声明式定义基础设施。和 JSON、YAML 不同,HCL 写起来既顺手又有结构——你只需要声明"要什么",Terraform 自己决定怎么做。
VPC + 子网 + 安全组完整示例
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "prod-vpc"
Environment = "production"
ManagedBy = "terraform"
}
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = {
Name = "prod-public-subnet-${count.index}"
}
}
resource "aws_security_group" "web" {
name = "web-sg"
description = "Allow HTTP and HTTPS inbound"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
注意 count 与 for_each 的选择: 上面用 count 批量创建子网,简单场景下没问题。如果需要对每个子网做差异化配置(比如不同子网用不同路由表),for_each 更灵活,后面模块化部分会提到。
二、版本控制:把配置文件交给 Git
写好 HCL 后,下一步就是放进 Git 管理。这一步看似简单,但目录结构、.gitignore、版本约束都有讲究。
推荐的目录结构
terraform-project/
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ ├── backend.tf
│ │ └── terraform.tfvars
│ ├── staging/
│ └── prod/
├── modules/
│ ├── vpc/
│ ├── ecs/
│ └── rds/
├── .gitignore
├── .terraform-version
└── README.md
每个环境独立一个目录,各自的 backend.tf 指向不同的 state key。模块集中放在 modules/ 下跨环境复用。
.gitignore
# 状态文件——绝对不能提交
*.tfstate
*.tfstate.backup
# .terraform 目录(含 provider 二进制和模块缓存)
.terraform/
# 环境变量文件(可能含敏感信息)
*.tfvars
为什么 .tfstate 不能提交: 状态文件中可能包含数据库密码、API Key 等敏感信息。而且多人协作时,本地 state 会导致状态不同步——一个人 apply 后,其他人的 state 还是旧的。
版本约束
用 .terraform-version 文件统一管理 Terraform 版本,避免"我本地能跑、CI 上不行"的尴尬:
1.9.0
同时在 versions.tf 中声明 provider 版本约束(详见第七节)。
三、远程状态管理
本地存状态文件只适合个人测试,团队协作必须用远程后端。
S3 + DynamoDB 方案
# backend.tf
terraform {
backend "s3" {
bucket = "my-org-terraform-state"
key = "prod/network/terraform.tfstate"
region = "ap-northeast-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
关键点:
encrypt = true:S3 上的状态文件自动用 SSE-S3 加密,防止敏感信息泄露dynamodb_table:状态锁,防止多人同时 apply 导致 state 损坏- 未来方向: HashiCorp 已宣布 DynamoDB 锁方案将被废弃,推荐 S3 原生锁(基于条件写入),无需额外维护 DynamoDB 表
状态锁与 force-unlock
Terraform 在执行任何可能写入状态的操作时,会自动获取锁。如果锁获取失败,Terraform 直接报错停止。
常见坑: CI 任务中途被手动取消或异常退出,DynamoDB 锁没释放,后面所有人都跑不了 apply。
# 手动释放锁
terraform force-unlock 7f3a1b2c-9e44-4d11-8b21-1a4b5c6d7e8f
⚠️ 这个命令必须谨慎使用: 必须确认没有其他进程正在操作状态,否则可能导致状态损坏。我们团队的规定是——只有指定的两个人有权限执行 force-unlock,执行前必须在群里确认。
四、模块化设计
项目小的时候一个 main.tf 就够了,但资源一多,几百行的配置文件维护起来简直是灾难。模块化是解决这个问题的核心手段。
VPC 模块示例
modules/vpc/
├── main.tf
├── variables.tf
├── outputs.tf
└── versions.tf
main.tf: 放资源定义
resource "aws_vpc" "this" {
cidr_block = var.cidr
enable_dns_hostnames = var.enable_dns_hostnames
enable_dns_support = var.enable_dns_support
tags = merge(
{
Name = var.name
ManagedBy = "terraform"
},
var.tags
)
}
resource "aws_subnet" "private" {
for_each = var.private_subnets
vpc_id = aws_vpc.this.id
cidr_block = each.value.cidr
availability_zone = each.value.az
tags = {
Name = "${var.name}-private-${each.key}"
}
}
这里用 for_each 代替 count,每个子网通过 map key 区分,可以单独配置。
variables.tf: 声明输入变量
variable "name" {
description = "VPC name"
type = string
}
variable "cidr" {
description = "VPC CIDR block"
type = string
}
variable "private_subnets" {
description = "Map of private subnets"
type = map(object({
cidr = string
az = string
}))
default = {}
}
variable "tags" {
description = "Additional tags"
type = map(string)
default = {}
}
outputs.tf: 定义输出值
output "vpc_id" {
description = "VPC ID"
value = aws_vpc.this.id
}
output "private_subnet_ids" {
description = "Private subnet IDs"
value = [for s in aws_subnet.private : s.id]
}
模块的使用与版本化
# 使用本地模块
module "vpc" {
source = "./modules/vpc"
name = "prod"
cidr = "10.0.0.0/16"
}
# 使用 Git 模块(推荐,可版本化)
module "vpc" {
source = "git::https://github.com/org/terraform-modules.git//vpc?ref=v1.2.0"
name = "prod"
cidr = "10.0.0.0/16"
}
模块版本化实践: 用 Git tag 管理模块版本,ref=v1.2.0 确保环境间可追溯。dev/staging/prod 三个环境都可以复用同一个模块,只需要传不同参数。
踩坑: 模块的 source 路径改了之后,必须重新跑 terraform init。Terraform 会在 .terraform/modules/ 下缓存模块代码,改了路径忘了 init,apply 时用的还是旧代码——排查了半天才发现这个问题。
五、变量管理
terraform.tfvars 分环境
# environments/prod/terraform.tfvars
vpc_cidr = "10.0.0.0/16"
instance_type = "m5.large"
min_size = 3
max_size = 10
db_instance_class = "db.r6g.large"
# environments/dev/terraform.tfvars
vpc_cidr = "172.16.0.0/16"
instance_type = "t3.small"
min_size = 1
max_size = 3
db_instance_class = "db.t3.medium"
敏感变量处理
对于数据库密码、API Key 等敏感信息,不要写在 tfvars 里提交到 Git。推荐方式:
# 环境变量方式
export TF_VAR_db_password="your-secret-password"
# 或集成密钥管理服务
# Vault、AWS SSM Parameter Store、AWS Secrets Manager
Terraform 可以直接从 SSM 读取:
data "aws_ssm_parameter" "db_password" {
name = "/prod/db/password"
}
六、生命周期管理
create_before_destroy
替换资源时,先创建新资源再销毁旧资源,实现零宕机更新:
resource "aws_instance" "web" {
# ... 实例配置
lifecycle {
create_before_destroy = true
}
}
prevent_destroy
关键资源加上保险,防止误操作删除:
resource "aws_db_instance" "prod" {
# ... 数据库配置
lifecycle {
prevent_destroy = true
}
}
加了 prevent_destroy 的资源,terraform destroy 会报错退出,不会真的删除。
precondition / postcondition(Terraform 1.2+)
执行前后做断言验证:
resource "aws_instance" "web" {
# ... 实例配置
lifecycle {
precondition {
condition = var.instance_type != "t2.micro"
error_message = "Production instances must be at least t3.medium."
}
postcondition {
condition = self.public_ip != ""
error_message = "EC2 instance did not receive a public IP."
}
}
}
七、Terraform 版本与 Provider 约束
踩坑: 之前经历过 Terraform 从 1.5 升级到 1.6 之后,某个 state 操作的行为变了,导致 plan 输出了一堆意料之外的变更。从那以后就在所有配置文件里加上了版本约束。
# versions.tf
terraform {
required_version = ">= 1.5.0, < 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.0"
}
}
}
版本约束符号含义:
| 写法 | 含义 |
|---|---|
~> 5.0 |
允许 5.x 任何版本,不允许升到 6.0 |
~> 5.36.0 |
允许 5.36.x 任何版本,不允许升到 5.37 |
>= 1.5, < 1.7 |
允许 1.5 及以上、1.7 以下 |
个人习惯用 ~> 约束,既能拿到补丁更新,又不会意外升级大版本。
八、CI/CD 集成
手动跑 terraform apply 在小团队还行,人一多就乱套了。所有 apply 都应该走 CI/CD 流水线,禁止从本地直接 apply 到生产环境。
GitHub Actions 配置
name: Terraform Prod Apply
on:
push:
branches: [main]
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: hashicorp/setup-terraform@v2
- name: Terraform Init
run: terraform init
- name: Terraform Plan
run: terraform plan -out=tfplan
- name: Terraform Apply
run: terraform apply tfplan
最佳实践
- Plan 输出作为 PR 评论:让团队成员 review 变更后再 merge。可以用 GitHub Actions 自动在 PR 中发布
plan结果 - Atlantis:专门的 Terraform PR 自动化工具——在 PR 中
/atlantis plan和/atlantis apply,无需手动执行 - 多环境 pipeline 策略:
- dev → 自动 apply(低风险)
- staging → 手动触发(review 后执行)
- prod → 审批后 apply(需要多人确认)
九、依赖管理
Terraform 通过引用自动推断依赖关系,但有些场景需要显式声明。
# 隐式依赖:通过引用自动推断
resource "aws_eip" "nat" {
instance = aws_instance.nat.id
# 自动依赖 aws_instance.nat
}
# 显式依赖:当没有直接引用但需要控制顺序时
resource "aws_s3_bucket_policy" "example" {
bucket = aws_s3_bucket.example.id
policy = data.aws_iam_policy_document.example.json
depends_on = [
aws_s3_bucket_public_access_block.example
]
}
# 模块级别的 depends_on(Terraform 1.0+)
module "vpc" {
source = "./modules/vpc"
# ...
}
resource "aws_subnet" "extra" {
depends_on = [module.vpc]
# ...
}
十、实战踩坑记录
坑 1:并发 apply 导致状态损坏
现象: 两个 CI 任务同时跑 apply,state 文件损坏,Terraform 无法再操作任何资源,整个基础设施卡住。
根因: 没有状态锁,或锁超时配置不当,两个进程同时写入 state。
修复:
- 使用 S3 + DynamoDB backend 自动加锁
- CI pipeline 级别串行化(GitHub Actions 用
concurrency: 1)
坑 2:CI 中断导致锁残留
现象: CI 任务被手动取消后,DynamoDB 锁没释放,后续所有人的 apply 都报 Error acquiring the state lock。
修复: terraform force-unlock <LOCK_ID>。但必须确认没有其他进程在操作状态后执行,否则可能雪上加霜。
团队规范: 仅指定人员可执行 force-unlock,操作前在群里确认。
坑 3:不同环境混用同一 state
现象: dev 和 staging 共用同一个 terraform.tfstate,dev 上改了 VPC 配置导致 staging 服务异常。
修复: 每个环境有独立的 state key:
# environments/dev/backend.tf
key = "dev/network/terraform.tfstate"
# environments/staging/backend.tf
key = "staging/network/terraform.tfstate"
# environments/prod/backend.tf
key = "prod/network/terraform.tfstate"
坑 4:模块版本未锁定
现象: 线上环境"突然"用了模块的新版本,出现 breaking change——实际上是因为 source 引用了 main 分支,有人推了新代码。
修复: 模块 source 始终指定 Git tag:
# ❌ 危险——每次 init 可能拉不同版本
source = "git::https://github.com/org/modules.git//vpc"
# ✅ 安全——锁定版本
source = "git::https://github.com/org/modules.git//vpc?ref=v1.2.0"
坑 5:改了模块路径忘了 re-init
现象: 模块 source 路径改了但没跑 terraform init,apply 时用的还是旧模块代码,修改不生效。
根因: Terraform 在 .terraform/modules/ 下缓存模块代码,改了 source 路径需要重新 init 拉取。
修复: 每次修改模块 source 后,跑一次 terraform init。
总结与建议
Terraform 基础设施版本控制的全链路可以总结为:
- HCL 写配置 → 声明式定义,代码管基础设施
- Git 做版本控制 → 目录结构、.gitignore、.terraform-version
- 远程状态管理 → S3 + DynamoDB,锁防并发损坏
- 模块化提复用 → 模块拆分、Git tag 版本化
- 生命周期策略 → create_before_destroy、prevent_destroy、条件校验
- CI/CD 做自动化 → GitHub Actions、Atlantis、多环境 pipeline
建议: 不要一上来就搞特别复杂的架构,循序渐进地来——先写简单配置、再加远程状态、再拆模块、再加 CI/CD,每一步踩实了再往下走。基础设施即代码这条路,越早走越好。手动操作一时爽,排查问题火葬场。
关联页面
| 页面 | 关联点 |
|---|---|
| terraform-production-guide | Terraform 基础设施即代码实战(入门到生产、阿里云实操) |
| devops-interview-guide | DevOps 面试指南(含 Terraform 相关考题) |
| jenkins-multi-master-k8s-deployment | Jenkins on K8s(CI/CD pipeline 中 Terraform 的典型位置) |