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 在键值层做精确取值。下图展示了从模板仓库到最终作业的合并路径与优先级。
五、实践中的判断依据与常见陷阱
选择哪种机制,可以按“复用范围”判断:跨项目共享用 include,同项目多作业共享用 extends,同文件内一段脚本或一组键值用 anchors,需要精确取某个键或拼接数组用 !reference。四者可以叠加使用,但叠加越多,排查成本越高。
三个高频陷阱值得警惕。其一,数组被整体替换:模板里写了 before_script,子作业又写一个,结果模板的安装步骤消失,构建因缺少依赖而失败。其二,变量作用域:extends 继承的 variables 会与当前作业的 variables 合并,同名键以子作业为准,但 include 引入的全局变量可能被本地同名变量覆盖,导致镜像地址或缓存路径意外改变。其三,循环继承:.a extends .b 同时 .b extends .a 会直接报错,多级继承超过 11 层同样失败。
gitlab-ci-local 在本地渲染,快速定位某个键最终来自哪个文件。一个可落地的组织方式是:模板仓库只放隐藏作业(. 开头)和 !reference 片段,不直接定义可运行作业;业务项目通过 include 引入模板,再用 extends 派生具体作业,仅在子作业中覆盖必要键。这样模板升级只需改一处,下游通过 ref 标签控制升级节奏,既保证一致性,又保留项目级差异。