2. Terraform 模块基础回顾:模块结构、输入输出变量、本地值与资源定义的最佳实践

在深入模块测试策略之前,必须统一团队对 Terraform 模块基础结构的认知。一个标准化的模块骨架是测试自动化的前提——如果模块结构混乱、变量命名随意、资源定义缺乏约束,测试将无法有效覆盖关键路径。本节从模块文件布局、输入输出契约、本地值使用规范以及资源定义原则四个维度,梳理服务目录中模块应遵循的最佳实践。

2.1 标准模块文件结构

Terraform 模块本质上是一个包含 .tf 或 .tf.json 文件的目录。服务目录中的模块应遵循以下文件组织约定,确保可读性与可测试性:

文件/目录 用途 是否必须
main.tf 核心资源定义,包含所有被管理的云资源 是
variables.tf 输入变量声明,定义模块的对外接口 是
outputs.tf 输出值声明,定义模块的返回契约 是
locals.tf 本地值定义,用于表达式复用与逻辑简化 推荐
versions.tf Terraform 与 Provider 版本约束 推荐
README.md 模块说明、用法示例、注意事项 推荐
examples/ 完整可运行的用法示例目录 推荐
tests/ 测试用例目录(后续章节重点) 推荐
实践建议: 将 variables.tf 和 outputs.tf 视为模块的“API 契约”。任何对这两个文件的修改都应触发版本号变更和回归测试。服务目录的 CI 流水线应自动检测这两个文件的变更。

2.2 输入变量设计原则

输入变量是模块与调用者之间的主要接口。设计不当的变量会导致模块难以使用、测试困难。以下是服务目录模块中变量定义的最佳实践:

  • 类型约束优先:始终使用 type 参数,避免 any。优先使用 object() 对复杂参数进行结构化约束。
  • 提供合理默认值:对于非必填参数,默认值应体现最常用的生产配置,而非空值。
  • 使用 nullable 控制空值行为:Terraform 1.x 中显式设置 nullable = false 可避免调用者意外传入 null 导致错误。
  • 变量描述必须包含业务含义:描述中应说明该变量对基础设施的影响,而非仅重复变量名。
  • 敏感变量标记:对密码、密钥等敏感信息设置 sensitive = true。
# variables.tf 示例:符合服务目录标准的变量定义
variable "environment" {
  description = "部署环境名称,用于资源命名和标签。可选值:dev, staging, prod"
  type        = string
  default     = "dev"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "环境名称必须为 dev、staging 或 prod。"
  }
}

variable "vpc_config" {
  description = "VPC 网络配置,包含 CIDR 和子网信息"
  type = object({
    cidr_block       = string
    public_subnets   = list(string)
    private_subnets  = list(string)
    enable_nat_gateway = optional(bool, true)
  })
  default = {
    cidr_block       = "10.0.0.0/16"
    public_subnets   = ["10.0.1.0/24", "10.0.2.0/24"]
    private_subnets  = ["10.0.10.0/24", "10.0.20.0/24"]
    enable_nat_gateway = true
  }
  nullable = false
}

2.3 输出值契约规范

输出值是模块对调用者的承诺。服务目录中的每个模块应遵循以下输出规范:

  • 最小必要原则:只输出调用者确实需要的属性,避免输出整个资源对象。
  • 统一命名风格:使用 资源类型_属性名 格式,例如 vpc_id、subnet_ids。
  • 输出描述必须说明用途:描述中应包含该输出值在调用方如何被使用。
  • 敏感输出标记:对可能泄露敏感信息的输出设置 sensitive = true。
# outputs.tf 示例:清晰、可测试的输出契约
output "vpc_id" {
  description = "创建的 VPC ID,用于关联安全组和子网"
  value       = aws_vpc.main.id
}

output "public_subnet_ids" {
  description = "公有子网 ID 列表,用于负载均衡器和 NAT 网关"
  value       = aws_subnet.public[*].id
}

output "database_endpoint" {
  description = "RDS 数据库连接端点,格式:host:port"
  value       = aws_db_instance.main.endpoint
  sensitive   = true
}

2.4 本地值(locals)使用规范

本地值用于在模块内部简化表达式、避免重复计算。服务目录模块中应遵循以下规范:

