4. 模块化与可复用基础设施

当一套环境从“十几个资源”增长到“几十甚至上百个资源”时,单个 Bicep 文件或单个 ARM 模板会迅速变得难以维护:参数越来越多、资源之间引用错综复杂、不同环境之间只能靠复制粘贴来区分。模块化的核心目标,就是把一个庞大部署拆成若干职责单一、接口清晰、可独立演进的单元,再通过组合的方式拼装出完整环境。本节围绕 Bicep 模块(module)与 ARM 嵌套模板(nested template)两条主线,讲清拆分思路、共享模块库的设计、版本与依赖管理,以及如何判断拆分是否合理。

一、为什么需要模块化:从“一个文件”到“一组积木”

模块化解决的不是“能不能部署”的问题,而是“能不能长期维护”的问题。一个典型的非模块化部署文件往往同时承担了网络、存储、计算、身份、监控等多类资源的定义,任何一处改动都可能牵动全局。模块化之后,每个模块只负责一类资源或一个业务能力,对外暴露少量参数和输出,内部实现可以独立替换。这样带来的直接收益是:同一套模块可以在开发、测试、生产环境中复用,差异通过参数注入而不是复制文件来体现;模块可以被多个项目共享,形成组织级的模块库;模块的变更影响范围被限制在接口契约之内。

需要强调的是,模块化不是把文件机械地切成更小的文件。判断拆分是否合理的标准是:模块是否有明确的职责边界、是否有稳定的输入输出契约、是否可以被独立测试和复用。如果一个模块只是把原文件按行数切开,参数和输出仍然互相纠缠,那它只是“分散”,不是“模块化”。

二、Bicep 模块:声明式组合的基本单元

Bicep 使用 module 关键字引用另一个 Bicep 文件,被引用的文件就是一个模块。模块通过 params 接收输入,通过 outputs 向外暴露结果。下面是一个存储账户模块的接口示例:

// modules/storageAccount.bicep
@description('存储账户名称,全局唯一,3-24位小写字母和数字')
param storageAccountName string

@description('部署区域')
param location string = resourceGroup().location

@allowed(['Standard_LRS', 'Standard_GRS', 'Premium_LRS'])
param skuName string = 'Standard_LRS'

@description('是否允许公共网络访问')
param allowPublicAccess bool = false

resource sa 'Microsoft.Storage/storageAccounts@2023-01-01' = {
  name: storageAccountName
  location: location
  sku: { name: skuName }
  kind: 'StorageV2'
  properties: {
    allowPublicAccess: allowPublicAccess
    minimumTlsVersion: 'TLS1_2'
  }
}

output storageAccountId string = sa.id
output primaryEndpoints object = sa.properties.primaryEndpoints

在主部署文件中,通过 module 引用它,并把输出传递给下游模块:

module storage 'modules/storageAccount.bicep' = {
  name: 'storageDeploy'
  params: {
    storageAccountName: 'stappdev001'
    skuName: 'Standard_LRS'
    allowPublicAccess: false
  }
}

module webApp 'modules/webApp.bicep' = {
  name: 'webAppDeploy'
  params: {
    appName: 'app-dev-001'
    storageAccountId: storage.storageAccountId
  }
}

这里的关键点是:主文件只关心“有哪些模块、模块之间如何连接”,不关心模块内部如何实现。模块之间的依赖关系由 Bicep 自动推导——当 webApp 的参数引用了 storage 的输出时,Bicep 会保证存储账户先部署。这种基于数据引用的隐式依赖,比手工维护 dependsOn 更可靠,也更符合声明式思维。

三、ARM 嵌套模板:Bicep 之外的另一种组合方式

在 Bicep 出现之前,ARM 模板通过 Microsoft.Resources/deployments 资源实现嵌套部署。嵌套模板有两种形态:内联(inline)嵌套,即把子模板内容直接写在父模板的 properties.template 中;外部链接(linked)嵌套,即通过 templateLink 指向一个可访问的模板 URI。内联嵌套适合小型、一次性的拆分;外部链接嵌套适合跨项目共享的模板库,但要求模板文件可被 Azure 部署引擎通过 HTTPS 访问,通常放在存储账户或模板规格(Template Spec)中。

Bicep 在编译时,会把 module 转换为 ARM 的嵌套部署资源。因此理解嵌套模板的机制,有助于排查 Bicep 模块部署时的错误。例如,当模块部署失败时,错误信息会出现在父部署的 Microsoft.Resources/deployments 子资源下,需要逐层展开才能定位到真正失败的资源。这也是模块化带来的一个副作用:部署层级变深,排障路径变长。

Bicep 模块组合与依赖关系示意 main.bicep(主部署文件) 声明模块引用、参数注入、输出传递 network.bicep VNet / 子网 / NSG 输出:subnetId storageAccount.bicep 存储账户 / 容器 / 生命周期 输出:storageAccountId webApp.bicep App Service / 配置 / 身份 输出:appId / principalId subnetId storageAccountId 共享模块库(Git 仓库 / 模板规格 / 私有注册表) 版本标签 v1.2.0 · 语义化版本 · 变更日志 · 接口契约文档 依赖方向:主文件 → 模块;模块间通过输出引用形成隐式依赖;共享库为模块提供版本化来源 部署层级:父部署 → 嵌套部署(每个 module 对应一个 Microsoft.Resources/deployments 子资源)

