2. Runner核心架构与执行模型

要治理 Runner 集群,先要看清一个 Runner 内部到底由哪些角色组成,以及一次作业从排队到结束究竟消耗了哪些资源。很多团队把“Runner”当成一个整体来扩容,结果发现加了机器仍然排队,或者单机负载忽高忽低,根源往往是没有区分 Runner 进程、Executor 执行器和底层运行环境这三层。

三层结构:Runner、Executor 与运行环境

GitLab Runner 是一个用 Go 编写的守护进程,它本身不执行作业,只负责三件事:向 GitLab 注册并领取作业、把作业交给某个 Executor、把日志和结果回传。真正决定“作业在哪里跑、怎么隔离、消耗什么资源”的是 Executor。可以把它理解为:Runner 是调度外壳,Executor 是执行策略,Shell、Docker、Kubernetes 是三种不同的落地环境。

常见的 Executor 差异可以这样理解:

  • Shell Executor:直接在 Runner 所在主机的 shell 中执行,作业脚本与主机共享文件系统、环境变量和用户权限。启动最快、开销最小,但隔离性最差,一个作业写坏主机目录会影响后续所有作业。
  • Docker Executor:每个作业启动一个容器,镜像由 image 指定,服务由 services 指定。隔离性好、环境可复现,但每次作业都要拉镜像、创建容器、挂载卷,启动延迟和磁盘 IO 明显高于 Shell。
  • Kubernetes Executor:Runner 把作业转成 Pod 提交给集群,由 K8s 调度到节点。弹性最好、资源按 Pod 申请,但受集群调度、镜像拉取、PVC 挂载影响,冷启动最慢,且需要处理命名空间、ServiceAccount、资源配额等集群侧约束。

选择 Executor 本质上是在“隔离性、启动速度、弹性、运维复杂度”之间做权衡。Shell 适合构建机固定、追求速度的场景;Docker 适合大多数需要环境一致性的 CI;Kubernetes 适合负载波动大、希望按需伸缩的集群。

Runner 三层结构与作业生命周期 GitLab 服务端 作业队列 / 调度 Runner 守护进程 注册 / 领取作业 / 回传日志 并发控制 concurrent Executor 执行器 决定隔离与运行方式 shell / docker / k8s 领取 分发 Shell Executor 直接运行在主机 shell 共享文件系统 / 环境变量 启动最快 · 隔离最弱 资源:主机 CPU/内存直占 Docker Executor 每作业一个容器 镜像 / services / 卷挂载 隔离好 · 环境可复现 资源:容器限额 + 镜像 IO Kubernetes Executor 作业转为 Pod 调度 命名空间 / 配额 / PVC 弹性最好 · 冷启动最慢 资源:requests/limits 申请 作业生命周期与资源消耗特征 pending 排队等待 Runner preparing 拉镜像 / 建容器 running 执行脚本 · 峰值 CPU cleanup 上传产物 / 删容器 finished 释放槽位 资源峰值集中在 preparing(镜像 IO)与 running(CPU/内存),cleanup 阶段常被忽略但会占用磁盘与网络

作业生命周期与资源消耗特征

一次作业并不是“跑脚本”那么简单。它经历 pending、preparing、running、cleanup、finished 五个阶段,每个阶段的资源画像完全不同。理解这一点,才能解释为什么“CPU 不高却一直排队”或“磁盘总是满”。

pending:作业在 GitLab 侧排队,等待有空闲槽位的 Runner。此时 Runner 本机几乎不消耗资源,但队列长度直接反映并发能力是否足够。如果 concurrent 设置过小,或所有 Runner 都被长作业占满,pending 会持续堆积。

preparing:Runner 领取作业后开始准备环境。Docker Executor 会拉取镜像、创建容器、挂载缓存卷;Kubernetes Executor 会创建 Pod、拉镜像、挂载 PVC。这个阶段的主要消耗是网络带宽和磁盘 IO,CPU 反而不高。镜像越大、层越多,preparing 越久。很多“作业慢”其实慢在这里,而不是脚本本身。

running:脚本真正执行,是 CPU 和内存的峰值阶段。编译、测试、打包类作业会短时间吃满多核;如果多个作业共享同一台 Runner,running 阶段会互相争抢。Docker 和 K8s 可以通过 limits 限制单作业资源,Shell 则没有天然隔离,需要靠外部手段约束。

cleanup:上传产物、推送缓存、删除容器或 Pod、清理临时目录。这个阶段容易被忽视,但它会占用磁盘空间和网络带宽。如果容器或镜像长期不清理,磁盘会被逐步吃满,最终导致新作业无法 preparing。

下面是一份用于观察单作业资源画像的示例配置片段,参数为教学示例,实际值需按项目调整:

# config.toml 片段(示例)
concurrent = 8

[[runners]]
  name = "docker-runner-01"
  executor = "docker"
  [runners.docker]
    image = "alpine:3.19"
    privileged = false
    volumes = ["/cache"]
    # 单作业资源上限(示例值)
    memory = "2g"
    cpus = "2"
  [runners.cache]
    Type = "s3"
    Path = "runner-cache"

观察作业各阶段耗时,最直接的方式是看 GitLab 作业日志中的时间戳,或使用 Runner 的 Prometheus 指标。例如 gitlab_runner_job_duration_seconds 反映作业总时长,配合日志可以判断时间花在 preparing 还是 running。如果 preparing 占比过高,优先优化镜像体积和缓存策略;如果 running 占比高且 CPU 打满,考虑提高并发或拆分作业。

判断扩容方向的简单方法:pending 长期大于 0 且 Runner 槽位已满,说明并发不足;preparing 时间远大于 running,说明镜像或缓存有问题;running 阶段 CPU 长期饱和,说明单机算力不够或作业粒度过大。

并发模型与槽位

Runner 的并发由两层控制:全局 concurrent 限制该 Runner 同时能跑多少作业,单个 [[runners]] 下的 limit 限制该注册项的最大并发。两者取较小值生效。例如全局 concurrent=8、某 runner limit=3,则该 runner 最多同时跑 3 个作业。槽位被占满后,新作业只能等待,这就是 pending 的直接来源。

需要特别注意的是,Shell Executor 的“槽位”并不等于资源隔离。8 个并发 Shell 作业会同时争抢同一主机的 CPU、内存和磁盘,一个作业的 rm -rf 可能影响其他作业。因此 Shell 更适合并发低、作业可信的场景;高并发场景应优先 Docker 或 Kubernetes,用容器或 Pod 做资源边界。

把这三层关系记牢:Runner 负责领取和回传,Executor 决定隔离方式,运行环境决定资源边界。作业生命周期的每个阶段消耗不同资源,治理时先定位瓶颈在 pending、preparing 还是 running,再决定是加并发、换 Executor,还是优化镜像与缓存。