3. 流水线配置与继承机制

当流水线从几十行扩展到上千行、从单项目扩展到多项目共享时,真正决定可维护性的不是 YAML 写得多花哨,而是复用与继承的边界是否清晰。GitLab CI 提供了四类互补的复用手段:include 负责跨文件引入,extends 负责作业级继承,YAML anchors(&/*)负责同文件内的片段复用,!reference 负责精确引用某个键值。理解它们的求值顺序和覆盖规则,才能避免“改了模板却没生效”或“变量被意外覆盖”这类高频问题。

一、include:把配置拆成可组合的模块

include 在流水线创建前把外部 YAML 合并进当前配置,支持四种来源:local(同仓库路径)、project(同实例其他项目)、remote(公网 URL)、template(GitLab 官方模板)。它解决的是“同一份规则被多个项目引用”的问题,例如把安全扫描、镜像构建规范集中到一个 ci-templates 仓库。

include:
  - local: '/ci/build.yml'
  - project: 'devops/ci-templates'
    ref: v2.3.0
    file:
      - '/templates/security.yml'
      - '/templates/deploy.yml'
  - template: Security/SAST.gitlab-ci.yml

variables:
  BUILD_IMAGE: registry.example.com/base/node:20

关键规则有三点。第一,include 的合并发生在流水线解析阶段,被引入文件中的 stages、variables、作业定义都会参与最终配置。第二,后引入者覆盖先引入者,当前文件自身的定义优先级最高,因此本地覆盖模板是推荐做法。第三,project 类型务必锁定 ref(分支、标签或 commit SHA),否则上游模板变更会直接冲击所有下游流水线。实践中建议用标签而非分支,让模板升级成为一次显式动作。

二、extends:作业级的继承与覆盖

extends 让一个作业继承另一个作业(或隐藏作业,即以 . 开头的作业)的键值,并支持数组形式的多级继承。它比 anchors 更适合表达“模板作业 + 具体作业”的关系,因为继承发生在合并之后、且语义更明确。

.node-job:
  image: $BUILD_IMAGE
  before_script:
    - npm ci --cache .npm --prefer-offline
  cache:
    key: node-$CI_COMMIT_REF_SLUG
    paths: [.npm/]

build:app:
  extends: .node-job
  stage: build
  script:
    - npm run build
  artifacts:
    paths: [dist/]

test:unit:
  extends: .node-job
  stage: test
  script:
    - npm run test:unit

覆盖规则需要重点记忆:哈希(如 cache、artifacts)做深度合并,子作业只写需要改的键即可;数组(如 script、before_script)整体替换,不会追加。因此若模板里已有 before_script,子作业再写一个就会完全覆盖它。多级继承时,extends: [.a, .b] 中靠后的元素优先,且最多支持 11 层,超过会报循环或深度错误。

三、anchors 与 !reference:同文件内的精确复用

YAML anchors 用 &name 定义、*name 引用,适合复用一段脚本或一组键值;<<: *name 可把映射合并进当前节点。它的局限是只能整体引用,无法只取其中某个键。

.default-rules: &default-rules
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

lint:
  script: npm run lint
  <<: *default-rules

.scripts:
  install: &install_deps
    - npm ci --prefer-offline

build:
  script:
    - *install_deps
    - npm run build

!reference 则更进一步,可以按路径取出任意层级的键值,并支持数组拼接,这是 anchors 做不到的:

.setup:
  script:
    - echo "prepare env"
    - export NODE_ENV=production

deploy:
  script:
    - !reference [.setup, script]
    - ./deploy.sh

注意 !reference 只能引用同一文件或通过 include 引入的文件中的键,不能跨 include 边界引用未合并的路径;同时它不参与 extends 的深度合并,属于“取值后拼接”的语义。

四、四类机制的协作关系

把四者放在一起看,它们分别作用于不同层级:include 在文件层做组合,extends 在作业层做继承,anchors 在片段层做复用,!reference 在键值层做精确取值。下图展示了从模板仓库到最终作业的合并路径与优先级。

GitLab CI 配置复用与继承的合并路径 模板仓库 ci-templates ref: v2.3.0(锁定版本) 官方模板 Security/SAST.gitlab-ci.yml 本地文件 /ci/build.yml include 文件层合并 后引入覆盖先引入 本地定义优先级最高 合并后配置 stages / variables 隐藏作业 .node-job anchors 与 !reference extends 作业级继承 哈希深合并 / 数组替换 最终作业 build:app / test:unit deploy 覆盖优先级(从低到高) include 引入内容 extends 父作业 anchors / !reference 当前作业自身定义 • 哈希键(cache、artifacts、variables)深度合并,子级只写差异键 • 数组键(script、before_script、rules)整体替换,不会追加 • project include 必须锁定 ref,避免上游变更冲击下游流水线

五、实践中的判断依据与常见陷阱

选择哪种机制,可以按“复用范围”判断:跨项目共享用 include,同项目多作业共享用 extends,同文件内一段脚本或一组键值用 anchors,需要精确取某个键或拼接数组用 !reference。四者可以叠加使用,但叠加越多,排查成本越高。

三个高频陷阱值得警惕。其一,数组被整体替换:模板里写了 before_script,子作业又写一个,结果模板的安装步骤消失,构建因缺少依赖而失败。其二,变量作用域:extends 继承的 variables 会与当前作业的 variables 合并,同名键以子作业为准,但 include 引入的全局变量可能被本地同名变量覆盖,导致镜像地址或缓存路径意外改变。其三,循环继承:.a extends .b 同时 .b extends .a 会直接报错,多级继承超过 11 层同样失败。

排查继承问题的实用方法:在 CI Lint 页面(项目 → CI/CD → 流水线编辑器 → Lint)粘贴配置,可看到合并后的完整 YAML;也可用 gitlab-ci-local 在本地渲染,快速定位某个键最终来自哪个文件。

一个可落地的组织方式是:模板仓库只放隐藏作业(. 开头)和 !reference 片段,不直接定义可运行作业;业务项目通过 include 引入模板,再用 extends 派生具体作业,仅在子作业中覆盖必要键。这样模板升级只需改一处,下游通过 ref 标签控制升级节奏,既保证一致性,又保留项目级差异。