Chapter 05

模块系统:创建与复用模块

掌握 Terraform 模块的设计与使用,构建可复用的基础设施代码库

模块的核心概念

模块(Module)
一组 .tf 文件的集合,封装了一组相关资源的定义和配置逻辑。每个 Terraform 配置都是模块——当前工作目录的 .tf 文件是根模块(Root Module),它调用的其他模块是子模块(Child Module)。模块是 Terraform 代码复用和组织的基本单元。
根模块(Root Module)
运行 terraform init/plan/apply 命令时所在目录的 .tf 文件构成根模块。每次 Terraform 操作从根模块开始,递归处理它调用的所有子模块。根模块拥有 State 文件的所有权,子模块的资源也存储在根模块的 State 中(以 module.name.resource_type.resource_name 形式)。
子模块(Child Module)
被根模块或其他子模块通过 module 块调用的模块。子模块通过 variables.tf 定义输入接口,通过 outputs.tf 定义输出接口,内部实现对调用者透明——这与面向对象编程中的类和封装概念类似。
模块接口
模块的公共 API:输入变量(variables.tf 中的 variable 块)是构造函数参数,输出值(outputs.tf 中的 output 块)是公有属性,内部资源对调用者不可见(调用者不能直接引用子模块内的 resource.name,只能引用 module.name.output_name)。

标准模块目录结构

一个设计良好的模块应当包含以下文件,职责分明,便于维护和测试:

modules/vpc/ ├── main.tf # 主要资源定义(aws_vpc, aws_subnet 等) ├── variables.tf # 输入变量声明(模块的"参数") ├── outputs.tf # 输出值声明(模块的"返回值") ├── versions.tf # terraform 块,声明 Provider 版本约束 ├── locals.tf # 局部变量,内部计算值(可选) ├── data.tf # Data Source 声明(可选,避免 main.tf 过长) └── README.md # 模块文档(必须!说明用途、参数、示例)
# modules/vpc/versions.tf — 声明 Provider 版本约束
# 子模块声明依赖,根模块负责安装和配置 Provider
terraform {
  required_version = ">= 1.5"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0"   # 子模块只声明最低要求,不锁定具体版本
    }
  }
}
# modules/vpc/variables.tf — 输入变量(模块的参数)

variable "name" {
  type        = string
  description = "VPC 名称前缀,用于资源命名"
  # 无 default 值:调用者必须显式传入(必填参数)
}

variable "vpc_cidr" {
  type        = string
  description = "VPC 的 CIDR 地址块"
  default     = "10.0.0.0/16"

  # 输入验证:确保传入合法的 CIDR 格式
  validation {
    condition     = can(cidrhost(var.vpc_cidr, 0))
    error_message = "vpc_cidr 必须是合法的 CIDR 格式,如 10.0.0.0/16"
  }
}

variable "public_subnet_cidrs" {
  type        = list(string)
  description = "公有子网 CIDR 列表,数量应与 azs 相同"
}

variable "private_subnet_cidrs" {
  type        = list(string)
  description = "私有子网 CIDR 列表"
  default     = []   # 默认不创建私有子网
}

variable "azs" {
  type        = list(string)
  description = "可用区列表,数量应与子网 CIDR 数量相同"
}

variable "enable_nat_gateway" {
  type        = bool
  description = "是否创建 NAT Gateway(私有子网出站用)"
  default     = false   # NAT Gateway 费用较高,默认不创建
}

variable "tags" {
  type        = map(string)
  description = "附加到所有资源的标签"
  default     = {}
}
# modules/vpc/main.tf — 资源定义

locals {
  # 合并传入标签与模块默认标签
  common_tags = merge(var.tags, {
    Module = "vpc"
    Name   = var.name
  })
}

resource "aws_vpc" "this" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true
  tags = merge(local.common_tags, { Name = "${var.name}-vpc" })
}

# 公有子网(数量由 public_subnet_cidrs 列表长度决定)
resource "aws_subnet" "public" {
  count             = length(var.public_subnet_cidrs)
  vpc_id            = aws_vpc.this.id
  cidr_block        = var.public_subnet_cidrs[count.index]
  availability_zone = var.azs[count.index]
  map_public_ip_on_launch = true
  tags = merge(local.common_tags, {
    Name = "${var.name}-public-${count.index + 1}"
    Type = "Public"
  })
}

# 私有子网(只在 private_subnet_cidrs 非空时创建)
resource "aws_subnet" "private" {
  count             = length(var.private_subnet_cidrs)
  vpc_id            = aws_vpc.this.id
  cidr_block        = var.private_subnet_cidrs[count.index]
  availability_zone = var.azs[count.index]
  tags = merge(local.common_tags, {
    Name = "${var.name}-private-${count.index + 1}"
    Type = "Private"
  })
}

