使用Docker构建时缓存Rust依赖项

54

我在Rust+Actix-web中有一个“Hello World”网站项目,遇到了几个问题。首先是代码的每一次更改都会导致整个项目重新编译,包括下载和编译每个crate,希望能像普通开发一样工作——即缓存已编译的crates,仅重新编译我的代码库。第二个问题是我的应用程序无法通过Web浏览器访问。

Dockerfile:

FROM rust

WORKDIR /var/www/app

COPY . .

EXPOSE 8080

RUN cargo run

docker-compose.yml:

version: "3"
services:
  app:
    container_name: hello-world
    build: .
    ports:
      - '8080:8080'
    volumes:
      - .:/var/www/app
      - registry:/root/.cargo/registry

volumes:
  registry:
    driver: local

主函数.rs:

extern crate actix_web;

use actix_web::{web, App, HttpServer, Responder};

fn index() -> impl Responder {
    "Hello world"
}

fn main() -> std::io::Result<()> {
    HttpServer::new(|| App::new().service(web::resource("/").to(index)))
        .bind("0.0.0.0:8080")?
        .run()
}

Cargo.toml:

[package]
name = "hello-world"
version = "0.1.0"
authors = []
edition = "2018"

[dependencies]
actix-web = "1.0"

请提供您的 cargo.toml 文件。 - ckaserer
你的Dockerfile缺少CMD行;容器启动时应该运行什么?(构建序列是否实际完成?) - David Maze
如果答案解决了您的问题,请接受它或提供其他信息以进一步调试手头的问题。 - ckaserer
10个回答

51

看起来你在使用docker构建过程中缓存Rust依赖项的努力并不孤单。这是一篇很好的文章,可以帮助你完成此过程:https://blog.mgattozzi.dev/caching-rust-docker-builds/

它的要点是首先需要一个dummy.rs和你的Cargo.toml,然后构建它以缓存依赖项,最后复制你的应用程序源代码,以便不会在每次构建时使缓存无效。

Dockerfile

FROM rust
WORKDIR /var/www/app
COPY dummy.rs .
COPY Cargo.toml .
RUN sed -i 's#src/main.rs#dummy.rs#' Cargo.toml
RUN cargo build --release
RUN sed -i 's#dummy.rs#src/main.rs#' Cargo.toml
COPY . .
RUN cargo build --release
CMD ["target/release/app"]

CMD 应用程序名称 "app" 是基于您在 Cargo.toml 中为二进制文件指定的内容。

dummy.rs

fn main() {}

Cargo.toml

[package]
name = "app"
version = "0.1.0"
authors = ["..."]
[[bin]]
name = "app"
path = "src/main.rs"

[dependencies]
actix-web = "1.0.0"

src/main.rs

extern crate actix_web;

use actix_web::{web, App, HttpServer, Responder};

fn index() -> impl Responder {
    "Hello world"
}

fn main() -> std::io::Result<()> {
    HttpServer::new(|| App::new().service(web::resource("/").to(index)))
        .bind("0.0.0.0:8080")?
        .run()
}

23
好的回答。供参考,COPY dummy.rs可以替换为RUN echo "fn main() {}" > dummy.rs,这样就避免了在仓库中有一个dummy.rs文件。 - Jorge Leitao
1
不必操纵 Cargo.toml,您可以向其中添加第二个 [[bin]] 目标。请确保将 src/dummy.rs 与您的 Cargo.toml 一起复制。然后添加第二个 bin 目标(例如您示例中的第 5-7 行)。设置 name= "download-only"path = "src/dummy.rs" 并调用 cargo build --bin download-only - Martin Sommer
5
如果你执行了RUN echo "fn main() {}" > ./src/main.rs,然后进行COPY ./src ./src,cargo可能不会重新构建你的应用程序。你需要更新main.rs文件的最后修改时间来通知cargo重新构建它。 在第二次运行cargo build之前,你需要运行RUN touch -a -m ./src/main.rs来更新文件的最后修改时间。 - Ari Seyhun
2
我的cargo.toml文件里没有main.rs,结合其他评论,我想出了这个版本,它不需要sed或dummy.rs。Docker按内容缓存,所以我认为您不需要任何特殊的touch命令。 RUN mkdir src RUN echo "fn main() {}" > ./src/main.rs COPY ["Cargo.toml", "Cargo.lock", "./"] RUN cargo build --release COPY src src RUN cargo build --release - casret
1
原始的blog.mgatozzi.dev文章现在已经404了。Wayback Machine是你的好朋友:https://web.archive.org/web/20221028051630/https://blog.mgattozzi.dev/caching-rust-docker-builds/ - richb-hanover
显示剩余8条评论

