My Rust Dockerfile

Lets deploy small docker images for Rust

Fredrik Park published on
9 min, 1607 words

Categories: CI/CD

⚠️ This post is old.

While the information might still be usefull it is not garantueed to work out of the box on a modern setup.

In this article I will present a Rust Dockerfile setup I am using for my rust projects. It focuses focus on getting a small image sizes and a workflow that fits into a CI/CD setup nicely. This focus means that we will require our tests to run and we try to take as much advantage of the docker build cache as possible to keep build times low.

During the course of this article I will track 3 statistics, the final image size, the initial build time and the time it takes to rebuild the project after a code change that does not affect the dependencies of the project. Image size makes deployments faster as there is less data to download (for example compare a ~700 Mb Ubuntu image versus a ~5 Mb Alpine image). Initial build time will affect all pull/merge requests made for a branch and having quick rebuild will of course affect any subsequent commits to a branch.

All builds where done on my Core i5 and time was meassured with the linux time command, docker images sizes where taken from docker images.

Creating our test project

First lets create a standard hello world rust project, add a dependency and run it to verify that it works.

cargo new --bin hello
cd hello
echo 'chrono = "0.4.0"' >> Cargo.toml
cargo run

This will print Hello, world! on your console together with some compilation text.

If you do any local development you will generate rest products in the target directory when your are building and testing your project. We should therefore setup a .dockerignore file for our project (the rest of the post assumes that the commands below have been executed).

echo "target" > .dockerignore
echo "Dockerfile" >> .dockerignore

A naive Dockerfile

Lets create a Dockerfile for this project. I want to utilize the musl libc implementation to get a statically compiled binary as that will simplify our deployment artifact quite a lot. If you can not or do not want to have a static binary as the output a lot of the techniques listed here are still applicable (the way you construct your final image will need to be modified a bit).

Add the following Dockerfile to your project root.

FROM ekidd/rust-musl-builder

COPY . .
RUN cargo test
RUN cargo build --release

ENTRYPOINT ["./target/x86_64-unknown-linux-musl/release/hello"]
  • Image size: 1.4 Gigabytes
  • Initial build time: ~50 seconds
  • Rebuild time: ~50 seconds

The final binary has a size of roughly 5 megabytes so there is a big overhead in this native image, I will address this issue in the next section. Also the rebuild time indicates that we are actually not utilizing the docker cache at all and the build output from docker build . verifies this as all dependencies are rebuilt even if we just change the output text in our src/main.rs.

Utilize a Multi-stage Dockerfile

Let us address the size issue - we will utilize a feature introduced in Docker 17.05 called multi-stage dockerfiles to fix this. This allows us to define several Dockerfiles inside of a single Dockerfile and access data from the previous dockerfiles to construct our final image. A side bonus of us building a static binary is that we can utilize a even smaller docker images as our final product.

FROM ekidd/rust-musl-builder as builder

WORKDIR /home/rust/

COPY . .
RUN cargo test
RUN cargo build --release

ENTRYPOINT ["./target/x86_64-unknown-linux-musl/release/hello"]

FROM scratch
WORKDIR /home/rust/
COPY --from=builder /home/rust/target/x86_64-unknown-linux-musl/release/hello .
ENTRYPOINT ["./hello"]
  • Image size: 5.3 Megabytes

As we can see now the final docker image is several fractions smaller as we got rid of all build time dependencies as well as where able to utilize the scratch base image (which is basically a empty docker image).

It should be noted that with this setup it is impossible for docker to produce a final image that does not pass the tests as the build will fail if our tests fails.

Optimizations

So now we have a pretty nice setup that generates a very small docker image for us. There are a couple of things that can be done to improve the process though. I will isolate each optimization to make it clearer what each step does before summarizing it all in a single dockerfile that utilizes all improvements at the same time.

Stripping the binary

Another thing we can do to get rid of some extra size of our final binary (which now is the largest part of our docker image) is to strip the binary of anything that is not needed to run it. Please be aware that this might not always be desired as it removes any debug information as well.