# Internet Gateway
resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id
  tags   = merge(local.common_tags, { Name = "${var.name}-igw" })
}

# NAT Gateway(只在 enable_nat_gateway = true 时创建)
resource "aws_eip" "nat" {
  count  = var.enable_nat_gateway ? 1 : 0   # 条件创建
  domain = "vpc"
}

resource "aws_nat_gateway" "this" {
  count         = var.enable_nat_gateway ? 1 : 0
  allocation_id = aws_eip.nat[0].id
  subnet_id     = aws_subnet.public[0].id   # 放在第一个公有子网
  tags          = merge(local.common_tags, { Name = "${var.name}-nat" })
}
# modules/vpc/outputs.tf — 输出值(模块的"返回值")
# 调用者通过 module.vpc.output_name 引用这些值

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

output "vpc_cidr_block" {
  value       = aws_vpc.this.cidr_block
  description = "VPC CIDR 地址块"
}

output "public_subnet_ids" {
  value       = aws_subnet.public[*].id   # splat 表达式,返回 ID 列表
  description = "公有子网 ID 列表"
}

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

output "nat_gateway_ip" {
  value       = var.enable_nat_gateway ? aws_eip.nat[0].public_ip : null
  description = "NAT Gateway 公网 IP(未启用时为 null)"
}

调用模块的完整语法

# environments/prod/main.tf

# ===== 调用本地模块(最常用)=====
module "vpc" {
  source = "../../modules/vpc"  # 相对路径,指向模块目录

  # 传递模块的输入变量(必填变量必须显式传入)
  name                 = "prod"
  vpc_cidr             = "10.1.0.0/16"
  public_subnet_cidrs  = ["10.1.1.0/24", "10.1.2.0/24"]
  private_subnet_cidrs = ["10.1.11.0/24", "10.1.12.0/24"]
  azs                  = ["us-east-1a", "us-east-1b"]
  enable_nat_gateway   = true
  tags = {
    Environment = "production"
    ManagedBy   = "Terraform"
  }
}

# 使用模块的输出值
resource "aws_instance" "app" {
  ami       = data.aws_ami.amazon_linux.id
  subnet_id = module.vpc.private_subnet_ids[0]   # 引用模块输出
  vpc_security_group_ids = [aws_security_group.app.id]
  # ...
}

# ===== 调用 Terraform Registry 公共模块 =====
module "eks" {
  source  = "terraform-aws-modules/eks/aws"   # Registry 格式:命名空间/模块名/Provider
  version = "~> 20.0"                          # 必须指定版本!

  cluster_name    = "my-eks-cluster"
  cluster_version = "1.29"
  vpc_id          = module.vpc.vpc_id
  subnet_ids      = module.vpc.private_subnet_ids
}

# ===== 调用 Git 仓库中的模块 =====
module "security_baseline" {
  # 格式:git::URL//子目录路径?ref=tag/branch/commit
  source = "git::https://github.com/my-org/terraform-modules.git//security?ref=v2.1.0"

  # 使用 SSH(内网 GitLab 等)
  # source = "git::git@github.com:my-org/terraform-modules.git//security?ref=v2.1.0"
}

# ===== 调用 S3/GCS 上托管的模块(私有存储)=====
module "database" {
  source  = "s3::https://s3-us-east-1.amazonaws.com/my-modules/rds-module.zip"
  version = "~> 1.5"
}

# 模块的元参数(Meta-arguments,适用于所有 module 块)
module "app_servers" {
  source = "../../modules/ec2-cluster"

  # depends_on:显式声明模块依赖(通常不需要,Terraform 自动推断)
  depends_on = [module.vpc]

  # count:创建多个模块实例(0.13+ 支持)
  count = var.create_app_servers ? 1 : 0
}

for_each 动态创建多个模块实例

for_each 允许用一个 module 块创建多个模块实例,每个实例有独立的 State 和独立的资源集合。

locals {
  # 定义多个环境的配置,key 是环境名称
  environments = {
    dev = {
      cidr               = "10.1.0.0/16"
      subnet_cidrs       = ["10.1.1.0/24"]
      azs                = ["us-east-1a"]
      enable_nat_gateway = false
    }
    staging = {
      cidr               = "10.2.0.0/16"
      subnet_cidrs       = ["10.2.1.0/24", "10.2.2.0/24"]
      azs                = ["us-east-1a", "us-east-1b"]
      enable_nat_gateway = false
    }
    prod = {
      cidr               = "10.3.0.0/16"
      subnet_cidrs       = ["10.3.1.0/24", "10.3.2.0/24"]
      azs                = ["us-east-1a", "us-east-1b"]
      enable_nat_gateway = true
    }
  }
}

