模块注册表与分发:版本、文档与测试

把 Terraform 模块变成可分发资产:模块来源类型与消费方式、仓库结构约定、语义化版本与发布流水线、私有 Registry 与鉴权、自动文档生成,以及模块测试与废弃策略。

1. 模块的消费方式与来源类型

一句话总结: 模块的价值来自「被引用」,而引用方式决定了版本能否被锁定;本地路径最灵活但不可复现,Registry 最规范但需要发布流程。

Terraform 的 source 参数支持多种来源,各有适用场景:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.5"

  name = "${local.prefix}-vpc"
  cidr = var.vpc_cidr
}
来源类型语法示例可锁版本适用场景
公共 Registrynamespace/name/provider是通用能力
私有 Registryapp.terraform.io/org/name/provider是组织内标准
Gitgit::https://...?ref=v1.2.0需显式 ref内部快速共享
本地路径./modules/vpc不适用同仓库复用
对象存储s3::https://.../mod.zip需带哈希特殊场景

Git 来源的 ref 必须写成明确的 tag 或 commit,而不是分支名:

module "network" {
  source = "git::https://example.com/infra/modules.git//network?ref=v2.1.0"
}

写成 ?ref=main 会让每次 init 拉到不同内容,terraform plan 结果不可复现——这是团队协作中最隐蔽的一类「我本地是好的」。

一句话:任何不可锁定版本的模块来源,都会让「基础设施即代码」退化成「基础设施即运气」。

2. 模块仓库的结构约定

一句话总结: 官方约定是「根目录即模块」,把每个模块放在独立仓库的根或独立子目录,并配齐 main.tf、variables.tf、outputs.tf、versions.tf 与 README.md。

terraform-aws-network/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
├── README.md
├── examples/
│   └── complete/
│       └── main.tf
└── test/
    └── network_test.go

versions.tf 声明 provider 约束,而不是在 main.tf 里散落:

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0"
    }
  }
}

变量定义要带类型、描述与校验,这三者是模块「自解释」的基础:

variable "vpc_cidr" {
  type        = string
  description = "VPC 的 IPv4 网段,需为 /16 到 /24 之间"

  validation {
    condition     = can(cidrnetmask(var.vpc_cidr))
    error_message = "vpc_cidr 必须是合法的 CIDR 格式。"
  }
}
文件职责缺失后果
main.tf资源定义无
variables.tf输入契约使用者无法判断必填项
outputs.tf输出契约无法串联下游模块
versions.tf版本约束隐式依赖最新版
README.md使用说明只能读源码

一句话:模块的接口就是它的变量与输出,其余都算实现细节——接口一旦发布就很难再改。

3. 语义化版本与发布流程

一句话总结: 模块版本必须严格遵循语义化版本:新增可选变量是次版本,改默认值或删变量是主版本,任何让使用者必须改代码的变更都要升主版本。

变更版本位使用者影响
修文档、修内部 bug补丁无
新增可选变量(有默认值)次版本无
新增输出次版本无
改变量默认值主版本行为可能变化
删除变量或输出主版本配置报错
重命名资源导致重建主版本可能停机

发布靠打 tag 触发:

git tag v2.1.0
git push origin v2.1.0

若模块发布到公共 Registry,还需要在仓库根放一个描述文件,Registry 会据此识别模块信息:

# 用于公共 Registry 的命名约定:terraform-<provider>-<name>
# 仓库名 terraform-aws-network → 引用为 namespace/network/aws

GitHub Release 会自动被公共 Registry 索引,因此 tag 命名必须规范(v 前缀 + 三段版本)。

# 发布前校验模块可被解析
terraform init -backend=false
terraform validate

一句话:模块的版本号是给使用者的承诺,改默认值这类「看起来无害」的动作必须进主版本。

4. 私有 Registry 与鉴权

一句话总结: 私有 Registry 让模块分发具备鉴权、审计与版本检索能力;配置的核心是 .terraformrc 中的凭证块与 required_providers 的 source 地址。

terraform {
  required_providers {
    internal = {
      source = "app.terraform.io/example/internal"
    }
  }

  cloud {
    organization = "example"
    workspaces {
      name = "platform-prod"
    }
  }
}

本地开发需要在 CLI 配置文件中声明凭证:

# ~/.terraformrc
credentials "app.terraform.io" {
  token = "xxxxx.atlasv1.xxxxx"
}

在 CI 中不要把 token 写进文件,用环境变量注入:

export TF_TOKEN_app_terraform_io="$TERRAFORM_CLOUD_TOKEN"
terraform init
鉴权方式适用环境安全性
~/.terraformrc 凭证块本地开发中,文件易泄漏
TF_TOKEN_* 环境变量CI高,随密钥系统注入
OIDC 联合云上流水线高,无长期凭证

自建 Registry 或使用 OCI 镜像分发也是常见方案,尤其在需要离线环境时:

