当Rust项目的CI流水线跑着跑着,突然cargo build的任务红了,日志里全是看不懂的网络错误,本地明明能跑,这时候别急着改代码,八成是缓存或网络的坑在搞鬼。我们一步步来看怎么排查。

一、CI里cargo build莫名炸了?先别急改代码

1.1 踩坑现场重现

举个实际遇到的例子:上周我帮朋友的Rust项目改个小功能,代码提交后CI跑了20分钟,最后卡住失败,日志最后一行是“failed to fetch registry index: the connection was reset”。本地用同样的Rust版本、同样的依赖,cargo build一次就过。一开始以为是我改的代码有问题,回滚到上一个能跑的版本,CI还是炸。这时候就该把怀疑对象从代码移到CI的配置和底层机制上了。

1.2 怎么区分是缓存还是网络的锅?

先看日志的最后几行:如果错误里有“cache”、“cached”相关的内容,大概率是缓存的问题;如果是“network”、“timeout”、“connection reset”这类关键词,那就是网络的问题。不过有时候两者是关联的——比如缓存的旧索引和网络拉取的新索引不匹配,也会报错。

二、先挖缓存的坑——CI里的缓存不是万能的?

很多人用CI缓存的时候,就是“把需要的目录缓存起来就行”,其实这里面藏着不少坑。

2.1 常见的CI缓存配置误区

先给一个错误的缓存配置示例,这也是新手最常踩的坑:

name: Rust CI
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # 错误的缓存配置:只按操作系统和分支缓存,不关联依赖的哈希
      - name: Cache cargo
        uses: actions/cache@v3
        with:
          path: |
            ~/.cargo/bin
            ~/.cargo/registry
            target
          key: ${{ runner.os }}-cargo-${{ github.ref_name }}
      - name: Build
        run: cargo build --release

这个配置的问题在于,缓存键只加了分支名。比如主分支今天更新了Cargo.lock,依赖版本变了,但CI还会用之前缓存的旧索引,拉取新依赖的时候,旧索引里找不到对应的版本,就会报错。

那正确的缓存配置应该怎么写?核心是把Cargo.lock的哈希作为缓存键的一部分,因为Cargo.lock里记录了所有依赖的精确版本,只要这个文件变了,缓存就应该更新。示例:

name: Rust CI
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # 先计算Cargo.lock的哈希,作为缓存键的唯一标识
      - name: Get Cargo lock hash
        id: cargo-lock-hash
        run: echo "hash=$(sha256sum Cargo.lock | cut -d' ' -f1)" >> $GITHUB_OUTPUT
      # 正确的缓存配置:用依赖哈希生成缓存键,避免脏缓存
      - name: Cache cargo
        uses: actions/cache@v3
        with:
          path: |
            ~/.cargo/bin
            ~/.cargo/registry
            target
          key: ${{ runner.os }}-cargo-${{ steps.cargo-lock-hash.outputs.hash }}
          restore-keys: |
            ${{ runner.os }}-cargo-
      - name: Build
        run: cargo build --release

这里的restore-keys是个小技巧,如果新缓存找不到,会自动匹配前面的前缀,用最近的旧缓存,加快第一次构建的速度,又不会用完全不匹配的旧缓存。

2.2 缓存的“过期陷阱”

还有一种容易忽略的情况:CI服务器的系统时间偏差,或者缓存的索引文件本身有过期。比如cargo的索引每两周会自动更新一次,缓存里的旧索引可能因为CI服务器的时间比实际时间慢了几天,导致cargo判断索引过期,强制重新拉取,这时候如果网络不好,就会失败。解决方法是在CI里主动更新索引,比如在build前加cargo update -w(-w是只更新已存在的包,不会修改Cargo.lock),或者配置cargo不用缓存的索引,直接从源拉。

三、再揪网络策略的暗雷——依赖拉取没那么简单

缓存没问题的话,就该看网络了,尤其是国内开发者,CI用海外服务器的话,网络问题概率很高。

