文章摘要

缘起:为什么需要 Terraform

最近,我参与了一个 AWS Serverless 项目。业务需求本身并不复杂:几个 Lambda 函数、一个 S3 存储、一个 EventBridge 定时触发,再加上精细化的 IAM 权限控制。在部署这套服务的过程中,我先后尝试了三种方案,几乎踩遍了云基础设施管理中的经典巨坑:

  1. AWS Console:一开始为了快,直接上控制台点鼠标。创建 S3 Bucket、手动上传 Lambda zip 包、配置 EventBridge 规则。问题是:项目要部署到 dev、staging、prod 三个环境,在控制台里点一遍要花半小时,三个环境配置完一个半小时。两个月后,当有人问“这个安全组是谁配的,为什么开了 443端口”时,没人说得清。

  2. SAM:手动上传 zip 太繁琐,同事推荐了 SAM,通过一个 template.yaml 定义 Lambda 和权限,结合 sam build && sam deploy 确实省心。但当面对 VPC、安全组、S3 高级配置、更精细的 IAM 策略时,SAM 显得力不从心。于是我变成了“混合打法”:SAM 管理 Lambda,其余资源还是在控制台管理。两个工具,两套流程,让人身心俱疲。

  3. 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 会自动查漏补缺,智能处理以下三种场景:

  1. 现实中没有:自动创建。
  2. 现实中有,但配置不一致:自动修改、校准。
  3. 代码里删除了:下次 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 的两大核心场景:

  1. 直观可视:让运维人员或 CI/CD 脚本在部署完后,能快速直接获取到关键节点信息
  2. 跨模块联动:下游的 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 的核心其实就三件事:

  1. 声明你想要什么(Resource 描述期望状态)
  2. 记录现在有什么(State 文件记录现实快照)
  3. 计算差距并严格执行(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 啦!

赞赏博主
相关推荐 随便逛逛
.NET 生态下的 Agent 框架选型:从 ReAct 到原生推理 本文对比了 Semantic Kernel、Microsoft.Extensions.AI 和 Microsoft Agent Framework 三种 .NET 生态下的 Agent 框架,详细测试了流式输出、工具调用和推理内容获取能力。随着 OpenAI o1、DeepSeek Reasoner、Claude 4 等推理模型的兴起,Agent 系统正从 ReAct 模式向原生推理演进。本文实测了三大框架对 reasoning_content 的兼容情况,并提供了 DeepSeek 推理模型的接入方案,最后针对不同场景给出了选型建议。
从「能用」到「好用」:LLM 流式响应实现方式的探索之路 本文记录了在 ASP.NET Core 中实现 LLM 流式响应的完整探索历程。从直接操作 Response 的朴素写法,到引入事件抽象,再到 IAsyncEnumerable 的陷阱与中间件方案的局限,最终通过自定义的 SseResult 实现了优雅、可复用且符合框架哲学的 Server-Sent Events (SSE) 流式输出方案。文章对比了五种实现方式的复杂度与适用场景,为需要集成生成式 AI 流式交互的开发者提供了一条从「能用」到「好用」的清晰路径。
评论 隐私政策