Multi-arch Docker builds with Rust
Introduction
I’ve recently been playing with some Rust projects, and one of the biggest struggles I had was getting a small platform agnostic Docker image that I could use to build any platform using docker buildx
.
The code
I wrote a very small program that spits out a random number along with the current architecture to use as an sample for our Docker work.
use rand::Rng;
use current_platform::CURRENT_PLATFORM;
fn main() {
let mut rng = rand::thread_rng();
let r_num: u8 = rng.gen();
println!("My random number is: {} | {}", r_num, CURRENT_PLATFORM);
}
To compile this code we use cargo build --release
, this puts our binary in the target/release/
folder.
Initial Dockerfile
FROM rust:1.67 as builder
WORKDIR /build
COPY . .
RUN cargo build --release
RUN mv /build/target/release/rust-random-number-generator /
FROM scratch
WORKDIR /app
COPY --from=builder /rust-random-number-generator /app
CMD ["./rust-random-number-generator"]
Running this with a docker buildx build --push --platform linux/amd64,linux/arm64 -t <yourimage>:v0.1 .
works perfectly fine, it builds and pushes the images to the reposity of your choice. Perfect!
The Issue
While our above program works locally, and will work on any architecture supported by Rust if we build it using cargo build --release
on those architecutre machines, trying to run this on any arch other than the host arch results in errors.
Why? Because we need to build static binaries where Rust defaults to dynamic ones. In a scratch
image we don’t have a lot of the underlying services a dynamic application can use, so we have to package it ourselves.
Improved Dockerfile
FROM rust:1.67 as builder
WORKDIR /build
COPY . .
RUN RUSTFLAGS="-C target-feature=+crt-static" cargo build --release
RUN mv /build/target/release/rust-random-number-generator /
FROM scratch
WORKDIR /app
COPY --from=builder /rust-random-number-generator /app
CMD ["./rust-random-number-generator"]
With the addition of RUSTFLAGS=-C target-feature=+crt-static
we’ve specified we want to create static binaries for the architecture being built.
A more complex example
The above program and dockerfile now work without issue, but this was a very simple setup. What if we use a more complex one?
For a more complex example I decided to use one of the Helium Oracles
Our updated Dockerfile looks like this:
FROM rust:1.67 as builder
WORKDIR /build
COPY . .
RUN RUSTFLAGS="-C target-feature=+crt-static" cargo build -p ingest --release
RUN mv /build/target/release/ingest /
FROM scratch
WORKDIR /app
COPY --from=builder /ingest /app
CMD ["./ingest"]
The second error
Running our more complex program in the same way as our first example program results in the following error:
#0 26.94 error: cannot produce proc-macro for `async-stream-impl v0.3.3` as the target `aarch64-unknown-linux-gnu` does not support these crate types
Which is odd because we can compile these programs on both amd64
, and arm64
outside of Docker without issue. I tested both.
I spent a good bit of time debugging here and it seems the root cause is when you specify the build flags RUSTFLAGS="-C target-feature=+crt-static"
Rust will default to building a static binary for the host architecture, not the Docker container architecture. So attempting to natively compile for the <host arch>
while on the <container arch>
and we end in an error.
We can confirm this is the case by adding a specific architecture with the --target
parameter and testing. Now that we know how to make it work, how do we make it architecture agnostic again while using the --target
parameter?
The solution
The magic of rustc -vV
. With a little bit of shell scripting we can get the current architecture of our container doing the complation: $(rustc -vV | sed -n 's|host: ||p')
Our updated Dockerfile:
FROM rust:1.67 as builder
WORKDIR /build
COPY . .
RUN echo "$(rustc -vV | sed -n 's|host: ||p')" > rust_target
RUN RUSTFLAGS="-C target-feature=+crt-static" cargo build --target $(cat rust_target) -p ingest --release
RUN mv /build/target/$(cat rust_target)/release/ingest /
FROM scratch
WORKDIR /app
COPY --from=builder /ingest /app
CMD ["./ingest"]
Now we’re able to use the RUSTFLAGS
to compile a static binary, and we’re specifying the proper architecture dynamically based on the container doing the compiling.
Building this with docker buildx build --push --platform linux/amd64,linux/arm64 -t <image>:v0.2
works without issues. You can pull the image locally and run on either of those architectures.
This may not work everywhere. Just as the first program we had working without needing --target
, there may be more complex programs out there that need additional tweaking to get working right. For now though, I’m pretty happy with getting this finished and off my to-do/learning list.