极狐GitLab

流水线排队太久,用 Kubernetes 执行器给 Runner 做弹性伸缩

极狐GitLab
2026年9月23日
13000
分享:

流水线排队太久,用 Kubernetes 执行器给 Runner 做弹性伸缩

定义框:弹性伸缩(Autoscaler)是极狐GitLab Runner Kubernetes 执行器的一项能力,它按 cron 策略在集群中预先撑起一批低优先级的暂停 Pod 作为"热容量",当真正的 CI 作业 Pod 需要资源时抢占这些暂停 Pod 的位置,从而把"等集群扩容出新节点"的时间从分钟级压掉,再由 Deployment 补回暂停 Pod、触发集群自动扩缩器补节点。


为什么流水线越排越长

多数团队第一次注意到 CI 变慢,不是在构建本身,而是在"等待"上。提交代码后作业卡在 pending,转圈几分钟才开始跑;到发版窗口,几十个作业一起挂起,排队时间比执行时间还长。

问题往往不在 Runner 数量不够,而在扩容时机比作业到达晚了半拍。集群层面的自动扩缩器只有在 Pod 因为资源不足调度不下时才会动手,申请节点、节点初始化、镜像预热这一串动作全部发生在这之后——这段时间里,作业就在队列里干等。

Runner 自己的 Kubernetes 弹性伸缩解决的正是这个时间差:它不等作业来了再要资源,而是按你设定的时间表先把容量烧热摆在那儿。低优先级的暂停 Pod 占着位置,真实作业 Pod 一进来就把它们挤掉,立刻拿到算力;被挤掉的暂停 Pod 由 Deployment 重建,这时候才轮到集群扩缩器在后台慢慢补节点。

换句话说,它把"扩容"这件事从串行改成并行——你的作业先跑起来,集群的补容在作业运行的同时完成。

先搞清楚 Kubernetes 执行器在干什么

在配置弹性伸缩之前,值得先确认一件事:每个 CI 作业在集群里到底对应什么。

Kubernetes 执行器会为每一个作业创建一个 Pod,整个执行过程分四步:准备阶段创建 Pod,预构建阶段完成代码克隆、缓存恢复与产物下载,构建阶段跑你的 script,构建后阶段创建缓存并上传产物。Pod 里通常有三个角色:build 容器跑你的命令,helper 容器负责克隆仓库、处理缓存与产物,以及若干服务容器(svc-N 或 DNS 别名)。

理解这个结构很重要,因为后续几乎所有资源参数都是围绕它来的:cpu_limit / memory_limit 限制的是 build 容器,helper_cpu_limit / helper_memory_limit 限制的是 helper 容器,两者都吃同一个 Pod 的资源配额。很多人只把 build 容器调到 2 核 4G,却没意识到 helper 还在旁边默默分资源。

四步配好弹性伸缩

第 1 步:补齐 RBAC 权限

Runner 要在集群里管 Pod,权限比只跑作业时要多一层。基础运行需要 podscreate / delete / get / list / watch(其中 list / watch 是 Runner 17.9.0 起为 Informer 机制引入的)、pods/execcreate / delete / get / patchsecretscreate / delete / get / update,以及 serviceaccountsgetservicescreate / get

一旦开启弹性伸缩,还要额外给 apps/deployments(create / delete / get / list / update)和 scheduling.k8s.io/priorityclasses(get / create)。前者用来管暂停 Pod 的 Deployment,后者用来创建低优先级的 PriorityClass——这是弹性伸缩最常见的启动失败原因:配了 autoscaler 却在日志里看到权限拒绝。用 Helm Chart 部署时,确认 rbac.create=true,或自行指定 serviceAccount.name

第 2 步:给作业定资源上限

concurrent = 20

[[runners]]
  name = "k8s-runner"
  url = "https://gitlab.example.com"
  token = "glrt-xxxx"
  executor = "kubernetes"
  [runners.kubernetes]
    namespace = "gitlab-runner"
    cpu_request = "500m"
    cpu_limit = "2"
    memory_request = "1Gi"
    memory_limit = "4Gi"
    helper_cpu_limit = "500m"
    helper_memory_limit = "250Mi"
    service_cpu_limit = "1"
    service_memory_limit = "1Gi"
    poll_interval = 3
    poll_timeout = 180
    pull_policy = ["if-not-present", "always"]
    [runners.kubernetes.node_selector]
      workload = "ci"

关于 helper_memory_limit 的取值,官方文档给出了明确参考:涉及缓存与产物处理的工作负载建议不低于 250MiB,无缓存产物的基础负载 128–200MiB 即可。给得太小,最典型的症状不是 OOM 报错,而是产物上传在最后一步悄悄失败

