Leveraging docker caching building rust binaries
With the compile time required for rust projects and the sheer size of the target/
directory, building rust projects in docker requires to pay attention to some details.
Small images with multi-stage builds
Using docker multi-stage builds it’s easy to build small images (even smaller with rust:alpine and alpine:latest):
FROM rust:latest as builder
WORKDIR /usr/src
COPY . .
RUN cargo install --locked --path . --root /usr/local
FROM debian:latest
COPY --from=builder /usr/local/bin/* /usr/local/bin/
For the sake of completenesse, an appropriate .dockerignore
would look like this:
.git
.idea
.vscode
target
The cargo install
step will take a long time, even for small projects, since it needs to build all dependencies every time a file in your project changes.
Optimize dockerfile for caching
For NodeJS projects it’s common to copy package.json and yarn.lock, then execute yarn install
before copying project files to leverage docker caching. With rust it’s not that straight forward as cargo refuses to do a lot if you do not have a src/lib.rs
or src/main.rs
.
Well if cargo insits on src/main.rs
, why don’t we just give cargo a main.rs
?
Thus the initial Dockerfile becomes:
FROM rust:latest as builder
WORKDIR /usr/src
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && \
echo "fn main() {}" > src/main.rs && \
# debug-build (to maybe run tests/clippy, optional)
cargo build --locked && \
# release-build (for install)
cargo build --release --locked && \
rm -r src
# up to this point docker should use the cache for most builds
COPY . .
RUN cargo install --locked --path . --root /usr/local
FROM debian:latest
COPY --from=builder /usr/local/bin/* /usr/local/bin/
Now docker’s caching of layers works perfectly as long as you do not change the Cargo.toml
or Cargo.lock
— i.e. add/remove/update crates.
Oh, and of course, if rust:latest
changes, that totally busts the cache, too.
Order of magnitude
To give you a rough idea of the order of magnitude (no exact benchmark, just single runs) I took the numbers from our Jenkins for a (small) rust project:
Time | |
---|---|
No cache | 25:30m |
changes in src/ | 1:08m |
no changes | 13s |
There’s one more thing…
The layers of the builder are of course only available at the host/node that executes the build (docker build
). (Pre-) building the builder-stage separatly and pushing the builder-stage makes it possible to reuse it on other machines (e.g. randomly assigned build-agents/slaves):
# pull any previous builder-images
docker pull my-project:builder || true
# if Cargo.{toml,lock} did not change, this will only take a few seconds
docker build --pull --target builder -t my-project:builder .
docker push my-project:builder
# could also be a later build-stage, but will only need to build *your* source-code (under src/)
docker build --pull -t my-project:latest .
Conclusion
Given these tricks it’s easy to build docker images for rust projects, and to neglect rust’s long compilation time. Trigger periodic builds of the builder stage to always have an up-to-date builder-image, even if the rust-baseimages changes.