Sägetstrasse 18, 3123 Belp, Switzerland +41 79 173 36 84 info@ict.technology

      Terraform @ Scale - 第 6b 部分:嵌套模块的实践处理

      在上一篇文章中,我们已经考察了嵌套模块的隐藏复杂性以及由此引发的 Ripple-Effect,并且越来越清楚地认识到,这些情况在运维和生命周期管理中可能带来的不良后果。此类问题的入口会在初学者错误操作时被大开 - 尤其是将多个甚至所有 Terraform 模块塞进一个共享的 Git 仓库这一错误。同样地,如果具备一定的经验和良好的规划,这类问题则可以从一开始就被最大限度地减轻。

      在本部分中,我们将看看在实践中如何处理这些依赖关系,而不会让痛点完全占据主导。

      模块版本跟踪的实用解决方案

      这里的基本原则是这条黄金法则:

      1. 在使用免费版本的 Terraform 时,每个 Terraform 模块始终必须拥有自己独立的 Git 仓库,并且必须始终通过 Tags 独立进行版本管理。
      2. 在使用 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,并检查其中引用模块的版本号。如果一切正常,它会生成如下输出:

       

      Screenshot 2025 09 10 115640

      如果检测到版本冲突,则会给出相应的错误提示并返回错误码:

      Screenshot 2025 09 10 115759

      您可以将此类脚本集成到测试环境的 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 会被透明地传递到上层。这会带来新的影响:

      1. 在 Terraform Enterprise 的集成私有模块注册表下,可以省去在依赖模块中进行不必要的生命周期管理。这减少了人力投入和出错风险 -> 带来直接的、额外的价值,也正是 HashiCorp 或 IBM 收费的 Enterprise 特性之一。
      2. 这同时意味着,在版本管理上必须严格遵守规则并保持谨慎:什么才算是 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。感谢您一路跟随到这里。