云计算
悠悠
2026年5月26日

Terraform 基础设施版本控制:从写配置到上线全流程,这些坑我都替你踩过了

我之前在做一个多云项目的时候,整个基础设施的管理全靠手动操作——控制台点来点去,改个安全组规则都得登录上去找半天。后来有一次,同事在控制台改了个 VPC 的路由表,没通知任何人,结果线上服务挂了两个小时才排查出来。从那以后我就下定决心,基础设施必须用代码管起来,Terraform 就是从那个时候开始真正深入我的工作流的。

今天这篇文章,我把自己在实际生产环境中用 Terraform 做基础设施版本控制的经验整理出来,从写 HCL 配置到远程状态管理,从模块化拆分到 CI/CD 集成,每个环节都会给到具体的配置示例和踩坑记录。内容比较硬核,建议先收藏再慢慢看。


用 HCL 定义基础设施状态

Terraform 用的是自己的配置语言 HCL(HashiCorp Configuration Language),写起来比 JSON 舒服,比 YAML 有结构,我个人觉得是声明式配置语言里最顺手的之一。

来看一个实际的例子,我之前在 AWS 上搭一套基础网络的时候,VPC + 子网 + 安全组的配置大概长这样:

# main.tf

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

这里有个点要注意,aws_subnet.public 用了 count 来批量创建子网,这种方式在简单场景下没问题,但如果你需要对每个子网做差异化配置,for_each 会更灵活,后面讲模块化的时候会提到。

HCL 的核心思想就是声明式——你只需要告诉 Terraform 你要什么,不需要告诉它怎么做。比如上面的代码,我声明了要一个 VPC、两个公有子网、一个安全组,至于创建顺序、API 调用这些,Terraform 自己会处理。


版本控制:把配置文件交给 Git

写完 HCL 配置文件,下一步就是把它放进 Git 里管理。这一步看似简单,但有不少细节容易忽略。

先说目录结构,我一般这样组织:

terraform-project/
├── environments/
│   ├── dev/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   ├── outputs.tf
│   │   ├── backend.tf
│   │   └── terraform.tfvars
│   ├── staging/
│   │   └── ...
│   └── prod/
│       └── ...
├── modules/
│   ├── vpc/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── rds/
│   │   └── ...
│   └── ec2/
│       └── ...
└── .gitignore

.gitignore 里必须加上这几行,这个很重要:

.terraform/
*.tfstate
*.tfstate.backup
*.tfvars
.terraform.lock.hcl

*.tfstate*.tfstate.backup 绝对不能提交到 Git 仓库里——状态文件里可能包含敏感信息(数据库密码、API Key 等),而且多人协作时本地状态文件会导致状态不同步,后面会详细说这个问题。

terraform.tfvars 我也习惯忽略掉,因为这个文件里通常放的是环境特定的变量值,包括一些敏感配置,不同环境的值也不一样。实际操作中我会放一个 terraform.tfvars.example 作为模板提交上去。

Git 分支策略的话,我这边团队用的是 GitFlow 的简化版:main 分支对应生产环境,develop 分支对应测试环境,feature 分支做开发。每个环境有自己独立的 Terraform 状态,互不干扰。


terraform init:初始化不只是下载插件

terraform init 看起来就是下个插件,但实际用下来,这一步有不少值得说的东西。

$ terraform init

Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.82.0...
- Installed hashicorp/aws v5.82.0 (signed by HashiCorp)
- Finding hashicorp/random versions matching "~> 3.0"...
- Installing hashicorp/random v3.6.3...
- Installed hashicorp/random v3.6.3 (signed by HashiCorp)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections when you run
"terraform init" in the future.

注意最后那段话——Terraform 会生成一个 .terraform.lock.hcl 文件,这个文件应该提交到 Git 里(跟 .gitignore 里忽略的 .terraform/ 目录不一样)。它的作用是锁定 provider 的精确版本,确保团队里每个人用的都是同一个版本。

