GitLab CI 从 YAML 到 Runner:一条 Job 到底是怎么跑起来的
从 pipeline 创建、Runner 匹配、executor 建环境讲到 job runtime,再理清 artifacts、cache、Docker-in-Docker 和 BuildKit 分别处在哪一层。
很多 GitLab CI 问题看起来都发生在 .gitlab-ci.yml 里,实际故障点可能离 YAML 很远。
一条 job 没出现,应该查 rules;job 一直 pending,应该查 Runner 匹配;日志停在 preparing environment,问题多半在 executor;docker build 连不上 daemon,则已经进入容器构建这一层。几类问题都显示在同一个 pipeline 页面里,很容易被当成一件事。
理解 GitLab CI,先把它拆成四层:
YAML 配置
-> GitLab 创建 pipeline 和 jobs
-> Runner 领取 job,executor 准备环境
-> job runtime 执行脚本并传递文件
YAML 是声明,GitLab 负责把声明变成执行计划,Runner 才是干活的进程。executor 决定活在哪种环境里干。至于 Docker-in-Docker 和 BuildKit,它们只负责 job 里“构建容器镜像”这一小段,并不等于 Runner。
YAML 提交以后,GitLab 先生成一张执行图
一次 push、合并请求、定时任务或手动触发到来时,GitLab 会读取 CI 配置。项目只有一个入口文件,不代表配置只有一个文件。include 可以引入本地、其他项目或远程配置;GitLab 先解析这些 include,再与项目里的 .gitlab-ci.yml 合并。extends、默认值和变量等规则也会在这一阶段参与配置展开。
工程讨论里常把这段过程叫作“pipeline 编译”。它并不是在 Runner 里逐行解释 YAML,而是由 GitLab 根据当次事件和配置,生成一组确定的 jobs 及其依赖关系。
接下来才轮到 pipeline 和 job 的条件判断。workflow:rules 控制这次事件是否创建 pipeline,job 自己的 rules 决定它是否进入这次 pipeline。没有进入执行图的 job,不会等到 Runner 再做判断。
这一点在排障时很有用。如果 UI 里根本没有那条 job,先看下面这些内容:
- 当前
CI_PIPELINE_SOURCE是 push、merge request 还是 schedule。 workflow:rules是否提前挡住了整条 pipeline。- job 的
rules:if、rules:changes或rules:exists是否匹配。 - include 是否成功解析,合并后的配置是否符合预期。
GitLab 的 Pipeline Editor 和 CI Lint 能查看合并配置,通常比盯着某个模板文件猜测更快。pipeline 创建后,会使用当时取得的配置快照。单独重试一条 job,不会重新抓取已经变化的 include;重新运行一条 pipeline,才会重新解析配置。
stages 给 job 规定大体顺序,同一 stage 内的 job 可以并行。needs 则显式描述依赖关系,让 job 不必等整个前置 stage 结束。配置最终形成的更接近一张有向图,而不是从上到下解释 YAML。
Runner 是执行代理,executor 是它选择的工作场地
GitLab 创建出可执行 job 后,需要找到符合条件的 Runner。Runner 是独立运行的代理程序,会向 GitLab 请求任务,执行后再回报日志和状态。job 的 tags、受保护分支设置、Runner 的可用范围和容量,都会影响匹配。
因此,job 长时间 pending 时,常见原因不是脚本有错,而是没有合适的 Runner:
- job 要求的 tag 没有 Runner 同时具备;
- Runner 离线、暂停或并发槽位已满;
- 目标分支或标签与 Runner 的 Protected 设置不匹配;
- Runner 只允许特定项目或 group 使用。
Runner 拿到 job 后,再由 executor 决定如何提供执行环境。几种常见 executor 的边界很不一样。
shell executor 直接在 Runner 主机上执行命令。它简单、启动快,但 job 能接触主机上的工具、文件和历史状态,隔离能力有限。
docker executor 为 job 创建独立容器。.gitlab-ci.yml 里的 image 是 job 容器镜像,services 则会启动数据库、缓存或 Docker daemon 等辅助容器。Runner 还会使用 helper image 完成拉代码、收发 artifacts 和 cache 等动作。
kubernetes executor 通常为每条 job 创建一个 Pod。这个 Pod 里有执行脚本的 build container、Runner 使用的 helper container,以及 services 定义的辅助容器。它们属于同一个 Pod,共享网络命名空间和配置的卷,并不是每个 service 再创建一个独立 Pod。
services 也不等于“服务已经可用”。数据库或 daemon 容器进程可能刚启动,还没有完成初始化。对启动顺序敏感的 job 应配置健康检查或在脚本中显式等待端口和业务状态,不能只依赖容器已经被创建。
所以 image: 只是 job 的运行环境,不是 Runner 本身。删除 job 容器也没有卸载 Runner;更换 Runner 的 executor,也不是改一行 image 就能完成。
一条 job 在容器里不只是跑 script
Runner 准备好 executor 后,会按固定流程处理 job。细节会随 executor 和配置变化,但主线基本如下:
准备代码
-> 恢复 cache
-> 下载上游 artifacts
-> before_script
-> script
-> after_script
-> 上传 cache 和 artifacts
-> 清理临时数据
这解释了几个常见现象。
script 还没执行,job 就可能因为拉镜像、创建 Pod、挂卷或获取源码失败。after_script 在新的 shell 上下文中运行,不能想当然地依赖 script 里临时导出的 shell 变量。文件仍然可以留在工作目录里,并在后面的上传阶段进入 artifacts。
Runner 会先恢复 cache,再下载 artifacts。如果两个配置覆盖同一路径,后下载的 artifacts 可能盖住 cache 内容。排查“为什么文件不是我以为的版本”时,恢复顺序比 YAML 的书写顺序更重要。
Artifacts 和 cache 看起来都在传文件,承诺却不同
Artifacts 是 job 的产出。编译包、测试报告、覆盖率文件或部署清单需要被后续 job 使用,应该明确声明为 artifacts。它们由 job 上传,保存在 GitLab 配置的制品存储中,并能在 pipeline 页面下载。后续 job 可以按 stage、needs:artifacts 或 dependencies 取得它们。
Cache 的目标是提速,典型内容是包管理器下载目录和可重复生成的依赖。它可能位于 Runner 本机,也可能放在 S3 一类分布式存储里。换了 Runner、cache key 变化、对象过期或上传失败,cache 都可能拿不到。
判断方法很简单:没有这份文件,job 只是变慢,还是会得到错误结果?只会变慢,可以放 cache;会影响正确性,就用 artifacts 或重新从可信来源生成。不要让一条 pipeline 把 cache 命中当作运行前提。
Job 要构建镜像,还缺一个构建引擎
在 job image 里装了 Docker CLI,并不代表容器里有 Docker daemon。docker build 是客户端命令,它需要连接一个真正执行构建的后端。
Docker-in-Docker(dind)会把 Docker daemon 作为 service 启动。job 容器里的 Docker CLI 通过 TCP、TLS 或共享的 Unix socket 连接它。每条 job 可以拥有独立 daemon,构建之间较少相互污染;代价是 dind 通常需要 privileged 权限,安全边界明显扩大。使用 dind 时要固定 CLI 和 daemon 版本,明确启用 TLS 或受控套接字,并把这类 Runner 与不可信任务隔离。
另一种做法是把宿主机的 /var/run/docker.sock 挂进 job。这样不需要再启动 daemon,缓存也容易复用,但 job 实际拿到了控制宿主 Docker 的能力。不同 job 还会共享同一个 daemon,命名、清理和并发都可能互相影响。它方便,却不比 dind 更天然安全。
BuildKit 是更现代的镜像构建引擎。GitLab 当前文档给出的 rootless BuildKit 方案不依赖 Docker daemon,也不要求把 job 容器整体置于 privileged 模式,适合把构建权限收窄。自建 Runner 仍需确认内核是否允许 user namespace、挂载及相关系统调用,同时自行处理镜像仓库认证。若使用 docker buildx,背后仍可能依赖 Docker daemon 和 dind,不能因为命令里出现 BuildKit 就忽略 daemon 的权限边界。
Kaniko 曾经常被用来做无 Docker daemon 构建,但新流水线没必要把它当默认答案。先看 rootless BuildKit 是否满足需求,再根据现有 Runner、缓存方式和多架构构建要求选择工具。
按层排障,日志会清楚很多
同一份 CI 页面,背后是几套独立组件。可以按故障出现的位置快速缩小范围:
| 现象 | 优先检查 |
|---|---|
| pipeline 没创建或 job 没出现 | workflow:rules、job rules、include 与变量 |
| job 一直 pending | Runner tags、保护设置、并发与在线状态 |
| 卡在 preparing environment | executor、镜像拉取、Pod、网络、卷和权限 |
| 进入 script 后命令失败 | job image 内的工具、工作目录与业务脚本 |
| 下游拿不到文件 | artifacts 依赖关系、过期时间、cache key 和恢复顺序 |
docker build 无法连接 | Docker daemon、dind service、socket、TLS 或 BuildKit 配置 |
GitLab CI 的配置可以写得很复杂,运行链路却始终能拆回这几层。先判断问题发生在“生成执行计划”“分配执行者”“准备运行环境”还是“执行 job”,再去看具体工具,通常比反复修改 YAML 更快。