Terraform 入门实战——Infrastructure as Code,从 AWS 资源开始写起
Terraform 入门实战——Infrastructure as Code,从 AWS 资源开始写起
适读人群:想用代码管理云资源的工程师、DevOps 新手 | 阅读时长:约20分钟 | 核心价值:从零开始写第一个 Terraform 配置,理解 IaC 的核心价值,避免新手常踩的坑
我刚开始做 DevOps 的时候,管理 AWS 资源是通过控制台点按钮。
那时候我们有三个环境:开发、测试、生产。每次新起一个项目,我要在三个环境里分别点:VPC、子网、安全组、EC2、RDS、S3 bucket、IAM 角色……每个环境配置基本一样,但每次都要重新点。
有一次我在测试环境配安全组的时候,把开放端口从 8080 写成了 80(测试和生产要求不同),这个差异直到上线前一天才被发现,紧急修复浪费了两个小时。
更糟糕的是,有一天新同事问我:"测试环境的 RDS 是怎么配置的?有文档吗?"
我翻了半天,找不到文档。真正的配置只存在于 AWS 控制台里,没有任何文本记录。
这就是 Terraform 要解决的问题:用代码来描述基础设施,让基础设施像代码一样可以版本控制、可以 review、可以复现。
Terraform 核心概念
Provider、Resource、Data Source
Terraform 的工作方式:你写 HCL(HashiCorp Configuration Language)代码描述你想要的基础设施状态,Terraform 去调用各个云厂商的 API 把这个状态实现出来。
三个最基础的概念:
Provider:Terraform 和各个云厂商 API 之间的适配层。用 AWS 就用 AWS provider,用阿里云就用阿里云 provider。
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "ap-northeast-1" # 东京区域
}Resource:要创建的基础设施资源,是 Terraform 的核心。
resource "aws_instance" "web_server" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = {
Name = "web-server"
Env = "production"
}
}Data Source:查询已存在的资源信息,不创建新资源。
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
}
# 使用 data source 的结果
resource "aws_instance" "web_server" {
ami = data.aws_ami.amazon_linux.id # 自动获取最新 AMI
instance_type = "t3.micro"
}Terraform 工作流程
terraform init # 下载 provider,初始化工作目录
terraform plan # 预览将要做的变更,不实际执行
terraform apply # 执行变更
terraform destroy # 销毁所有资源terraform plan 的输出会告诉你:哪些资源要新建(+)、哪些要修改(~)、哪些要删除(-)。每次 apply 之前一定要先 plan,仔细看变更内容。
从零开始:创建一个完整的 VPC 网络
我们来实战创建一个生产可用的 VPC 网络结构,包含公有子网和私有子网。
项目目录结构
infrastructure/
├── main.tf # 主配置文件
├── variables.tf # 变量定义
├── outputs.tf # 输出值定义
├── versions.tf # provider 版本约束
└── terraform.tfvars # 变量值(不提交到 git)versions.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.30"
}
}
# 状态文件存储在 S3(团队协作必需)
backend "s3" {
bucket = "my-terraform-state"
key = "production/network/terraform.tfstate"
region = "ap-northeast-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}variables.tf
variable "aws_region" {
description = "AWS 区域"
type = string
default = "ap-northeast-1"
}
variable "environment" {
description = "环境名称"
type = string
validation {
condition = contains(["dev", "staging", "production"], var.environment)
error_message = "环境必须是 dev、staging 或 production 之一。"
}
}
variable "vpc_cidr" {
description = "VPC CIDR 块"
type = string
default = "10.0.0.0/16"
}
variable "public_subnet_cidrs" {
description = "公有子网 CIDR 列表"
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24"]
}
variable "private_subnet_cidrs" {
description = "私有子网 CIDR 列表"
type = list(string)
default = ["10.0.10.0/24", "10.0.11.0/24"]
}main.tf
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Environment = var.environment
ManagedBy = "terraform"
Project = "my-project"
}
}
}
# 获取可用区
data "aws_availability_zones" "available" {
state = "available"
}
# VPC
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.environment}-vpc"
}
}
# 互联网网关
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.environment}-igw"
}
}
# 公有子网
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.environment}-public-subnet-${count.index + 1}"
Type = "public"
}
}
# 私有子网
resource "aws_subnet" "private" {
count = length(var.private_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.environment}-private-subnet-${count.index + 1}"
Type = "private"
}
}
# 公有路由表
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "${var.environment}-public-rt"
}
}
# 公有子网关联路由表
resource "aws_route_table_association" "public" {
count = length(aws_subnet.public)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
# NAT Gateway(私有子网访问互联网需要)
resource "aws_eip" "nat" {
count = 1 # 只在一个 AZ 创建,节省成本
domain = "vpc"
}
resource "aws_nat_gateway" "main" {
count = 1
allocation_id = aws_eip.nat[0].id
subnet_id = aws_subnet.public[0].id # NAT GW 放在公有子网
tags = {
Name = "${var.environment}-nat-gw"
}
depends_on = [aws_internet_gateway.main]
}
# 私有路由表
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[0].id
}
tags = {
Name = "${var.environment}-private-rt"
}
}
resource "aws_route_table_association" "private" {
count = length(aws_subnet.private)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}outputs.tf
output "vpc_id" {
description = "VPC ID"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "公有子网 ID 列表"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "私有子网 ID 列表"
value = aws_subnet.private[*].id
}踩坑实录
踩坑一:没有远程状态导致团队协作灾难
Terraform 的状态文件(terraform.tfstate)记录了当前基础设施的状态,是 Terraform 工作的核心。
默认情况下,状态文件是本地的 terraform.tfstate。如果多人在一个项目上用 Terraform,每个人的状态文件不同步,就会出现非常严重的问题——有人在他的本地以为某个资源不存在,执行 apply 创建了重复的资源;或者有人删除了本地状态文件,执行 apply 的时候 Terraform 以为所有资源都是全新的,开始创建重复资源。
解决方法:状态文件必须存储在远程,团队里所有人都从同一个远程状态读写。常用的是 S3 + DynamoDB:
terraform {
backend "s3" {
bucket = "company-terraform-state"
key = "project/env/terraform.tfstate"
region = "ap-northeast-1"
encrypt = true # 状态文件加密,里面可能有敏感信息
dynamodb_table = "terraform-state-lock" # 分布式锁,防止并发写
}
}DynamoDB 表的配置:
resource "aws_dynamodb_table" "terraform_lock" {
name = "terraform-state-lock"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}踩坑二:terraform destroy 删了不该删的东西
有一次我在清理一个测试环境,执行了 terraform destroy。因为 Terraform 配置里有一个 S3 bucket 是用来存数据的,里面有一些历史数据。terraform destroy 默认会删除所有 Terraform 管理的资源,包括那个 S3 bucket 和里面的数据。
防护措施:对于不能随便删的资源,加 lifecycle 保护:
resource "aws_s3_bucket" "data" {
bucket = "company-important-data"
lifecycle {
prevent_destroy = true # 尝试 destroy 这个资源时会报错
}
}另外,养成习惯:在执行 terraform destroy 之前,先 terraform plan -destroy 预览会删除哪些资源,确认没问题再执行。
踩坑三:不用变量直接硬编码 AMI ID 导致跨区域失效
很多教程里的示例代码直接硬编码了 AMI ID,比如 ami-0c55b159cbfafe1f0。
AMI ID 是区域相关的,us-east-1 的 AMI ID 在 ap-northeast-1 里是不存在的。如果你照抄了这个配置,换了一个区域就会报错。
正确做法:用 data source 动态查询 AMI:
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
}
resource "aws_instance" "app" {
ami = data.aws_ami.amazon_linux.id # 自动适应当前区域
}深度解析:Infrastructure as Code 的真正价值
很多人第一次接触 Terraform,会觉得:"这不就是把我在控制台点的操作用代码写出来吗?"
是的,表面上是这样。但 IaC 的真正价值远不止自动化这一点。
价值一:基础设施变更的可审查性
当一个工程师提交了一个 Terraform 的 PR,改了安全组规则,团队里的其他人可以在 PR 里看到 terraform plan 的输出,清楚地知道这次变更会新增哪些规则、删除哪些规则。这和代码 PR 一样,可以 review,可以讨论,可以拒绝。
而在控制台点按钮时,其他人完全不知道发生了什么,也没有任何审计记录(除非你专门去查 CloudTrail)。
价值二:环境一致性的保障
用 Terraform 管理的基础设施,各个环境之间的配置差异是显式的、可见的、可控的。你可以看到生产环境和测试环境的 Terraform 代码,清楚地知道两者的差异(比如生产用了三副本,测试用了单副本;生产开了 Multi-AZ,测试没有)。
控制台管理的基础设施,这些差异往往是隐性的、不可见的,某天测试出了问题,发现是因为测试环境少配了一个安全组规则,而没有人知道这个差异是什么时候、由谁产生的。
我遇到过这样一种情况:一个团队的测试环境和生产环境看起来配置一样,但偶尔测试没问题的功能到生产就出 bug。排查了很久,最后发现是两个环境的 RDS 参数组有差异,某个参数在生产被手动改过(不知道什么时候、不知道是谁),但没有更新到测试环境。如果用 Terraform 管理,这种差异不可能悄悄产生。
价值三:灾难恢复的速度
极端情况下,如果某个云区域(Region)发生严重故障,需要在另一个区域快速重建整套基础设施。
有 IaC:修改几行 region 配置,terraform apply,几分钟到几十分钟全部重建完毕。
没有 IaC:凭记忆在新区域重建,一个资源一个资源地配置,遗漏和错误在所难免,几小时到几天不等,而且很可能有遗漏。
这个场景虽然不常发生,但一旦发生,IaC 的价值无可替代。我认识一位运维工程师,他在某次机房故障中,用 Terraform 在 20 分钟内在另一个区域重建了整套环境(70+ 个云资源),而同行的其他公司花了一整天还没搭好,业务损失的差距是巨大的。Terraform 就是这类场景下的"保险",平时感受不到,关键时刻价值巨大。
深度解析:Terraform State 是什么,为什么这么重要
Terraform State(状态文件)是很多 Terraform 新手最感到困惑的概念,有必要深入解释一下。
当你用 Terraform 创建了一台 EC2 实例,这个实例的 ID(比如 i-0abc123def456789)被记录在状态文件里。下次你修改这台实例的配置(比如改 instance type),Terraform 通过状态文件知道"要修改的是 i-0abc123def456789 这台实例",而不是创建一台新的。
没有状态文件,Terraform 就不知道你的配置和实际的云资源之间的对应关系,它无法做"更新现有资源"这件事,只能"创建新资源"。
这就是为什么状态文件很重要,也解释了为什么状态文件必须在团队里共享(放在远程后端)——如果每个人有自己的本地状态文件,每个人都会"以为"某些资源不存在而重新创建,造成资源重复。
状态文件还有一个容易被忽视的问题:它可能包含敏感信息。Terraform 在状态文件里记录资源的所有属性,包括数据库密码、API 密钥等。所以状态文件的访问权限要严格控制——S3 bucket 要开启加密,只有授权的人和 CI/CD 系统可以访问,不要把状态文件放在公开的版本库里。
团队协作时,状态文件的并发写入也是一个问题:两个人同时跑 terraform apply,可能导致状态文件损坏。这就是为什么要配置 DynamoDB 做状态锁——任何 apply 操作开始前先在 DynamoDB 里锁定,结束后释放,确保同一时刻只有一个 apply 在运行。
第一次执行
准备好配置之后,执行流程如下:
# 1. 初始化(下载 provider 插件)
terraform init
# 2. 格式化代码
terraform fmt
# 3. 验证语法
terraform validate
# 4. 预览变更
terraform plan -var="environment=staging"
# 5. 执行(需要输入 yes 确认)
terraform apply -var="environment=staging"
# 或者用变量文件
terraform apply -var-file="staging.tfvars"深度解析:Terraform 的学习路径和常见误区
Terraform 入门不难,但真正用好需要避开几个常见的误区。
误区一:把 Terraform 当脚本用
很多人刚开始用 Terraform,会写出"过程式"的配置:先创建 VPC,再创建子网,再创建安全组……用各种 depends_on 来控制创建顺序。
这是对 Terraform 的误用。Terraform 是声明式的——你只需要描述"期望的状态是什么",不需要描述"怎么达到这个状态"。Terraform 会自动分析资源之间的依赖关系(通过引用关系,比如子网 ID 引用了 VPC ID,Terraform 就知道先创建 VPC),只有在依赖关系不能通过引用表达时,才需要显式 depends_on。
把 Terraform 当声明式工具用,代码会更简洁、更可读、更容易维护。
误区二:资源太细,模块太浅
有些团队把每一个 AWS 资源都写成独立的 .tf 文件,没有任何模块化,整个基础设施是一大堆散落的资源定义,关联关系只能靠人脑理解。
另一个极端是模块封装过度,每隔几行就封装一个模块,模块的 variables.tf 把底层资源的每个参数都透传上来,模块的"抽象"完全是空的,没有任何封装价值。
好的模块设计是:把一个有意义的业务单元封装成模块(比如"完整的应用环境"包含 VPC、子网、EKS、RDS),暴露有意义的接口("这个环境部署在哪个区域、需要多大的数据库"),隐藏内部细节(子网 CIDR 怎么规划、安全组规则怎么配)。
模块设计的一个实用标准:如果你能写出一句话来描述这个模块的用途,那这个模块的抽象是合理的。比如"创建一套完整的 EKS 应用运行环境"是一句清晰的描述,说明这个模块有明确的业务语义。如果你写不出这句话,或者只能说"这个模块管理一堆 AWS 资源",那可能需要重新思考模块的边界。
优秀的模块还要有 README.md(描述用途、接口、示例)和版本控制(放在独立仓库,用 Git Tag 打版本,消费者引用特定版本而不是直接引用 main 分支)。这样模块本身就成了一个内部开源产品,有文档、有版本,可以被多个团队安全地使用。
误区三:忽视 Terraform 版本管理
Terraform 的版本更新频率较高,Provider 的版本也在持续演进。如果团队里不同人、不同 CI 环境使用不同版本的 Terraform,很容易出现"在我本地 plan 能通过,CI 里报错"的问题。
解决方案是用 .terraform-version 文件(配合 tfenv 工具)锁定 Terraform 的版本,在 versions.tf 里锁定 Provider 的版本范围。确保团队里所有人和 CI 用的是完全相同的版本。这是 Terraform 工程化的基础,不应该被忽视。
版本锁定之外,还要用 terraform.lock.hcl 文件(Terraform 0.14+ 自动生成)来锁定 Provider 的精确版本。这个文件应该提交到 git,确保每个人和 CI 下载的是完全相同的 Provider 版本。这和 package-lock.json、poetry.lock 的作用是一样的——不是限制版本升级,而是保证在升级之前所有环境的一致性。
Terraform 工程化的另一个重要实践是对 terraform plan 的产物做持久化,保存 plan 文件(-out=tfplan),然后 terraform apply 使用这个保存的 plan。这确保 apply 执行的正是 review 和审批时看到的那个变更,中间没有任何漂移。
总结
Terraform 的核心价值不只是"自动化创建资源",而是让基础设施变得:
- 可版本控制:Git 历史告诉你基础设施是什么时候、由谁、做了什么变更
- 可审查:
terraform plan的输出可以作为 infrastructure change request 供团队 review - 可复现:同样的代码可以在不同环境创建完全一致的基础设施
从控制台点按钮到 Infrastructure as Code,这是 DevOps 成熟度的一个重要跨越。下一篇我会讲 Terraform 的模块化、状态管理和团队协作的进阶方案。
开始用 Terraform 时最难的往往不是技术,而是"把现有的手动管理的资源迁移进 Terraform"。不要急于把所有资源一次性迁移进来,可以从新项目开始全部用 Terraform,老资源逐步迁移(用 terraform import 导入现有资源)。重要的是迈出第一步,建立 IaC 的文化,剩下的随着时间积累会越来越完善。基础设施管理的最终目标是:任何人都不需要、也无法通过控制台手动修改生产环境,所有变更都通过 Terraform 的 PR 走审批流程。
