1. 模块的消费方式与来源类型
一句话总结: 模块的价值来自「被引用」,而引用方式决定了版本能否被锁定;本地路径最灵活但不可复现,Registry 最规范但需要发布流程。
Terraform 的 source 参数支持多种来源,各有适用场景:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.5"
name = "${local.prefix}-vpc"
cidr = var.vpc_cidr
}
| 来源类型 | 语法示例 | 可锁版本 | 适用场景 |
|---|---|---|---|
| 公共 Registry | namespace/name/provider | 是 | 通用能力 |
| 私有 Registry | app.terraform.io/org/name/provider | 是 | 组织内标准 |
| Git | git::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-docs | CI 中比对差异 |
| 示例配置 | 手写 | 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 工作负载上的一步。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。