concurrent 控制的是这个 Runner 进程能同时处理多少个作业,它是弹性伸缩的上限锚点;而 poll_timeout 默认 180 秒,指的是作业在队列中等待 Runner 接手的超时时间——如果高峰期经常超过这个值,说明要么扩 concurrent,要么加 Runner 实例,而不是单纯加大 autoscale。

第 3 步:打开自动扩缩器

[[runners]]
  executor = "kubernetes"
  [runners.kubernetes]
    namespace = "gitlab-runner"
    cpu_request = "500m"
    memory_request = "1Gi"
    [runners.kubernetes.autoscaler]
      max_pause_pods = 10          # 0 表示不限制
      [runners.kubernetes.autoscaler.policy]
        idle_count = 5
        periods = ["* 8-20 * * mon-fri"]
        timezone = "Asia/Shanghai"
      [[runners.kubernetes.autoscaler.policy]]
        idle_count = 0
        periods = ["* * * * *"]

这段配置的含义是:工作日 8 点到 20 点之间,集群里始终维持 5 个暂停 Pod 待命,其余时间归零。多个 policy 同时匹配时以最后一条匹配到的为准,所以兜底的 * * * * * 永远放在最后。

第 4 步:按业务节奏而不是按平均值设计 policy

periods 是 cron 数组,真正的价值在于贴合研发作息。多数公司的提交分布有明显的双峰:上午开工前和下午发版前。与其全天维持高水位,不如用两段 policy 分别覆盖两个高峰,其余时间留给按需扩缩。

如果负载难以预测,可以启用 scale_factor:它有默认值 0(即关闭),设为比如 1.5 后,目标暂停 Pod 数会取 max(idle_count, 活跃作业数 × 1.5),让热度跟随实际作业动态浮动,再配合 scale_factor_limit 封顶。缩容则由 idle_time(默认 5 分钟)控制冷却,避免在低谷期反复抖动。

三个常见坑

坑一:把 Docker 执行器的经验照搬过来。 capacity_per_core 是 Docker 执行器的参数,Kubernetes 执行器压根不使用它——在 k8s 里想控制并发,请回到 concurrent 与 autoscaler policy 这两个旋钮上来。

坑二:只在纳管标签上做文章,忽略了 node 约束。 生产集群里 CI 作业和业务负载混跑时,建议用 node_selectornode_tolerations 把作业固定到专用节点池,否则你的 CI 高峰期就是业务抖动的开始。pod_disruption_budget 也不妨配上,避免节点缩容时作业集体被驱逐。

坑三:资源 limit 设得过于激进。 ephemeral_storage_limit 常常被忘掉,结果是镜像层把节点磁盘写满、整节点的 Pod 一起 Evicted。另外每个资源项都有对应的 *_overwrite_max_allowed 变体,开了 overwrite 就一定要设上限,否则项目里一行 KUBERNETES_CPU_LIMIT 就能吃掉半个节点。

常见问题

开了弹性伸缩之后,作业还是 pending 怎么办?

先确认三条:concurrent 是否卡住了并发上限;autoscaler 的 ClusterRole 是否包含 apps/deploymentspriorityclassesidle_count 是否真的在你期望的时间段生效——注意 periods 的 timezone 默认为 UTC,不显式指定 Asia/Shanghai 很容易算错 8 小时。

暂停 Pod 会占真实算力吗?

会占位置但不占满。pause_pod_priority_class_name 默认为 gitlab-runner-idle-capacity,优先级是 -1 且由 Runner 自动创建,真实作业 Pod 需要资源时会被 Kubernetes 抢占。代价是这些 Pod 在账单上是真实存在的节点容量。

清理残留 Pod 太慢是怎么回事?

cleanup_resources_timeout 默认 5 分钟,指的是作业结束后清理 Pod 的等待时间。值太小会导致缓存来不及归档,太大则让异常 Pod 长期占着配额,按作业的缓存大小调整即可。

小结与下一步

CI 变慢的账,很多时候算不到"构建"头上。长队里干等的作业,本质上是在为扩容的时差买单。Kubernetes 执行器的弹性伸缩做的事很朴素——按你的作息表提前把容量烧热,让作业不用等新节点。

建议的验证方式:先在 CI 专用节点池里开一组 idle_count=3 的 policy,对齐团队上午的提交高峰,记录一周内作业 pending 时长的 p50 与 p95 变化,再决定是否铺开到全天。

延伸阅读:Runner Kubernetes 执行器文档 https://docs.gitlab.cn/docs/runner/executors/kubernetes.html

想了解极狐GitLab CI/CD 的资源调度与 Runner 管理能力,可访问 https://gitlab.cn 预约演示或申请试用。