如何在 Gitlab CI 中加速 Rust 编译

最近团队 gitlab 上新加入了一个 rust 项目,发现 cargo 构建起来非常慢,由于 crates 是用源码分发,如果没有中间产物的缓存,cargo 每回都要把所有依赖重新编译一遍,因此没有缓存时就会速度堪忧。可是 CI 容器环境相当复杂。怎样在 gitlab ci runner 上配置 cargo 的缓存呢?

基本思路有三个:

首先第一个,配置写到一般就发现问题了。因为 gitlab ci 使用 docker 运行,cargo 项目编译又使用 docker 容器,所以存在 dind(docker in docker) 容器嵌套的状况,在 ci runner 外层容器中配置的缓存目录无法被内层容器使用。这种方法只适用于简单的 ci 流程,ci job 内部有用到 docker 时无能为力。

第二个是使用了 docker 的缓存机制,外挂一个临时存储卷挂到指定目录,可以在多次 build 之间共享缓存,理论上只要 ci runner 宿主有足够空间缓存就不会被回收,效果应该不错,但需要 docker 版本支持。

第三个层间缓存是最简单的,但可能效果有限。调整一下 dockerfile 中的命令即可,技巧是把长 RUN 步骤拆分成多个短 RUN 步骤,文件变更少的靠前放,变更最多的编译步骤放最后。这样 docker 服务检测到前面没有变更就会跳过执行,直接使用已有的层。

实际操作时,第二种方式我发现完全没有效果(具体原因忘记了,好像是 gitlab 版本不支持)。最后使用第三种方式,又另外了一点点 cargo 黑魔法后有奇效…… 具体来说是这样的,这是正常的 Dockerfile:

FROM ... as builder
WORKDIR /workdir
RUN mkdir -p ./.cargo
COPY config.toml ./.cargo/
COPY ./ ./
RUN cargo update
RUN cargo build --release --bin main
...

可以看到就是正常的建立工作目录、拷贝配置、源码、编译结束。但是问题在于 cargo build 是把项目依赖和实际代码一块编译的,哪怕项目代码只有一点点🤏变更,在 docker 服务里都算作一整个步骤有变更,所有依赖都得跟着一块重新编译。但实际上项目依赖往往很少有变更,根本不需要重新编译,那可不可以把两种编译拆分开呢?看下面修改的 Dockerfile:

FROM ... as builder
WORKDIR /workdir
RUN mkdir -p ./.cargo
COPY config.toml ./.cargo/

COPY Cargo.lock Cargo.toml ./
RUN mkdir -p src && echo 'fn main() {}' > src/main.rs
RUN cargo update
RUN cargo build --release --bin dummy
RUN rm -Rvf src target/release/dummy

COPY ./ ./
RUN touch src/* && cargo build --release --bin main
...

解释下大意就是先把 cargo 项目的依赖清单 Cargo.toml, Cargo.lock 拷进来,再造个假的空入口函数,把所有的依赖外加假主程序 dummy 编译出来。完事后删除假程序假源码,将真项目代码拷入再 build,因为上一个步骤里所有依赖都有编译产物了,所以只编译项目本体。这样下次项目代码再有改动,第 10 行以前包括编译依赖的步骤没有变动就全跳过,只需要重新编译项目本体。

实际效果很满意,原本需要近一小时,现在十秒不到,比另一个 python 打包项目都快。