IaC:把基础设施变成可版本化的意图
一、引言
容器解决了应用的可重复交付,Kubernetes 解决了容器的声明式编排。但运行这一切的基础设施本身呢?那些 VPC、子网、安全组、数据库实例、负载均衡器——谁来管,怎么管?
大多数团队的起点是:登录云控制台,点点点。这在小规模下没问题,但有两个随时间累积的隐性代价,最终会变成灾难。
二、为什么:基础设施的配置漂移
控制台操作的两个隐性代价
第一个代价:不可追溯。
三个月前,有人在控制台悄悄改了一条安全组规则,放开了一个本不该开放的端口。没有记录,没有通知,没有 diff。直到今天排查安全漏洞,才发现这个规则在那里。追责?无从追起。
第二个代价:不可重现。
生产环境出了问题,需要在另一个区域快速搭起一个相同的环境做隔离验证。能保证搭出来的和生产一模一样吗?不能——你能记住三年里在控制台做过的每一个操作吗?控制台操作没有”复制粘贴整个环境”的语义。
这两个代价的根源是同一件事:基础设施的状态存在人脑和云平台的数据库里,没有任何一个地方有完整的、可读的、有版本历史的表达。
IaC 的核心翻转
IaC(Infrastructure as Code)的逻辑和容器镜像完全一致:把状态变成可版本化的声明。
# 不是"去控制台创建一个 S3 bucket"
# 而是:这个 bucket 应该存在,它应该处于这个状态
resource "aws_s3_bucket" "assets" {
bucket = "my-company-assets"
tags = {
Environment = "production"
Team = "platform"
}
}这个 .tf 文件提交进 Git,就意味着:谁改了基础设施,改了什么,什么时候改的,为什么改——全部有迹可查。
声明式 vs 命令式:两个工具解决的不是同一个问题
IaC 领域经常被拿来对比的两个工具是 Terraform 和 Ansible,但它们解决的问题层次不同,不是竞争关系。
Terraform 是声明式的:你描述”基础设施应该处于什么状态”,Terraform 计算当前状态和期望状态的差异,执行必要的变更。它不关心步骤,只关心结果。擅长管理云资源的生命周期:创建 VPC、开数据库、配置 DNS。
Ansible 是命令式的:你描述”在服务器上按顺序执行哪些步骤”,Ansible 通过 SSH 登录机器逐步执行。它有幂等性设计(大多数模块可以安全重跑),但本质上是任务序列。擅长在已有的机器上配置软件、管理服务。
两者的合理分工:Terraform 把 VM 开出来,Ansible 在 VM 上装软件、改配置。用 Ansible 管云资源(反复调 API 来模拟声明式),或用 Terraform 管应用配置(把 nginx.conf 内容写进 HCL),都是把工具用在错误的层次上。
三、怎么设计:Terraform 的工作原理
三个核心概念
Resource(资源):你想要存在的基础设施对象。一个 EC2 实例、一条 DNS 记录、一个数据库——每个 resource 块声明一个对象的期望状态。
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = {
Name = "web-server"
}
}Provider(提供商):把 Terraform 的声明翻译成具体云平台 API 调用的插件。AWS、GCP、Azure、Kubernetes、GitHub——都有对应的 Provider。terraform init 会下载你配置的 Provider。
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}State(状态):Terraform 记录”上次 apply 之后,现实世界是什么样子”的文件。这是理解 Terraform 工作原理最关键的概念,下面单独展开。
plan / apply 的执行模型
Terraform 的核心工作流是两步:先计算,再执行。
terraform planPlan 做三件事:读取你的 .tf 文件(期望状态);读取 state 文件(上次记录的现实);调用 Provider API 刷新真实的云资源状态。然后计算三者之间的差异,输出一个变更计划:
Plan: 2 to add, 1 to change, 0 to destroy.
+ aws_s3_bucket.logs # 新建
+ aws_security_group.web # 新建
~ aws_instance.web # 修改(instance_type 从 t3.micro 改为 t3.small)terraform applyApply 执行 plan 计算出的变更,然后把新的现实状态写回 state 文件。Apply 之前必须先 plan,确认变更符合预期——直接 apply 是生产环境的危险操作。
state 文件:IaC 的整个世界观
state 存在的原因
云资源是有状态的,Terraform 无法通过”纯粹重新读取 .tf 文件”来知道某个 EC2 实例的实际 ID 是什么。State 文件保存了 .tf 文件里的逻辑名称(aws_instance.web)和云平台实际资源 ID(i-0123456789abcdef0)之间的映射,以及上次 apply 时记录的所有属性值。
没有 state,Terraform 不知道”这个 .tf 文件描述的资源,在云上对应哪个对象”——它会认为什么都不存在,然后试图把所有东西重新创建一遍。
为什么不能把 state 提交进 Git
State 文件包含敏感信息(数据库密码、访问密钥);多人协作时 state 会产生冲突(两个人同时 apply,state 文件会被覆盖);state 文件可能很大,不适合 Git 版本管理。
正确做法是使用远程后端(Remote Backend):把 state 存在 S3(配合 DynamoDB 做并发锁)或 Terraform Cloud,本地只操作代码,state 的读写通过网络进行。
state 和真实资源不一致时
这是配置漂移的 IaC 版本:有人绕过 Terraform 在控制台直接改了资源,导致 state 记录的和云上真实的不一样。下次 terraform plan 时,Terraform 会发现偏差,并把它列在变更计划里——该纠正的纠正,该忽略的可以标记 ignore。
模块化
随着基础设施规模增大,把所有 .tf 文件堆在一个目录里会变得难以维护。Module(模块)是 Terraform 的复用单元:
# 使用一个封装了 ECS 服务的模块
module "web_service" {
source = "./modules/ecs-service"
service_name = "web"
image = "myapp:v1"
cpu = 256
memory = 512
port = 8080
}模块封装了一组相关资源的声明,对外只暴露必要的变量,内部实现细节对调用者不可见。这和应用代码的函数抽象逻辑完全一致。
四、上手:用 Terraform 把第一个云资源跑起来
安装与初始化
# macOS 安装
brew install terraform
# 验证安装
terraform version
# 创建工作目录
mkdir my-infra && cd my-infra写第一个资源
以创建一个 AWS S3 bucket 为例(AWS 的免费层覆盖,适合学习):
# main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "example" {
bucket = "my-learning-bucket-20260605" # 全局唯一名称
tags = {
Name = "learning-bucket"
Environment = "dev"
}
}
# 输出 bucket 的 ARN,方便其他模块引用
output "bucket_arn" {
value = aws_s3_bucket.example.arn
}核心工作流
# 第一步:初始化,下载 Provider 插件
terraform init
# 输出:Terraform has been successfully initialized!
# 第二步:格式化代码(养成习惯)
terraform fmt
# 第三步:验证语法
terraform validate
# 第四步:预览变更
terraform plan
# 输出变更计划,仔细阅读后再执行
# 第五步:执行变更
terraform apply
# 会再次展示 plan,输入 yes 确认
# 输出:Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
# 清理资源(学习完后执行,避免产生费用)
terraform destroy读懂 plan 的输出
Terraform will perform the following actions:
# aws_s3_bucket.example will be created
+ resource "aws_s3_bucket" "example" {
+ bucket = "my-learning-bucket-20260605"
+ id = (known after apply) # apply 之后才知道
+ tags = {
+ "Environment" = "dev"
+ "Name" = "learning-bucket"
}
}
# aws_instance.web will be updated in-place
~ resource "aws_instance" "web" {
id = "i-0123456789abcdef0"
~ instance_type = "t3.micro" -> "t3.small" # ~ 表示修改
}
# aws_security_group.old will be destroyed
- resource "aws_security_group" "old" { # - 表示删除
- id = "sg-0abc123"
}符号含义:+ 新建,~ 修改(in-place,不重建),- 删除,-/+ 需要销毁后重建(破坏性变更,要特别注意)。
查看和管理 state
# 列出 state 里管理的所有资源
terraform state list
# 查看某个资源的详细 state
terraform state show aws_s3_bucket.example
# 查看 state 文件(不要手动编辑!)
cat terraform.tfstate五、走向生产:让基础设施代码跑得稳
目录结构:多环境的组织方式
常见的多环境目录结构:
infra/
├── modules/ # 可复用模块
│ ├── vpc/
│ ├── ecs-service/
│ └── rds/
├── environments/
│ ├── dev/
│ │ ├── main.tf # 调用模块,传入 dev 的参数
│ │ ├── variables.tf
│ │ └── backend.tf # dev 环境的远程 state 配置
│ ├── staging/
│ └── prod/
└── shared/ # 跨环境共用的资源(DNS zone、IAM 基础角色)每个环境是独立的 Terraform 工作区,有独立的 state 文件。dev 和 prod 的代码几乎相同,只是变量值不同(实例规格、副本数、域名)。
变量与输出
# variables.tf
variable "environment" {
description = "部署环境"
type = string
}
variable "instance_type" {
description = "EC2 实例规格"
type = string
default = "t3.micro"
}
# 在 main.tf 里引用
resource "aws_instance" "web" {
instance_type = var.instance_type
tags = {
Environment = var.environment
}
}
# outputs.tf
output "web_public_ip" {
description = "Web 服务器公网 IP"
value = aws_instance.web.public_ip
}变量值通过 .tfvars 文件按环境传入:
# prod.tfvars
environment = "prod"
instance_type = "t3.large"terraform apply -var-file="prod.tfvars"远程后端:state 存在哪里
# backend.tf(以 S3 + DynamoDB 为例)
terraform {
backend "s3" {
bucket = "my-company-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-lock" # 防止并发 apply 导致 state 损坏
encrypt = true
}
}DynamoDB 锁的机制:apply 开始时写入一条锁记录,apply 结束后删除。如果另一个 apply 同时运行,发现锁存在则等待或报错,避免两个 apply 同时修改 state 导致数据损坏。
CI/CD 集成
成熟的 IaC 工作流:
flowchart TD A[开发者提交 PR] --> B[CI 自动执行\nterraform plan] B --> C[plan 输出作为\nPR Comment 展示] C --> D{团队 Review\n变更计划} D -- "发现问题" --> E[修改 .tf 文件\n重新提交] E --> B D -- "确认无误" --> F[合并到主分支] F --> G[CD 自动执行\nterraform apply] G --> H[apply 结果\n通知 PR / Slack]
plan 在 PR 里审查是 IaC 工作流里最重要的安全门:任何基础设施变更在执行前都有人工确认,避免意外的 -/+(销毁重建)操作在生产上悄悄发生。
import:把已有资源纳入管理
如果云上已有资源是手动创建的(没有 Terraform 管理),可以用 import 把它纳入 state,之后就可以用 Terraform 统一管理:
# 先在 main.tf 里写好对应的 resource 块
# 然后 import 现有资源
terraform import aws_s3_bucket.existing my-existing-bucket-name
# import 后跑 plan,确认没有意外的变更
terraform plan六、故障剧场:亲手把它弄坏
实验一:在控制台手改资源,看 drift 浮现
# 用 Terraform 创建一个 S3 bucket
terraform apply
# 去 AWS 控制台,给这个 bucket 手动加一个 tag(或修改任何属性)
# 回来跑 plan
terraform plan
# 输出:
# ~ aws_s3_bucket.example
# tags = {
# + "manual-tag" = "added-in-console" # 控制台加的 tag 出现了
# }Terraform 发现了 drift——控制台的修改被识别出来,并显示为”现实和期望状态不一致”。你可以选择:apply 把这个 tag 删掉(让现实符合代码),或者把这个 tag 加到 .tf 文件里(让代码符合现实)。没有第三种选项——你必须明确表态。
实验二:删掉 state 文件,看工具如何”失忆”
# 先创建资源
terraform apply
# 删掉 state 文件(模拟 state 丢失)
rm terraform.tfstate
# 再跑 plan
terraform plan
# Terraform 认为什么都不存在,试图重新创建所有资源
# Plan: 1 to add, 0 to change, 0 to destroy.
# 但云上其实已经有了!如果真的 apply,会报错:bucket already existsState 不是缓存,是 Terraform 的整个世界观。State 丢失不意味着云上的资源消失了,意味着 Terraform 不再知道它们的存在。恢复方式是 terraform import 把云上资源重新纳入管理,或者从远程后端恢复 state 备份——这也是为什么 state 必须存在有版本管理的远程后端里,而不是本地文件。
实验三:state 锁死的解除
# 场景:apply 进行中,进程被强杀(Ctrl+C 或机器断电)
# 远程 state 上的锁没有被正常释放
terraform apply
# Error: Error acquiring the state lock
# Lock Info:
# ID: 12345678-abcd-efgh-ijkl-000000000000
# Operation: OperationTypeApply
# Who: user@machine
# 确认这个 apply 进程确实已经死了(不是还在跑)
# 然后强制解锁
terraform force-unlock 12345678-abcd-efgh-ijkl-000000000000force-unlock 是危险操作:如果另一个 apply 真的还在运行,强制解锁会导致两个 apply 同时修改 state,造成 state 损坏。只在确认没有其他 apply 在运行时才执行。
实验四:terraform taint,强制重建资源
# 标记某个资源为"污染状态",下次 apply 时强制销毁后重建
terraform taint aws_instance.web
# plan 会显示 -/+(销毁重建)
terraform plan
# -/+ aws_instance.web (tainted)
# apply 后 taint 标记自动清除taint 的使用场景:实例配置混乱需要从干净状态重建;想强制触发 user_data 重新执行;实例出现奇怪的底层问题需要换一台物理机。注意 -/+ 意味着有停机时间,要在合适的维护窗口执行。
七、日常排障:IaC 坏了怎么查
apply 报错:常见错误类型
资源已存在(需要 import)
Error: creating S3 Bucket: BucketAlreadyOwnedByYou: my-bucket云上已经有这个资源,但 state 里没有记录。不要手动删了重建——用 terraform import 把它纳入管理。
依赖关系未就绪
Error: InvalidParameterException: Subnet subnet-xxxx does not exist资源 A 依赖资源 B,但 B 还没创建完成。通常 Terraform 能自动推断依赖顺序(通过引用关系),但有时需要显式声明:
resource "aws_instance" "web" {
depends_on = [aws_internet_gateway.gw] # 显式声明依赖
}Provider 认证失败
Error: No valid credential sources foundAWS 凭证未配置或已过期。检查 ~/.aws/credentials 或环境变量 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY。
state 锁死
# 查看当前锁信息
terraform plan
# Error: Error acquiring the state lock
# 锁 ID 在错误信息里
# 确认没有其他 apply 在运行后解锁
terraform force-unlock <lock-id>drift 修复:三种处理方式
发现 plan 里有意外变更(drift),有三种处理路径:
# 方式一:apply 纠正,让现实符合代码(推荐:代码是真相之源)
terraform apply
# 方式二:修改代码,让代码承认现实(适合有意保留的手动修改)
# 把控制台的修改写进 .tf 文件,再 plan 确认没有 diff
# 方式三:refresh 更新 state,忽略 drift(危险:放弃了代码即真相的原则)
terraform refresh # 把 state 更新成云上的现实,不改代码
# 只在特殊情况下使用,且要记录原因原则:drift 的存在本身就是一个警告信号——说明有人绕过了 IaC 直接修改了基础设施。除了技术上修复 drift,更重要的是找到绕过的原因并堵上这个口子。
八、往哪走
Ansible 的定位:VM 配置管理
Terraform 把机器开出来,Ansible 在机器上做配置:装 Docker、配置 nginx、管理系统用户、执行初始化脚本。两者不是竞争关系,是基础设施管理的两个层次。在容器化程度高的现代架构里,Ansible 的使用场景在收缩——因为”在 VM 上配置软件”这件事越来越多地被”用容器交付软件”替代。但裸金属服务器、边缘设备、Legacy 系统的配置管理,Ansible 仍然不可替代。
Pulumi / CDK:用真正的编程语言写 IaC
HCL 是领域特定语言,表达复杂逻辑(循环、条件、动态生成)时很笨拙。Pulumi 和 AWS CDK 允许你用 Python、TypeScript、Go 写基础设施声明——完整的编程语言带来了完整的测试能力、类型系统和 IDE 支持。代价是引入了完整的编程语言复杂度。对于有强烈编程偏好的团队,这条路值得考虑。
GitOps 的延伸
IaC 文件在 Git 里,是 Git 成为”系统运行时真相之源”的前半段——代码和基础设施的声明都在 Git 里,变更都通过 PR 审查,合并触发自动 apply。下一篇会探讨 GitOps 如何把这个模式推向极致:不只是 apply 由 Git 触发,而是系统持续 watch Git,一旦发现集群状态和 Git 里的声明不一致,自动拉取并收敛。
这条线的终点:IaC 把基础设施从”人脑里的操作历史”变成了”Git 里的版本化意图”。可审计(谁改了什么),可重现(相同代码相同结果),可协作(PR review 基础设施变更)。三个性质加在一起,让基础设施和应用代码拥有了同等的工程纪律——这是 DevOps 文化的物质基础。
关联
- → 04-GitOps-Git成为系统运行时的真相之源:IaC 文件在 Git 里,GitOps 接管同步与收敛逻辑
- → 05-平台工程-把开发者体验当产品来建:IDP 的后台通常调用 IaC 来自动配置资源(self-service 基础设施)
- → 02-Kubernetes-声明意图,控制器维持世界:K8s 集群本身也要用 IaC 管理,K8s 上的资源用 YAML 声明