Terraform State 与多环境实战

新人入职第一天打开 AWS 控制台。30 多个 EC2、十几个安全组、几个 RDS。没人能说清哪个资源谁开的、什么时候开的、为什么开的。也没人敢删——万一是关键服务呢?

这就是手点基础设施的代价。新人接手两眼一抹黑,变更全凭口口相传。Terraform 的承诺只有一句:

所有云资源用代码描述,所有变更走 Git。

1. 5 分钟入门

先把流程跑通,再讲原理。这一节用 10 行代码完整走一遍 Terraform 生命周期。

1.1 安装与第一个项目

mac 上一行装好:

1
2
brew install terraform
terraform -version

新建目录,写 main.tf

1
2
3
4
resource "local_file" "hello" {
filename = "hello.txt"
content = "Hello, Terraform!"
}

跑三个命令:

1
2
3
terraform init    # 下载 provider 插件
terraform plan # 预览将要做什么
terraform apply # 真的执行

打开当前目录。多了 hello.txt,也多了 terraform.tfstate。这个 state 文件就是后面的主角。

1.2 三个核心动作的本质

init:下载插件。每个 provider(aws/local/…)都是独立的二进制插件。

plan:对比代码和现状,给出要新增什么、改什么、删什么。

apply:执行 plan 给出的计划。

记住这个三角,Terraform 一辈子都在转:

1
代码 → plan → apply → state → 再 plan → 再 apply → ...

local_file 是热身。下一节切到真正的 AWS。

1.3 切到真实 AWS

先配 AWS 凭证:

1
aws configure

main.tf

1
2
3
4
5
6
7
8
9
10
11
provider "aws" {
region = "us-west-2"
}

resource "random_id" "suffix" {
byte_length = 4
}

resource "aws_s3_bucket" "demo" {
bucket = "tf-demo-${random_id.suffix.hex}"
}

terraform init && terraform apply。AWS 控制台上看到 bucket 出现。

terraform destroy。bucket 消失。

完整生命周期就这样跑通了。剩下的内容都是把这个流程工业化。先从最容易踩坑的 state 开始。


2. State 深水区

State 是 Terraform 的灵魂。80% 的疑难杂症根源都在这里。

2.1 State 到底是什么

打开 terraform.tfstate。它是一个 JSON 文件。

里面记录的是每个资源的真实云端 ID。

例子:你写了 resource "aws_s3_bucket" "demo"

代码里它叫 demo。云上它叫 tf-demo-a1b2c3d4

state 就是那张「代码名字 ↔ 云端 ID」的映射表。

为什么需要?因为 Terraform 必须知道哪些资源归它管。没有 state,下次 apply 它会再创建一遍。

2.2 为什么本地 State 是定时炸弹

三个致命问题。

  1. 丢了就完蛋。state 文件丢失,Terraform 不再认识那些云资源。要么手动 import 重新纳管,要么全部 destroy 重建。
  2. 多人协作必撞车。A 在跑 apply,B 也在跑。两个 state 互相覆盖。轻则资源乱序,重则 state 损坏。
  3. 含明文密钥。RDS 密码、API Key 都明文躺在 state 里。state 一旦进 Git,等于把家门钥匙挂网上。

结论:⚠️ 生产项目永远不要用本地 state。下一节给出业界标准的远端后端配置。

2.3 远端后端:S3 + DynamoDB 完整配置

业界标准做法:state 存 S3,锁存 DynamoDB。

新建 backend.tf

1
2
3
4
5
6
7
8
9
terraform {
backend "s3" {
bucket = "my-tf-state-prod"
key = "vpc/terraform.tfstate"
region = "us-west-2"
dynamodb_table = "tf-locks"
encrypt = true
}
}

每个字段的意义:

字段作用
bucketstate 文件存放的 S3 桶
keystate 在桶内的路径,按项目划分
dynamodb_table存放锁记录的 DynamoDB 表
encrypt服务端加密 state 文件

鸡和蛋问题:S3 桶和 DynamoDB 表本身怎么建?

新建一个独立的 bootstrap/ 目录,用本地 state 把它们建出来。建好之后这个目录基本不再动。

DynamoDB 表的主键必须叫 LockID,类型 String。这是约定。

1
2
3
4
5
aws dynamodb create-table \
--table-name tf-locks \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST

⚠️ S3 桶必须开 versioning + 加密 + 锁权限。这三件事是非协商的——后面 state 损坏自救全靠它。

2.4 锁机制:你以为锁很神秘

