返回首页

Terraform 基础设施版本控制与 CI/CD 工作流

📅 创建于 2026-06-01 🔄 更新于 2026-06-01 📝 1257 字

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"]
  }
}

注意 countfor_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 基础设施版本控制的全链路可以总结为:

  1. HCL 写配置 → 声明式定义,代码管基础设施
  2. Git 做版本控制 → 目录结构、.gitignore、.terraform-version
  3. 远程状态管理 → S3 + DynamoDB,锁防并发损坏
  4. 模块化提复用 → 模块拆分、Git tag 版本化
  5. 生命周期策略 → create_before_destroy、prevent_destroy、条件校验
  6. CI/CD 做自动化 → GitHub Actions、Atlantis、多环境 pipeline

建议: 不要一上来就搞特别复杂的架构,循序渐进地来——先写简单配置、再加远程状态、再拆模块、再加 CI/CD,每一步踩实了再往下走。基础设施即代码这条路,越早走越好。手动操作一时爽,排查问题火葬场。

关联页面

页面关联点
terraform-production-guideTerraform 基础设施即代码实战(入门到生产、阿里云实操)
devops-interview-guideDevOps 面试指南(含 Terraform 相关考题)
jenkins-multi-master-k8s-deploymentJenkins on K8s(CI/CD pipeline 中 Terraform 的典型位置)