← 返回博客
2026-11-04 08:00:01

基础设施即代码:Terraform 模块化实践

基础设施即代码:Terraform 模块化实践

团队把一套 K8s 集群从测试环境复制到生产环境时,复制粘贴了 300 行 Terraform 配置。三个月后,两个环境的安全组规则不一致,生产环境多开了一个 0.0.0.0/0 的入站端口,安全审计直接卡住。问题不在 Terraform 本身,而在配置的组织方式:所有资源平铺在一个 main.tf 里,没有模块边界,没有输入约束,改一处不知道会影响哪里。

这篇讲怎么把 Terraform 从“一堆平铺的资源”整理成可复用、可测试的模块。场景贯穿到底:把一套 VPC + EKS + RDS 的配置模块化,让测试环境和生产环境共用同一份代码,只靠参数区分。

先看平铺配置长什么样

模块化之前,目录通常是这样的:

infra/
├── main.tf        # 300 行,VPC、子网、EKS、RDS 全在里面
├── variables.tf
└── prod.tfvars

main.tf 里 VPC、EKS、RDS 混在一起,资源之间靠 resource.aws_vpc.main.id 这种硬引用连接。想给测试环境也来一套,只能复制目录再改。复制出去的两份代码从此各走各的路,漂移是迟早的事。

模块化要做的事很简单:按“一个模块 = 一组一起创建、一起销毁的资源”切开。VPC 一个模块,EKS 一个模块,RDS 一个模块。每个模块对外只暴露必要的输入和输出,内部实现藏起来。

模块怎么切、接口怎么定

以 VPC 模块为例。先建目录:

modules/
└── vpc/
    ├── main.tf
    ├── variables.tf
    └── outputs.tf

variables.tf 定义这个模块接受什么。关键点是给每个变量加类型和校验,别让调用方传个字符串进来当 CIDR 用:

variable "name" {
  type = string
}

variable "cidr_block" {
  type = string
  validation {
    condition     = can(cidrhost(var.cidr_block, 0))
    error_message = "cidr_block 必须是合法 CIDR,例如 10.0.0.0/16。"
  }
}

variable "az_count" {
  type    = number
  default = 2
  validation {
    condition     = var.az_count >= 2 && var.az_count <= 3
    error_message = "az_count 只允许 2 或 3。"
  }
}

main.tf 里用 count 按 az_count 创建子网,私有子网和公有子网分开:

resource "aws_vpc" "this" {
  cidr_block           = var.cidr_block
  enable_dns_hostnames = true
  tags                 = { Name = var.name }
}

resource "aws_subnet" "private" {
  count             = var.az_count
  vpc_id            = aws_vpc.this.id
  cidr_block        = cidrsubnet(var.cidr_block, 8, count.index)
  availability_zone = data.aws_availability_zones.available.names[count.index]
  tags              = { Name = "${var.name}-private-${count.index}" }
}

outputs.tf 只暴露下游真正需要的东西,不要把整个 VPC 对象甩出去:

output "vpc_id" {
  value = aws_vpc.this.id
}

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

关键点在两个地方。一是 validation 块,它在 plan 阶段就拦住非法输入,比等到 apply 时 AWS 报错早得多。二是 outputs 的粒度,只输出 ID 列表这类稳定标识,下游模块拿不到 VPC 内部细节,耦合就低了。容易踩的坑是 cidrsubnet(var.cidr_block, 8, count.index) 里的 8:它把 /16 切成 /24,如果调用方传的是 /24,这里会算出非法网段。所以 cidr_block 的校验最好再收紧,或者把子网位数也做成变量。

根目录怎么调用模块

模块写好后,环境目录只负责“组装”和“传参”:

module "vpc" {
  source     = "../../modules/vpc"
  name       = "shop-${var.env}"
  cidr_block = var.env == "prod" ? "10.0.0.0/16" : "10.1.0.0/16"
  az_count   = var.env == "prod" ? 3 : 2
}

module "eks" {
  source             = "../../modules/eks"
  cluster_name       = "shop-${var.env}"
  vpc_id             = module.vpc.vpc_id
  private_subnet_ids = module.vpc.private_subnet_ids
  node_count         = var.env == "prod" ? 6 : 2
}

source 用相对路径,模块和环境代码在同一个仓库里,改模块和改调用方在同一个 PR 里评审,版本对齐不会出问题。

这里的关键是 env 变量只出现在根目录,不下沉到模块里。模块不知道自己是 prod 还是 test,它只认 az_count、node_count 这些具体参数。这样模块才能被任意环境复用。常见坑是把 var.env 传进模块内部做 if env == "prod" 判断,模块立刻变成“知道太多”的配置,复用性就没了。

状态文件和 provider 怎么隔离

模块化之后,一个容易忽略的问题是 state。所有模块共用一个 state 文件,plan 一次要刷新几百个资源,慢且互相牵连。按模块拆 state:

terraform {
  backend "s3" {
    bucket         = "shop-tfstate"
    key            = "prod/vpc/terraform.tfstate"
    region         = "ap-southeast-1"
    dynamodb_table = "shop-tf-lock"
  }
}

每个模块目录一个 key,比如 prod/vpc/、prod/eks/。dynamodb_table 提供锁,防止两个人同时 apply。坑在于拆了 state 之后,模块间的数据传递不能再靠 terraform_remote_state 硬读别人的 state,那样又把耦合加回来了。更干净的做法是把共享输出写进 SSM Parameter Store 或直接用根目录的 module.vpc.vpc_id 引用——只要 VPC 和 EKS 在同一个根目录下组装,state 拆分只影响 apply 的粒度,不影响引用关系。

改完怎么验证

模块化最大的收益是可测试。用 terraform plan 做冒烟检查,在 CI 里跑:

terraform init -backend=false
terraform validate
terraform plan -var-file=envs/test.tfvars -out=plan.out

-backend=false 让 init 不连远程 state,CI 里跑得快。validate 检查语法和变量类型。真正值钱的是对模块单独写测试,用 Terratest 起一个临时目录调用模块:

vpcOpts := &terraform.Options{
    TerraformDir: "../modules/vpc",
    Vars: map[string]interface{}{
        "name":       "test-vpc",
        "cidr_block": "10.99.0.0/16",
        "az_count":   2,
    },
}
defer terraform.Destroy(t, vpcOpts)
terraform.InitAndApply(t, vpcOpts)
vpcID := terraform.Output(t, vpcOpts, "vpc_id")
assert.NotEmpty(t, vpcID)

这段测试的价值在于:改了模块内部的子网逻辑,测试会在真实 AWS 上创建再销毁一套资源,output 拿不到就失败。比人工 review 靠谱。注意 defer terraform.Destroy 一定要写,否则测试账号里会堆一堆孤儿 VPC。跑测试的账号单独开一个,别用生产凭证。

收尾

模块化的核心就三件事:模块只认具体参数不认环境名,输入加类型和校验,输出只给稳定标识。下一步建议先挑一个最常被复制的资源组(通常是 VPC 或安全组)抽成模块,在两个环境里各跑一次 plan,确认输出一致再推广到 EKS 和 RDS。改动小、验证快,比一次性重构整个 main.tf 稳得多。

本文关键词:Terraform、基础设施即代码、模块化、Terratest、状态管理