锁的本质就是 DynamoDB 表里一行数据。apply 跑起来时的完整流程:

sequenceDiagram
    participant Dev as 开发者
    participant TF as Terraform
    participant DDB as DynamoDB 锁表
    participant S3 as S3 state 桶
    participant AWS as AWS API

    Dev->>TF: terraform apply
    TF->>DDB: 写入 LockID
    alt 写入成功(拿到锁)
        DDB-->>TF: OK
        TF->>S3: 读取当前 state
        TF->>AWS: 创建/修改资源
        AWS-->>TF: 返回结果
        TF->>S3: 写回新 state
        TF->>DDB: 删除 LockID
    else 写入失败(已被占用)
        DDB-->>TF: 锁冲突
        TF-->>Dev: 报错退出
    end

如果 apply 过程中你 Ctrl+C 强杀,锁可能没释放。报错会提示 lock ID。手动解锁:

1
terraform force-unlock abc-123-def-456

⚠️ 只有在你确认没人在跑时才能解锁,否则可能损坏 state。

2.5 漂移:理论与实战

代码声明 EC2 是 t3.small。但有人在控制台手改成了 t3.large。

云上现实 ≠ Terraform 记忆。这就是漂移。

Terraform 跑 plan 时做三件事:

flowchart LR
    A["代码<br/>期望状态"] --> P[terraform plan]
    B["State 文件<br/>记忆状态"] --> P
    C["云 API<br/>真实状态"] --> P
    P --> D{三方对比}
    D -->|代码 vs State| E[正常变更]
    D -->|State vs 云| F[漂移!]

发现真实状态和 state 不符就报告漂移。修法两条路:

  • 代码服从现实:把代码改成 t3.large,apply 让 state 同步
  • 现实服从代码:直接 apply,把云上改回 t3.small

哪条对?看那次手改是不是合理变更。合理变更就补回代码,非法变更就用代码强制还原。

主动检测漂移:

1
terraform plan -refresh-only

不改任何东西,只对比并刷新 state。CI 里可以定期跑。

2.6 Import 实战:纳管已存在的资源

场景:公司云上已有 100 个手建资源,要改用 Terraform 管理。不能 destroy 重建(线上服务跑着)。

方法:把现有资源 import 到 state。

第一步,代码里写一个空壳:

1
2
3
resource "aws_s3_bucket" "legacy" {
bucket = "legacy-bucket-name"
}

第二步,执行 import:

1
terraform import aws_s3_bucket.legacy legacy-bucket-name

第三步,跑 plan 看差异:

1
terraform plan

plan 会报告差异——比如 versioning 配置、tags、encryption。把这些配置补全到代码里,反复 plan,直到显示 “No changes”。

此时这个资源就完全归 Terraform 管了。

Terraform 1.5+ 还支持 import block,可以批量声明:

1
2
3
4
import {
to = aws_s3_bucket.legacy
id = "legacy-bucket-name"
}

这种方式可以走 PR review,比命令行更适合团队。

2.7 State 三大手术刀:mv / Rm / Replace

terraform state mv:重命名或搬迁。

你把代码里的 aws_instance.web 改成 aws_instance.api。直接 apply 会先删后建——业务中断。

正确做法:

1
terraform state mv aws_instance.web aws_instance.api

告诉 state:这俩是同一个资源,只是改了名。真实云资源不动。

terraform state rm:从 state 移除(不删云上资源)。适用于让某个资源不再被 Terraform 管,但保留它本身。

1
terraform state rm aws_s3_bucket.legacy

S3 还在,Terraform 装作不认识它。常用于把资源从一个项目移交给另一个项目。

terraform apply -replace:强制重建。某个资源你想销毁重建(比如 EC2 配置脏了想重来):

1
terraform apply -replace="aws_instance.web"

这是老命令 terraform taint 的现代替代。

2.8 State 损坏自救

最坏情况:state 文件损坏或丢失。

如果 S3 桶开了 versioning(前面再三强调过),就有救:

1
2
3
aws s3api list-object-versions \
--bucket my-tf-state-prod \
--prefix vpc/terraform.tfstate

挑一个昨天的版本下载,覆盖回去。然后跑 terraform plan -refresh-only 检查漂移。缺的资源手动 import 回来。

State 这一关过了,就该面对下一个新人 500 行 main.tf 的痛苦——模块化。


3. 模块化工程

3.1 不模块化的代价

