4. 第一个可复用模块

在 Terraform 中,模块(Module)本质上就是一组被封装在独立目录中的 .tf 文件集合。它把“创建一套网络”这件事从散落在各处的资源声明,收敛成一个有明确输入、明确输出、可被反复调用的黑盒。本节我们以最常见的云网络场景为例,从零编写一个标准化网络模块,覆盖 VPC、子网、路由表、网关与关联关系,并同步完成变量验证与文档生成。

一、模块目录与职责划分

一个可维护的模块,第一步不是写资源,而是划分文件职责。推荐的最小结构如下:

modules/network/
├── main.tf        # 资源定义:VPC、子网、路由表、网关、关联
├── variables.tf   # 输入变量与 validation 校验
├── outputs.tf     # 对外输出:vpc_id、subnet_ids、route_table_id
├── versions.tf    # required_providers 与 required_version
└── README.md      # 由 terraform-docs 自动生成

这种划分让调用者只需阅读 variables.tf 和 outputs.tf 就能理解模块契约,而不必翻遍资源实现。模块的“接口”就是变量与输出,其余都是内部实现细节。

二、输入变量设计:让模块可配置而非可猜测

网络模块的输入应围绕“调用者真正需要决定的东西”来设计。以下是一组经过取舍的变量定义,包含类型、默认值与校验规则:

variable "name" {
  description = "网络模块统一命名前缀"
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{2,20}$", var.name))
    error_message = "name 必须以小写字母开头,仅含小写字母、数字和连字符,长度 3-21。"
  }
}

variable "vpc_cidr" {
  description = "VPC 的 IPv4 网段"
  type        = string
  default     = "10.0.0.0/16"

  validation {
    condition     = can(cidrnetmask(var.vpc_cidr))
    error_message = "vpc_cidr 必须是合法的 CIDR,例如 10.0.0.0/16。"
  }
}

variable "public_subnets" {
  description = "公有子网 CIDR 列表,需落在 vpc_cidr 内"
  type        = list(string)
  default     = ["10.0.1.0/24", "10.0.2.0/24"]

  validation {
    condition     = length(var.public_subnets) >= 1
    error_message = "至少需要一个公有子网。"
  }
}

variable "private_subnets" {
  description = "私有子网 CIDR 列表"
  type        = list(string)
  default     = ["10.0.11.0/24", "10.0.12.0/24"]
}

variable "azs" {
  description = "可用区列表,长度需与子网数量匹配"
  type        = list(string)
  default     = ["cn-hangzhou-b", "cn-hangzhou-c"]
}

variable "enable_nat_gateway" {
  description = "是否为私有子网创建 NAT 网关"
  type        = bool
  default     = true
}

variable "tags" {
  description = "附加到所有资源的标签"
  type        = map(string)
  default     = {}
}

这里有两个关键设计点。第一,validation 块把错误提前到 terraform plan 阶段,而不是等到云 API 报错。例如 cidrnetmask() 能验证 CIDR 合法性,regex() 能约束命名规范。第二,azs 与子网列表通过 length 校验保持数量一致,避免“两个子网只给了一个可用区”这类隐蔽错误。

变量校验只应拦截“结构性错误”,不要过度限制业务取值。例如不要硬编码只允许 10.0.0.0/16,否则模块复用性会下降。

三、资源实现:VPC、子网、路由与关联

资源部分的核心是建立正确的引用关系。下面以阿里云 provider 为例,展示关键资源与关联逻辑(其他云厂商结构类似,仅资源名不同):

resource "alicloud_vpc" "this" {
  vpc_name   = var.name
  cidr_block = var.vpc_cidr
  tags       = var.tags
}

resource "alicloud_vswitch" "public" {
  count        = length(var.public_subnets)
  vpc_id       = alicloud_vpc.this.id
  cidr_block   = var.public_subnets[count.index]
  zone_id      = var.azs[count.index % length(var.azs)]
  vswitch_name = "${var.name}-public-${count.index + 1}"
  tags         = var.tags
}

resource "alicloud_vswitch" "private" {
  count        = length(var.private_subnets)
  vpc_id       = alicloud_vpc.this.id
  cidr_block   = var.private_subnets[count.index]
  zone_id      = var.azs[count.index % length(var.azs)]
  vswitch_name = "${var.name}-private-${count.index + 1}"
  tags         = var.tags
}