# 一个 module 块创建 3 套 VPC(dev, staging, prod)
module "vpc" {
  for_each = local.environments   # each.key = 环境名,each.value = 配置对象
  source   = "../../modules/vpc"

  name                = each.key
  vpc_cidr            = each.value.cidr
  public_subnet_cidrs = each.value.subnet_cidrs
  azs                 = each.value.azs
  enable_nat_gateway  = each.value.enable_nat_gateway
  tags = { Environment = each.key }
}

# 引用格式:module.vpc["dev"].vpc_id, module.vpc["prod"].public_subnet_ids

# 在其他资源中引用各环境的输出
output "all_vpc_ids" {
  # for 表达式:将 module.vpc 的 map 转换为 env => vpc_id 的 map
  value = { for env, mod in module.vpc : env => mod.vpc_id }
}

# State 中的资源地址:
# module.vpc["dev"].aws_vpc.this
# module.vpc["staging"].aws_vpc.this
# module.vpc["prod"].aws_vpc.this

模块设计最佳实践

1. 变量验证(variable validation)

为模块变量添加验证逻辑,让错误的输入在 plan 阶段被早期发现,而不是等到 apply 时 API 报错:

# modules/rds/variables.tf
variable "instance_class" {
  type        = string
  description = "RDS 实例规格,如 db.r6g.large"

  validation {
    # can() 在表达式报错时返回 false 而不是抛出异常
    condition     = can(regex("^db\\.(r|t|m)[3-9]g?\\.", var.instance_class))
    error_message = "instance_class 必须是合法的 RDS 实例规格,如 db.r6g.large 或 db.t3.micro"
  }
}

variable "environment" {
  type        = string
  description = "部署环境"

  validation {
    # contains() 检查值是否在允许列表中
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment 必须是 dev、staging 或 prod 之一"
  }
}

variable "backup_retention_days" {
  type        = number
  description = "备份保留天数(生产建议 7 天以上)"
  default     = 7

  validation {
    # 多个条件用 && 连接
    condition     = var.backup_retention_days >= 1 && var.backup_retention_days <= 35
    error_message = "backup_retention_days 必须在 1 到 35 之间(AWS RDS 限制)"
  }
}

variable "allowed_cidr_blocks" {
  type        = list(string)
  description = "允许访问数据库的 CIDR 列表"

  validation {
    # alltrue() + for 表达式验证列表中的每个元素
    condition     = alltrue([for cidr in var.allowed_cidr_blocks : can(cidrhost(cidr, 0))])
    error_message = "allowed_cidr_blocks 中的所有条目必须是合法的 CIDR 格式"
  }
}

variable "port" {
  type        = number
  description = "数据库端口号"
  default     = 5432

  validation {
    condition     = var.port > 1024 && var.port < 65535
    error_message = "port 必须在 1025 到 65534 之间(避免系统保留端口)"
  }
}

2. 灵活的可选变量设计

# 使用 object 类型变量传递结构化配置
variable "scaling_config" {
  type = object({
    min_size         = number
    max_size         = number
    desired_capacity = optional(number)   # optional() 让字段可选(Terraform 1.3+)
  })
  default = {
    min_size         = 1
    max_size         = 3
    desired_capacity = null   # null 表示使用 AWS 默认值
  }
}

# 使用 optional() 默认值(Terraform 1.3+)
variable "alb_config" {
  type = object({
    idle_timeout              = optional(number, 60)   # 第二个参数为默认值
    enable_deletion_protection = optional(bool, false)
    access_logs_bucket        = optional(string)     # 无默认值,不传时为 null
  })
  default = {}   # 空对象,所有字段使用 optional 默认值
}

3. .terraform.lock.hcl — Provider 锁文件

.terraform.lock.hcl 是 Terraform 的 Provider 锁文件(类似 npm 的 package-lock.json),记录所有 Provider 的精确版本和哈希校验值,确保团队所有成员和 CI/CD 使用完全相同的 Provider 版本。

# .terraform.lock.hcl(自动生成,应提交到 Git)
provider "registry.terraform.io/hashicorp/aws" {
  version     = "5.50.0"    # 精确版本
  constraints = "~> 5.0"   # 来源于 required_providers 的约束
  hashes = [
    # 不同操作系统和架构对应的二进制文件哈希
    "h1:xxxx...",            # h1 哈希:目录中文件内容的哈希
    "zh:xxxx...",            # zh 哈希:zip 包的哈希
  ]
}
# 更新 Provider 到满足约束的最新版本
terraform init -upgrade

