Optimizing Rust images with scratch base image

by

in

About scratch image

The scratch image, as its name suggests, is nothing but a empty layer in your image.

FROM scratch

This line will be the starting point for building container image. The following line will be forming the initial filesystem layer in your image.
To this understand container image layer better, take a look at this Docker documentation.

Scratch base image is mostly used in building base images (like debian and python:3.7-alpine).
More and more companies are turning to distroless as a preferred base image, primarily due to its significant benefits – mostly in security – outweighing other potential challenges – mostly in debugging.
The best known distroless image solutions maybe those from Google, or Chainguard.

In this blog, we will find out how to containerize an Rust application using scratch base image. While Google distroless is usually sufficient for many cases, sometimes you might want to use scratch for a even lighter and more-controlled container image.

In this blog, I’m focusing on Rust, a compiled language I’ve been learning.

But why Rust, or why a compiled language?

Containerizing interpreted language application, such as Nodejs, Python, requires you to ship the interpreter along with the code.
While with compiled language, we can build the binary ahead of time (thanks to multi-stage build) then run it, without having to include the compiler. It is simpler and more generic for this showcase.

Multi-stage build

Dockerfile.alpine and Dockerfile.scratch will look like this

# Build stage
FROM rust:1.84.0 AS builder
WORKDIR /usr/src/

# Download the cargo target
RUN rustup target add x86_64-unknown-linux-musl

# Make a dummy application leveraging the docker cache for dependencies
RUN USER=root cargo new hello-world-distroless
WORKDIR /usr/src/hello-world-distroless
COPY Cargo.toml Cargo.lock ./
RUN cargo build --release

# Copy application code
COPY src ./src
RUN cargo install --target x86_64-unknown-linux-musl --path .

# Bundle Stage
# FROM alpine
FROM scratch
COPY --from=builder /usr/local/cargo/bin/hello-world-distroless .
USER 1000
CMD ["./hello-world-distroless"]

Dockerfile.google-distroless

# Build stage
FROM rust:1.84.0 as builder
WORKDIR /app
COPY Cargo.toml Cargo.lock src ./
RUN cargo build --release

# Bundle stage
FROM gcr.io/distroless/cc-debian12
COPY --from=builder /app/target/release/hello-world-distroless /
CMD ["./hello-world-distroless"]

Note: Please keep in mind that distroless images typically do not include a shell. Therefore, if you define the Dockerfile’s ENTRYPOINT command, make sure it is specified in vector form as above. This approach helps prevent the container runtime from prefixing it with a shell.

You can find example code at this Github repo

Let’s take a look at the two pieces of Dockerfile code. The alpine and scratch Dockerfile build stages are more complicated than the distroless one.

  • For Google distroless case, both base images rust:1.84.0 and gcr.io/distroless/cc-debian12 use dynamic-linked standard library libc as linking target of the compiled binary.
  • For alpine case, rust:1.84.0 uses libc while alpine image uses musl-libc as alternative. So we need to build with the x86_64-unknown-linux-musl target and link it with musl.
  • Regarding scratch base image, it is empty and doesn’t come with any library. Therefore while compiling, we need to embed the necessary library functions directly into the program’s executable binary. As musl is a static-linked library, is also used instead of the default library libc.

Note: you will get this error during runtime if you compile Rust binary file without using the x86_64-unknown-linux-musl target.

Visit this link if you want to know further about Static and Dynamic linking.

Size

There is a significant difference between sizes of images. Compared to Rust image one-staged built with the default rust image, they are much smaller.

Conclusion

  • Google distroless base image is recommended in most of the case, it’s not the smallest in size but the configuration is easier.
  • Alpine is also widely used, but you should switch to rust:1.84.0-alpine to avoid more configuration complexity.
  • Scratch in this showcase is the best in term of size. Smaller size often means smaller attacking surface. Size also matters from Kubernetes perspective.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *