一次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
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)。核心问题:

  1. Runner 进程会维护一个内存中的 builds map,记录所有正在运行的 Job 状态。
  2. 当 Runner 与 GitLab Server 通信超时或网络抖动时,部分 Job 会被标记为”已 unregister”但 builds map 中并未真正删除
  3. 这个 map 没有 LRU 或者 TTL 清理机制,导致残留对象会一直累积
  4. 当残留 Job 数量接近或者超过 concurrent 配置值时,Runner 内部的 jobSlot 信号量被永久占满,新的 JobRequest 永远拿不到 slot
  5. 表现为: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 超时。

间接原因

  1. 缺乏 builds 目录的定期清理机制:GitLab Runner 本身不保证历史 Job 目录一定能被清理,必须有外部脚本兜底。
  2. 没有针对 Runner 自身状态的监控告警:CPU/内存/并发使用率无任何监控,故障积累 2 个月才被察觉
  3. Runner 版本长期未升级:社区版 Runner 升级对业务无影响,但运维团队未建立定期升级机制,错过 5 个 Bug 修复版本。

六、预防措施

为了避免类似问题再次发生,我们做了如下改进:

  1. 建立 Runner 版本升级 SOP:每季度(3/6/9/12 月)评估 Runner 新版本,根据官方 Release Notes 决定是否升级。升级前在测试 Runner 上跑一遍冒烟测试。

  2. builds 目录自动清理:增加 cron 每天清理超过 7 天的历史 Job 目录,超过 50GB 立即告警。

  3. 健康检查多维度监控:CPU、内存、builds 数量、jobSlot 占用率、JobRequest 失败率 5 个维度全部纳入监控。关键指标持续 3 分钟异常即触发 P2 告警

  4. Runner 容量规划:原来 4 核 8G 跑 4 并发捉襟见肘(高峰期 CPU 经常 60%+),扩容到 8 核 16G 跑 6 并发,预留 50% 余量。

  5. CI 任务 Quota 机制:在 GitLab 项目级别配置 pipeline_triggers 限速,避免研发短时间大量提交触发雪崩。

  6. 建立 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,比盲目重启有效得多。