在上一篇文章中,我们已经考察了嵌套模块的隐藏复杂性以及由此引发的 Ripple-Effect,并且越来越清楚地认识到,这些情况在运维和生命周期管理中可能带来的不良后果。此类问题的入口会在初学者错误操作时被大开 - 尤其是将多个甚至所有 Terraform 模块塞进一个共享的 Git 仓库这一错误。同样地,如果具备一定的经验和良好的规划,这类问题则可以从一开始就被最大限度地减轻。
在本部分中,我们将看看在实践中如何处理这些依赖关系,而不会让痛点完全占据主导。
模块版本跟踪的实用解决方案
这里的基本原则是这条黄金法则:
- 在使用免费版本的 Terraform 时,每个 Terraform 模块始终必须拥有自己独立的 Git 仓库,并且必须始终通过 Tags 独立进行版本管理。
- 在使用 Terraform Enterprise 时,则应使用其内部的模块注册表。这允许像 Provider 块中那样使用版本约束,即通过诸如 "=", "<=", ">=", "~>" 之类的运算符进行限制。
为什么要这样做?
我们来看一个来自 OCI 世界的真实示例。假设有一个基础模块,其最新更新将会破坏依赖模块的功能:
# Base Module: base/oci-compute v1.2.0 variable "freeform_tags" { type = map(string) default = {} description = "Freeform tags for cost allocation" # NEW in v1.2.0: Mandatory CostCenter Tag validation { condition = can(var.freeform_tags["CostCenter"]) error_message = "CostCenter tag is required for all compute instances." } } resource "oci_core_instance" "this" { for_each = var.instances display_name = each.value.name compartment_id = var.compartment_id freeform_tags = var.freeform_tags # ... etc. }
然而,服务模块 web-application v1.3.4 却是在这个Breaking Change 之前开发的:
# Service Module: services/web-application v1.3.4 module "compute_instances" { source = "git::https://gitlab.ict.technology/modules//base/oci-compute" instances = { web1 = { name = "web-server-1" } web2 = { name = "web-server-2" } } compartment_id = var.compartment_id # freeform_tags missing - Breaking Change! }
如果像这样处理,那么就会出问题,正如本系列第 6a 部分所描述的那样。
策略 1 (Terraform):显式固定 Service- 和 Basis-Module 的版本
Basis-Module 是使用 Provider 并管理资源的模块。它们始终拥有独立的仓库并且必须进行版本化。
对于 Service-Module 也是如此。Service-Module 只会调用 Basis-Module,它们自身并不实现资源。 在调用 Basis-Module 时,Service-Module 必须显式固定其版本。只有这样,传递式升级才能得到可控。
Root-Module 的情况类似。Root-Module 调用 Service-Module 并且同样需要精确固定其版本。
让我们再次回到上一段中的 Service-Module 示例:
# Service-Module: services/web-application v1.3.5 module "compute_instances" { source = "git::https://gitlab.ict.technology/modules//base/oci-compute?ref=v1.1.0" instances = { web1 = { name = "web-server-1" } web2 = { name = "web-server-2" } } compartment_id = var.compartment_id # freeform_tags missing - Breaking Change! }
看到区别了吗?不同于:
source = "git::https://gitlab.ict.technology/modules//base/oci-compute"
我们现在固定了基础模块的精确版本:
source = "git::https://gitlab.ict.technology/modules//base/oci-compute?ref=v1.1.0"
这样一来,基础模块中的更改在运行环境中就不会伤害到任何人。
但仍然可能带来痛点的情况是:在一个较大的 Service-Module 或 Root-Module 中,同一个模块被多次调用,却引用了不同的版本。这种情况一定会出问题。最终您必须决定要引用哪个版本。
因为很遗憾,Terraform 并不会自动识别 Root-Module 内部的重复或不一致固定(即相同的基础模块在不同版本中同时存在)。 Terraform 只会验证单个 source 的解析结果,而不会检查多个调用之间的全局一致性。
在这里我向您提供一个名为 check-module-dependencies.sh 的脚本,您可以将其作为灵感并在非商业范围内使用。由于其接近 200 行的长度,我不会在此展示脚本本身,而是提供 Git 仓库链接:https://github.com/ICT-technology/check-module-dependencies/
该脚本会分析一个 Root- 或 Service-Module,并检查其中引用模块的版本号。如果一切正常,它会生成如下输出:
如果检测到版本冲突,则会给出相应的错误提示并返回错误码:
您可以将此类脚本集成到测试环境的 CI/CD 流水线中,从而实现自动化检查。
使用策略 2 就会更简单。
策略 2 (Terraform Enterprise):带范围的语义化版本
在 Terraform Enterprise 中,这就变得简单得多(同时也更专业)。在这里,您应该使用集成的 Private Module Registry 来实现真正的语义化版本控制,就像您已经在 Provider 的版本控制中使用的一样,只不过这里是作为模块调用的一部分:
# Terraform Enterprise on-prem and HCP Terraform only module "web_service" { source = "registry.ict.technology/ict-technology/web-application/oci" version = "~> 1.1.0" # Permits 1.1.0, 1.1.1, 1.1.2 ... 1.1.x, but NOT 1.0.x or 1.2.x environment = "production" }
那么在实践中会是怎样的情况
在这种结构下,如果某个 Basis-Module 或 Service-Module 被修补并获得了新的版本号,这并不会影响到您的 Root-Module 和流水线的运行。让我们再来看看上一章节中的这张示意图:
您可以看到这里的四个层级。最上方是第 1 层,最下方是第 4 层。当第 4 层发生变化时,该层的模块会获得一个新的版本号,比如从 v1.1.0 升级到 v1.2.0。
但是由于第 3 层的模块仍然固定在 v1.1.0,因此新的 v1.2.0 并不会产生影响。必须先对第 3 层的模块进行测试。测试结果会有两种可能的场景:
- 第 3 层的 Service-Module 依旧能够正常运行 -> 那么 Basismodul 的固定版本会从 v1.1.0 升级到 v1.2.0,同时 Service-Module 升级为新的 Minor Release,例如从 v1.3.4 升级到 v1.3.5。
- 第 3 层的 Service-Module 无法继续正常运行。这意味着在 Basis-Module v1.2.0 中出现了一个 Breaking Change。因此受影响的第 3 层 Service-Module 会被修补并重新发布,这一次作为 Major Release,例如从 v1.3.4 升级到 v1.4.0。通过这个 Major Release 的版本跳跃,第 2 层的模块维护者就能够清楚地知道,这是一项重要的功能性更新,而不仅仅是一次依赖的调整。
在采用 Terraform Enterprise 的策略 2 下,情况会有所不同:在上述场景中,Service-Module 从 v1.3.4 到 v1.3.5 的 Minor Update 会被透明地传递到上层。这会带来新的影响:
- 在 Terraform Enterprise 的集成私有模块注册表下,可以省去在依赖模块中进行不必要的生命周期管理。这减少了人力投入和出错风险 -> 带来直接的、额外的价值,也正是 HashiCorp 或 IBM 收费的 Enterprise 特性之一。
- 这同时意味着,在版本管理上必须严格遵守规则并保持谨慎:什么才算是 Minor Update?它与 Major Update 有什么区别?因此,您需要一个计划,进而定义出明确的结构和内部版本控制合规要求,而不能再像牛仔一样随心所欲,随便抛出一些看似酷炫的版本号。至于该如何正确地进行版本管理,我们将在本系列的第 7 部分中进行探讨。
模块依赖中的 Rate-of-Change 陷阱
在嵌套模块中一个经常被忽视的问题是累积性变更风险。模块层级越深,发生意外更改的概率就越高。最迟随着 NIS2 的落地,风险管理将成为强制要求,因此我们在此背景下简单探讨一下。我们这里使用的是一个简化的百分比模型,而非专业、数学上严格的风险计算,目的在于让您快速理解这个问题的核心。
在一个扁平的层级中,设定变更频率以符合运维策略是很简单的。
经典示例:"我们每周二下午 15 点至 18 点有一个维护窗口"。
在实践中,这意味着在每周二下午(或可能顺延至周三)才可能发生一次运维问题。一周一次,一年最多 52 次(暂不考虑影响范围和故障持续时间,粗略计算即可)。
再减去节假日和圣诞、复活节的 Frozen Zones,大约剩下 34 次潜在的运维中断。1 / 34 = 0.0294,即大约 3% 的风险。
但在 Infrastructure-as-Code 并带有模块依赖的环境中,情况会复杂得多,尤其是在一些所谓敏捷和 Continuous Deployment 掩盖下的“混乱”环境中。实际上,风险远远不止 3%。
举个例子:
Root Module
├── 变更概率: 10% (稀有的环境更改)
└── Service Module
├── 变更概率: 30% (业务逻辑更新)
└── Base Module
└── 变更概率: 50% (Provider 更新, Bug 修复)
累积变更概率 = 1 - (0.9 × 0.7 × 0.5) = 68.5%
在这个三层结构下,存在 68.5% 的概率在给定时间段内至少有一个组件会发生变更。
至此应当完全清楚,您不能再像 James Dean 那样随心所欲 - 面对这样的数字,您必须真正清楚自己在做什么。
结论
通过一些前瞻性思考和纪律性要求,从运维角度看,潜在的严重事故在嵌套模块中是可以避免的。但正如您所看到的,这依然是一个必须纳入风险管理的重要风险。
在接下来的第 6c 部分,我们将结束这个子主题,并探讨一些更高级的实践和推荐的方法,例如 Policy-as-Code。感谢您一路跟随到这里。