记一次 GitLab Runner 并发构建压垮宿主机的排查

问题背景

公司研发团队使用 GitLab CI/CD 进行代码检查和镜像构建,所有 Runner 以 Docker executor 方式运行在一台 64 核 256GB 的物理机上。配置了 8 个 GitLab Runner,单个 Runner 最大并发 concurrent=8,理论最大并发任务数 64 个。日均 Pipeline 执行约 2000 次。

故障现象

上午 10:30 开始,GitLab CI/CD 流水线大面积失败,错误信息集中在:

1
2
3
ERROR: Job failed: preparing environment: 
waiting for pod/runner-xxx to be running, status Pending
timeout: the script was killed by timeout

Runner 宿主机负载飙升:

1
2
3
4
5
$ uptime
11:15:00 up 42 days,  3:15,  2 users,  load average: 85.23, 78.56, 65.89

$ top -bn1 | head -5
%Cpu(s): 98.2 us, 1.5 sy, 0.0 ni, 0.0 id, 0.0 wa

64 核 CPU 全部打满到 100%,load average 高达 85。Docker daemon 也因 CPU 争抢而响应缓慢,导致新容器无法及时创建——CI 任务全部超时。

排查过程

第一步:定位 CPU 消耗来源

1
2
3
4
5
6
$ docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"
NAME                         CPU %        MEM USAGE/LIMIT
runner-abc123-build          625.50%      2.1GB / 256GB
runner-def456-build          618.22%      2.3GB / 256GB
runner-ghi789-test           312.40%      1.5GB / 256GB
...

多个构建容器各占用 600%+ CPU,远超预期。进入容器内部查看:

1
2
3
4
5
6
$ docker exec -it runner-abc123-build top
  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
 5821 root      20   0  852412 234560  12340 R  99.7  0.1   12:45.32 cc1plus
 5825 root      20   0  756234 198340  10450 R  99.5  0.1   12:42.18 cc1plus
 5829 root      20   0  812456 215600  11230 R  99.3  0.1   12:41.55 cc1plus
...共18个cc1plus进程

cc1plus 是 GCC 的 C++ 编译器内部进程。有人在构建大型 C++ 项目,且开了 18 个并行编译任务。

第二步:检查 Runner 并发设置

1
2
3
4
5
# /etc/gitlab-runner/config.toml
concurrent = 8              # 每个Runner最大并发任务数
[[runners]]
  limit = 64                # 全局限制:最多64个并发任务
  ...

此时 Runner 上有 28 个正在执行的任务,其中 8 个是多线程 C++ 编译,每个使用了 -j$(nproc) 启动 64 个编译线程。但 Docker 默认不限制 CPU,容器内 nproc 看到的是宿主机的 64 核,实际每个构建容器都在尝试跑满 64 核

第三步:分析 Docker CPU 隔离问题

1
2
3
4
5
6
7
8
$ docker inspect runner-abc123-build | jq '.[].HostConfig'
{
  "CpuShares": 0,       # 未设置 CPU 权重
  "CpuPeriod": 0,       # 未设置 CPU 周期
  "CpuQuota": 0,        # 未设置 CPU 配额
  "CpusetCpus": "",     # 未绑定核心
  ...
}

所有构建容器均无任何 CPU 限制,每个容器都可以使用的全部 64 核。

解决方案

紧急处理:暂停非关键 Pipeline

通过 GitLab API 批量取消正在排队的非关键构建任务,将并发恢复到可控水平。

修复 Runner 配置

1
2
3
4
5
6
7
8
9
# config.toml
concurrent = 4                    # 降低每 Runner 并发
limit = 32                        # 全局限制降到32

[[runners]]
  [runners.docker]
    cpu_shares = 512              # CPU 权重(默认1024),降低构建容器优先级
    cpus = "4"                    # 每个容器最多用4核
    cpuset_cpus = "0-63"          # 但可以跨所有物理核心

优化构建策略

  1. 容器内限制编译并行度:在 CI 脚本中显式设置 make -j4 而非 make -j$(nproc)
  2. 调整 Docker daemon 配置
1
2
3
4
5
// /etc/docker/daemon.json
{
  "default-cpus": "4",
  "cpu-rt-runtime": 0
}
  1. 引入构建缓存:使用 Docker layer cache + 分布式缓存(如 GCS/S3 as cache backend),减少重复编译。

根因分析

直接原因:多个 C++ 构建任务在无 CPU 限制的容器中启动 64 线程并行编译,64 核宿主机被完全打满。Docker daemon 的 API 调用因 CPU 争抢而超时,新构建任务无法启动,形成恶性循环。

深层原因:

  • Runner 配置中 concurrentlimit 设置过高,未与宿主机物理资源匹配
  • Docker 容器未设置 CPU 资源限制,单容器可消耗全部核心
  • CI 脚本中使用了 nproc 自动探测核心数,在 Docker 容器中返回的是宿主机核心数
  • 缺少 CI Runner 资源使用监控和限流机制

预防措施

  1. 强制 CPU 限制:所有 CI Runner 容器默认分配 --cpus=4,编译任务使用 make -j4
  2. 并发上限策略:全局并发上限 ≤ 物理核心数 / 单容器核心数(64/4 = 16),而非盲目设置 64
  3. CI 资源监控:Prometheus + Grafana 监控 Runner 宿主机 CPU、任务排队深度、构建时长分布
  4. 构建优先级队列:区分关键服务构建和普通构建,高峰期优先调度发布相关 Pipeline
  5. 构建规范文档:要求各团队在 .gitlab-ci.yml 中显式指定并行度,禁止使用 nproc

总结

Docker 提供了资源隔离,但默认是"无限"模式——容器可以看到宿主机的全部 CPU,也能用满。这在 CI/CD 场景下尤其危险,因为构建任务天然容易触发 CPU 密集型编译。正确的做法是:宿主机总核心数 / 每容器限制核心数 = 最大安全并发数。同时,CI 脚本中永远不要用 nproc 来决定并行度,这是给自己埋坑。

使用 Hugo 构建
主题 StackJimmy 设计