极狐GitLab

流水线偶发失败总在手动重跑,重试与超时该怎么配

极狐GitLab
2026年10月22日
18051
分享:

流水线偶发失败总在手动重跑,重试与超时该怎么配

定义框:作业重试(retry)是 CI/CD 中的作业级关键字,用于声明作业失败时允许被重新执行的次数与触发条件,取值只能是 0、1、2。它与"失败放行"(allow_failure)不同:重试是再跑一次直到成功,放行是不再重跑但让流水线继续往下走。

偶发失败为什么总被当成真失败

凌晨三点被叫起来看一条红掉的流水线,点进去发现是拉取镜像超时,手动点一下"重试"就绿了——这个场景几乎每个团队都经历过。问题不是它红了,而是它必须由人来点这一下。

要理解为什么,得先分清失败的两类来源。一类是确定性失败:代码没编译过、单测断言不成立、镜像里缺依赖。这类失败重跑一万次结果一样,重试纯属浪费 Runner 时间。另一类是非确定性失败:Runner 拉取镜像的网络抖动、外部制品源限流、Runner 节点被回收、作业长时间卡在 pending。这类失败跟代码无关,重跑大概率能过。

麻烦在于,CI 系统默认不区分这两类。retry 不写就是 0,任何失败都会直接把流水线判死;而很多团队为了"少被叫醒",干脆不加区分地写 retry: 2,于是确定性失败也被多跑两遍,构建机时间翻倍,真正的编译错误要等六分钟才暴露。

区分的代价不在技术上,而在配置粒度上。极狐GitLab 的 retry 提供了 when 与 exit_codes 两个维度来划边界:前者按失败原因分类,后者按退出码分类。用对了,偶发失败自动消化;用错了,就是把真问题埋进两次重试里。

第一步:按失败类型而不是"全部"来重试

最简单的写法是给次数,这也是最常见的写法:

test:
  script: bundle exec rspec
  retry: 2

这行的含义是"任何失败都最多再跑两次"。对单测作业来说,这是纯粹的浪费——断言失败不会因为再跑一次而变绿。

更实用的写法是限定失败原因。极狐GitLab 从 19.1 开始把原先笼统的失败原因拆得更细,建议直接用新的分类:

build:
  script: docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
  retry:
    max: 2
    when:
      - runner_system_failure
      - stuck_pending_no_matching_runners
      - no_updates_running

这里每一项都对应一种与代码无关的情况:

  • runner_system_failure:Runner 自身出了问题,比如拉取 Docker 镜像失败。

  • stuck_pending_no_matching_runners:作业长时间待处理,因为没有 Runner 匹配标签或受保护状态。

  • stuck_pending_with_matching_runners:有匹配的 Runner 却仍然长时间 pending,通常是 Runner 侧调度异常。

  • no_updates_running:作业在跑但长时间没有任何输出,通常是脚本挂死或 Runner 失联。

  • no_updates_canceling:取消请求发出后长时间没有响应。

需要注意两点。第一,stuck_or_timeout_failure 这个老值在 19.1 已废弃,仅为历史数据保留,新作业不会再命中它,要重试这类场景必须同时写上前面那几个更具体的原因。第二,script_failure 在部分场景下已从"脚本失败"细化为 runner_configuration_error,如果你依赖 script_failure 做重试判断,升级后可能发现命中率变了。

退出码维度则适合那些"用什么信号退出"比"为什么失败"更明确的场景,比如容器被 OOM 杀掉固定返回 137:

test_advanced:
  script:
    - echo "Run a script that results in exit code 137."
    - exit 137
  retry:
    max: 2
    when: runner_system_failure
    exit_codes: 137

第二步:用退出码把"可以放行"的失败单独圈出来

有一类作业失败是预期内的:合规扫描扫出了中危漏洞、兼容性测试在旧版本上不通过、可选的质量门禁没达标。它们不应该阻断交付,但也不该静默消失。

allow_failure: true 是最粗的做法——只要失败就放行,包括那些你没预料到的失败。更精确的做法是列出允许放行的退出码:

compat_test:
  script:
    - ./scripts/compat-check.sh
  allow_failure:
    exit_codes: [64, 65]

security_scan:
  script:
    - ./scripts/sast-scan.sh
  allow_failure:
    exit_codes: 3

含义是:脚本以 64 或 65 退出时作业被标记为"允许失败",流水线继续;以其他任何退出码退出时,作业照常判失败并阻断后续阶段。这样脚本作者只要在退出码上做好约定,就能把"扫到中危"和"扫描器自己崩了"区分开——后者退出码是 1,会立刻拦住流水线。