30
使用(仍处于实验阶段的)Docker Buildkit,您最终可以在docker build步骤中正确缓存构建文件夹。 Dockerfile:
# syntax=docker/dockerfile:experimental
from rust
ENV HOME=/home/root
WORKDIR $HOME/app
[...]
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/home/root/app/target \
    cargo build --release

接着运行:

DOCKER_BUILDKIT=1 docker build . --progress=plain

随后的docker构建将从缓存中重用cargo和target文件夹,从而大大加速您的构建。

要清除docker缓存,请安装: docker builder prune --filter type=exec.cachemount

如果您没有看到适当的缓存,请确保确认docker镜像中cargo/registry和target文件夹的位置是否正确。

最小化工作示例:https://github.com/benmarten/sccache-docker-test/tree/no-sccache


使用 --mount=type=cache,target=/home/rust/src/target 不会意味着其他同时进行的构建访问 target 目录吗?在该缓存行中应该添加 sharing=privatesharing=locked 吗?也许可以结合一个单独的层来从缓存中填充构建本地的 target/,以及另一个层将新内容推回到缓存中? - Cody Schafer
如果您正在使用多阶段构建,并且需要从缓存中复制某些内容(例如已构建的可执行二进制文件),则必须将其从缓存中复制出来。例如,请参考此答案https://dev59.com/NlMH5IYBdhLWcg3wvyE4#64141061 - cymruu

14
你可以使用cargo-chef通过多阶段构建来利用Docker层缓存。
FROM rust as planner
WORKDIR app
# We only pay the installation cost once, 
# it will be cached from the second build onwards
RUN cargo install cargo-chef 
COPY . .
RUN cargo chef prepare  --recipe-path recipe.json

FROM rust as cacher
WORKDIR app
RUN cargo install cargo-chef
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json

FROM rust as builder
WORKDIR app
COPY . .
# Copy over the cached dependencies
COPY --from=cacher /app/target target
RUN cargo build --release --bin app

FROM rust as runtime
WORKDIR app
COPY --from=builder /app/target/release/app /usr/local/bin
ENTRYPOINT ["./usr/local/bin/app"]

它不需要使用Buildkit,适用于简单项目和工作区。您可以在发布公告中找到更多详细信息。


1
使用 --mount=type=cache,target=/home/rust/src/target 不会意味着其他同时进行的构建也会访问 target 目录吗?在缓存行中应该添加 sharing=privatesharing=locked 吗?或者与另一层结合使用,从缓存中填充本地构建的 target/,再使用另外一层将新内容推回缓存中? - Cody Schafer
我遇到了这个错误:docker: Error response from daemon: OCI runtime create failed: container_linux.go:370: starting container process caused: exec: "./usr/local/bin/app": stat ./usr/local/bin/app: no such file or directory: unknown. - Paul Razvan Berg
1
更新:我通过删除“ENTRYPOINT”数组值中的点来修复了错误。 - Paul Razvan Berg
这是一个不错的答案。但它缺少 crates.io 的缓存索引,并且复制大目标目录需要很长时间。 - S.R

6

虽然electronix384128的回答非常好,但我想通过为.cargo/git添加缓存(这对于使用git的任何依赖项都是必需的)并添加一个多阶段docker示例来扩展它。

使用rust-musl-builder和Docker Buildkit功能,它现在是Docker Desktop 2.4的默认功能。在其他版本上,您可能仍需要通过DOCKER_BUILDKIT=1 docker build .启用它。

rusl-musl-builder的工作目录是/home/rust/src
尝试在--mount上设置uid/gid,但由于目标中的权限问题而无法编译rust。

# syntax=docker/dockerfile:1.2
FROM ekidd/rust-musl-builder:stable AS builder