# 同时生成多平台的哈希(开发用 Mac ARM64,CI 用 Linux AMD64)
# 避免在不同平台上 init 时报哈希不匹配错误
terraform providers lock \
  -platform=linux_amd64 \
  -platform=linux_arm64 \
  -platform=darwin_arm64 \
  -platform=darwin_amd64

# 验证 Provider 完整性(防止供应链攻击)
terraform providers mirror /tmp/mirror-dir   # 本地缓存所有 Provider

4. 常见模块设计模式

# 模式1:条件创建资源(count 或 for_each + 空集合)
variable "create_bastion" {
  type    = bool
  default = false
}

resource "aws_instance" "bastion" {
  count = var.create_bastion ? 1 : 0   # 0 表示不创建,1 表示创建
  # ...
}

output "bastion_ip" {
  # 当 count = 0 时,aws_instance.bastion 为空列表,用 try 避免索引越界
  value = try(aws_instance.bastion[0].public_ip, null)
}

# 模式2:动态块(dynamic block)——可选的嵌套配置
variable "ingress_rules" {
  type = list(object({
    from_port   = number
    to_port     = number
    protocol    = string
    cidr_blocks = list(string)
  }))
  default = []
}

resource "aws_security_group" "this" {
  name   = "${var.name}-sg"
  vpc_id = var.vpc_id

  # dynamic 块根据变量动态生成多个同类嵌套块
  dynamic "ingress" {
    for_each = var.ingress_rules   # 遍历规则列表
    content {                      # content 块定义每个 ingress 块的内容
      from_port   = ingress.value.from_port
      to_port     = ingress.value.to_port
      protocol    = ingress.value.protocol
      cidr_blocks = ingress.value.cidr_blocks
    }
  }
}

# 模式3:模块版本控制 — 使用 Git Tag 固定版本
module "network" {
  # ?ref=v1.2.3 锁定到特定 Git Tag(语义化版本标签)
  # 每次 terraform init 都会检出相同的代码,保证可重现性
  source = "git::https://github.com/my-org/modules.git//vpc?ref=v1.2.3"
}

模块来源类型速查

本地路径(Local Path)
source = "../../modules/vpc":同一 repo 内的私有模块,路径相对于调用模块的目录。不支持 version 参数,代码修改立即生效(无需 init)。适合单仓库(monorepo)架构。
Terraform Registry(公共)
source = "terraform-aws-modules/eks/aws" + version = "~> 20.0"。地址格式:命名空间/模块名/Provider。使用前检查模块的 Stars 数、最近更新时间和 README 质量。知名模块如 terraform-aws-modules 系列经过大量生产验证。
Git 仓库
source = "git::https://github.com/org/repo.git//path?ref=v2.0.0":私有模块仓库。//path 指定子目录(双斜杠语法),ref 指定 tag/branch/commit 锁定版本。SSH 认证用 git::git@github.com
私有 Registry(Terraform Cloud/HCP)
Terraform Cloud 提供私有模块 Registry。格式类似公共 Registry,但需要 Terraform Cloud 账号认证。适合大型企业,支持版本管理、访问控制和模块文档生成。
HTTP/S3/GCS URL
直接从 URL 下载 zip 包:source = "https://example.com/vpc-module-1.0.0.zip"source = "s3::https://s3.amazonaws.com/bucket/vpc.zip"。适合私有存储的预打包模块。

模块测试

Terraform 1.6+ 引入了原生测试框架(terraform test),可以编写 HCL 测试用例验证模块行为:

# modules/vpc/tests/basic.tftest.hcl

# 创建临时测试资源(运行后自动销毁)
run "creates_vpc_with_correct_cidr" {
  # command = apply(默认,实际创建资源)或 plan(只验证计划)
  command = plan

  variables {
    name                = "test"
    vpc_cidr            = "10.0.0.0/16"
    public_subnet_cidrs = ["10.0.1.0/24"]
    azs                 = ["us-east-1a"]
  }

  # 断言验证期望行为
  assert {
    condition     = aws_vpc.this.cidr_block == "10.0.0.0/16"
    error_message = "VPC CIDR 应为 10.0.0.0/16"
  }

  assert {
    condition     = aws_vpc.this.enable_dns_hostnames == true
    error_message = "必须启用 DNS hostnames"
  }

  assert {
    condition     = length(aws_subnet.public) == 1
    error_message = "应创建 1 个公有子网"
  }
}

# 运行测试:
# cd modules/vpc && terraform test

本章小结

本章核心要点