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

      Terraform @ Scale - 第6c部分:高级模块依赖管理(献给那些自虐型工程师)

      "去他的可见性 - 只要能跑就行!"

      这正是大多数 Platform-Engineering 团队自取灭亡的态度。就像在陌生的厨房里蒙着眼睛做饭:一开始也许还能凑合,但一旦烧焦,那就真的完蛋了。

      而最糟糕的莫过于当系统崩溃时,看着团队成员一脸茫然的样子。

      在前两部分中,我们主要讨论了依赖关系带来的痛苦。今天我们来看看,当孩子已经掉进井里时,我们还有哪些办法能把它完好无损地救上来。

       

      模块依赖关系的可视化方法

      在复杂的模块层级结构中,第一个问题就是可见性。理论上,Terraform 为此提供了命令 terraform graph


      terraform graph | dot -Tpng > graph.png

      Terraform 的 terraform graph 输出虽然出发点是好的,但经过图形转换后基本无法使用。Terraform 不支持 GraphML、GrapAR、TGF、GML 或 JSON,仅支持 .dot 格式。该格式虽然在 Linux 环境中的代码生成和自动化领域很常见,但在可视化方面,没有任何企业级建模或数据分析应用支持它。

      要将 .dot 文件转换为图像,您需要安装 graphviz 包,效果如下:

      我觉得这与我的网络基础架构的相似度,还不如摩纳哥一级方程式赛场开赛后所有赛车同时冲进第一个弯道时的连环车祸。用户友好性?根本谈不上。

      而且即使您花费大量人力精细调整,把这份 .dot 文件整理成一张相对清晰的图表,在企业环境中它依然和原始生成的 .dot 文件一样——毫无用处。

      另一个命令是 terraform show,它确实能列出所有资源。但想用它来可视化依赖关系?依然不可能。

      因此,这里提供一个小脚本,它尽力在 Shell 中以某种方式展示 terraform graph 的输出。使用这个脚本,您可以立即看出模块 X 依赖于哪个 Remote State Y。
      脚本名为 terraform-graph-visualizer.sh,为了不在此展示 200 多行 Bash 代码,我直接给出 GitHub 仓库地址:GitHub - ICT-technology/terraform-graph-visualizer

      上面图形输出对应的结果如下(这里是大幅简化版):


      $ terraform graph | ~/bin/terraform-graph-visualizer.sh 
      ╔══════════════════════════════════════════════════════════╗
      ║ TERRAFORM GRAPH VISUALIZATION                            ║
      ╚══════════════════════════════════════════════════════════╝ Analyzing: stdin (terraform graph) GRAPH STATISTICS
      ═════════════════
      ├─ Total Nodes: 96
      ├─ Total Edges: 63
      ├─ Modules: 10
      └─ Data Sources: 3 TERRAFORM MODULES
      ══════════════════
      ├─ module.drg
      │ ├─ oci_core_drg_attachment.this
      │ └─ oci_core_drg.this ├─ module.drg_attachments
      │ ├─ oci_core_drg_attachment.this
      │ └─ oci_core_drg.this
      [...] DATA SOURCES
      ═════════════
      ├─ data.terraform_remote_state.icttechnology_buckets
      ├─ data.terraform_remote_state.icttechnology_compartments
      ├─ data.terraform_remote_state.tfstate-icttechnology_root DEPENDENCY RELATIONSHIPS
      ═════════════════════════
      ┌─ data.terraform_remote_state.icttechnology_compartments
      │ depends on:
      │ ├─ module.drg.oci_core_drg.this
      │ ├─ module.drg.oci_core_drg_attachment.this
      │ ├─ module.drg_attachments.oci_core_drg.this
      │ ├─ module.natgw.data.oci_core_services.services
      │ ├─ module.natgw.oci_core_vcn.this
      │ ├─ module.servicegw.data.oci_core_services.services
      │ ├─ module.servicegw.oci_core_nat_gateway.this
      │ ├─ module.servicegw.oci_core_vcn.this
      │ ├─ module.vcn.data.oci_core_services.services
      │ ├─ module.vcn.oci_core_nat_gateway.this
      │ └─ module.vcn.oci_core_vcn.this

      ┌─ module.drg.oci_core_drg_attachment.this
      │ depends on:
      │ ├─ module.drg_attachments.oci_core_drg_attachment.this
      │ ├─ module.route_table_bastion-classic.oci_core_route_table.this
      │ └─ module.route_table_vcn_FRA.oci_core_route_table.this
      [...] Graph visualization complete!

       虽然依然算不上美观,但至少可读。而且除了纯粹的依赖关系,您还能立即看出:

      • Remote-State 引用
      • 模块的 Fan-in / Fan-out 特征
      • Data Source 热点

      Policy-as-Code:当 Sentinel 阻止最严重的错误决策

      在 Terraform Enterprise 中,Sentinel 作为 Policy-as-Code 引擎可用。Sentinel 确保企业策略与合规性要求得到遵守——也就是说,它能在我们做出真正愚蠢的决定之前阻止它们造成损害。

      用于模块版本固定(Version Pinning)的 Sentinel Policy

      使用如下策略,您可以强制要求模块版本必须被固定:


      import "tfconfig/v2" as tfconfig
      import "strings"
      
      // Heuristic: source is VCS if it has a git:: prefix OR a URI scheme present
      is_vcs = func(src) {
        strings.has_prefix(src, "git::") or strings.contains(src, "://")
      }
      
      // extract ref from the query (if present)
      ref_of = func(src) {
        // very simple extraction: everything after "?ref=" until the end
        // returns "" if not present
        idx = strings.index_of(src, "?ref=")
        idx >= 0 ? strings.substring(src, idx+5, -1) : ""
      }
      
      // Policy: all non-local modules must be versioned
      mandatory_versioning = rule {
        all tfconfig.module_calls as name, module {
          // local modules (./ or ../) are excluded
          if strings.has_prefix(module.source, "./") or strings.has_prefix(module.source, "../") {
            true
          } else if is_vcs(module.source) {
            // VCS modules: ref must be vX.Y.Z
            ref = ref_of(module.source)
            ref matches "^v[0-9]+\\.[0-9]+\\.[0-9]+$"
          } else {
            // Registry modules: version argument must be X.Y.Z
            module.version is not null and
            module.version matches "^[0-9]+\\.[0-9]+\\.[0-9]+$"
          }
        }
      }
      
      // Detailed validation for OCI modules
      validate_oci_modules = rule {
        all tfconfig.module_calls as name, module {
          if strings.contains(module.source, "oci-") {
            if is_vcs(module.source) {
              // strict path structure + SemVer tag
              module.source matches "^git::.+//base/oci-.+\\?ref=v[0-9]+\\.[0-9]+\\.[0-9]+$"
            } else {
              module.version is not null and
              module.version matches "^[0-9]+\\.[0-9]+\\.[0-9]+$"
            }
          } else {
            true
          }
        }
      }
      
      main = rule {
        mandatory_versioning and validate_oci_modules
      }

       这样,第一步就完成了。

      再进一步:通过 Policy-as-Code 实现 Allow-List 与 Deny-List

      如果您想更进一步,下面的策略能带来更多价值。通过它,您可以定义模块来源的允许列表(Allowlist)以及存在缺陷或漏洞版本的拒绝列表(Denylist),并可选地添加“Advisory”提示信息。但请不要直接 1:1 套用此策略,而应根据您自己的使用场景,尤其是实际存在的版本与安全通告进行调整,否则规则不会生效:


      import "tfconfig/v2" as tfconfig
      import "strings"
      
      // Helper functions
      is_local = func(src) {
        strings.has_prefix(src, "./") or strings.has_prefix(src, "../")
      }
      
      is_vcs = func(src) {
        strings.has_prefix(src, "git::") or strings.contains(src, "://")
      }
      
      ref_of = func(src) {
        i = strings.index_of(src, "?ref=")
        i >= 0 ? strings.substring(src, i+5, -1) : ""
      }
      
      // Allowlist of module sources
      allowed_module_sources = [
        "git::https://gitlab.ict.technology/modules//",
        "app.terraform.io/ict-technology/"
      ]
      
      // Banned lists separated for VCS tags and Registry versions
      banned_vcs_tags = [
        "v1.2.8",  // CVE-2024-12345
        "v1.5.2"   // Critical bug in networking
      ]
      
      banned_registry_versions = [
        "1.2.8",
        "1.5.2"
      ]
      
      // Rule: only allowed sources OR local modules
      only_allowed_sources = rule {
        all tfconfig.module_calls as _, m {
          is_local(m.source) or
          any allowed_module_sources as pfx { strings.has_prefix(m.source, pfx) }
        }
      }
      
      // Rule: no banned versions (VCS: tag in ref, Registry: version argument)
      no_banned_versions = rule {
        all tfconfig.module_calls as _, m {
          if is_local(m.source) {
            true
          } else if is_vcs(m.source) {
            t = ref_of(m.source)
            not (t in banned_vcs_tags)
          } else {
            // Registry
            m.version is string and not (m.version in banned_registry_versions)
          }
        }
      }
      
      // Advisory: warning for old major versions (set policy enforcement level to "advisory")
      warn_old_modules = rule {
        all tfconfig.module_calls as _, m {
          if is_local(m.source) {
            true
          } else if is_vcs(m.source) {
            r = ref_of(m.source)
            // If v1.*, then print warning, rule still passes
            strings.has_prefix(r, "v1.")
              ? (print("WARNING: Module", m.source, "uses v1.x - consider upgrading to v2.x")) or true
              : true
          } else {
            // Registry
            m.version is string and strings.has_prefix(m.version, "1.")
              ? (print("WARNING: Module", m.source, "uses 1.x - consider upgrading to 2.x")) or true
              : true
          }
        }
      }
      
      // Main rule: hard checks must pass
      main = rule {
        only_allowed_sources and no_banned_versions
      }

       

      Testing Framework:Terraform 1.10+ 中的自动化防护栏

      Sentinel Policy 是一种手段——但有时,您希望能在项目代码所在的位置直接验证规则。从 Terraform 1.10 开始,官方提供了原生 Testing Framework。它允许您编写轻量、聚焦的测试,用于增强模块的防错能力。无需外部引擎,无需额外负担——在项目内即可声明式定义。


      # tests/guardrails.tftest.hcl
      
      variables {
        max_creates = 50
        max_changes = 25
      }
      
      run "no_destroys_in_plan" {
        command = plan
      
        assert {
          condition = length([
            for rc in run.plan.resource_changes : rc
            if contains(rc.change.actions, "delete")
          ]) == 0
          error_message = "Plan contains deletions. Please split the change or use an approval workflow."
        }
      }
      
      run "cap_creates" {
        command = plan
      
        # Counts pure creates, not replacements
        assert {
          condition = length([
            for rc in run.plan.resource_changes : rc
            if contains(rc.change.actions, "create") && !contains(rc.change.actions, "delete")
          ]) <= var.max_creates
          error_message = format(
            "Too many new resources (%d > %d) in one run – split into smaller batches.",
            length([for rc in run.plan.resource_changes : rc if contains(rc.change.actions, "create") && !contains(rc.change.actions, "delete")]),
            var.max_creates
          )
        }
      }
      
      run "cap_changes" {
        command = plan
      
        assert {
          condition = length([
            for rc in run.plan.resource_changes : rc
            if contains(rc.change.actions, "update")
          ]) <= var.max_changes
          error_message = "Too many updates on existing resources - blast radius is too high."
        }
      }
      
      run "cap_replacements" {
        command = plan
      
        assert {
          condition = length([
            for rc in run.plan.resource_changes : rc
            if contains(rc.change.actions, "create") && contains(rc.change.actions, "delete")
          ]) == 0
          error_message = "Plan contains replacements (create+delete) - please review and minimize them."
        }
      }

      这样,您就能一举两得:工程师可在项目层面确保模块配置规范,而 CI/CD 流水线则多了一条安全带。如果有问题,也能在早期发现,而不是在生产环境中爆雷。

      不过,解释一下机制可能更有帮助。

      原理其实很简单:

      1. Terraform 为每个 run 执行一次 plan。Testing Framework 将结果以结构化数据形式暴露在 run.plan 下。这不是纯文本输出,而是一个对象,其中包含 resource_changes
      2. run.plan.resource_changes 是一个列表,每个元素代表一个计划的资源操作。每个元素都有 change.actions,即 Terraform 为该资源计划执行的动作。可能的内容包括:
        • 仅包含 "create",表示新建资源;
        • 仅包含 "update",表示修改现有资源;
        • 同时包含 "create" 和 "delete",表示替换操作,即先删除再新建,这是典型的 Replacement。
      3. 断言(Assertions)就是针对该列表的计数规则:
        • no_destroys_in_plan 过滤出包含 "delete" 动作的项,如果集合非空,测试失败,从而防止执行破坏性删除。
        • cap_creates 仅统计纯新增(包含 "create" 且不含 "delete")的资源,故不计入替换操作。错误信息使用 format(...),避免引号冲突并显示实际值与阈值。
        • cap_changes 限制更新(即修改现有资源)的数量。
        • cap_replacements 明确捕获高风险的替换操作,即同一资源的 "create" + "delete" 组合。此类操作通常带来最大风险,例如短暂的停机或副作用。
      4. 文件开头的 variables { ... } 定义了全局阈值,可在不同流水线或环境中调整策略,而无需修改测试逻辑。
      5. 该测试无需依赖项目内部特有逻辑,它仅基于 Terraform 的标准化 Plan 模型运行。因此具有高度可移植性,并可即刻使用——当然,前提是我没在里面又犯了什么低级错误。

      如果您还想测试预期的 Precondition 失败,也可以做到,但需要在代码中定义具名的 check 块。例如:


      # In modules or root
      check "api_limits_reasonable" {
        assert {
          condition     = var.instance_count <= 200
          error_message = "Instance-Batch zu groß."
        }
      }
      
      # and a test which deliberately violates the precondition
      run "expect_api_limit_breach" {
        command = plan
        variables {
          instance_count = 1000
        }
        expect_failures = [ check.api_limits_reasonable ]
      }

      如果没有这些具名的 check,测试将无法正确匹配目标。对于模块版本控制或版本约束检查,Testing Framework 并不适用,因为它只基于 Plan 内容而非模块元数据。此类验证应使用 Sentinel Policy 或 CI 脚本,通过解析模块源地址及 ?ref= 标签或 version 约束实现。

      事已至此

      假设现在您的 State 已经损坏,依赖关系也出现错误,那么也许是时候采取非常规措施了。这里有一个针对“硬核玩家”的应急方案,同样只提供 Git 仓库链接:Github - ICTtechnology/module-state-recovery
      该脚本能为您省下繁琐的手工排查工作,自动找出 Terraform State 中存在问题的模块。它会先备份 State,执行一次 plan 运行,将结果导出为 JSON,然后仔细分析哪些模块引发了问题。受影响的资源会在预览中列出。如果您愿意,还可以使用映射文件(mapping file),在正式清理前自动、干净地迁移 State 地址。

      在正常模式下不会有破坏性操作——一切都以 Dry-Run 模式运行。只有当您明确地使用 CONFIRM=1I_UNDERSTAND=YES 参数执行时,脚本才会真正介入——那时它会“全力出击”,动用“大斧头”。

      这使它成为一个非常有用的训练与测试工具,可帮助理解并解决模块冲突问题。但它明确不适用于生产环境。

       

      稳健模块依赖关系检查清单

      ✅ 版本策略

      [ ] 所有基础模块和服务模块都采用语义化版本(Semantic Versioning)。若在此发挥创意,后果将加倍。
      [ ] 每个模块层级(Root、Service、Base)均定义固定版本(Pinning)策略。写“latest”的人注定会失败。
      [ ] 已建立并维护模块版本兼容矩阵(Compatibility Matrix)。
      [ ] 已集成版本锁定机制(Dependency Lock)或用于版本校验的 CI 脚本。

      ✅ 治理(Governance)

      [ ] 模块版本跟踪已集成至 CI/CD 流程。
      [ ] 针对允许模块来源及版本范围的 Sentinel Policy 已建立。
      [ ] 已配置包含已知错误或漏洞模块版本的允许列表(Allow-List)与拒绝列表(Deny-List)。
      [ ] 已建立重大变更通知机制(Breaking Change Notification),包括发布说明与安全通告系统。
      [ ] 模块清单(Inventory)会定期更新与审核。

      ✅ 监控(Monitoring)

      [ ] 模块更新与 State 变更的审计日志(Audit Logging)已启用。
      [ ] 已实现破坏性变更的早期预警系统(Plan 分析与退出码控制)。若不存在,您只能在生产环境中“听到爆炸声”。
      [ ] 对异常模块变更或依赖漂移(Dependency Drift)的告警系统已部署并处于激活状态。

      ✅ 恢复(Recovery)

      [ ] 已定义、记录并传达 State 修复(State Surgery)与地址迁移(Address Migration)流程。
      [ ] 已制定针对错误模块更新的回滚策略。
      [ ] 已建立并传达应对关键模块问题的应急响应手册(Emergency Response Playbook)。
      [ ] 团队已接受模块依赖关系故障排查(Troubleshooting)培训。

      是的,当您的自动化基础架构达到一定规模时,以上所有措施都必须具备——即便要顶住那些自称“高级工程师”的反对声。

      因为,没有什么比当系统着火时,所有人都一脸茫然更糟糕的事了。