resource "alicloud_nat_gateway" "this" {
  count         = var.enable_nat_gateway ? 1 : 0
  vpc_id        = alicloud_vpc.this.id
  nat_gateway_name = "${var.name}-nat"
  payment_type  = "PayAsYouGo"
  vswitch_id    = alicloud_vswitch.public[0].id
  tags          = var.tags
}

注意 count.index % length(var.azs) 这一写法:当子网数量多于可用区数量时,它会循环复用可用区,保证索引不越界。这是模块中常见的健壮性处理。NAT 网关使用条件表达式 var.enable_nat_gateway ? 1 : 0,让调用者可以按需关闭,避免为测试环境产生额外费用。

四、输出设计:让模块可被组合

输出是模块对外的“返回值”。网络模块最常被下游引用的就是 VPC ID、子网 ID 列表和路由表 ID:

output "vpc_id" {
  description = "VPC 的 ID"
  value       = alicloud_vpc.this.id
}

output "public_subnet_ids" {
  description = "公有子网 ID 列表"
  value       = alicloud_vswitch.public[*].id
}

output "private_subnet_ids" {
  description = "私有子网 ID 列表"
  value       = alicloud_vswitch.private[*].id
}

output "nat_gateway_id" {
  description = "NAT 网关 ID,未启用时为空"
  value       = try(alicloud_nat_gateway.this[0].id, null)
}

使用 [*] 展开列表,让调用者可以直接用 module.network.public_subnet_ids[0] 取用。对于可能不存在的资源,用 try() 包裹并返回 null,避免在未启用 NAT 时输出报错。

五、调用与验证

在根模块中调用该网络模块:

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

  name             = "prod"
  vpc_cidr         = "10.0.0.0/16"
  public_subnets   = ["10.0.1.0/24", "10.0.2.0/24"]
  private_subnets  = ["10.0.11.0/24", "10.0.12.0/24"]
  azs              = ["cn-hangzhou-b", "cn-hangzhou-c"]
  enable_nat_gateway = true
  tags = {
    env  = "prod"
    team = "platform"
  }
}

执行 terraform init 后运行 terraform plan,重点观察三件事:一是变量校验是否通过,若 name 写成大写或 vpc_cidr 写成非法值,plan 会直接报错并给出 error_message;二是资源数量是否符合预期,例如 2 个公有子网 + 2 个私有子网 + 1 个 NAT 网关;三是输出值是否可被下游引用。若 plan 通过,再执行 terraform apply,随后用 terraform output 查看 vpc_id 与子网 ID 列表。

六、文档生成:让模块可被他人使用

模块写完后,README 不应手写,而应由工具生成。安装 terraform-docs 后,在模块目录执行:

terraform-docs markdown table --output-file README.md --output-mode inject .

它会自动扫描 variables.tf 和 outputs.tf,生成包含输入变量表、输出表、provider 要求的 Markdown 文档。配合 --output-mode inject,可以在 README 中保留手写说明,只替换自动生成的部分。这样每次修改变量后重新执行命令,文档就与代码保持同步。

网络模块调用关系与数据流 输入变量 → 资源创建 → 输出引用 → 下游模块 根模块调用 module "network" name / vpc_cidr public_subnets azs / tags variables.tf type / default validation 校验 regex / cidrnetmask length 一致性 main.tf 资源创建 VPC cidr_block 公有子网 ×2 count + azs 私有子网 ×2 count + azs NAT 网关 条件创建 outputs.tf 输出 vpc_id subnet_ids nat_gateway_id(try 容错) 下游模块 计算 / 数据库 安全组 / 负载均衡 引用 subnet_ids terraform-docs markdown table --output-file README.md --output-mode inject . 自动生成输入变量表、输出表与 provider 要求,保持文档与代码同步

七、常见问题与判断依据

在编写和调用过程中,有几类问题出现频率最高。第一类是 CIDR 重叠:如果 public_subnets 与 private_subnets 有交集,云 API 会拒绝创建,plan 阶段不一定能发现,需要在变量校验中增加更复杂的重叠检测,或至少在文档中明确要求。第二类是可用区与子网数量不匹配:当 azs 长度小于子网数量时,使用取模可以避免越界,但语义上应提醒调用者补充可用区。第三类是输出为空:若 enable_nat_gateway = false,nat_gateway_id 返回 null,下游模块必须用 try() 或条件判断处理,不能直接引用。

判断模块是否“标准化”,可以看三个信号:调用者是否只需阅读 variables 和 outputs 就能使用;错误是否在 plan 阶段就被拦截;文档是否由工具生成而非手工维护。做到这三点,这个网络模块就具备了被团队复用的基础条件。