COPY . .
RUN --mount=type=cache,target=/home/rust/.cargo/git \
    --mount=type=cache,target=/home/rust/.cargo/registry \
    --mount=type=cache,sharing=private,target=/home/rust/src/target \
    sudo chown -R rust: target /home/rust/.cargo && \
    cargo build --release && \
    # Copy executable out of the cache so it is available in the final image.
    cp target/x86_64-unknown-linux-musl/release/my-executable ./my-executable

FROM alpine
COPY --from=builder /home/rust/src/my-executable .
USER 1000
CMD ["./my-executable"]

使用 --mount=type=cache,target=/home/rust/src/target 不会意味着其他同时进行的构建访问 target 目录吗?在该缓存行中应该添加 sharing=privatesharing=locked 吗?也许可以结合一个单独的层来从缓存中填充构建本地的 target/,以及另一个层将新内容推回到缓存中? - Cody Schafer
1
谢谢提示,我已经更新了示例,dockerfile语法现在不再是实验性的。 - jetersen
谢谢您对从缓存中复制可执行文件的评论。非常有帮助! - cymruu
非常感谢您对从缓存中复制可执行文件的评论。这对我非常有帮助! - undefined

5

基于@ckaserer的答案,你可以运行RUN echo "fn main() {}" > ./src/main.rs在编译应用程序之前构建依赖项。

首先只复制你的Cargo.tomlCargo.lock文件,并构建虚拟的main.rs文件:

FROM rust as rust-builder
WORKDIR /usr/src/app

# Copy Cargo files
COPY ./Cargo.toml .
COPY ./Cargo.lock .

# Create fake main.rs file in src and build
RUN mkdir ./src && echo 'fn main() { println!("Dummy!"); }' > ./src/main.rs
RUN cargo build --release

然后您可以复制真实的src目录并再次运行构建:

# Copy source files over
RUN rm -rf ./src
COPY ./src ./src

# The last modified attribute of main.rs needs to be updated manually,
# otherwise cargo won't rebuild it.
RUN touch -a -m ./src/main.rs

RUN cargo build --release

接下来,我们可以将文件复制到一个精简版的Debian中。以下是完整的Docker文件:

FROM rust as rust-builder
WORKDIR /usr/src/app
COPY ./Cargo.toml .
COPY ./Cargo.lock .
RUN mkdir ./src && echo 'fn main() { println!("Dummy!"); }' > ./src/main.rs
RUN cargo build --release
RUN rm -rf ./src
COPY ./src ./src
RUN touch -a -m ./src/main.rs
RUN cargo build --release

FROM debian:buster-slim
COPY --from=rust-builder /usr/src/app/target/release/app /usr/local/bin/
WORKDIR /usr/local/bin
CMD ["app"]

在我的情况下,在 RUN rm -rf ./src 之后,我需要添加 RUN rm -rf ./target/release - Erlang Parasu

4
这是对 @ckaserer 的回答进行改进,结合了评论和一些个人经验。它不需要在您的存储库中创建虚拟文件,也不需要在构建时编辑 Cargo.toml 文件。
RUN echo 'fn main() { panic!("Dummy Image Called!")}' > ./src/main.rs
COPY ["Cargo.toml", "Cargo.lock",  "./"]
RUN cargo build --release
COPY src src
#need to break the cargo cache
RUN touch ./src/main.rs
RUN cargo build --release

1

我遇到了和你一样的问题,并尝试了多种方法来缩短构建时间,包括缓存依赖项。

1. @ckaserer 的回答

这个方法能够解决问题,并且提供了易于理解的解释,是一个很好的解决方案。但是,如果你不想用这种方式缓存依赖项,可以尝试 #2。

2. 使用 cargo-chef

@LukeMathWalker 是该工具的创建者,他详细介绍了使用 cargo-chef 的步骤,这里有一个稍微调整过的示例,摘自 github 页面。

Dockerfile

FROM lukemathwalker/cargo-chef:latest-rust-1.60.0 AS chef
WORKDIR /app

FROM chef as planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

FROM chef as builder
COPY --from=planner /app/recipe.json recipe.json
# Build the dependencies (and add to docker's caching layer)
# This caches the dependency files similar to how @ckaserer's solution
# does, but is handled solely through the `cargo-chef` library.
RUN cargo chef cook --release --recipe-path recipe.json
# Build the application
COPY . .
RUN cargo build --release --bin emailer

