Terraform State 与多环境实战
新人入职第一天打开 AWS 控制台。30 多个 EC2、十几个安全组、几个 RDS。没人能说清哪个资源谁开的、什么时候开的、为什么开的。也没人敢删——万一是关键服务呢?
这就是手点基础设施的代价。新人接手两眼一抹黑,变更全凭口口相传。Terraform 的承诺只有一句:
所有云资源用代码描述,所有变更走 Git。
1. 5 分钟入门
先把流程跑通,再讲原理。这一节用 10 行代码完整走一遍 Terraform 生命周期。
1.1 安装与第一个项目
mac 上一行装好:
1 | brew install terraform |
新建目录,写 main.tf:
1 | resource "local_file" "hello" { |
跑三个命令:
1 | terraform init # 下载 provider 插件 |
打开当前目录。多了 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 | provider "aws" { |
跑 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 是定时炸弹
三个致命问题。
- 丢了就完蛋。state 文件丢失,Terraform 不再认识那些云资源。要么手动 import 重新纳管,要么全部 destroy 重建。
- 多人协作必撞车。A 在跑 apply,B 也在跑。两个 state 互相覆盖。轻则资源乱序,重则 state 损坏。
- 含明文密钥。RDS 密码、API Key 都明文躺在 state 里。state 一旦进 Git,等于把家门钥匙挂网上。
结论:⚠️ 生产项目永远不要用本地 state。下一节给出业界标准的远端后端配置。
2.3 远端后端:S3 + DynamoDB 完整配置
业界标准做法:state 存 S3,锁存 DynamoDB。
新建 backend.tf:
1 | terraform { |
每个字段的意义:
| 字段 | 作用 |
|---|---|
bucket | state 文件存放的 S3 桶 |
key | state 在桶内的路径,按项目划分 |
dynamodb_table | 存放锁记录的 DynamoDB 表 |
encrypt | 服务端加密 state 文件 |
鸡和蛋问题:S3 桶和 DynamoDB 表本身怎么建?
新建一个独立的 bootstrap/ 目录,用本地 state 把它们建出来。建好之后这个目录基本不再动。
DynamoDB 表的主键必须叫 LockID,类型 String。这是约定。
1 | aws dynamodb create-table \ |
⚠️ 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 | resource "aws_s3_bucket" "legacy" { |
第二步,执行 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 | import { |
这种方式可以走 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 | aws s3api list-object-versions \ |
挑一个昨天的版本下载,覆盖回去。然后跑 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 | infra/ |
核心思想:
modules/是零件——通用、参数化、不含具体值envs/是装配——把零件组装成具体环境
3.3 一个完整的 VPC Module
modules/vpc/main.tf:
1 | resource "aws_vpc" "this" { |
modules/vpc/variables.tf:
1 | variable "name" { type = string } |
modules/vpc/outputs.tf:
1 | output "vpc_id" { value = aws_vpc.this.id } |
三个文件就是 module 标配:
main.tf:资源定义variables.tf:输入参数outputs.tf:导出值
module 设计原则:输入用 variables,输出用 outputs,绝不在 module 里硬编码具体值。
3.4 在环境里调用 Module
envs/dev/main.tf:
1 | module "vpc" { |
envs/prod/main.tf:
1 | module "vpc" { |
同一份 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 | module "vpc" { |
?ref=v1.2.0 锁版本是关键。不锁版本 = 哪天 master 推个 breaking change 你就翻车。
也可以是 Terraform Registry(社区开源):
1 | module "vpc" { |
社区已经把 VPC、EKS、RDS 等高频模块写得很完善。先去 registry 看看有没有现成的,不要重造轮子。
4. 排错诊断
下面这 5 个错误工作中会反复遇到。每个给「症状 → 原因 → 修复」三件套。
4.1 错误一:state 锁卡死
症状:
1 | Error: Error acquiring the state lock |
原因:上次 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 | resource "aws_security_group" "a" { /* 不写 ingress */ } |
资源粒度变细,环就断了。通用思路:遇到循环依赖,把「关系」独立成一个资源。
4.3 错误三:资源已存在
症状:
1 | Error: BucketAlreadyExists: my-bucket already exists |
原因:云上已经有同名资源。可能是你之前手建的,或者别人建的。
修复:用 import 把它纳管(见 2.6)。不要改名字了事——那是回避问题。后患是那个孤儿资源永远不归 Terraform 管,下次有人想创建同样的名字又会撞。
4.4 错误四:apply 部分失败
症状:apply 跑到一半出错。一些资源建成功了,一些没建。
原因:网络抖动、API 限流、依赖资源失败、quota 不足。
修复套路:
- 先
terraform plan,看现在还差什么 - 直接
terraform apply再跑一次 - Terraform 是幂等的——已建的不会重建
- 如果某个资源卡在中间状态,
terraform state list看 state,必要时state rm后重建
核心心智:⚠️ Terraform 失败是常态,不是异常。重试是第一手段。
4.5 错误五:看不懂 Plan 输出
症状:plan 输出几百行,看不出哪些是关键变更。
关键符号速查表:
| 符号 | 含义 | 风险 |
|---|---|---|
+ | 创建 | 低 |
- | 销毁 | 高(可能丢数据) |
~ | 修改属性 | 中 |
-/+ | 销毁后重建 | 极高(业务中断) |
-/+ 是生产环境最大的雷。重点看哪些字段触发了重建——plan 会标注 “forces replacement”,盯紧这一行。
常见触发重建的字段:
- EC2 的
availability_zone - RDS 的
engine_version(某些情况) - 安全组的某些 rule 改动
修这种问题:用 lifecycle 块或拆分资源。
1 | resource "aws_instance" "web" { |
4.6 排错工具箱
terraform console:交互式调试表达式。
1 | $ terraform console |
写表达式不确定语法时,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 | infra/ |
.gitignore 必备内容:
1 | *.tfstate |
注意:.terraform.lock.hcl 建议提交到 Git——它锁 provider 版本,保证团队一致。
versions.tf 锁 Terraform 和 provider 版本:
1 | terraform { |
~> 5.30 含义:允许 5.30.x 升级,禁止升到 5.31。这是「既要补丁又要稳定」的折中写法。
骨架到此完整。下一节给一个一句话总结和后续学习路径。