3.1 国内网络特殊情况的坑

最常见的是访问crates.io慢或者被断连。比如GitHub的CI服务器在美国,访问crates.io的平均延迟有100多毫秒,高峰期可能超时。这时候最简单的方法是换国内的crates.io镜像,比如清华的镜像,速度能快10倍以上,失败率也低很多。配置方法是在CI里生成cargo的配置文件,示例:

# 配置清华的crates.io镜像,解决国内CI的网络问题
mkdir -p ~/.cargo
cat > ~/.cargo/config.toml << EOF
# 替换默认的crates.io源为清华镜像
[source.crates-io]
replace-with = 'tuna'
# 用稀疏索引,比传统索引快很多
[source.tuna]
registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"
# 用系统git克隆,避免libgit2的网络坑
[net]
git-fetch-with-cli = true
EOF

这里要注意,稀疏索引(就是sparse+开头的地址)是cargo 1.68之后支持的,比传统的git索引快很多,推荐用。另外,git依赖的话,把SSH链接改成HTTPS,比如把git+ssh://git@github.com/rust-lang/cargo.git改成git+https://github.com/rust-lang/cargo.git,避免SSH密钥的问题和网络限制。

3.2 网络层面的小故障

网络不是总稳定的,CI里偶尔会有断连的情况,比如拉取一个100MB的大依赖(比如openssl),中间断了,就会失败。这时候要给CI加重试策略,不用每次都手动重新跑。示例是在build的时候加3次重试:

      - name: Build with retry
        run: |
          # 最多重试3次,每次间隔5秒
          for i in {1..3}; do
            cargo build --release && exit 0
            echo "Build failed, retrying ($i/3)..."
            sleep 5
          done
          # 3次都失败才返回错误
          exit 1

这个脚本很简单,但是很实用,能应对大部分临时的网络波动。还有,如果用了CI的代理,要确保HTTPS的代理也配置了,因为很多依赖的拉取是HTTPS请求,只配置HTTP代理没用。

四、实战排查流程:从日志到解决

遇到cargo build CI失败,按这个流程走,10分钟内能搞定:

4.1 第一步:抓准CI的错误日志

先打开CI的详细日志,用关键词过滤:errorfailednetworkcache,找到错误的具体原因。比如如果是“error: failed to download crate serde v1.0.193”,那就是拉取这个包失败;如果是“error: the cache key is invalid”,就是缓存的问题。

4.2 第二步:针对性修复

如果是缓存问题:检查缓存键是不是关联了Cargo.lock的哈希,把之前的错误缓存配置改成正确的;如果是网络问题:先换国内镜像,再加重试策略,还不行的话就检查CI服务器的网络,是不是被墙了,换一个CI的运行环境(比如用ubuntu-22.04代替ubuntu-latest)。

五、优缺点和注意事项

5.1 缓存方案的优缺点

优点:CI构建速度快,不用每次都拉所有依赖,节省时间;缺点:容易出现脏缓存(旧依赖和新代码不兼容),缓存有大小限制,太大的缓存会被CI服务自动清理,导致构建失败。注意:不要缓存太大的目录,比如target目录可以缓存,但如果项目的target特别大(比如有很多不同的feature),可以只缓存~/.cargo/registry,不缓存target,或者根据需要缓存部分feature。

5.2 网络策略的注意事项

注意:不要用公共的代理,确保代理的速度;如果用git的话,尽量用HTTPS,避免SSH的密钥配置问题;还有,不要用过时的cargo版本,旧版本的cargo网络实现有bug,比如对稀疏索引的支持不好,要升级到最新的稳定版。

六、总结

遇到Rust项目CI里cargo build莫名失败,不要先怀疑自己的代码,先看CI日志的关键词,区分是缓存还是网络的问题。缓存的核心是把依赖的精确版本(Cargo.lock)纳入缓存键,避免脏缓存;网络的核心是换国内镜像、加重试、配置正确的代理。只要按这个流程排查,大部分问题都能快速解决,让CI跑的更稳。