Rust 可以让程序运行得非常快,并且内存效率很高,但它有一个代价——编译时间。
在 Web 开发中,将程序作为 Docker 镜像交付,然后在 Kubernetes / Amazon ECS / Docker Compose / 等中运行它们是标准做法。
近年来,随着 ARM 处理器的流行,程序员面临着准备多架构 Docker 镜像的额外步骤(这意味着同一个镜像应该能够在 x86-64/amd64 和 aarch64/arm64 处理器上本地运行)。
为解释型语言准备 Docker 镜像通常不是问题: 使用 Docker Buildx,它在模拟器内部运行,并为每个架构原生构建镜像。
- NodeJS / Python 只需通过 npm / pip 安装依赖项,复制项目代码,进行少许润色,就这么简单。
- 即使在编译后的 Go 中,这种方法也能正常工作,因为 Go 拥有广泛的标准库。
对于 Rust 来说,即使编译一个简单的 Web 应用程序也是对“宇宙”的重建——几乎任何 Web 应用程序都需要:HTTP 服务器、HTTP 客户端(这反过来需要一个库来处理 https 加密)、异步运行时(tokie 等)、序列化/反序列化(serde、serde_json);在 Rust 中,应该将其作为外部库(crate)安装,并应在每次构建程序时进行编译。
尽管 Rust 编译器有很多工作要做,但它可以快速完成。即使不是最强大的 CI,也可以在几分钟内构建一个普通项目。
但是,这仅适用于在本机架构上构建的情况(例如在 amd64 处理器上构建 amd64 二进制文件)。一旦我们需要构建多架构镜像,我们就必须进行模拟,编译速度就会急剧下降。
例如,在简单公共项目ghstats上,从头开始构建多架构镜像大约需要 50 分钟,而本机架构的相同构建只需 2-3 分钟。
通过正确使用 Docker 层可以优化构建时间,这样只有在依赖项实际发生变化时才会发生重建步骤。因此,只有第一次构建会很长,后续构建会很快。不幸的是,Rust 基础设施在这一点上存在问题——文件中的任何更改Cargo.toml(例如版本号)都会使 Docker 层无效并触发所有依赖项的完全重建。
问题定义 因此,为 Rust 项目构建多架构 Docker 镜像存在两个问题:
- 任何更改发生时,层失效并完全重建Cargo.toml
- 由于模拟,多架构构建速度非常慢
更好的依赖关系建立 解决第一个问题最简单的方法是使用cargo-chef专门为它创建的。cargo-chef将Cargo.toml&转换Cargo.lock为一个特殊recipe.json文件,该文件将保持不变,直到项目依赖项发生变化。然后我们可以使用此 json 文件来缓存编译依赖项的昂贵 Docker 层。
Dockerfilecargo-chef将使用多阶段构建,分为 5 个部分:
- 安装cargo-chef并构建依赖项(OpenSSL、linux-headers 等)
- 准备recipe.json包含项目依赖项描述的文件
- 安装并构建项目依赖项recipe.json
- 建设整体项目
- 将二进制文件复制到运行时镜像并进行最终完善
# (1) 安装 cargo-chef 和构建工具 FROM rust:alpine AS chef WORKDIR /app RUN cargo install --locked cargo-chef RUN apk add --no-cache musl-dev openssl-dev # (2) 预制配方文件 FROM chef AS planner COPY . . RUN cargo chef prepare --recipe-path recipe.json # (3) 在 COPY 命令中构建项目资源库、缓存魔法 FROM chef AS builder COPY --from=planner /app/recipe.json recipe.json RUN cargo chef cook --recipe-path recipe.json --release # (4) 实际项目建设 COPY . . RUN cargo build -r # (5) 运行时镜像,您可以使用任何基本镜像 FROM alpine:latest AS runtime WORKDIR /app COPY --from=builder /app/target/release/prog /app/prog CMD "/app/prog" |
这种方法将加速 Docker 镜像构建,同时项目依赖关系不会改变。由于 Docker 单独构建阶段,因此每次都会执行规划器cargo-chef阶段(但速度很快!),而构建器阶段将被部分缓存,直到recipe.json文件相同。
如果您需要单一架构构建,这种方法就很好了。但如果您需要多架构镜像,这种方法仍然很慢。
具有交叉编译功能的多架构镜像 由于 QEMU 模拟,使用 Docker Buildx 进行多架构构建运行速度非常慢。如果我们摆脱模拟,编译将全速进行。Rust 为其他架构内置了交叉编译,因此我们在 Docker 构建中对其进行了调整。
Rust 本身的交叉编译工作得很好,但有些包是基于 C 库的(OpenSSL、SQLite 等)。编译和链接 C 代码相当复杂,而且并不总是很清楚(通常你必须在 Stack Overflow 或 Github Issues 上寻找错误代码,直到你得到正确的编译器/头文件集)。还有另一个工具令人惊讶地很好地解决了交叉编译 C 代码的问题——Zig(实际上这是一种编程语言,但他们也有构建工具链)。
为了将 Zig 构建工具链与 Rust 连接起来,我将使用cargo-zigbuildcrate。我的其他 Docker 文件看起来与我们的cargo-chef变体非常相似,只是我在 Cargo 中添加了第二个目标来构建并将cargo build替换为cargo zigbuild。
# (1) 此阶段将始终在当前 arch上运行 # 添加了 zigbuild 和Cargo 目标 FROM --platform=$BUILDPLATFORM rust:alpine AS chef WORKDIR /app ENV PKG_CONFIG_SYSROOT_DIR=/ RUN apk add --no-cache musl-dev openssl-dev zig RUN cargo install --locked cargo-zigbuild cargo-chef RUN rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl # (2) nothing changed FROM chef AS planner COPY . . RUN cargo chef prepare --recipe-path recipe.json # (3) 构建项目部署:需要指定所有目标;使用 zigbuild FROM chef AS builder COPY --from=planner /app/recipe.json recipe.json RUN cargo chef cook --recipe-path recipe.json --release --zigbuild \ --target x86_64-unknown-linux-musl --target aarch64-unknown-linux-musl # (4) actuall project build for all targets # binary renamed to easier copy in runtime stage COPY . . RUN cargo zigbuild -r \ --target x86_64-unknown-linux-musl --target aarch64-unknown-linux-musl && \ mkdir /app/linux && \ cp target/aarch64-unknown-linux-musl/release/prog /app/linux/arm64 && \ cp target/x86_64-unknown-linux-musl/release/prog /app/linux/amd64 # (5) this staged will be emulated as was before # TARGETPLATFORM 从构建阶段复制二进制权利 # ARG 由 docker 自行填充 FROM alpine:latest AS runtime WORKDIR /app ARG TARGETPLATFORM COPY --from=builder /app/${TARGETPLATFORM} /app/prog CMD "/app/prog" |
总体来说就是这样。这种构建方法会更快。对于我的项目:
- 初始构建 50 分钟 -> 13 分钟 (3.8 倍)
- 代码更新时间从 7 分钟 -> 3 分钟 (2.3 倍)