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:

  1. Create a new “dummy crate” within the Docker container using cargo init.
  2. Copy your Cargo.toml / Cargo.lock files into the Docker container.
  3. Run cargo build to build the dummy (essentially empty) created by cargo init.
  4. Copy your actual source into the Docker container (overwriting the dummy crate).
  5. 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:

  1. Copy your Cargo.toml / Cargo.lock files into the Docker container.
  2. Run cargo vendor to download your project's dependencies.
  3. Copy your project source into the Docker container.
  4. 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:

replace-with = 'vendored-sources'

directory = '/usr/src/app/vendor'

We've replaced the 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.

Discussion on Reddit