我之前就踩过这个坑:没提交 lock 文件,同事 terraform init 的时候拉到了一个新版本的 AWS provider,跑 terraform plan 输出了一大堆跟我不一样的变更,排查了半天才发现是 provider 版本不一致导致的。

如果你想更新 provider 版本,用 terraform init -upgrade 就行。


terraform plan 和 terraform apply:执行变更的正确姿势

这两个命令是 Terraform 工作流的核心。terraform plan 生成执行计划,terraform apply 执行变更。

# 先看看会改什么
$ terraform plan

# 确认没问题再执行
$ terraform apply

# 或者直接指定 plan 文件,避免 plan 和 apply 之间状态发生变化
$ terraform plan -out=tfplan
$ terraform apply tfplan

terraform plan -out=tfplan 这个用法我强烈推荐。它的好处是 plan 的结果被保存到一个文件里,apply 的时候直接用这个文件,中间不会有状态漂移。如果不这样做,plan 之后有人改了基础设施,apply 的时候可能执行的不是你刚才看到的那个计划了——这在生产环境是很危险的。

terraform plan 的输出会告诉你三类变更:

  • + 绿色:将要创建的资源
  • ~ 黄色:将要修改的资源(会显示修改前后的值)
  • - 红色:将要删除的资源

看到红色的时候一定要特别小心,我习惯在 apply 之前仔细检查 plan 输出里的每一个 - 号。

还有个实用的参数 terraform plan -detailed-exitcode,它返回不同的退出码:0 表示没变更,2 表示有变更,1 表示出错。在 CI/CD 里做判断很好用。


状态文件:Terraform 的心脏

状态文件 terraform.tfstate 是 Terraform 最核心的东西。它记录了你的代码和真实基础设施之间的映射关系——代码里写的 aws_instance.web 对应云上哪个具体的 EC2 实例(ID 是 i-0abc12345),全靠状态文件来关联。

没有这个文件,Terraform 就不知道哪些资源已经存在了,它会尝试重新创建所有东西。所以状态文件丢失约等于基础设施失控,这个事情绝对不能马虎。

远程后端存储状态

本地存状态文件只适合个人测试,团队协作必须用远程后端。我用得最多的是 S3 后端,配置如下:

# 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 导致状态文件损坏

说到状态锁,这个真的非常重要。Terraform 在执行任何可能写入状态的操作时,会自动获取锁。如果锁获取失败,Terraform 会直接报错停止,不会继续执行。这个机制保护了状态文件不被并发写入搞坏。

不过有个烦人的情况:CI 任务跑到一半挂了,锁没释放,后面所有人都跑不了 apply。这时候需要用 terraform force-unlock 来手动释放锁:

$ terraform force-unlock 7f3a1b2c-9e44-4d11-8b21-1a4b5c6d7e8f

但这个命令一定要谨慎使用,必须确认没有其他进程正在操作状态,否则可能导致状态损坏。我们团队的规定是只有指定的两个人有权限执行 force-unlock,而且执行之前必须在群里确认。

顺带提一句,HashiCorp 官方已经宣布 DynamoDB 做状态锁的方式会被废弃,未来推荐用 S3 原生锁(基于 S3 条件写入),这样就不需要额外维护一个 DynamoDB 表了。新项目可以考虑直接用原生 S3 锁。

状态文件按环境和领域拆分

状态文件不应该把所有资源都塞进一个里面。我按"爆炸半径"来拆分——网络层(VPC、子网、路由)一个状态,数据层(RDS、ElastiCache)一个状态,计算层(EC2、EKS)一个状态。这样改计算层资源的时候不会影响网络层,出了问题也能快速定位。

s3://my-org-terraform-state/
├── prod/
│   ├── network/terraform.tfstate
│   ├── data/terraform.tfstate
│   └── compute/terraform.tfstate
├── staging/
│   └── ...
└── dev/
    └── ...

模块化:别把所有代码写在一个文件里