新人常见写法:所有代码塞 main.tf。500 行后开始痛苦:

  • dev 想用小实例,prod 用大的——满文件 if 判断
  • VPC 要在 5 个项目复用——复制粘贴 5 份
  • 改一个 tag 要在 10 个文件里搜替换

模块化解决两个问题:复用和隔离。

3.2 推荐目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
infra/
├── bootstrap/ # 一次性:建 state 桶+锁表
│ └── main.tf
├── modules/ # 可复用的零件
│ ├── vpc/
│ ├── compute/
│ └── database/
├── envs/ # 装配环境
│ ├── dev/
│ │ ├── backend.tf
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ ├── stg/
│ └── prod/
├── .gitignore
└── README.md

核心思想:

  • modules/ 是零件——通用、参数化、不含具体值
  • envs/ 是装配——把零件组装成具体环境

3.3 一个完整的 VPC Module

modules/vpc/main.tf

1
2
3
4
5
6
7
8
9
10
11
12
resource "aws_vpc" "this" {
cidr_block = var.cidr
enable_dns_hostnames = true
tags = merge(var.tags, { Name = var.name })
}

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

modules/vpc/variables.tf

1
2
3
4
5
6
7
8
variable "name"           { type = string }
variable "cidr" { type = string }
variable "azs" { type = list(string) }
variable "public_subnets" { type = list(string) }
variable "tags" {
type = map(string)
default = {}
}

modules/vpc/outputs.tf

1
2
output "vpc_id"     { value = aws_vpc.this.id }
output "subnet_ids" { value = aws_subnet.public[*].id }

三个文件就是 module 标配:

  • main.tf:资源定义
  • variables.tf:输入参数
  • outputs.tf:导出值

module 设计原则:输入用 variables,输出用 outputs,绝不在 module 里硬编码具体值。

3.4 在环境里调用 Module

envs/dev/main.tf

1
2
3
4
5
6
7
8
9
module "vpc" {
source = "../../modules/vpc"

name = "dev-vpc"
cidr = "10.0.0.0/16"
azs = ["us-west-2a", "us-west-2b"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
tags = { Environment = "dev" }
}

envs/prod/main.tf

1
2
3
4
5
6
7
8
9
module "vpc" {
source = "../../modules/vpc"

name = "prod-vpc"
cidr = "10.1.0.0/16"
azs = ["us-west-2a", "us-west-2b", "us-west-2c"]
public_subnets = ["10.1.1.0/24", "10.1.2.0/24", "10.1.3.0/24"]
tags = { Environment = "prod" }
}

同一份 module 代码,两份不同参数,两个完全独立的环境。state 也分开存(每个 env 有自己的 backend.tf)。这就是模块化的核心红利。

3.5 Workspace Vs 多目录:常见困惑

新手会问:能不能用 terraform workspace 隔离 dev/prod?

不要。

workspace 的本质:一个目录,多个 state 文件。问题在于 dev 和 prod 共用同一份代码。

你想让 dev 实例小、prod 实例大——满代码 if workspace == "prod"。代码很快就乱,版本一致性也不可控。

多目录 ≠ 多 workspace。多目录才是隔离环境的正解。

workspace 适合什么?临时的、短命的、同构的环境。比如 PR 预览环境——每个 PR 一个 workspace,合并后销毁。

记住口诀:⚠️ 长期环境用多目录,短期环境用 workspace

3.6 远程 module:站在巨人肩上

source 不一定是本地路径。可以是 Git 仓库:

1
2
3
module "vpc" {
source = "git::https://github.com/your-org/tf-modules.git//vpc?ref=v1.2.0"
}

?ref=v1.2.0 锁版本是关键。不锁版本 = 哪天 master 推个 breaking change 你就翻车。

也可以是 Terraform Registry(社区开源):

1
2
3
4
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.0"
}

社区已经把 VPC、EKS、RDS 等高频模块写得很完善。先去 registry 看看有没有现成的,不要重造轮子。

4. 排错诊断

下面这 5 个错误工作中会反复遇到。每个给「症状 → 原因 → 修复」三件套。

4.1 错误一:state 锁卡死

症状:

1
2
3
4
5
6
Error: Error acquiring the state lock
Lock Info:
ID: abc-123-def
Operation: OperationTypeApply
Who: liuwei@laptop
Created: 2026-05-02 10:23:45 UTC

原因:上次 apply 异常退出,锁没释放。或者真有同事在跑——这种情况要先去问。

修复:

1
terraform force-unlock abc-123-def