场景 推荐做法 避免做法
资源命名 使用 local.name_prefix 统一拼接 在每个资源中重复写 "${var.project}-${var.environment}"
标签合并 使用 local.common_tags 定义公共标签 在每个资源中逐个写标签键值对
条件表达式 将复杂条件提取为 local.enable_feature 在资源参数中直接写三元表达式嵌套
数据转换 使用 local.merged_config 合并默认值与用户输入 在资源参数中写 merge() 调用
# locals.tf 示例:合理使用本地值提升可维护性
locals {
  name_prefix = "${var.project}-${var.environment}"

  common_tags = {
    Environment = var.environment
    Project     = var.project
    ManagedBy   = "Terraform"
    Module      = "vpc-network"
  }

  # 合并用户自定义标签与公共标签,用户标签可覆盖公共标签
  all_tags = merge(local.common_tags, var.extra_tags)

  # 根据环境决定是否启用 NAT 网关
  enable_nat = var.environment == "prod" ? true : var.vpc_config.enable_nat_gateway
}

2.5 资源定义最佳实践

资源定义是模块的核心。服务目录中的资源定义应遵循以下原则:

  • 显式依赖优于隐式依赖:使用 depends_on 明确声明资源间依赖关系,避免 Terraform 自动推导失败导致的并行执行问题。
  • 使用 for_each 而非 count 管理同类资源:for_each 在资源增删时不会引起索引偏移,更适合服务目录场景。
  • 生命周期管理:对关键资源(如数据库、存储桶)设置 prevent_destroy = true,对可替换资源设置 create_before_destroy = true。
  • 避免硬编码:所有环境相关的值必须通过变量或本地值传入,禁止在资源参数中直接写字符串常量。
# main.tf 示例:符合最佳实践的资源定义
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_config.cidr_block
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = merge(local.all_tags, {
    Name = "${local.name_prefix}-vpc"
  })

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_subnet" "public" {
  for_each = { for idx, cidr in var.vpc_config.public_subnets : idx => cidr }

  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value
  availability_zone = data.aws_availability_zones.available.names[each.key]

  tags = merge(local.all_tags, {
    Name = "${local.name_prefix}-public-subnet-${each.key}"
    Tier = "public"
  })

  depends_on = [aws_vpc.main]
}
常见陷阱: 在 for_each 中使用 toset() 对列表去重时,如果列表元素顺序不重要,务必在文档中说明。Terraform 的集合是无序的,可能导致每次 plan 显示资源被重建的假象。

2.6 模块结构全景图

下图展示了服务目录中一个标准 Terraform 模块的完整结构、数据流以及各文件之间的调用关系。该图可作为团队模块开发的参考模板。

Terraform 模块标准结构全景图 Terraform 模块标准结构全景图 模块目录:terraform-aws-vpc-network/ main.tf 核心资源定义 variables.tf 输入变量声明 outputs.tf 输出值声明 locals.tf 本地值定义 versions.tf 版本约束 README.md 文档说明 examples/ 用法示例目录 tests/ 测试用例目录 调用者传入 var.xxx 引用 local.xxx 引用 资源属性引用 返回给调用者 模块契约要点 • variables.tf 和 outputs.tf 是模块的 API • 修改这两个文件必须升级模块版本 • CI 流水线应自动检测 API 变更

2.7 模块版本化与语义化版本

服务目录中的每个模块都应遵循语义化版本控制(SemVer),版本号格式为 MAJOR.MINOR.PATCH:

版本位 变更类型 示例
MAJOR 不兼容的 API 变更(删除/重命名变量或输出) 2.0.0
MINOR 向后兼容的新功能(新增可选变量、新增输出) 1.3.0
PATCH 向后兼容的缺陷修复或内部优化 1.2.1
版本约束在 versions.tf 中的声明方式: 模块自身应声明其所需 Terraform 版本和 Provider 版本的最小值,而非固定版本。调用方通过 source 和 version 参数锁定模块版本。
# versions.tf 示例
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0.0, < 6.0.0"
    }
  }
}

2.8 模块测试前置条件检查清单

在进入后续章节的测试策略之前,请使用以下清单确认你的模块已满足基础质量要求:

  • 模块目录包含 main.tf、variables.tf、outputs.tf、versions.tf
  • 所有输入变量都有 type、description 和合理的 default
  • 所有输出值都有 description,且不暴露整个资源对象
  • 本地值用于命名、标签和条件表达式,避免在资源中重复硬编码
  • 资源定义使用 for_each 而非 count(除非有明确理由)
  • 关键资源设置了 prevent_destroy 或 create_before_destroy
  • 模块遵循语义化版本,并在 versions.tf 中声明了版本约束
  • 模块包含 README.md 和 examples/ 目录