四、设计可共享的模块库

模块库的价值在于“一次编写,多处复用”。要让模块真正可共享,需要从接口、版本、文档三个维度进行约束。

接口设计。模块的参数应当遵循“最小必要”原则:只暴露调用方必须决策的选项,其余使用合理默认值。例如存储账户模块不应要求调用方传入 kind,而应固定为 StorageV2;但 skuName 和 allowPublicAccess 因环境而异,应作为参数暴露。参数命名要统一,建议采用 资源类型 + 属性 的驼峰命名,如 storageAccountName、vnetAddressPrefix。输出同样要克制,只输出下游模块真正需要的标识符或端点,避免把整个资源对象暴露出去。

版本管理。模块库应使用 Git 仓库管理,并通过语义化版本(SemVer)打标签。Bicep 支持通过 br: 前缀引用注册表中的模块,例如 br:myregistry.azurecr.io/bicep/modules/storage:v1.2.0。版本号的含义应明确:主版本号变更表示接口不兼容,次版本号表示新增可选参数或输出,修订号表示内部实现修复。调用方在引用模块时应锁定具体版本,而不是使用 latest,否则模块库的一次变更可能同时影响所有环境。

文档与测试。每个模块应附带一个 README,说明用途、参数表、输出表和最小调用示例。更进一步,可以为模块编写独立的测试用例,使用 az deployment group what-if 或 Bicep 的 build 命令验证语法,再在隔离的资源组中实际部署一次,确认输出符合预期。模块库的 CI 流水线应在合并请求时自动执行这些检查。

五、依赖管理与拆分粒度

模块之间的依赖分为两类:显式依赖和隐式依赖。显式依赖通过 dependsOn 声明,通常用于没有数据引用但存在顺序要求的场景,例如“先创建诊断设置,再部署资源”。隐式依赖通过参数引用输出自动建立,是更推荐的方式。滥用 dependsOn 会掩盖真实的依赖关系,也会让部署图变得难以理解。

拆分粒度需要权衡。拆得太粗,模块内部仍然复杂,复用价值低;拆得太细,模块数量膨胀,主文件变成一张庞大的连接图,参数传递成本上升。一个实用的经验法则是:按“变更频率”和“生命周期”拆分。网络、身份、监控这类基础设施变更频率低、生命周期长,适合独立成模块;应用层资源变更频繁,适合按应用拆分。另一个法则是:如果一个模块的输出被三个以上下游模块引用,考虑它是否应该上移一层,避免形成星型依赖中心。

对于跨订阅、跨资源组的部署,Bicep 模块支持 scope 属性,可以把模块部署到不同的资源组或订阅。此时模块内部的 resourceGroup() 函数会返回目标作用域的信息,参数中的位置也应显式传入,避免隐式继承导致部署到错误区域。

六、一个可参考的模块库目录结构

下面给出一个组织级模块库的目录结构示例,便于团队统一约定:

infra-modules/
├── modules/
│   ├── network/
│   │   ├── vnet.bicep
│   │   ├── subnet.bicep
│   │   └── nsg.bicep
│   ├── storage/
│   │   ├── storageAccount.bicep
│   │   └── blobContainer.bicep
│   ├── compute/
│   │   ├── appServicePlan.bicep
│   │   └── webApp.bicep
│   └── monitoring/
│       ├── logAnalytics.bicep
│       └── diagnosticSettings.bicep
├── tests/
│   ├── network.tests.bicep
│   └── storage.tests.bicep
├── docs/
│   └── module-catalog.md
└── .github/workflows/
    └── validate-modules.yml

在这个结构中,modules 按资源域分组,tests 存放模块级测试,docs 维护模块目录和接口说明,CI 工作流在每次提交时执行 bicep build 和 what-if 校验。调用方项目通过 Git 子模块、Bicep 注册表或模板规格引用这些模块,并锁定版本。

七、常见问题与判断依据

模块化实践中,最常见的三个问题是:参数爆炸、输出滥用和版本漂移。参数爆炸表现为一个模块有二十多个参数,调用方每次都要填一遍;解决方法是把参数分组为对象类型,或为常用组合提供预设。输出滥用表现为模块输出了大量内部资源属性,下游模块随意引用,导致模块内部实现被外部耦合;解决方法是只输出契约中承诺的标识符。版本漂移表现为不同环境引用了模块的不同版本,导致行为不一致;解决方法是在环境配置中集中声明模块版本,并在 CI 中校验一致性。

判断模块化是否成功的最终标准,不是模块数量,而是:新增一个环境时,是否只需要提供一份参数文件,而不需要修改任何模块代码;修改一个模块的内部实现时,是否不需要改动调用方;一个模块的接口变更,是否能通过版本号清晰地传达影响范围。如果这三个问题的答案都是肯定的,那么这套模块化设计就是有效的。