CNB 云构建 GPU 并发调优:从单路串行到多路并行的踩坑实录

2026-06-30

从「一个一个来」到「三路并发」

最开始是这样的:一个小项目用 CNB 云构建处理视频文件,处理器的工作方式是单路串行——一个视频处理完,下一个才能开始。对着三千多个文件的队列算了一下,按这个速度要跑好几天。

需求很简单:让处理变快。但在这个「变快」的过程中,踩了七个不同的坑。每个坑都很小,但串在一起能把一个简单的并发调整拖成整夜。

CNB 的并发模型

CNB 的核心配置文件是 .cnb.yml,通过 api_trigger 声明式的定义构建触发。在 YAML 中设置 CI_VIDEO_CONCURRENT 环境变量来控制并发路数:

master:
  api_trigger_gpu:
    - name: process-video-gpu
      env:
        CI_VIDEO_CONCURRENT: "3"
        CI_BATCH_SIZE: "32"
      stages:
        - name: process
          timeout: 43200s
          script: |
            python3 process_video.py

参数演进是从 concurrent=1, batch=8 开始的,目标是推到 3 路并行。看起来就改个数字的事,但每个数字背后都有故事。

坑 1:GPU OOM — 并发不是越多越好

第一次调优:直接把 concurrent 从 1 改成 5,5 路并行跑起来。十几分钟后进程全部被 kill。

根因:每个并发任务加载 YOLO 模型大约占 1.5GB 显存。5 路就是 7.5GB,虽然 T4 是 16GB,但模型运行时还有其他内存开销(图像缓冲、中间计算),合计超过可用显存。

修复:concurrent 回退到 3。为了弥补单路吞吐,把 CI_BATCH_SIZE 从 16 提升到 32——每批处理更多帧,让 GPU 利用率更高而不是靠更多路数。

教训:并发路数不是线性扩展的,上限由显存决定。先算出单路占用,再反推上限,比「先试 5 路看看」靠谱。

坑 2:无输出超时 — 构建跑到一半被截断

concurrent 调好后,几个长时间处理的构建中途被终止。查 CNB 日志发现是无输出超时(默认 10 分钟)。YOLO 推理的批次间隔在某些视频上超过 10 分钟,被当作「无输出」截断了。

修复:每个 process stage 加 timeout: 43200s

stages:
  - name: process
    timeout: 43200s
    script: |
      python3 process_video.py --timeout 43200

CNB 有一个特性:声明 timeout 后,无输出超时也会同步为这个值。设 43200s(12小时)后,最长不会因为处理慢而被截断。

坑 3:Workspace 和 CI 的超时是两套配置

项目有两种运行模式:CI(api_trigger 触发构建)和 Workspace(面板上点「云原生开发」启动长驻环境)。超时配置在两个地方位置不同:

  • CI 场景:timeout 在 stages 级别
  • Workspace 场景:timeout 在 $.vscode 作业级别
$:
  vscode:
    - name: gpu-processor
      timeout: 43200s
      script: |
        while true; do
          python3 process_video.py
          sleep 60
        done

如果只改了 CI 的 timeout 没改 Workspace,长驻处理仍然会超时。

坑 4:API 触发路径用错了

通过 OpenAPI 触发构建,一开始用的 CLI 命令返回 404。查文档发现正确路径是:

curl -X POST "https://api.cnb.cool/{owner}/{repo}/-/build/start" \
  -H "Authorization: Bearer $(cat ~/.cnb/token | python3 -c 'import json,sys;print(json.load(sys.stdin)["access_token"])')" \
  -H "Accept: application/vnd.cnb.api+json" \
  -H "Content-Type: application/json" \
  -d '{"branch":"master","name":"process-video-gpu","sync":"false"}'

有三个注意点:
1. 路径是 /-/build/start,不是某些 CLI 命令构造的路径
2. sync 必须是字符串 "false",不是布尔值(CNB API 的参数类型校验有 bug)
3. Accept header 必须指定(否则部分 API 返回 406,见下一个坑)

坑 5:Accept Header 缺失 → 406

调用 CNB 删除 asset 的 API 时,一直返回 406 Not Acceptable。根因是请求头缺少 Accept: application/vnd.cnb.api+json

这不是每次都触发——某些 API 不需要这个 header 也能返回正确结果,但 delete asset 等操作会校验。修复很简单:所有 CNB API 调用统一加这个 header,不再区分。

坑 6:镜像路径变量不展开

配置中本来用 ${CNB_REPO_SLUG_LOWERCASE} 变量来构造镜像路径:

docker:
  image: docker.cnb.cool/${CNB_REPO_SLUG_LOWERCASE}/{cpu,gpu}-latest

结果在跨仓库触发时变量不展开,镜像拉取失败。修复方法:直接硬编码完整路径。

docker:
  image: docker.cnb.cool/username/cam-filter:gpu-latest

这个变量在当前仓库的 push 触发中能正常工作,但 api_trigger 跨仓库调用时不展开。

防重复认领:Upstash Redis 解决并发冲突

3 路并行意味着 3 个处理实例同时从任务池取任务,不能重复处理同一个文件。解决方案是 Upstash Redis 的 SETNX 原子操作:

import requests

def claim_file(file_id: str, upstash_url: str) -> bool:
    """返回 True 表示认领成功,False 表示别人在处理"""
    resp = requests.post(upstash_url, json=["SETNX", f"claim:{file_id}", "1"])
    result = resp.json()
    return result.get("result") == 1

每个文件在 Redis 中有一个 key,SETNX 保证只有一个处理实例能设置成功。失败的实例跳过这个文件取下一个。

这个方案的好处是无状态、零额外基础设施(Upstash 免费实例 50 万命令/月),并且通过 HTTP 协议访问,不需要 Redis 客户端库。

总结:并发调优的三个原则

从单路串行到三路并行的过程,总结出三条原则:

  1. 并发上限由显存决定,不是 CPU 核心数。GPU 场景一定要先算单路显存占用,留 20% 余量
  2. 超时配置是隐形成本。默认值可能不适合你的场景,每个 stage 显式声明 timeout
  3. API 的每个 header 和参数类型都值得较真。字符串和布尔值不一样,少一个 header 就是 406

最终配置的核心结构:

master:
  api_trigger_gpu:
    - name: process-video-gpu
      timeout: 43200s
      env:
        CI_VIDEO_CONCURRENT: "3"
        CI_BATCH_SIZE: "32"
        CI_YOLO_INTERVAL: "2"
      runner:
        tags: cnb:arch:amd64:gpu
      stages:
        - name: process
          timeout: 43200s
          script: |
            python3 process_video.py --timeout 43200

这套配置处理完三千多个文件只用了不到四分之一的时间。七个坑,每个都小,但填平之后就是稳定的生产级并行。

标签: CI/CD GPU 云构建