← 返回博客
2026-07-23 13:00:01

基础设施即代码:Terraform 模块化实践——从混乱到有序的架构重构

Terraform 模块化实战:把一个 800 行的 main.tf 拆清楚

上周接手了一个项目。打开 infrastructure/ 目录,只有一个 main.tf,832 行。VPC、子网、NAT 网关、EKS 集群、RDS 实例、安全组、IAM 角色,全塞在一个文件里。改一个变量的默认值,往下翻八百行确认有没有别的地方被覆盖。每个环境(dev / staging / prod)各有一份这样的文件,三份之间靠复制粘贴同步,diff 出来几千行。

这篇文章用一个具体例子,从头走一遍拆解过程。

你要拆的是什么

假设你有一个三层的 AWS 应用:

目前三个环境各有一个 main.tf,内容高度重复,只是 CIDR、实例规格、副本数不同。

目标:抽出一个 modules/ 目录,每个环境只保留几十行调用代码,通过 terraform.tfvars 区分环境。

第一步:拆出 VPC 模块

从最大的开始。VPC 相关的资源通常占整个文件的三分之一。先梳理清楚这个模块需要什么输入、输出什么。

输入(variables.tf):环境的网络规划不同——dev 可能只需要两个 AZ、不需要 NAT 网关(省钱),prod 需要三个 AZ、完整的 NAT 网关。

# modules/vpc/variables.tf
variable "vpc_cidr" {
  description = "VPC 的 CIDR 块"
  type        = string
}

variable "azs" {
  description = "可用区列表"
  type        = list(string)
}

variable "private_subnets" {
  description = "私有子网 CIDR 列表,每个 AZ 一个"
  type        = list(string)
}

variable "public_subnets" {
  description = "公有子网 CIDR 列表,每个 AZ 一个"
  type        = list(string)
}

variable "enable_nat_gateway" {
  description = "是否创建 NAT 网关。dev 环境可设为 false 以节省成本"
  type        = bool
  default     = true
}

注意 enable_nat_gateway 的默认值设为 true。这是最小惊讶原则:不做特殊配置时走安全的路(有 NAT),明确知道自己在省钱的 dev 环境再主动关掉。

输出(outputs.tf):只暴露其他模块真正需要的东西。

# modules/vpc/outputs.tf
output "vpc_id" {
  value = aws_vpc.this.id
}

output "private_subnet_ids" {
  value = aws_subnet.private[*].id
}

output "public_subnet_ids" {
  value = aws_subnet.public[*].id
}

一个常见错误是输出太多东西——路由表 ID、NAT 网关的弹性 IP、Internet Gateway ID 全暴露出去。问自己:有哪个模块会在意 VPC 的 NAT 网关 EIP 是什么?如果没有,就不输出。输出越少,模块的调用方越清晰,内部修改的影响范围也越小。

VPC 模块的 main.tf 这里不贴完整版,核心逻辑和原来单文件时一样,只是把硬编码的 CIDR、AZ 列表换成 var.* 引用。关键变化是:原来 cidr_block = "10.0.0.0/16" 变成 cidr_block = var.vpc_cidr

第二步:拆出 RDS 模块

数据库模块的输入比 VPC 更"业务化"——它不关心网络拓扑,只关心数据库本身的配置。

# modules/rds/variables.tf
variable "db_name" {
  description = "数据库名称"
  type        = string
  validation {
    condition     = can(regex("^[a-z][a-z0-9-]*$", var.db_name))
    error_message = "数据库名称必须以小写字母开头,仅含小写字母、数字和连字符。"
  }
}

variable "engine_version" {
  description = "PostgreSQL 引擎版本"
  type        = string
  default     = "15.4"
}

variable "allocated_storage" {
  description = "分配的存储容量(GB)"
  type        = number
  default     = 100
}

variable "instance_class" {
  description = "实例规格"
  type        = string
  default     = "db.t4g.medium"
}

variable "vpc_id" {
  description = "RDS 实例所在的 VPC ID"
  type        = string
}

variable "subnet_ids" {
  description = "RDS 实例所在的子网 ID 列表(至少两个 AZ)"
  type        = list(string)
}