项目小的时候一个 main.tf 就够了,但资源一多,几百行的配置文件维护起来简直是灾难。模块化是解决这个问题的核心手段。

我之前做了一个 VPC 模块,目录结构如下:

modules/vpc/
├── main.tf
├── variables.tf
├── outputs.tf
└── versions.tf

main.tf 里放资源定义:

# modules/vpc/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}"
  }
}

variables.tf 里声明输入变量:

# modules/vpc/variables.tf

variable "name" {
  description = "VPC name"
  type        = string
}

variable "cidr" {
  description = "VPC CIDR block"
  type        = string
}

variable "enable_dns_hostnames" {
  description = "Enable DNS hostnames"
  type        = bool
  default     = true
}

variable "enable_dns_support" {
  description = "Enable DNS support"
  type        = bool
  default     = true
}

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 里定义输出值:

# modules/vpc/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]
}

然后在环境的 main.tf 里调用这个模块:

# environments/prod/main.tf

module "vpc" {
  source = "../../modules/vpc"

  name = "prod-vpc"
  cidr = "10.0.0.0/16"

  private_subnets = {
    "a" = { cidr = "10.0.1.0/24", az = "ap-northeast-1a" }
    "b" = { cidr = "10.0.2.0/24", az = "ap-northeast-1c" }
    "c" = { cidr = "10.0.3.0/24", az = "ap-northeast-1d" }
  }

  tags = {
    Environment = "production"
    Team        = "platform"
  }
}

模块化的好处很明显:VPC 的逻辑写一次,dev/staging/prod 三个环境都可以复用,只需要传不同的参数。而且修改 VPC 模块的时候,所有环境都会受益,不用到处改。

这里有个我踩过的坑:模块的 source 路径改了之后,必须重新跑 terraform init,因为 Terraform 会在 .terraform/modules/ 下面缓存模块代码。我之前改了模块路径忘了 init,apply 的时候用的还是旧代码,排查了好一会儿。


Terraform 版本锁定:别让版本升级搞崩你的基础设施

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

required_version 限制了 Terraform 自身的版本范围。如果用不在这个范围内的 Terraform 版本执行命令,会直接报错退出。

provider 的版本约束符号含义:

  • ~> 5.0:允许 5.x 的任何版本,不允许升到 6.0
  • ~> 5.36.0:允许 5.36.x 的任何版本,不允许升到 5.37
  • >= 5.0, < 6.0:允许 5.0 及以上,6.0 以下

我个人习惯用 ~> 约束,它既能拿到补丁更新,又不会意外升级大版本。


变量管理:让配置更灵活

变量是让 Terraform 配置可复用的关键。除了前面模块里看到的 variable 块,还有几种管理变量的方式。

terraform.tfvars 文件用来给变量赋值,不同环境用不同的 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 这类工具来管理。Terraform 也可以直接从 SSM 读:

variable "db_password" {
  description = "Database password"
  type        = string
  sensitive   = true
}

data "aws_ssm_parameter" "db_password" {
  name = "/prod/db/password"
}

sensitive = true 这个标记会让 Terraform 在 plan 和 apply 的输出中隐藏这个变量的值,不会明文打印出来。


生命周期管理:防止资源被意外删除

Terraform 的 lifecycle 块是保护生产资源的重要手段,我几乎在所有关键资源上都会加。

resource "aws_rds_cluster" "main" {
  cluster_identifier = "prod-db-cluster"
  engine             = "aurora-mysql"
  engine_version     = "8.0.mysql_aurora.3.04.0"
  database_name      = "appdb"
  master_username    = "admin"
  master_password    = var.db_password

  # 防止被意外删除
  lifecycle {
    prevent_destroy = true
  }
}

resource "aws_launch_template" "web" {
  name_prefix   = "web-launch-template"
  image_id      = data.aws_ami.ubuntu.id
  instance_type = var.instance_type

  # 更新时先创建新资源再删除旧资源
  lifecycle {
    create_before_destroy = true
  }
}