module "network" {
  source  = "oci://registry.example.com/modules/network/aws"
  version = "2.1.0"
}

一句话:模块凭证的存放位置,决定了整个私有模块体系的安全下限。

5. 文档生成与示例

一句话总结: 文档应当由代码生成而非手写,terraform-docs 从变量、输出与资源注释中提取内容,保证文档与实现永不脱节。

# 生成 Markdown 表格形式的输入输出说明
terraform-docs markdown table --output-file README.md --output-mode inject .

配置化生成可以固定格式,避免每个模块的 README 风格各异:

# .terraform-docs.yml
formatter: markdown table

sections:
  show:
    - requirements
    - providers
    - inputs
    - outputs
    - resources

output:
  file: README.md
  mode: inject
  template: |-
    <!-- BEGIN_TF_DOCS -->
    {{ .Content }}
    <!-- END_TF_DOCS -->

examples/ 目录承担两重职责:既是使用者的起点,也是 CI 中可被 validate 的真实配置。

# examples/complete/main.tf
module "network" {
  source = "../../"

  vpc_cidr    = "10.0.0.0/16"
  environment = "example"
}
产物生成方式检查方式
README 输入输出表terraform-docsCI 中比对差异
示例配置手写terraform validate
变更日志按 tag 汇总发布时生成

一句话:手写文档的模块,文档一定会在第三个版本之后开始说谎。

6. 模块测试与 CI 校验

一句话总结: 模块的质量门槛是「能初始化、能校验、能通过集成测试」三级;前两级无成本必做,第三级在真实环境创建资源验证行为。

# 一级:语法与引用校验,不需要凭证
terraform fmt -check -recursive
terraform init -backend=false
terraform validate
// 二级:用 Terratest 在真实账号中创建并断言
func TestNetworkModule(t *testing.T) {
	t.Parallel()

	terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
		TerraformDir: "../examples/complete",
		Vars: map[string]interface{}{
			"environment": "test",
		},
	})

	defer terraform.Destroy(t, terraformOptions)
	terraform.InitAndApply(t, terraformOptions)

	vpcID := terraform.Output(t, terraformOptions, "vpc_id")
	assert.NotEmpty(t, vpcID)
}

CI 中的检查清单:

阶段命令是否需凭证
格式terraform fmt -check否
校验terraform validate否
静态扫描tflint、checkov否
文档同步terraform-docs --output-check否
集成测试go test ./test/...是
# 文档与代码不一致时直接失败
terraform-docs markdown table --output-check .

一句话:模块仓库的 CI 应该在「不需要凭证」的前提下拦掉绝大多数问题,凭证只留给最后一道集成测试。

7. 版本升级与废弃策略

一句话总结: 模块的长期维护靠两条纪律:使用者用约束表达式控制升级范围,维护者用废弃变量与迁移指南平滑过渡。

使用者侧用版本约束表达可接受范围:

module "network" {
  source  = "example/network/aws"
  version = "~> 2.1"

  # 只接受 2.1.x,不会自动跳到 2.2
}
约束写法含义适用
= 2.1.0精确锁定极稳定场景
~> 2.1允许 2.1.x常规生产
~> 2允许 2.x信任次版本兼容
>= 2.1, < 3.0区间明确边界

维护者侧则需要一个过渡期,而不是直接删除变量:

variable "disk_size" {
  type        = number
  default     = null
  description = "已废弃,请使用 volume_size"

  validation {
    condition     = var.disk_size == null
    error_message = "disk_size 已在 v3 中移除,请改用 volume_size。"
  }
}

主版本发布时,仓库根应同时提供迁移说明与升级示例:

# 使用者升级前先看计划差异
terraform init -upgrade
terraform plan

一句话:模块的废弃不是「删掉旧变量」,而是「让使用者在报错信息里读到怎么改」。

8. 总结

模块从「能用」到「可分发」需要补齐六个环节:

环节要点
来源优先 Registry,Git 来源必须锁 ref
结构根目录即模块,五个标准文件齐备
版本严格语义化,改默认值进主版本
发布tag 触发,命名规范 vX.Y.Z
鉴权CI 用环境变量或 OIDC,不落盘
文档terraform-docs 生成,CI 校验同步
测试无凭证三级校验 + 有凭证集成测试
升级约束表达式 + 废弃提示 + 迁移指南

一句话收尾:模块是把「一次性编排」沉淀为「组织资产」的唯一途径。一个写得再好的 VPC 配置,如果只能复制粘贴,第三次使用就会分叉出三个版本;而当它以语义化版本发布、有自动生成的文档、有 CI 守住接口,它就变成了真正的内部标准。下一篇讨论 Helm Provider 与应用发布,那是把模块化思路延伸到 Kubernetes 工作负载上的一步。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「terraform」更多文章

  1. Helm Provider 与应用发布:值注入与回滚
  2. DNS 与证书编排:托管区域与自动验证
  3. IAM 策略建模:条件键、边界与跨账号