2团
Published on 2026-04-07 / 1 Visits
0
0

通过Cargo-Chef及BuildKit解决Docker构建Rust项目时编译过慢的问题

1. 问题描述

在Ubuntu 24.04(2C4G 云主机)上使用Docker构建Rust项目时,发现cargo build长时间卡在Updating crates.io index,单次构建耗时可达500 ~ 900+秒,而真正下载依赖和编译的时间反而只占小部分。

典型构建日志:

=> [builder 5/5] RUN cargo build --release    502.3s
=> => #     Updating crates.io index

2. 原因分析

2.1 Docker层缓存失效

FROM rust:1.94.1 AS builder

WORKDIR /app
COPY Cargo.toml /app/Cargo.toml
COPY src /app/src
RUN cargo build --release

FROM rust:1.94.1

WORKDIR /app
COPY --from=builder /app/target/release/blog-indexnow /app/blog-indexnow
COPY config/application.yaml /app/config/application.yaml

ENV APP_CONFIG=/app/config/application.yaml

ENTRYPOINT ["/app/blog-indexnow"]

Docker层缓存规则:只要某一层的输入发生变化,该层及之后所有层的缓存全部失效。原始写法将src/cargo build放在同一层,任何业务代码改动都会导致cargo从零开始——重新拉取索引、下载并重新编译全部依赖。

此外,部署脚本使用了docker compose down --rmi all,连镜像都被销毁,在无任何缓存的情况下每次构建都是全量重建。

2.2 两类缓存的区别

特性

Docker 层缓存

BuildKit --mount=type=cache

生命周期

--rmi all 清除

独立持久,镜像删除后依然保留

粒度

整层命中或失效

目录级别,按需更新

适用

Dockerfile 指令未变时跳过整层

cargo registry / git / target 目录缓存

BuildKit cache mount的关键特性:缓存目录存储在宿主机,不随镜像删除而丢失,非常适合cargo registry和编译产物。

2.3 crates.io 稀疏索引

cargo构建前需同步crates.io包索引。Cargo 1.70+支持sparse 协议,相比传统git索引大幅减少请求次数;配合国内镜像源可进一步降低延迟。

3. 推荐方案:cargo-chef

cargo-chef 是专为Docker层缓存设计的工具,当前项目中验证有效。

工作原理

cargo-chef通过以下三个阶段将依赖编译与源码编译彻底隔离:

  1. planner:扫描整个项目,生成仅描述依赖树的recipe.json(不包含业务代码)

  2. builder — cook:凭借recipe.json 还原并编译全部依赖;只要Cargo.toml/Cargo.lock 不变,该层永远命中缓存

  3. builder — build:复制真实源码,在已编译好依赖的基础上只编译业务代码(通常几十秒)

cargo-chef原生支持稀疏索引(sparse registry),通过标准Cargo配置文件($CARGO_HOME/config.toml)生效,无需额外配置。

完整 Dockerfile

# syntax=docker/dockerfile:1

# ── Base: install cargo-chef (layer cached unless Rust image changes) ────────
FROM rust:1.94.1 AS chef
RUN cargo install cargo-chef
WORKDIR /app

# ── Stage 1: Analyze dependencies → produce recipe.json ─────────────────────
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

# ── Stage 2: Cook deps + build app ──────────────────────────────────────────
FROM chef AS builder
# Mirror config: cargo-chef supports sparse registries via standard cargo config
COPY config/cargo-config.toml /usr/local/cargo/config.toml
COPY --from=planner /app/recipe.json recipe.json

# Cook dependencies — this layer is only re-run when Cargo.toml / Cargo.lock changes
RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \
    --mount=type=cache,id=cargo-git,target=/usr/local/cargo/git \
    cargo chef cook --release --recipe-path recipe.json

# Now copy source and build only the application binary
COPY . .
RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \
    --mount=type=cache,id=cargo-git,target=/usr/local/cargo/git \
    cargo build --release

# ── Stage 3: Minimal runtime image ──────────────────────────────────────────
FROM rust:1.94.1

WORKDIR /app
COPY --from=builder /app/target/release/blog-indexnow /app/blog-indexnow
COPY config/application.yaml /app/config/application.yaml

ENV APP_CONFIG=/app/config/application.yaml

ENTRYPOINT ["/app/blog-indexnow"]

cargo-config.toml(清华稀疏镜像)

[source.crates-io]
replace-with = "tuna"

[source.tuna]
registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"

[net]
git-fetch-with-cli = true

常用国内 sparse 镜像备选:

名称

地址

清华 TUNA

sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/

字节 rsproxy

sparse+https://rsproxy.cn/crates.io-index/

中科大 USTC

sparse+https://mirrors.ustc.edu.cn/crates.io-index/

部署脚本

#!/bin/bash
# 仅删除本地构建的镜像,保留 BuildKit cache,然后重新构建并启动
docker compose down --rmi local
docker compose up -d --build

--rmi local 代替--rmi all:只删本地构建的最终镜像,不影响BuildKit cache mount中的缓存数据。

4. 其他方法(备选,生产环境验证失败)

以下三种方法来源于 How to Speed Up Docker Build for Rust Projects,本项目均在生产部署时尝试过,但实际上线时出现构建失败,原因未能分析清楚。记录于此作为参考。

方法一:空壳编译(Dummy Build Trick)

先用空main.rs编译依赖,再替换为真实源码:

FROM rust:1.77-slim AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src target/release/deps/myapp* target/release/myapp*
COPY src ./src
RUN cargo build --release

问题:需要手动清理特定的编译产物(target/release/deps/myapp*)才能让cargo重新编译业务代码,否则直接使用旧的空壳产物。项目名称、workspace 结构稍有复杂就容易失效。

方法二:仅使用BuildKit cache mount

# syntax=docker/dockerfile:1
FROM rust:1.77-slim AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/usr/local/cargo/git \
    --mount=type=cache,target=/app/target \
    cargo build --release && \
    cp target/release/myapp /usr/local/bin/myapp

注意:target/是cache mount,其内容在RUN指令结束后对后续层不可见,必须用cp将产物复制到挂载目录之外。

方法三:空壳编译 + cache mount组合

综合方法一和方法二,是上述两种方法的叠加:

# syntax=docker/dockerfile:1
FROM rust:1.77-slim AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/usr/local/cargo/git \
    --mount=type=cache,target=/app/target \
    cargo build --release
COPY src ./src
RUN touch src/main.rs
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/usr/local/cargo/git \
    --mount=type=cache,target=/app/target \
    cargo build --release && \
    cp target/release/myapp /usr/local/bin/myapp

相比cargo-chef,我认为上述三种方法本质上是对Docker构建流程的"手工魔改",在简单项目场景下也许有效,但在实际部署时存在不稳定因素;cargo-chef通过标准工具链解决同类问题,更可靠


Comment