The most surprising thing about building Rust apps in Docker using multi-stage builds is that the final image can be smaller than a single cargo build output if you’re not careful.
Let’s see it in action. Imagine a simple Rust web server:
// src/main.rs
use hyper::{Body, Request, Response, Server};
use hyper::service::{make_service_fn, service_fn};
use std::convert::Infallible;
use std::net::SocketAddr;
async fn handle(_req: Request<Body>) -> Result<Response<Body>, Infallible> {
Ok(Response::new(Body::from("Hello from Rust in Docker!")))
}
#[tokio::main]
async fn main() {
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
let make_svc = make_service_fn(|_conn| async {
Ok::<_, Infallible>(service_fn(handle))
});
let server = Server::bind(&addr).serve(make_svc);
println!("Listening on {}", addr);
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
}
To compile this, we’d typically run cargo build --release. This produces an executable, usually in target/release/your_app_name.
Now, let’s put this into a Dockerfile using a multi-stage build. The goal is to have a small final image containing only the compiled binary and its necessary runtime dependencies, not the entire Rust toolchain.
# Stage 1: The Builder
FROM rust:1.75 as builder
WORKDIR /usr/src/app
COPY . .
# Build the release binary.
# This command downloads dependencies and compiles the code.
RUN cargo build --release
# Stage 2: The Runner
# We'll use a minimal base image like Alpine Linux.
FROM alpine:3.18
# Install runtime dependencies for the compiled binary.
# For many Rust binaries, especially those using Tokio,
# musl-based libc is often sufficient.
RUN apk add --no-cache musl-dev
WORKDIR /app
# Copy the compiled binary from the builder stage.
# The path matches where cargo build --release places it.
COPY --from=builder /usr/src/app/target/release/your_app_name .
# Expose the port the application listens on.
EXPOSE 3000
# Define the command to run the application.
CMD ["./your_app_name"]
Let’s break down how this works.
The first FROM rust:1.75 as builder line pulls a Docker image containing the Rust toolchain. This is our "builder" environment. We set a working directory, copy our application’s source code into it, and then run cargo build --release. This is where all the compilation happens. The output is the statically linked executable in target/release/your_app_name.
The second FROM alpine:3.18 starts a completely new, minimal image. Alpine is a popular choice for its small size. We install musl-dev because Rust binaries compiled with the default musl target (which is common in minimal Docker images) need its libraries. Then, we copy only the compiled binary from the builder stage into this new, lean image. We don’t copy source code, the Cargo cache, or any of the Rust compiler tools.
Finally, EXPOSE 3000 documents that the application listens on port 3000, and CMD ["./your_app_name"] specifies how to run it when the container starts.
The crucial part for understanding the "smaller than you’d think" aspect is that cargo build --release itself can produce a large executable if it includes debugging symbols or isn’t fully stripped. However, the multi-stage build allows us to discard the entire Rust toolchain after compilation.
To optimize the binary size within the builder stage, you can add strip to your build command. This removes debugging symbols and other non-essential information. You can do this by modifying the RUN command in the builder stage:
# In Stage 1 of the Dockerfile:
RUN cargo build --release && strip target/release/your_app_name
The strip command is part of binutils and is usually available in the rust Docker image. This makes the binary itself significantly smaller.
If you’re building an application that has dynamic linking requirements beyond what musl provides (e.g., it depends on specific glibc features or external libraries not present in Alpine), you might need to use a different base image for your runner stage, like debian:stable-slim. In that case, you’d install the necessary shared libraries using apt-get install instead of apk add.
The real magic of multi-stage builds for Rust isn’t just about getting rid of the compiler; it’s about creating a truly minimal runtime environment. Your final image contains only the executable and whatever shared libraries it dynamically links to (if any). For statically linked Rust binaries compiled for musl, this can mean images under 10MB.
The next common pitfall is dealing with build-time dependencies that aren’t Rust code, like C libraries that your Rust code needs to link against.