Andrey Batyuk

Building Rust microservice with Alpine Linux

I am trying to optimize the image size of small microservice written in Rust - and to demonstrate it I’m using Juniper integration for Actix Web as it has sufficient number of dependencies and shows the time savings.

First of all, let’s try building a straightforward two-stage docker image that will take existing code and produce a service:

FROM rust:1.36

ADD Cargo.toml /build/Cargo.toml
ADD Cargo.lock /build/Cargo.lock
ADD src /build/src

WORKDIR /build

RUN apt-get update && \
    apt-get install -y --no-install-recommends cmake musl-tools && \
    rustup target add x86_64-unknown-linux-musl && \
    cargo build --target x86_64-unknown-linux-musl --release --locked

FROM alpine:latest
WORKDIR /app
COPY --from=0 /build/target/x86_64-unknown-linux-musl/release/blaah /app/

EXPOSE 8080

CMD [ "/app/blaah" ]

Running this - time docker build . -t blaah - takes a bit over 2 minutes to build on my beast of machine and produces 14.1 MB image.

Let’s split this into two Dockerfiles.

First one is to actually get all of the dev tools dependencies installed, second one is using the first one to produce an image:

FROM rust:1.36

RUN apt-get update && \
    apt-get install -y --no-install-recommends cmake musl-tools && \
    rustup target add x86_64-unknown-linux-musl

Building it with time docker build -f Dockerfile.toolchain . -t toolchain takes 19 seconds - mostly spend waiting on data transfer (living in the middle of the forest has its downsides) - but cutting 15% of build time that don’t need to repeat is always good.

Let’s build the rest of the code now:

FROM toolchain

ADD Cargo.toml /build/Cargo.toml
ADD Cargo.lock /build/Cargo.lock
ADD src /build/src

WORKDIR /build

RUN cargo build --target x86_64-unknown-linux-musl --release --locked

FROM alpine:latest
WORKDIR /app
COPY --from=0 /build/target/x86_64-unknown-linux-musl/release/blaah /app/

EXPOSE 8080

CMD [ "/app/blaah" ]

Running this one (time docker build -f Dockerfile.restofcode . -t restofcode) takes 1:42 which is not surprising. Let’s make it faster!

Given that dependencies update less frequently than the code itself, let’s split these two into separate docker builds.

First one is to download and build all the dependencies. As of today, there’s no way to do it just for dependencies see GitHub issue, so let’s just build a dummy build.

FROM toolchain

ADD Cargo.toml /build/Cargo.toml
ADD Cargo.lock /build/Cargo.lock
ADD src /build/src

WORKDIR /build

RUN cargo build --target x86_64-unknown-linux-musl --release --locked

It is 1:40 (2 seconds saved on building a new layer with just my executable), but now the grand finale:

FROM deps

ADD Cargo.toml /build/Cargo.toml
ADD Cargo.lock /build/Cargo.lock
ADD src /build/src

WORKDIR /build

RUN cargo build --target x86_64-unknown-linux-musl --release --locked && \
    strip /build/target/x86_64-unknown-linux-musl/release/blaah

FROM alpine:latest
WORKDIR /app
COPY --from=0 /build/target/x86_64-unknown-linux-musl/release/blaah /app/

EXPOSE 8080

CMD [ "/app/blaah" ]

Running it: time docker build -f Dockerfile.justtheapp . -t blaah takes 9.1 seconds. Just 8% of original build time. And thanks to strip it also reduced image size from 14 to 10 MB.

Of course, build machine/CI engine can still perform ‘complete build’, and in some cases you add multiple dependencies, but in my case it reduced build of my pet project from ~7 minutes to roughly 30 seconds - 14 times improvement. Which I have already spent writing this post and creating a shell script that I’m ashamed of to automate these three steps (base, dependencies, app).

So, I don’t know what’s this all “Rust compilation times are horrible” about.