一次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
2
3
4
5
6
7
8
9
|
top - 14:18:21 up 42 days, 3 users, load average: 31.27, 28.95, 26.13
Tasks: 247 total, 3 running, 244 sleeping
%Cpu(s): 99.3 us, 0.4 sy, 0.0 ni, 0.3 id, 0.0 wa, 0.0 hi, 0.0 si
KiB Mem : 8009532 total, 2145720 free, 4883216 used, 980596 buff/cache
PID USER PR NI VIRT RES RES SHR S %CPU %MEM TIME+ COMMAND
8721 gitlab-r 20 0 3821044 4.6g 4.6g 12m R 98.7 59.2 1438:22 gitlab-runner
24109 gitlab-r 20 0 412180 31872 31872 4248 S 1.3 0.4 0:00.23 gitlab-runner
24110 gitlab-r 20 0 412180 31744 31744 4244 S 1.0 0.4 0:00.19 gitlab-runner
|
可以看到 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
2
3
4
5
6
|
ERRO[0145] Failed to process job builds=37 duration=12.4s err="context canceled"
WARN[0145] Job's log limit reached job=4218 limit=4194304
ERRO[0148] Failed to request job status=500 Job request timed out
WARN[0152] Runner lost contact with GitLab server runner=xxxxxx
ERRO[0156] Failed to process runner accepts=0 builds=0
ERRO[0158] Runner is not healthy cpu_usage=98.7% memory_usage=59.2%
|
关键报错: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
2
3
4
5
6
7
8
9
10
|
$ ping -c 4 gitlab.example.com
PING gitlab.example.com (10.20.30.10): 56 data bytes
64 bytes from 10.20.30.10: icmp_seq=0 ttl=64 time=0.124 ms
64 bytes from 10.20.30.10: icmp_seq=1 ttl=64 time=0.131 ms
64 bytes from 10.20.30.10: icmp_seq=2 ttl=64 time=0.118 ms
64 bytes from 10.20.30.10: icmp_seq=3 ttl=64 time=0.121 ms
$ curl -v http://gitlab.example.com/-/health
< HTTP/1.1 200 OK
{"status":"ok"}
|
网络通畅,GitLab Server 健康检查通过。问题不在网络层。
3.2 第二步:重启 Runner 服务试试
既然 CPU 100%,第一直觉是 Runner 内部出问题。先尝试软重启:
1
2
3
4
|
$ sudo gitlab-runner stop
$ sudo gitlab-runner start
$ sudo gitlab-runner status
gitlab-runner: Service is running!
|
重启后查看 top,CPU 占用短暂降到了 5%,load average 也开始回落。我以为问题解决了,结果不到 2 分钟,CPU 又飙回了 100%。这说明重启只是暂时缓解,根因没有解除。
3.3 第三步:抓 Runner 的 Goroutine 栈
GitLab Runner 是用 Go 写的,CPU 100% 一定是某个 goroutine 死循环或者阻塞。给它发 SIGQUIT 让它 dump goroutine 栈:
1
2
3
4
|
$ sudo kill -QUIT 8721
$ ls -lh /var/log/gitlab-runner/
-rw-r--r-- 1 root root 23M Jun 27 14:25 gitlab-runner.log
-rw-r--r-- 1 root root 8.2M Jun 27 14:25 stacktrace-20260627-1425.log
|
打开 stacktrace-20260627-1425.log,这是一个 8.2MB 的文本文件,里面有几十万个 goroutine。我用文本编辑器打开后搜索 “JobRequest”,看到有大量重复的 goroutine:
1
2
3
4
5
6
7
|
goroutine 1423857 [running]:
runtime/internal/syscall.Syscall6(...)
github.com/aymerick/douceur/parser.parseDeclaration(0xc00a4d2000, 0xc0007e6000, 0x400, 0x400, 0xc00a4d2000, 0x400)
github.com/aymerick/douceur/parser.parseRule(0xc00a4d2000, 0xc00a4d2000, 0x400, 0x400, 0x4, 0x0)
github.com/aymerick/douceur/parser.Parse(0xc00a4d2000, 0x4, 0xc00a4d2000, 0x400, 0x4, 0x0, 0x0, 0x0)
github.com/aymerick/douceur/css.Parse(0xc00a4d2000, 0x4, 0x0, 0xc00a4d2000, 0x4, 0x0, 0x0, 0x0)
...
|
虽然大部分栈都是 douceur 这个 CSS 解析库在干重活(巨量的 CSS 解析 goroutine 阻塞),但这不是根因。真正的问题在别处。我继续往下翻,发现关键信息:
1
2
3
4
5
6
7
8
9
|
goroutine 1 [running]:
main.runWait()
/go/src/gitlab.com/gitlab-org/gitlab-runner/cmd/gitlab-runner/main.go:108
runtime.gopark(...)
runtime.selectnbrecv(...)
main.(*Runner).requestJob(0xc0001ce000)
/go/src/gitlab.com/gitlab-org/gitlab-runner/runner.go:312
main.(*Runner).requestJob(0xc0001ce000)
/go/src/gitlab.com/gitlab-org/gitlab-runner/runner.go:412
|
关键信息:在 requestJob 函数的 for 循环里累积了大量残留状态。继续翻看代码,我意识到问题可能与 Runner 工作目录里堆积了大量未清理的 Job 有关。
3.4 第四步:检查 builds 目录
GitLab Runner 默认会在 /var/lib/gitlab-runner/builds/ 下为每个 Job 创建一个工作目录。Job 完成后,目录理论上应该自动清理。但查看:
1
2
3
4
5
|
$ sudo ls /var/lib/gitlab-runner/builds/ | wc -l
237
$ sudo du -sh /var/lib/gitlab-runner/builds/
78G /var/lib/gitlab-runner/builds/
|
**237 个 Job 目录,78G 磁盘占用!**这些目录有的是 2 个月前留下的。怪不得内存吃紧、CPU 打满——Runner 启动时会扫描所有这些目录,尝试加载历史 Job 状态做清理。
继续翻看 config.toml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
concurrent = 4
check_interval = 0
[[runners]]
name = "shared-runner-01"
url = "http://gitlab.example.com/"
token = "glrt-xxxxxxxx"
executor = "docker"
builds_dir = "/var/lib/gitlab-runner/builds"
cache_dir = "/var/lib/gitlab-runner/cache"
[runners.docker]
pull_policy = "if-not-present"
privileged = false
volumes = ["/cache"]
shm_size = 0
[runners.cache]
Type = "s3"
Path = "gitlab-ci-cache"
[runners.cache.s3]
ServerAddress = "minio.example.com:9000"
|
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
# 1. 停止所有 Runner
sudo gitlab-runner stop
# 2. 备份并清空 builds 目录(保留 cache 目录)
sudo mv /var/lib/gitlab-runner/builds /var/lib/gitlab-runner/builds.bak.20260627
sudo mkdir -p /var/lib/gitlab-runner/builds
sudo chown -R gitlab-runner:gitlab-runner /var/lib/gitlab-runner/builds
# 3. 升级到 15.11.2(修复 Bug 的版本)
curl -LJO "https://packages.gitlab.com/runner/gitlab-runner/packages/debian/bookworm/gitlab-runner_15.11.2_amd64.deb/download.deb"
sudo dpkg -i gitlab-runner_15.11.2_amd64.deb
# 4. 启动新版本
sudo gitlab-runner start
sudo gitlab-runner verify --delete # 删除已 unregister 的 runner 缓存
|
升级完成后,CPU 立即降回 5% 以下,新提交的 Pipeline 在 30 秒内开始正常运行。builds= 日志恢复正常值 0。
全部 3 台 Runner 重复上述操作后,部署流水线完全恢复。
4.2 调优 Runner 配置
修改 /etc/gitlab-runner/config.toml,做几项关键调优:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
concurrent = 4
check_interval = 5
log_level = "info"
[[runners]]
name = "shared-runner-01"
url = "http://gitlab.example.com/"
token = "glrt-xxxxxxxx"
executor = "docker"
builds_dir = "/var/lib/gitlab-runner/builds"
cache_dir = "/var/lib/gitlab-runner/cache"
# 增加 Job 超时保护
environment = ["GIT_CLEANUP=false"]
pre_cleanup_script = "docker system prune -af --volumes --filter 'until=24h'"
post_cleanup_script = "docker system prune -af --volumes --filter 'until=1h'"
[runners.docker]
pull_policy = "if-not-present"
privileged = false
volumes = ["/cache"]
shm_size = 0
network_mode = "bridge"
# 限制 Docker 容器资源,避免某个 Job 吃光资源
mem_limit = "2g"
mem_swap_limit = "2g"
cpu_limit = "1.5"
[runners.cache]
Type = "s3"
Path = "gitlab-ci-cache"
[runners.cache.s3]
ServerAddress = "minio.example.com:9000"
|
关键点:
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
2
3
4
|
# prometheus.yml
- job_name: 'gitlab-runner'
static_configs:
- targets: ['10.20.30.51:9252', '10.20.30.52:9252', '10.20.30.53:9252']
|
监控的关键指标:
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
2
3
4
5
6
7
8
|
# CPU 持续 5 分钟 > 80%
100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80
# builds 数量异常(> concurrent 的 1.5 倍)
runner_jobs_active > (runner_concurrent_limit * 1.5)
# 健康检查失败
up{job="gitlab-runner"} == 0
|
4.4 加自动清理 Cron
为了防止 builds 目录再次无限膨胀,加一个 cron:
1
2
3
4
5
6
7
8
9
|
# /etc/cron.daily/gitlab-runner-cleanup
#!/bin/bash
BUILD_DIR="/var/lib/gitlab-runner/builds"
# 只删除 7 天前且非活跃的目录
find "$BUILD_DIR" -maxdepth 2 -type d -mtime +7 -exec rm -rf {} + 2>/dev/null
# 清理孤儿 Docker 容器
docker container prune -f --filter "until=24h" 2>/dev/null
# 清理大体积 build cache
docker builder prune -f --filter "until=48h" --keep-storage=10GB 2>/dev/null
|
五、根因分析
本次故障的根本原因有两个层面:
直接原因: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,比盲目重启有效得多。