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 两类缓存的区别
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通过以下三个阶段将依赖编译与源码编译彻底隔离:
planner:扫描整个项目,生成仅描述依赖树的
recipe.json(不包含业务代码)builder — cook:凭借
recipe.json还原并编译全部依赖;只要Cargo.toml/Cargo.lock不变,该层永远命中缓存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 镜像备选:
部署脚本
#!/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通过标准工具链解决同类问题,更可靠。