prevent_destroy 的作用是,如果 terraform plan 的结果包含删除这个资源,Terraform 会直接报错,不会执行。这对生产环境的数据库、S3 桶这类绝对不能误删的资源来说是救命的。

create_before_destroy 适用于需要替换的资源(比如 launch template 的某些属性变更需要重建)。默认行为是先删后建,加了 create_before_destroy 就变成先建后删,减少服务中断时间。

还有个 ignore_changes,用来告诉 Terraform 忽略某些属性的变更:

resource "aws_autoscaling_group" "web" {
  # ... 其他配置

  lifecycle {
    ignore_changes = [desired_capacity]
  }
}

比如 ASG 的 desired_capacity 可能被自动扩缩容策略频繁修改,但你不希望 Terraform 每次都把它改回配置文件里的值,ignore_changes 就是干这个的。


资源依赖管理:隐式依赖和显式依赖

Terraform 会自动分析资源之间的引用关系来推断依赖。比如:

resource "aws_eip" "public_ip" {
  domain   = "vpc"
  instance = aws_instance.web_server.id  # 隐式依赖
}

resource "aws_instance" "web_server" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"
}

aws_eip.public_ip 引用了 aws_instance.web_server.id,Terraform 会自动识别这个依赖关系,先创建 EC2 实例再创建弹性 IP。这就是隐式依赖,大多数情况下够用了。

但有些时候依赖关系不能通过引用来表达,比如一个 EC2 实例需要在某个 S3 桶存在之后才能正常工作,但 EC2 的配置里并没有直接引用 S3 桶的任何属性。这时候就需要 depends_on

resource "aws_instance" "web_server" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"

  depends_on = [aws_s3_bucket.app_data]
}

depends_on 应该谨慎使用,只在隐式依赖无法覆盖的场景下才用。滥用 depends_on 会让 Terraform 无法并行创建无依赖的资源,拖慢执行速度。


CI/CD 集成:让基础设施变更自动化

手动跑 terraform apply 在小团队还行,人一多就乱套了。我们现在的做法是所有 apply 都走 CI/CD 流水线,禁止从本地直接 apply 到生产环境。

下面是一个 GitHub Actions 的配置示例:

# .github/workflows/terraform-prod.yml

name: Terraform Prod Apply

on:
  push:
    branches: [main]
    paths: ['terraform/**']

concurrency:
  group: terraform-prod-apply
  cancel-in-progress: false

jobs:
  plan:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/tf-prod
          aws-region: ap-northeast-1

      - uses: hashicorp/setup-terraform@v3

      - name: Terraform Init
        run: terraform init
        working-directory: terraform/environments/prod

      - name: Terraform Plan
        run: terraform plan -out=tfplan
        working-directory: terraform/environments/prod

      - name: Upload Plan
        uses: actions/upload-artifact@v4
        with:
          name: tfplan
          path: terraform/environments/prod/tfplan

  apply:
    needs: plan
    runs-on: ubuntu-latest
    environment: production  # 需要 GitHub 人工审批
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/tf-prod
          aws-region: ap-northeast-1

      - uses: hashicorp/setup-terraform@v3

      - name: Download Plan
        uses: actions/download-artifact@v4
        with:
          name: tfplan
          path: terraform/environments/prod

      - name: Terraform Apply
        run: terraform apply tfplan
        working-directory: terraform/environments/prod

几个关键点说一下:

concurrency 配置了 cancel-in-progress: false,这意味着如果有多个 apply 任务排队,后到的不会取消前面的,而是排队等待。这跟 Terraform 状态锁的思路一致,避免并发操作。

OIDC 认证:用 aws-actions/configure-aws-credentials 配合 IAM Role 的 OIDC 信任策略,不需要在 GitHub 里配置长期有效的 AWS Access Key。每次流水线运行时获取临时凭证,TTL 通常只有一小时,安全性比静态密钥高很多。