铁律:先确认全组没人在跑,再解锁。否则两人同时 apply 会损坏 state。

4.2 错误二:循环依赖

症状:

1
Error: Cycle: aws_security_group.a, aws_security_group.b

原因:A 引用了 B 的属性,B 又引用了 A。

典型场景:两个安全组互相把对方加入 ingress 规则。

修复:把规则拆成独立 resource。

1
2
3
4
5
6
7
8
9
resource "aws_security_group" "a" { /* 不写 ingress */ }
resource "aws_security_group" "b" { /* 不写 ingress */ }

resource "aws_security_group_rule" "a_from_b" {
security_group_id = aws_security_group.a.id
source_security_group_id = aws_security_group.b.id
type = "ingress"
# ...
}

资源粒度变细,环就断了。通用思路:遇到循环依赖,把「关系」独立成一个资源。

4.3 错误三:资源已存在

症状:

1
Error: BucketAlreadyExists: my-bucket already exists

原因:云上已经有同名资源。可能是你之前手建的,或者别人建的。

修复:用 import 把它纳管(见 2.6)。不要改名字了事——那是回避问题。后患是那个孤儿资源永远不归 Terraform 管,下次有人想创建同样的名字又会撞。

4.4 错误四:apply 部分失败

症状:apply 跑到一半出错。一些资源建成功了,一些没建。

原因:网络抖动、API 限流、依赖资源失败、quota 不足。

修复套路:

  1. terraform plan,看现在还差什么
  2. 直接 terraform apply 再跑一次
  3. Terraform 是幂等的——已建的不会重建
  4. 如果某个资源卡在中间状态,terraform state list 看 state,必要时 state rm 后重建

核心心智:⚠️ Terraform 失败是常态,不是异常。重试是第一手段

4.5 错误五:看不懂 Plan 输出

症状:plan 输出几百行,看不出哪些是关键变更。

关键符号速查表:

符号含义风险
+创建
-销毁高(可能丢数据)
~修改属性
-/+销毁后重建极高(业务中断)

-/+ 是生产环境最大的雷。重点看哪些字段触发了重建——plan 会标注 “forces replacement”,盯紧这一行。

常见触发重建的字段:

  • EC2 的 availability_zone
  • RDS 的 engine_version(某些情况)
  • 安全组的某些 rule 改动

修这种问题:用 lifecycle 块或拆分资源。

1
2
3
4
5
6
7
resource "aws_instance" "web" {
# ...
lifecycle {
create_before_destroy = true
ignore_changes = [tags["LastBackup"]]
}
}

4.6 排错工具箱

terraform console:交互式调试表达式。

1
2
3
4
5
$ terraform console
> aws_vpc.main.cidr_block
"10.0.0.0/16"
> length(var.public_subnets)
2

写表达式不确定语法时,console 是最快的反馈。

TF_LOG=DEBUG:开 debug 日志。

1
TF_LOG=DEBUG terraform apply 2>debug.log

看 API 调用细节。诊断「为什么这个属性 plan 显示有变更」特别有用。

terraform graph:生成依赖图。

1
terraform graph | dot -Tpng > graph.png

诊断循环依赖、理解执行顺序。需要先装 graphviz。

5. 项目骨架

把上面所有内容浓缩成一个起手即用的骨架。新项目复制这个结构即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
infra/
├── bootstrap/ # 一次性建 state 后端
│ ├── main.tf # S3 桶 + DynamoDB 锁表
│ └── outputs.tf
├── modules/
│ ├── vpc/
│ ├── compute/
│ └── database/
├── envs/
│ ├── dev/
│ │ ├── backend.tf # state: dev/terraform.tfstate
│ │ ├── main.tf # 调用各 module
│ │ ├── variables.tf
│ │ ├── terraform.tfvars
│ │ └── versions.tf # provider 版本锁
│ ├── stg/
│ └── prod/
├── .gitignore
└── README.md

.gitignore 必备内容:

1
2
3
4
5
6
*.tfstate
*.tfstate.*
.terraform/
crash.log
*.tfvars
override.tf

注意:.terraform.lock.hcl 建议提交到 Git——它锁 provider 版本,保证团队一致。

versions.tf 锁 Terraform 和 provider 版本:

1
2
3
4
5
6
7
8
9
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.30"
}
}
}

~> 5.30 含义:允许 5.30.x 升级,禁止升到 5.31。这是「既要补丁又要稳定」的折中写法。

骨架到此完整。下一节给一个一句话总结和后续学习路径。