缘起:为什么需要 Terraform
最近,我参与了一个 AWS Serverless 项目。业务需求本身并不复杂:几个 Lambda 函数、一个 S3 存储、一个 EventBridge 定时触发,再加上精细化的 IAM 权限控制。在部署这套服务的过程中,我先后尝试了三种方案,几乎踩遍了云基础设施管理中的经典巨坑:
AWS Console:一开始为了快,直接上控制台点鼠标。创建 S3 Bucket、手动上传 Lambda zip 包、配置 EventBridge 规则。问题是:项目要部署到 dev、staging、prod 三个环境,在控制台里点一遍要花半小时,三个环境配置完一个半小时。两个月后,当有人问“这个安全组是谁配的,为什么开了 443端口”时,没人说得清。
SAM:手动上传 zip 太繁琐,同事推荐了 SAM,通过一个
template.yaml定义 Lambda 和权限,结合sam build && sam deploy确实省心。但当面对 VPC、安全组、S3 高级配置、更精细的 IAM 策略时,SAM 显得力不从心。于是我变成了“混合打法”:SAM 管理 Lambda,其余资源还是在控制台管理。两个工具,两套流程,让人身心俱疲。Terraform:运维同事终于看不下去了:“你这些资源应该用 Terraform 管理起来”。我一开始是抗拒的——一个 S3 Bucket 要写好几行代码,还要声明 Provider、定义变量,控制台里点几下搞定的事,为什么要大费周章写代码?
但是,很快一次意外说服了我。某天,有人在控制台里偷偷修改了路由表,却没有同步到代码中。第二天跑 terraform plan,配置漂移直接暴露。那一刻我才真正顿悟:Terraform 不仅仅是帮你创建资源的工具,更是帮你锁定“期望状态”的终极防线。
故事到这里,你以为我全面拥抱 Terraform 了?并没有。对于 Lambda 函数的代码部署,我最后还是选择用 GitHub Actions + AWS CLI。因为:Terraform 擅长管理静态的基础设施,而高频的代码迭代更适合放在专业的 CI/CD 流水线中。所以,我最终的工程实践是:Terraform 掌管资源拓扑,CI/CD 流水线负责代码交付。架构没有银弹,关键在于各司其职。
什么是 Terraform
先说结论:用代码描述你期望的基础设施最终状态,然后让工具帮你自动实现,这就是基础设施即代码(IaC, Infrastructure as Code)的核心思想。我们可以对比一下两种思维模式:
- AWS CLI 是“命令式”——你必须精确告诉它每一步该怎么做:
# 命令式:按顺序下达指令
aws s3api create-bucket --bucket my-bucket --region us-west-2
aws s3api put-bucket-versioning --bucket my-bucket --versioning-configuration Status=Enabled
- Terraform 是“声明式”——你只需要描述你想要的最终结果:
# 声明式:只描述期望的最终状态
resource "aws_s3_bucket" "my_bucket" {
bucket = "my-bucket"
}
resource "aws_s3_bucket_versioning" "my_bucket" {
bucket = aws_s3_bucket.my_bucket.id
versioning_configuration {
status = "Enabled"
}
}
当你执行应用时,Terraform 会自动查漏补缺,智能处理以下三种场景:
- 现实中没有:自动创建。
- 现实中有,但配置不一致:自动修改、校准。
- 代码里删除了:下次 apply 时自动释放对应资源。
从“命令式指令”向“声明式状态”的思维转变,是开发者接触 Terraform 时需要跨越的第一道门槛。
核心概念
第一次看 Terraform 代码,你可能会被一堆配置搞得眼花缭乱。其实,学习 Terraform 不需要死记硬背,牢牢把握住三个核心概念即可:Resource(资源)、Provider(服务商) 和 State(状态文件)。
Resource:你想要什么
resource "aws_s3_bucket" "my_bucket" {
bucket = "my-unique-bucket-name"
}
其中:
- resource:关键字,声明“我要定义一个资源”。
- aws_s3_bucket:资源类型,由 Provider 定义,此处对应 AWS S3 存储桶。
- my_bucket:本地标识名(Logical Name),仅在代码内部引用时使用,可以自由命名。
- bucket = “…":参数/属性,配置该资源的真实具体属性。
核心避坑点:代码中的本地标识名 my_bucket 与云端真实的资源名称 my-unique-bucket-name 是两码事。前者类似于代码中的变量名,方便你在其他 text 文件中引用该资源;后者则是最终在 AWS 控制台上呈现的资源实体名称。
Provider:该找谁要
provider "aws" {
region = "us-west-2"
}
Provider 扮演着“翻译官”的角色。当你在代码中声明“我需要一个 S3 存储桶”时,Provider 负责将你的意图翻译成对应云厂商的底层的 API 调用:
aws_s3_bucket资源只能由 AWS Provider 来解析和驱动azurerm_storage_container资源则只能由 Azure Provider 来解析和驱动
State:都有什么
当你运行 Terraform 后,它会在本地或远程生成一个名为 terraform.tfstate 的状态文件。这个文件记录了:
- 当前代码接管了哪些现实资源。
- 这些资源在云端的真实 ID 和属性快照。
- 上一次成功应用配置后的基线状态。
状态文件是 Terraform 的心脏,唯有依靠它,Terraform 才能精准计算出“云端实际状态”与“代码期望状态”之间的差距。举个例子:代码里写了 bucket = "my-bucket"。下次 plan 时,Terraform 读状态文件发现“我此前管过一个存储桶,云端 ID 是 my-bucket”,于是去 AWS 实时验证,发现配置完全一致,便会输出 No changes。如果状态文件不慎丢失,Terraform 就会失去记忆,误以为这是新资源,进而盲目去云端创建,最终导致资源重名冲突或覆盖报错。
实战:创建第一个资源
让我们从最简单的配置开始,创建 main.tf文件:
provider "aws" {
region = "us-west-2"
}
resource "aws_s3_bucket" "my_bucket" {
bucket = "my-tutorial-bucket-2026"
}
接下来,便是标准的 Terraform 三板斧 命令:
# 1. 初始化:下载所需的 provider 插件
terraform init
# 2. 预览:对比差异,预览将要发生的变更(不会真正动云端资源)
terraform plan
# 3. 部署:将变更真正应用到云端
terraform apply
在执行 plan 时,终端会输出清晰的差异对比图:
Terraform will perform the following actions:
# aws_s3_bucket.my_bucket will be created
+ resource "aws_s3_bucket" "my_bucket" {
+ bucket = "my-tutorial-bucket-2026"
+ id = (known after apply)
+ arn = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
此时,必须小心谨慎地对待以下变更差异符号:
+创建:表示创建了新资源-删除:表示删除了某个资源~修改:表示在原资源上更新特定属性-/+破坏性替换:由于修改了不可变参数,,Terraform 必须先删后建。在生产环境中可能到这个符号务必小心,这意味着可能会删除原有数据,属于危险操作
执行完成后,你会看到目录中多出了 terraform.tfstate 文件,里面已经记录了当前存储桶的信息。
Variables:拒绝硬编码
在上面的例子中,我们把 region 和 bucket 名称直接写死了,这在软件工程中属于硬编码
provider "aws" {
region = "us-west-2" # hardcode
}
resource "aws_s3_bucket" "my_bucket" {
bucket = "my-tutorial-bucket-2026" # hardcode
}
考虑到代码的可维护性,我们引入一个新的文件 variables.tf,将可变参数抽离出来:
# variables.tf
variable "region" {
type = string
default = "us-west-2"
}
variable "bucket_name" {
type = string
}
然后在 main.tf 中通过 var.xxx 的形式动态引用变量:
# main.tf
provider "aws" {
region = var.region
}
resource "aws_s3_bucket" "my_bucket" {
bucket = var.bucket_name
}
在实际执行时,有三种常见的方式注入这些变量的值:
# 1. 命令行
terraform apply -var="bucket_name=my-prod-bucket"
# 2. 变量文件(推荐)
terraform apply -var-file="prod.tfvars"
# 3. 环境变量
export TF_VAR_bucket_name="my-prod-bucket"
terraform apply
其中 prod.tfvars 通常是类似下面这样的键值对形式:
bucket_name = "my-prod-bucket"
region = "us-west-2"
在需要多环境管理的生产级项目中,更推荐使用 workspaces 目录 + tfvars.json 实现多环境配置管理:
workspaces/
├── dev.tfvars.json
├── staging.tfvars.json
└── prod.tfvars.json
Outputs:交付产物
在 AWS 体系中,资源的 ARN(Amazon Resource Name,唯一资源标识) 是非常核心的概念。例如在编写 IAM 权限策略时,频繁需要引用其他资源的 ARN。当 Terraform 创建完资源后,我们如何将资源的 ID、ARN 等属性安全地暴露给下游服务或团队呢?这就需要用到 outputs.tf:
output "bucket_arn" {
description = "The ARN of the bucket"
value = aws_s3_bucket.my_bucket.arn
}
运行 terraform apply 命令后,终端将会打印出类似下面的信息:
Outputs:
bucket_arn = "arn:aws:s3:::my-tutorial-bucket-2026"
Output 的两大核心场景:
- 直观可视:让运维人员或 CI/CD 脚本在部署完后,能快速直接获取到关键节点信息
- 跨模块联动:下游的 Terraform 模块可以通过
terraform_remote_state远程读取,实现模块解耦
Locals:计算中间值
variables 负责处理来自外部的输入参数,它可以通过 tfvars 或者 tfvars.json 文件传入,而内部复杂的逻辑计算和复用,则应该交给 locals。例如,我们希望规范资源命名,自动为所有资源带上环境后缀,并统一注入 Tag 标签:
# local.tf
locals {
bucket_name = "my-app-${var.env}"
tags = {
Environment = var.env
ManagedBy = "terraform"
}
}
# mian.tf
resource "aws_s3_bucket" "my_bucket" {
bucket = local.bucket_name
tags = local.tags
}
什么时候该用 locals?
- 多个资源需要共享一段完全相同的计算逻辑或字符串。
- text 表达式太长、太复杂,不希望在 main.tf 中破坏可读性。
- 为了提高代码自解释性,给某段计算结果起个有意义的名字。
资源引用:隐式依赖处理
我们来看一个稍微复杂的场景:为刚刚创建的 S3 存储桶配置一条 Bucket Policy。首先,我们在独立的文件 policies/s3-policy.json 中定义好标准的 JSON 策略模板,并预留占位符:
- 先创建一个 JSON 模板文件
policies/s3-policy.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "${bucket_arn}/*"
}
]
}
- 接着,在
main.tf中用templatefile函数渲染该 JSON,并建立资源关联:
resource "aws_s3_bucket" "my_bucket" {
bucket = var.bucket_name
}
resource "aws_s3_bucket_policy" "my_policy" {
bucket = aws_s3_bucket.my_bucket.id
policy = templatefile("${path.module}/policies/s3-policy.json", {
bucket_arn = aws_s3_bucket.my_bucket.arn
})
}
注意看这里的 aws_s3_bucket.my_bucket.arn:在代码里,我们并没有手动去指定谁先创建、谁后创建。但 Terraform 在解析代码时,会通过这一行引用自动建立一张有向无环图(DAG)。它知道 Policy 依赖 Bucket 的 ARN,因此会极其智能地先创建存储桶,拿到 ARN 后再创建策略。这就是声明式编程带来的巨大红利。
进阶:目录拆分
随着项目规模的扩大,把所有基础设施全部写在一个 main.tf 里会变成一场灾难。优秀的实践是将资源按职责和生命周期拆分成独立的子目录。每个目录拥有自己独立的状态文件,这样可以极大程度地控制爆炸半径(Blast Radius)——哪怕你在改 Lambda 代码时配置写错了,绝对不会波及核心的网络和 IAM 权限:
infra/
├── iam/ # IAM 角色和策略
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── workspaces/
│ └── prod.tfvars.json
└── lambda/ # Lambda 函数
├── main.tf
├── variables.tf
└── workspaces/
└── prod.tfvars.json
在部署时,各自独立执行:
cd iam && terraform apply
cd lambda && terraform apply
此时面临一个新挑战:lambda 目录在配置函数时,需要引用 iam 目录刚刚创建出来的角色 ARN。它们不仅文件独立,连状态文件都不在一个地方,该怎么打通?答案是利用 terraform_remote_state 数据源,跨边界直接读取对方的部署产物:
data "terraform_remote_state" "iam" {
backend = "s3"
config = {
bucket = "my-terraform-state"
key = "iam/terraform.tfstate"
region = "us-west-2"
}
}
resource "aws_lambda_function" "my_function" {
role = data.terraform_remote_state.iam.outputs.lambda_role_arn
# ...
}
架构痛点与思考:使用这种方式,你需要在代码里硬编码对方的远程 S3 路径。当子目录繁多时,这些路径依赖会交织成一张复杂的蜘蛛网,维护成本极高。这解释了为什么在团队规模扩大后,往往需要引入 Terraform Cloud、Atlantis 或 Terragrunt 等高级编排工具的原因——它们能够帮你统一托管 Backend 和拓扑依赖,告别手动配置。
总结
万变不离其宗,掌握 Terraform 的核心其实就三件事:
- 声明你想要什么(Resource 描述期望状态)
- 记录现在有什么(State 文件记录现实快照)
- 计算差距并严格执行(Plan 预览变更,Apply 抹平差距)
至于变量、输出、局部变量、数据源、模块等概念,统统是围绕这三件事衍生出来的效率工具。例如,当一个资源由你来控制时,你应该使用 resource 关键字描述资源;当一个资源已经存在时,你应该使用 data 关键字查询资源。
什么时候用什么工具?
| 业务场景 | 推荐选型 | 选用理由 |
|---|---|---|
| 临时测试、一次性调研、做完就忘 | AWS 控制台 | 纯可视化,直观方便,无额外心智负担 |
| 临时运维小脚本、快速查询或单步销毁 | AWS CLI | 轻量快捷,不需要初始化环境和声明状态 |
| 纯粹的 Serverless / Lambda 应用开发 | AWS SAM / Serverless 框架 | 针对 FaaS 深度优化,代码打包与热编译体验极佳 |
| 多云环境、多层资源交织、团队协作、长期演进 | Terraform | 完善的状态管理、声明式依赖拓扑、变更计划预览 |
在实际的工业界项目中,混用(Hybrid)才是常态:我们用 Terraform 锁死 VPC、数据库、IAM 等底层重型基础设施;用 SAM 或专有 CI/CD 流水线去高频发布 Lambda 业务代码;用 AWS CLI 临时处理线上紧急巡检。
最后,请允许我向 Terraform 新手提供一份生存指南:
- 状态文件必须上云锁死:本地存 State 只适合个人玩具项目。多人协作务必配置远程 Backend(如 S3),并开启 DynamoDB 状态锁,防止两人同时 apply 导致状态文件损坏。
- 严禁控制台“偷渡”修改:一旦用了 Terraform,就请管住去控制台修改参数的手。任何微小的线上人肉微调,都会在下一次自动化 apply 时被无情覆盖,甚至引发灾难。
- 重视 prevent_destroy 护身符:对于生产环境的数据库、核心存储桶等不可再生资源,务必在代码中的 lifecycle 块里加上 prevent_destroy = true,最大程度防止误删风险。
- 警惕 -/+ 符号:再次强调,plan 结果里一旦出现 -/+,说明有资源要被干掉重建,核对好该资源是否有未备份的持久化数据!
如果你发现自己正在重复敲着一堆类似的 AWS CLI 命令,或者在控制台频繁为新客户克隆同一套配置,那么,请不要怀疑,你该用 Terraform 啦!