这里有个容易踩的连带效应:一旦作业被判为 allow_failure: true,它即便失败也被视为成功,因此配置了 when: on_failure 的后续作业不会被触发。如果你指望"扫描失败就通知人",用 allow_failure 放行后这条链路就断了,通知得改由脚本内部发,或者改用退出码维度控制而不是整体放行。

第三步:超时和可中断,管的是时间而不是结果

很多流水线的资源浪费不来自失败,而来自"跑得没必要那么久"。

超时是作业级关键字,可以长于项目级超时,但不能长于 Runner 自己的超时,两者同时设置时取较小值:

build:
  script: build.sh
  timeout: 3 hours 30 minutes

test:
  script: rspec
  timeout: 3h 30m

有两个细节值得记住。一是 timeout 不能写在 default 里(官方议题 213634),只能在具体作业上定义;二是无论超时怎么配,一小时没有任何输出的作业会被直接丢弃,要避免这种情况只能让脚本持续输出进度,例如构建过程里实时打印阶段名。

可中断解决的是另一件事:同一个分支推了新提交,旧流水线还在跑,值不值得让它继续。interruptible 配合项目设置里的"自动取消多余的流水线"一起工作,行为由 workflow:auto_cancel:on_new_commit 控制:

workflow:
  auto_cancel:
    on_new_commit: conservative

stages:
  - build
  - test
  - deploy

build_job:
  stage: build
  script: ./build.sh
  interruptible: true

deploy_job:
  stage: deploy
  script: ./deploy.sh
  # 不写 interruptible,默认 false

conservative 是默认行为,含义偏保守:一旦有不可中断的作业启动,整条流水线就再也不被视为可中断。把 on_new_commit 改成 interruptible 则会切换到按作业判断——只有标了 interruptible: true 且正在运行或待处理的作业会被取消。

经验法则很简单:构建、单测这类可重来的作业标 true,部署作业一律不标。部署被中途取消意味着半成品状态,恢复成本远高于多等的那几分钟。另外要清楚,自动取消只在"同一 ref 上因新提交触发了新流水线"时生效,手动重跑、API 触发、定时流水线都不在它的管辖范围内。

第四步:给失败留证据,别让重试抹掉现场

自动重试带来的副作用是现场丢失——作业重跑一次就成功了,没人知道第一次为什么红。解决办法是把失败瞬间的产物留下来:

test:
  script: ./run-tests.sh
  retry:
    max: 2
    when: runner_system_failure
  artifacts:
    when: on_failure
    paths:
      - logs/
      - reports/
    expire_in: 7 days

artifacts:when: on_failure 只在失败时上传,expire_in 限制保留期,避免把存储吃穿。需要注意 artifacts:reports 类型(测试报告、覆盖率报告等)不受这个开关影响,无论成败都会上传。

五个配置坑,配之前先扫一遍

  1. retry: 2 等价于 when: always。不加限定的重试会把确定性失败也重跑两遍,构建时长直接翻倍。上线前先确认你的作业失败里有多少属于非确定性原因,比例低于 10% 就别加全局重试。

  2. retry 最多 2 次。这是硬上限,不是建议值。如果两次都过不了,说明问题不在抖动层面。

  3. timeout 写进 default 不生效。只能在作业级定义,这是已知限制。

  4. interruptible: false 会传染整条流水线。在保守模式下,只要一个不可中断的作业启动了,后面即使写了 interruptible: true 的作业也取消不掉。

  5. 一小时零输出会被丢弃,与超时设置无关。长任务必须持续打印进度,否则配了 timeout: 3 hours 也救不了。

常见问题

问:重试会不会掩盖真正的 flaky 测试?

会。所以对测试类作业建议不加重试,或者只针对 runner_system_failure 这类环境原因重试,把 flaky 问题留在台面上修,而不是靠重跑消化掉。

问:项目级超时和作业级超时冲突时听谁的?

作业级可以比项目级长,但不能超过 Runner 的超时;项目级与 Runner 级同时存在时取较小值。三者里最短的那个最终生效。

问:开启自动取消后,部署作业会被中途掐掉吗?

取决于 interruptible。部署作业不写这个关键字就是默认 false,在保守模式下它一旦启动,整条流水线都不会被取消。真正的风险是误把部署作业标成 true,那才会在新提交推送时被掐断。


把重试策略落进你的流水线:以上配置均基于极狐GitLab CI/CD 的标准关键字,私有化部署与 SaaS 版本一致。想要一份可直接复制的 .gitlab-ci.yml 重试模板,或需要针对现有流水线做一次失败原因分布分析,欢迎联系极狐GitLab 团队获取实践支持。