Recently, I was working on a small Rust application that I wanted to run in a Docker container. Rust binaries can take a long time to compile, especially if you have a large number of dependencies. So, Docker’s built-in caching comes in handy to reduce build times.
With Docker caching, you want to do as much work up-front as possible, so that you only need to redo compilation as necessary. For us, this means that we want to save our compiled dependencies in the Docker cache so that we only recompile our own code each time we iterate.
Many tutorials I saw online suggested the following workflow:
- Create a new “dummy crate” within the Docker container using
cargo init
. - Copy your
Cargo.toml
/Cargo.lock
files into the Docker container. - Run
cargo build
to build the dummy (essentially empty)main.rs
created bycargo init
. - Copy your actual source into the Docker container (overwriting the dummy crate).
- Re-run
cargo build
to compile your project’s real source code.
In the above process, Docker can cache all the steps up to step 4. As long as you don’t make changes to your dependency list, the only thing that will need to be recompiled is your source code.
However, creating a dummy crate as part of the build feels like a hack. Furthermore, in some configurations you need to manually delete intermediate build artifacts for this workflow to work. Gross. Until recently, Cargo didn’t support building crate’s dependencies independently of the binary/library, which made using a dummy crate the simplest way to take advantage of the Docker cache.
Fortunately, cargo vendor
was created and solves just this issue: it allows
you to vendor your dependencies into a local directory so that dependencies can
be cached by Docker. Though cargo vendor
is now part of the main Cargo
project, it’s still worth checking out its
original repo for more details.
The workflow with cargo vendor
achieves the same cache performance, but is (in
my opinion) a lot cleaner:
- Copy your
Cargo.toml
/Cargo.lock
files into the Docker container. - Run
cargo vendor
to download your project’s dependencies. - Copy your project source into the Docker container.
- Re-run
cargo build
to compile your project.
Once again, we’re able to cache everything up until the point that we copy in our source code. However, with this flow we don’t have to worry about creating a dummy crate.
An example Dockerfile
using cargo vendor
looks something like this:
# -----------------
# Cargo Build Stage
# -----------------
FROM rust:1.39 as cargo-build
WORKDIR /usr/src/app
COPY Cargo.lock .
COPY Cargo.toml .
RUN mkdir .cargo
RUN cargo vendor > .cargo/config
COPY ./src src
RUN cargo build --release
RUN cargo install --path . --verbose
# -----------------
# Final Stage
# -----------------
FROM debian:stable-slim
COPY --from=cargo-build /usr/local/cargo/bin/my_binary /bin
CMD ["my_binary"]
Note: We pipe the output of cargo vendor
into .cargo/config
so that in
subsequent builds, Cargo recognizes that we’ve already vendored our
dependencies. The output of cargo vendor
looks like this:
[source.crates-io]
replace-with = 'vendored-sources'
[source.vendored-sources]
directory = '/usr/src/app/vendor'
We’ve replaced the crates.io
source with our own local source that points to
the vendor/
directory in which cargo vendor
built our dependencies.
So, now we have a quicker way to build Rust Docker containers without resorting to unfortunate hacks. Furthermore, by using multistage Dockerfiles, like the one presented above, we can dramatically reduce the size of Rust container images. 👏
Happy containerizing!
Edit (12/9): The downside of this approach is that cargo vendor
only
downloads your dependencies, whereas the hacky solution both downloads and
builds your dependencies. So, there’s still room for improvement. 😕 There’s a
pretty old issue on cargo
requesting a --dependencies-only
flag to cargo build
, but it’s unclear if
that’s on cargo’s roadmap.