FROM debian:buster-slim AS runtime
WORKDIR /app
COPY --from=builder /app/target/release/<Name of Rust Application> /usr/local/bin
ENTRYPOINT ["/usr/local/bin/<Name of Rust Application>"]

以上更改应显著降低构建时间!


顺带一提,据我所知,这篇博客文章虽然不是关于docker化构建的,但提供了有关在本地机器上更快编译rust应用程序的最佳信息。如果您感兴趣,建议查看一下。


1
这就是我的工作,它与构建脚本兼容。这是一个多阶段构建,因此结果是一个小的镜像,但在第一阶段缓存了构建的依赖项。
FROM rust:1.43 AS builder

RUN apt-get update
RUN cd /tmp && USER=root cargo new --bin <projectname>
WORKDIR /tmp/<projectname>

# cache rust dependencies in docker layer
COPY Cargo.toml Cargo.lock ./
RUN touch build.rs && echo "fn main() {println!(\"cargo:rerun-if-changed=\\\"/tmp/<projectname>/build.rs\\\"\");}" >> build.rs
RUN cargo build --release

# build the real stuff and disable cache via the ADD
ADD "https://www.random.org/cgi-bin/randbyte?nbytes=10&format=h" skipcache
COPY ./build.rs ./build.rs

# force the build.rs script to run by modifying it
RUN echo " " >> build.rs
COPY ./src ./src
RUN cargo build --release

FROM rust:1.43
WORKDIR /bin
COPY --from=builder /tmp/<projectname>/target/release/server /bin/<project binary>
RUN chmod +x ./<project binary>
CMD ./<project binary>

1
我认为问题出在你的 volumes 定义中没有进行绑定挂载。我相信你当前的配置是将 HOST ./registry/ 复制到 DOCKER /root/.cargo/registry/,写入 DOCKER /root/.cargo/registry/,并在容器关闭时丢弃其内容。
相反,你需要在卷上指定 bind 类型:
version: "3"
services:
  app:
    container_name: hello-world
    build: .
    environment:
      - CARGO_HOME=/var/www/
    ports:
      - '8080:8080'
    volumes:
      - .:/var/www/app
      - type: bind
        source: ./registry
        target: /root/.cargo/registry

然而,请记住还会创建一个/root/.cargo/.package-cache文件,但不会保存在此处。相反,您可以将source更改为./.cargo,并将目标更改为/root/.cargo


对于我自己的(大多数是 cli)rust项目,我喜欢使用一个我编写的可直接替换cargo的工具,我已确认它可以在构建之间缓存软件包,大大减少构建时间。这可以被复制到/usr/local/bin以全局使用,或者作为./cargo build在单个项目中运行。但请记住,此特定脚本假定应用程序位于容器内的/usr/src/app位置,因此您可能需要进行调整。


你能分享一下你的Dockerfile是什么样子的吗? - Brandon Ros

0

我意识到这个答案有点晚了,但是我相信我找到了一个稍微不同的解决方案(虽然基本思想是一样的),它将在单个Docker层中创建构建依赖项,这意味着它们将被缓存 - 你可以直接从以下内容复制RUN命令层:

...
COPY Cargo.toml /app/

RUN mkdir src && \
    echo 'fn main() {\nprintln!("Hello, world!");\n}' > src/main.rs && \
    cargo build && \ 
    cargo clean --package $(awk '/name/ {gsub(/"/,""); print $3}' Cargo.toml | sed ':a;N;$!ba;s/\n//g' | tr -d '\r') && \
    rm -rf src 

COPY src /app/src
...


首先复制您的`Cargo.toml`文件及其依赖项,然后在下方插入`RUN`层,然后再复制您的实际代码。
此操作首先运行一个虚拟应用程序(直接从`cargo init`复制),该包与您的项目同名,然后仅对该包运行`cargo clean`,这意味着如果您添加了代码并再次构建,所有的依赖项都已被缓存,只需重新构建您的代码。通过将整个过程合并到单个层中,可以节省时间,以防出现后续流程中的任何更改。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接