FROM ekidd/rust-musl-builder

COPY . .
RUN cargo test
RUN cargo build --release

RUN strip target/x86_64-unknown-linux-musl/release/hello

ENTRYPOINT ["./target/x86_64-unknown-linux-musl/release/hello"]

This changes the final size of the binary from ~5 megabytes to ~500 Kilobytes.

Fixing the docker cache utilization

One big issue we have with the current approach is that our CI/CD pipeline is not making full use of the docker image caching. Lets try to remedy that. The trick here is to first add Cargo.toml and Cargo.lock to the image and build the project to ensure that we build the dependencies of our project as soon as possible. Doing this will allow subsequent builds that does not change the dependencies to utilize the already cached steps in docker.

When applying this strategy a couple of workarounds where needed. First Cargo will not build dependencies of a project that does not contain a main.rs, so we will create a minimal one in the src folder so that we can build the dependencies. Related to this we also need to touch our real main.rs as the COPY command in docker does not affect the timestamps of the files it copies.

This process could be improved if Cargo is given a command to allow installation of dependencies without the need for the source folder

FROM ekidd/rust-musl-builder as builder

COPY Cargo.toml Cargo.lock ./
RUN mkdir src
RUN echo "fn main() {}" > src/main.rs
RUN cargo test
RUN cargo build --release

COPY . .
RUN sudo touch src/main.rs

RUN cargo test
RUN cargo build --release

ENTRYPOINT ["./target/x86_64-unknown-linux-musl/release/hello"]
  • Inital build time: ~50 seconds
  • Rebuild time: ~20 seconds

Initial build time is unchanged from the naive version, this is to be expected as we still need to do all work building the project and dependencies. We gain time during rebuilds though as we now only need to build our project (all dependencies are cached). Also do remember that we only have one dependency in this example project, this step will see greater benefits the more dependencies we have.

Putting it all together

This is my current approach for how to structure my Dockerfile for Rust projects and I hope that it will help you get a nice setup running. I have commented this version so that if you copy it there will be some future references as to what each step does.

FROM ekidd/rust-musl-builder as builder

WORKDIR /home/rust/

# Avoid having to install/build all dependencies by copying
# the Cargo files and making a dummy src/main.rs
COPY Cargo.toml .
COPY Cargo.lock .
RUN echo "fn main() {}" > src/main.rs
RUN cargo test
RUN cargo build --release

# We need to touch our real main.rs file or else docker will use
# the cached one.
COPY . .
RUN sudo touch src/main.rs

RUN cargo test
RUN cargo build --release

# Size optimization
RUN strip target/x86_64-unknown-linux-musl/release/hello

# Start building the final image
FROM scratch
WORKDIR /home/rust/
COPY --from=builder /home/rust/target/x86_64-unknown-linux-musl/release/hello .
ENTRYPOINT ["./hello"]

The final statistics as compared to the naive setup we started with is as follows.

  • Image size: 543 Kilobytes down from 1.4 Gigabytes
  • Inital build time: ~50 seconds
  • Rebuild time: ~20 seconds down from ~50 seconds

It should be noted that the whole project we have been working with is really simple so a real world example would not generate quite the same numbers but the setup works way better than the naive approach while still staying relatively simple.

Known issues

A drawback with the presented setup is that if we bump the version number in our Cargo.toml we will invalidate the cached dependencies. This is a side effect of how docker caching works (each cache is based on the previous build step), and I currently don't know of a nice and simple solution to this issue.

In my other project I also run clippy and use nightly - the changes needed for that to work was not included in this setup but should be fairly easy to include on your own.

Future work

My presented setup assumes that we can build everything into a static binary. If we for some reason can't make it a 100% static we will run into issues with having to provide the external library files to the final docker image. I have not explored how to solve this yet.

Acknowledgements

I would like to thank my dear friend and earlier colleague Fredrik Håård for proofreading this article.