一次GitLab CI Runner卡死导致生产部署流水线全停的排查实录
一次GitLab CI Runner卡死导致生产部署流水线全停的排查实录
一、问题背景
下午两点十五分,我正坐在工位上整理上午的网络变更文档,钉钉群里突然炸开了一连串告警信息。运维总监@我说:”线上部署流水线全挂了,研发提交了 7 个 Merge Request 等着合并发布,紧急发布也被卡住了,抓紧处理!”这是我们最核心的生产部署链路——从代码合并到镜像构建再到 K8s 滚动发布,全部跑在自建的 GitLab CI 上。一旦停摆,研发一天的工作量就没法上线。
我们公司自建了一套 GitLab(社区版 15.8.0)做代码托管,CI 部分使用了 3 台自建 Runner(其中 2 台是共享 Runner,1 台是项目级 Runner),部署在机房内部,配置是 4 核 8G 的虚拟机。平时每天大约跑 200~300 个 Pipeline,主要场景包括 Java 后端服务构建、Node 前端构建、Docker 镜像打包和 Helm Chart 同步等。系统从上线以来一直很稳定,从未出现过这么严重的全停故障。
紧急程度 P0——这意味着研发部门所有人在下班前都没办法发布代码,且因为关联到生产环境,可能影响晚上的业务运营。务必在 30 分钟内恢复。
二、故障现象
2.1 现象一:Pipeline 任务全部 pending
登录 GitLab Web UI 查看,所有 Pipeline 的 Job 状态全部是 pending(黄色圆圈),没有运行中的任务,也没有任何报错信息。我打开了几个 Job 的详情页,看到页面显示:
1 | This job is stuck, because you have no active runners online |
但诡异的是,GitLab 的 Admin → Runners 页面里,这 3 台 Runner 的状态都是 **绿色的”绿点”**,并没有显示”never contacted”或者”paused”。
2.2 现象二:Runner 进程 CPU 100% 占用
SSH 到 Runner 机器(10.20.30.51)上执行 top:
1 | top - 14:18:21 up 42 days, 3 users, load average: 31.27, 28.95, 26.13 |
可以看到 gitlab-runner 进程 CPU 占用 98.7%,内存吃掉了 4.6G(总共才 8G),load average 飙到了 31,但实际只有 1 个进程在真正吃 CPU。系统的 free 内存显示还有 2.1G,磁盘也没满。
2.3 现象三:日志显示连接数异常
查看 Runner 的日志(/var/log/gitlab-runner/gitlab-runner.log),看到大量重复告警:
1 | ERRO[0145] Failed to process job builds=37 duration=12.4s err="context canceled" |
关键报错:Failed to request job status=500 Job request timed out——Runner 在向 GitLab Server 拉取 Job 的时候出现超时。也就是说,Runner 没有真正”死掉”,而是处于一种”假活”状态:进程在、端口在,但拿不到新任务。
三、排查过程
3.1 第一步:检查 Runner 与 GitLab Server 的连通性
先排除网络层问题。从 Runner 机器 ping 和 curl GitLab Server:
1 | $ ping -c 4 gitlab.example.com |
网络通畅,GitLab Server 健康检查通过。问题不在网络层。
3.2 第二步:重启 Runner 服务试试
既然 CPU 100%,第一直觉是 Runner 内部出问题。先尝试软重启:
1 | $ sudo gitlab-runner stop |
重启后查看 top,CPU 占用**短暂降到了 5%,load average 也开始回落。我以为问题解决了,结果不到 2 分钟,CPU 又飙回了 100%**。这说明重启只是暂时缓解,根因没有解除。
3.3 第三步:抓 Runner 的 Goroutine 栈
GitLab Runner 是用 Go 写的,CPU 100% 一定是某个 goroutine 死循环或者阻塞。给它发 SIGQUIT 让它 dump goroutine 栈:
1 | $ sudo kill -QUIT 8721 |
打开 stacktrace-20260627-1425.log,这是一个 8.2MB 的文本文件,里面有几十万个 goroutine。我用文本编辑器打开后搜索 “JobRequest”,看到有大量重复的 goroutine:
1 | goroutine 1423857 [running]: |
虽然大部分栈都是 douceur 这个 CSS 解析库在干重活(巨量的 CSS 解析 goroutine 阻塞),但这不是根因。真正的问题在别处。我继续往下翻,发现关键信息:
1 | goroutine 1 [running]: |
关键信息:在 requestJob 函数的 for 循环里累积了大量残留状态。继续翻看代码,我意识到问题可能与 Runner 工作目录里堆积了大量未清理的 Job 有关。
3.4 第四步:检查 builds 目录
GitLab Runner 默认会在 /var/lib/gitlab-runner/builds/ 下为每个 Job 创建一个工作目录。Job 完成后,目录理论上应该自动清理。但查看:
1 | $ sudo ls /var/lib/gitlab-runner/builds/ | wc -l |
237 个 Job 目录,78G 磁盘占用!这些目录有的是 2 个月前留下的。怪不得内存吃紧、CPU 打满——Runner 启动时会扫描所有这些目录,尝试加载历史 Job 状态做清理。
继续翻看 config.toml:
1 | concurrent = 4 |
concurrent = 4 表示这台 Runner 最多同时跑 4 个 Job。但日志里显示 builds=37——说明有 37 个 Job 处于”已请求但未完成”的状态!也就是说,Runner 内部的状态机和磁盘上的 Job 目录完全对不上。
3.5 第五步:查 Sentry 找到已知 Bug
继续翻 Runner 的日志,看到一条关键的 Warning:
1 | WARN[0234] Runner builds=37 limit=4 |
这是来自 Runner 自身的健康检查日志。我去 GitLab 官方 Issue 搜索 builds=37 limit=4,发现这是一个 GitLab Runner 15.5 ~ 15.10 之间的已知 Bug(issue 编号 #27895)。核心问题:
- Runner 进程会维护一个内存中的
builds map,记录所有正在运行的 Job 状态。 - 当 Runner 与 GitLab Server 通信超时或网络抖动时,部分 Job 会被标记为”已 unregister”但 builds map 中并未真正删除。
- 这个 map 没有 LRU 或者 TTL 清理机制,导致残留对象会一直累积。
- 当残留 Job 数量接近或者超过
concurrent配置值时,Runner 内部的 jobSlot 信号量被永久占满,新的 JobRequest 永远拿不到 slot。 - 表现为:Runner 进程 CPU 100%、新的 Job 全部 pending、JobRequest 超时。
我们的 3 台 Runner 全部是 15.8.0 版本,全部中招。
四、解决方案
4.1 紧急止血:清空 builds 目录 + 升级版本
由于这是已知 Bug,单纯的清目录只能解决一部分问题,必须升级 Runner。执行:
1 | # 1. 停止所有 Runner |
升级完成后,CPU 立即降回 5% 以下,新提交的 Pipeline 在 30 秒内开始正常运行。builds= 日志恢复正常值 0。
全部 3 台 Runner 重复上述操作后,部署流水线完全恢复。
4.2 调优 Runner 配置
修改 /etc/gitlab-runner/config.toml,做几项关键调优:
1 | concurrent = 4 |
关键点:
- **
check_interval = 5**:每 5 秒检查一次 Job 状态,比默认值(0)更容易发现问题。 - **
pre_cleanup_script**:每个 Job 开始前清理 24 小时前的 Docker 资源。 - **
post_cleanup_script**:每个 Job 结束后清理 1 小时前的 Docker 资源。 - **
mem_limit / mem_swap_limit / cpu_limit**:防止单 Job 资源失控。
4.3 加监控告警
在 Prometheus 监控里加上 GitLab Runner 的关键指标:
1 | # prometheus.yml |
监控的关键指标:
runner_concurrent_limit:配置的并发数。runner_limit_used:当前在用的并发数(接近 limit 时应告警)。gitlab_runner_jobs_total:Job 总数。process_cpu_seconds_total:Runner 进程 CPU 使用。process_resident_memory_bytes:Runner 进程内存。
在 Grafana 里配置告警规则:
1 | # CPU 持续 5 分钟 > 80% |
4.4 加自动清理 Cron
为了防止 builds 目录再次无限膨胀,加一个 cron:
1 | # /etc/cron.daily/gitlab-runner-cleanup |
五、根因分析
本次故障的根本原因有两个层面:
直接原因:GitLab Runner 15.5~15.10 版本存在已知 Bug(issue #27895),Runner 内部 builds map 在 Job 状态异常时不会自动清理,导致残留对象无限累积。当残留数量超过 concurrent 配置值时,jobSlot 信号量被永久占满,新 JobRequest 永远拿不到 slot,从而引发 CPU 100% 和 JobRequest 超时。
间接原因:
- 缺乏 builds 目录的定期清理机制:GitLab Runner 本身不保证历史 Job 目录一定能被清理,必须有外部脚本兜底。
- 没有针对 Runner 自身状态的监控告警:CPU/内存/并发使用率无任何监控,故障积累 2 个月才被察觉。
- Runner 版本长期未升级:社区版 Runner 升级对业务无影响,但运维团队未建立定期升级机制,错过 5 个 Bug 修复版本。
六、预防措施
为了避免类似问题再次发生,我们做了如下改进:
建立 Runner 版本升级 SOP:每季度(3/6/9/12 月)评估 Runner 新版本,根据官方 Release Notes 决定是否升级。升级前在测试 Runner 上跑一遍冒烟测试。
builds 目录自动清理:增加 cron 每天清理超过 7 天的历史 Job 目录,超过 50GB 立即告警。
健康检查多维度监控:CPU、内存、builds 数量、jobSlot 占用率、JobRequest 失败率 5 个维度全部纳入监控。关键指标持续 3 分钟异常即触发 P2 告警。
Runner 容量规划:原来 4 核 8G 跑 4 并发捉襟见肘(高峰期 CPU 经常 60%+),扩容到 8 核 16G 跑 6 并发,预留 50% 余量。
CI 任务 Quota 机制:在 GitLab 项目级别配置
pipeline_triggers限速,避免研发短时间大量提交触发雪崩。建立 Runner 故障 Runbook:将本次排查过程整理为内部 Runbook,SRE 团队 7×24 共享,新人入职必读。
七、总结
这次故障的教训很深刻:
- 版本升级不能忽视:GitLab Runner 这类基础设施软件,官方版本迭代很快,每 1~2 个版本就有重要 Bug 修复。运维团队必须建立主动跟踪机制,而不是等到出事才升级。
- 状态泄漏是分布式组件的通病:任何带”内置状态机 + 外部存储”的服务(Runner、Agent、Worker),都必须有外部状态对账机制。我们缺少 builds 目录的定期清理,本质上是信任了组件内部的清理逻辑,而没有做兜底。
- 监控告警要前置:CPU 100% 是故障的”果”,不是”因”。真正该监控的是早期信号——builds 数量、jobSlot 占用率、JobRequest 失败率。如果这些指标 2 个月前就告警,我们有充足的时间优雅处理。
- 应急操作要分级:本次紧急止血时”清空 builds 目录”是有风险的(可能丢失正在运行的 Job),幸亏我们是在业务低谷期操作。**生产环境操作前,务必先评估”破坏半径”**。
CI/CD 是研发的生命线,保障它的稳定性是 SRE 团队的核心职责。下次遇到类似问题,第一时间定位到 Bug 版本和 Issue,比盲目重启有效得多。