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 plan

Plan 做三件事:读取你的 .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 apply

Apply 执行 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 文件。devprod 的代码几乎相同,只是变量值不同(实例规格、副本数、域名)。

变量与输出

# 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 exists

State 不是缓存,是 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-000000000000

force-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 found

AWS 凭证未配置或已过期。检查 ~/.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 文化的物质基础。

关联