environment: production:GitHub 的 Environment 功能可以配置审批规则。我们设置了至少一个人审批才能执行 apply,这样生产环境的变更就有了一道人工审核。

plan 和 apply 分成两个 job:plan 的结果保存为 artifact,apply 直接使用这个 artifact,避免 plan 和 apply 之间状态发生变化。

PR 阶段自动跑 plan 也是标配,我们在 PR 里会自动评论 plan 结果,reviewer 可以直接在 PR 里看到这次变更会影响哪些资源:

# PR 触发的 plan
on:
  pull_request:
    branches: [main]
    paths: ['terraform/**']

一些零散但重要的实战经验

terraform import 导入已有资源

很多时候你的 AWS 账号里已经有一些手动创建的资源,需要导入到 Terraform 管理中。先写好对应的 HCL 配置,然后:

$ terraform import aws_vpc.existing vpc-0abc12345

导入之后跑一下 terraform plan,看看配置和实际状态是否一致,如果不一致就调整配置或者调整资源,直到 plan 显示 no changes。

terraform state rmterraform state mv

terraform state rm 可以从状态文件中移除某个资源,但不会删除真实的云资源。适用于你想把某个资源从 Terraform 管理中剥离出来的场景。

terraform state mv 用来在状态中移动资源,比如你重构了模块,资源地址变了:

$ terraform state mv aws_instance.web module.compute.aws_instance.web

状态文件损坏的应急处理

S3 开启版本控制之后,如果状态文件损坏了,可以回滚到之前的版本。在 S3 控制台找到对应的 tfstate 文件,查看版本历史,下载一个正常的版本覆盖上去就行。这也是为什么我强烈建议 S3 后端一定要开版本控制。

terraform taint 已废弃

老版本里用 terraform taint 标记某个资源需要重建,1.2 版本之后官方推荐用 -replace 参数替代:

$ terraform apply -replace=aws_instance.web

Output 的妙用

模块之间传递信息靠 output。比如 VPC 模块输出 VPC ID 和子网 ID,其他模块引用这些输出:

# 在 RDS 模块里引用 VPC 模块的输出
resource "aws_db_subnet_group" "main" {
  name       = "prod-db-subnet"
  subnet_ids = module.vpc.private_subnet_ids
}

跨状态文件引用的话,可以用 terraform_remote_state

data "terraform_remote_state" "vpc" {
  backend = "s3"
  config = {
    bucket = "my-org-terraform-state"
    key    = "prod/network/terraform.tfstate"
    region = "ap-northeast-1"
  }
}

resource "aws_db_subnet_group" "main" {
  subnet_ids = data.terraform_remote_state.vpc.outputs.private_subnet_ids
}

总结

Terraform 做基础设施版本控制,核心就这几件事:用 HCL 写配置,用 Git 管版本,用远程后端存状态,用模块化提复用,用 CI/CD 做自动化。但每个环节都有细节,状态锁怎么处理、版本约束怎么写、生命周期怎么配、依赖关系怎么管理——这些都是在生产环境踩过坑之后才真正理解的。

我的建议是,如果你刚开始用 Terraform,先把基本的 Write-Plan-Apply 工作流跑通,然后尽快把状态文件迁到远程后端,再逐步做模块化和 CI/CD 集成。不要一上来就搞特别复杂的架构,循序渐进地来,每一步都踩实了再往下走。

基础设施即代码这条路,越早走越好。手动操作一时爽,排查问题火葬场,这话真不是开玩笑的。


如果你觉得这篇文章对你有帮助,欢迎点赞、在看、转发三连,让更多人看到。

公众号:耕云躬行录

个人博客:躬行笔记

关注 @耕云躬行录,持续分享实战干货!

文章目录

博主介绍

热爱技术的云计算运维工程师,Python全栈工程师,分享开发经验与生活感悟。
欢迎关注我的微信公众号@运维躬行录,领取海量学习资料

微信二维码