3. 流水线配置解析与语法陷阱

很多流水线故障并不是 Runner 执行失败,而是配置在“被 GitLab 解析”的阶段就已经走偏:关键字拼错、include 路径找不到、extends 覆盖顺序反了、锚点展开后变量没生效。要稳定排障,必须把 .gitlab-ci.yml 当成一份有明确解析阶段和合并规则的声明式文档来理解,而不是当成一段顺序执行的脚本。

一、解析的三个阶段:先展开、再合并、后求值

GitLab 处理一份流水线配置,可以粗略分成三个先后阶段,顺序错了就会得出错误结论:

  1. 结构展开阶段:读取主文件,处理 include 引入的外部文件,把 !reference 标签替换为被引用节点的内容,把 YAML 锚点 &name 与别名 *name 展开。这一阶段只做“文本级/结构级”的拼接与替换,不解析变量。
  2. 合并阶段:对每个 job 应用 extends、default、inherit 等继承规则,按优先级把父级配置合并进子 job,形成每个 job 的最终配置树。
  3. 变量求值阶段:在合并后的配置上展开 $VAR、${VAR},解析 rules、only/except、needs 等依赖与条件,最终生成可执行的流水线 DAG。

关键结论:锚点和 !reference 在变量求值之前就已完成展开,所以它们不能“动态”引用变量;而 extends 的合并发生在变量求值之前,因此父模板里写的 $CI_COMMIT_BRANCH 会在子 job 的上下文中求值,而不是在父模板定义处求值。

.gitlab-ci.yml 解析三阶段 ① 结构展开 include 引入外部文件 !reference 替换节点内容 锚点 &name / 别名 *name 展开 此阶段不解析 $VAR ② 合并继承 extends 父模板 → 子 job default 全局默认值 inherit 控制继承范围 子级覆盖父级,深合并 ③ 变量求值 展开 $VAR / ${VAR} 解析 rules / only / needs 生成可执行 DAG 此时才决定 job 是否运行 合并优先级(低 → 高,高者覆盖低者) default 全局默认 extends 父模板 可多层继承 include 内容 被引入后参与合并 job 自身定义 最终生效 常见陷阱 • 锚点/!reference 中写 $VAR → 展开时变量未求值,得到字面量 • extends 写成列表顺序颠倒 → 期望的覆盖被父模板反向覆盖 • include 路径相对根目录而非当前文件 → 找不到文件导致整条流水线不创建

二、include:路径、时机与作用域

include 支持 local、project、remote、template 四种来源。最容易踩的坑是路径基准:local 的路径相对于仓库根目录,而不是相对于当前 YAML 文件所在目录。如果模板放在 ci/templates/build.yml,主文件在根目录,应写 include: 'ci/templates/build.yml';若在 ci/main.yml 中再 include 同级文件,仍要写完整根路径 ci/templates/build.yml,写成 ./build.yml 会失败。

另一个高频问题是 include 的求值时机:它在结构展开阶段完成,因此被 include 的文件里如果引用了主文件中定义的变量,只要变量在变量求值阶段能解析到,通常仍可用;但被 include 文件中的 include 嵌套层级和循环引用会直接报错。排障时优先看流水线创建失败的报错信息,GitLab 会明确指出是哪个 include 路径无法解析。

三、extends 与 !reference:两种复用方式的差异

extends 是深合并:父模板的 script 数组会被子 job 的 script 整体替换(数组不合并),而 variables、cache 等映射会按键合并。子 job 中显式写的键覆盖父模板同名键。若 extends 写成列表,后面的元素优先级更高,例如 extends: [.base, .override] 中 .override 覆盖 .base。

!reference 则是精确取值:它把某个节点(如 .setup.script)的内容原样插入当前位置,不做合并。它适合“只借用一段 script 或 rules”,而不想继承整个模板。注意 !reference 只能引用同一文件或已 include 文件中的节点,且被引用节点必须以 . 开头(隐藏 job)或为顶层键。

.base:
  image: alpine:3.19
  before_script:
    - echo "prepare"

build:
  extends: .base
  script:
    - echo "build"
  variables:
    MODE: release

test:
  script:
    - !reference [.base, before_script]
    - echo "test"

上例中 build 继承 image 与 before_script,并新增 MODE;test 不继承 image,只把 .base 的 before_script 内容插入自己的 script 开头。两者语义完全不同,混用会导致“以为继承了却没继承”或“以为只借一段却带进整个模板”。

四、锚点与变量展开顺序的实战影响

YAML 锚点由 YAML 解析器处理,发生在 GitLab 变量求值之前。因此下面这种写法是无效的:

.common: &common
  script:
    - echo "$CI_COMMIT_BRANCH"

job_a:
  <<: *common

锚点展开后 job_a.script 就是字面量 echo "$CI_COMMIT_BRANCH",变量在 Runner 执行时才由 shell 展开,这通常没问题;但若把变量用在 rules:if 或 include 路径中,就必须依赖 GitLab 的变量求值阶段,锚点无法提前“算”出结果。典型错误是试图用锚点拼接 include 路径:include: *path 中 *path 若含 $VAR,GitLab 不会在 include 阶段展开它,导致路径解析失败。

排障口诀:include 和锚点看“结构”,extends 看“合并”,rules 和 script 看“变量”。报错发生在流水线创建阶段,多半是 include/锚点/语法问题;报错发生在 job 运行阶段,多半是变量或脚本问题。

五、一份可复用的配置自检清单

检查项正确做法典型错误
include 路径相对仓库根目录写全路径写成相对当前文件的 ./x.yml
extends 顺序高优先级模板放列表后面顺序颠倒导致覆盖失效
数组字段script 会被整体替换以为 script 会追加合并
!reference 目标引用已定义的隐藏 job 节点引用不存在的键或未 include 的文件
变量位置rules/if 中用变量需在求值阶段可解析在锚点或 include 路径中写 $VAR
YAML 语法用 CI Lint 校验缩进、冒号后缺空格、重复键

实操建议:每次修改配置后,先在 GitLab 的 CI/CD → Editor → Lint 中校验,它会同时报告 YAML 语法错误和 include 解析错误;再在 Pipelines → 新建流水线 页面查看“流水线创建失败”的具体原因。把这两步固定成习惯,绝大多数配置类故障都能在提交前拦截。