这里有两点值得展开。

第一,validation 块值得写。 没有它,有人把 db_name 写成 "My-DB",Terraform 会在 apply 时被 AWS API 拒绝,报错信息是 AWS 原生的,排查起来需要从 Terraform 日志翻到 AWS 错误码。花 30 秒写个 validation,出问题时 Terraform 在 plan 阶段就直接告诉你"数据库名称必须以小写字母开头",省掉的是团队每个人未来各自踩坑的时间。

第二,vpc_idsubnet_ids 是从外部注入的。 RDS 模块不应该自己去 data.aws_vpc 查 VPC——这会让模块和环境的耦合变深,换个 AWS 账号或 region 就坏了。让调用方传进来,模块只关心数据库本身。

第三步:在环境目录里把它们串起来

模块写好后,dev 环境的调用长这样:

# environments/dev/main.tf
module "vpc" {
  source = "../../modules/vpc"

  vpc_cidr         = "10.0.0.0/16"
  azs              = ["ap-southeast-1a", "ap-southeast-1b"]
  private_subnets  = ["10.0.10.0/24", "10.0.20.0/24"]
  public_subnets   = ["10.0.1.0/24", "10.0.2.0/24"]
  enable_nat_gateway = false
}

module "rds" {
  source = "../../modules/rds"

  db_name           = "app-dev"
  engine_version    = "15.4"
  allocated_storage = 20
  instance_class    = "db.t4g.small"
  vpc_id            = module.vpc.vpc_id
  subnet_ids        = module.vpc.private_subnet_ids
}

dev 的配置明显比 prod 低:存储 20G 而不是 100G,db.t4g.small 而不是 db.t4g.medium,NAT 网关关掉。但这些差异完全体现在模块调用参数里,不需要修改模块内部逻辑。

环境变量放到 terraform.tfvars

# environments/dev/terraform.tfvars
environment = "dev"
region      = "ap-southeast-1"

prod 的 terraform.tfvars 只是同样的变量赋不同的值。从 dev 切到 prod,改一行 tfvars 就够了,不再是"复制整个目录然后全局查找替换"。

第四步:状态隔离

多环境的 Terraform state 必须物理隔离——dev 的 apply 永远不能碰到 prod 的状态文件。

# environments/dev/terraform.tf
terraform {
  backend "s3" {
    bucket         = "company-terraform-state"
    key            = "environments/dev/terraform.tfstate"
    region         = "ap-southeast-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
  }
}

prod 的配置只改 key 里的 devprod。同一个 S3 bucket,不同路径,物理隔离。加上 DynamoDB 做状态锁——多人协作时,一个人正在 apply,另一个人的 apply 会直接报错而不是同时写状态导致损坏。

第五步:模块版本化(什么时候需要)

如果模块只在当前项目里用——这是大多数情况——上面的 source = "../../modules/vpc"(本地相对路径)就够了。简单、直接、改了立刻生效。

如果你有多个项目共享同一套基础设施模块(比如公司级的基础设施团队维护一套标准模块,多个业务团队引用),才需要版本化:

module "vpc" {
  source = "git::https://github.com/company/tf-modules.git//vpc?ref=v1.2.0"
  # ...
}

不要过早版本化。本地路径引用在 80% 的场景下是最好的选择——零运维开销,CI 里也不会有"忘记打 tag 导致引用不到新版本"的问题。等到真正有第二个项目要复用同一个模块时,再提取到独立仓库加 tag。

收尾:改之前 vs 改之后

维度改之前改之后
单文件行数832 行 main.tf环境目录每个约 40 行
新增环境复制整个目录,全局查找替换新建目录,写一份 tfvars
改一个模块三个环境各改一遍modules/ 下一次,三个环境继承
新人上手从 832 行里找入口先看 environments/dev/main.tf,40 行读完知道整体结构

拆模块不是炫技。目的是让"改一个东西"的成本局限在一个文件里,让"加一个环境"不需要复制粘贴,让接手的人能从 40 行开始而不是从 800 行开始。

本文关键词:Terraform、模块化、基础设施即代码、IaC、AWS