Rust: Docker Image Optimization With Multi-stage Builds

mattdark

Mario García

Posted on December 29, 2023

Rust: Docker Image Optimization With Multi-stage Builds

When deploying an application using Docker, if you build the image that will serve to create the container, using a Dockerfile, there are some best practices to follow. In the Docker documentation, there's a section you can check for more information.

Each instruction in a Dockerfile roughly translates to an image layer.

Image Layers

For example, in a Dockerfile that has the following content:



...

RUN apt-get update 
RUN apt-get install -y python3 python3-pip curl git
RUN curl -sSL https://install.python-poetry.org | python3 -
RUN curl https://pyenv.run | bash

...


Enter fullscreen mode Exit fullscreen mode

Each RUN instruction will create a new layer. A way to optimize it would be to combine commands wherever is possible. With fewer layers, there's less to rebuild after any change to the Dockerfile. Those lines could be modified as follows:



RUN apt-get update \
    && apt-get install -y python3 python3-pip curl git \
    && curl -sSL https://install.python-poetry.org | python3 - \
    && curl https://pyenv.run | bash


Enter fullscreen mode Exit fullscreen mode

In that way, the number of layers is reduced to one.

Docker introduced multi-stage builds in Docker 17.06 CE. Among other best practices, this feature could help optimize Docker images when containerizing an application.

Through this blog post, you will learn how to optimize a containerized Rust app with multi-stage builds.

Multi-stage Builds

Let's create a Hello, world! example with Rocket.

Create a new project:



$ cargo new hello_rocket


Enter fullscreen mode Exit fullscreen mode

Change to the project directory:



$ cd hello_rocket


Enter fullscreen mode Exit fullscreen mode

Replace the content of the src/main.rs with:



#[macro_use] extern crate rocket;

#[get("/")]
fn index() -> &'static str {
    "Hello, world!"
}

#[launch]
fn rocket() -> _ {
    rocket::build().mount("/", routes![index])
}


Enter fullscreen mode Exit fullscreen mode

Edit the Cargo.toml file and add the corresponding dependency:



[package]
name = "hello_rocket"
version = "0.1.0"
edition = "2021"

[dependencies]
rocket = "=0.5.0-rc.3"


Enter fullscreen mode Exit fullscreen mode

The above code will display Hello, world! on the browser.

To deploy this app using Docker, create a Dockerfile with the following content:



FROM rust:latest

WORKDIR /app

COPY . .

RUN cargo build --release

ENV ROCKET_ADDRESS=0.0.0.0
ENV ROCKET_PORT=8000
EXPOSE 8000

CMD ["./target/release/hello_rocket"]


Enter fullscreen mode Exit fullscreen mode
  1. The rust:latest image is used as base and contains the latest version of Rust
  2. The working directory is set to /app
  3. The code and manifest (Cargo.toml) are copied
  4. The application is built
  5. The ROCKET_ADDRESS environment variable is set
  6. The ROCKET_PORT environment variable is set
  7. The 8000 port in the host is exposed
  8. The command to run when the container starts is specified

Now it's time to build the image. Type the following in the terminal:



$ docker build . -t hello-rocket


Enter fullscreen mode Exit fullscreen mode

Once the building process has finished, the image will be available on your system. Now run the following command:



$ docker image ls hello-rocket


Enter fullscreen mode Exit fullscreen mode

It will give you the following output:



REPOSITORY     TAG       IMAGE ID       CREATED          SIZE
hello-rocket   latest    a3bb2fe01630   24 seconds ago   2GB


Enter fullscreen mode Exit fullscreen mode

Pay attention to the SIZE column, the image size is 2GB. Why? The image includes the binary of the application, the dependencies and every file generated during the building process.

How do you reduce the image size? Dividing the building process into two stages. At the first stage, the application is built, and the binary is obtained. Dependencies and any other file generated aren't required but the binary. At the second stage, the final image will be built, the binary is copied from the first stage and is the only file that will be included. This is how multi-stage builds work. Every FROM instruction can use a different base image.

The Dockerfile must be modified as follows:



FROM rust:latest AS builder
WORKDIR /app

COPY Cargo.toml .
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release

COPY src src
RUN touch src/main.rs
RUN cargo build --release

RUN strip target/release/hello_rocket

FROM alpine:latest as release
WORKDIR /app
COPY --from=builder /app/target/release/hello_rocket .

ENV ROCKET_ADDRESS=0.0.0.0
ENV ROCKET_PORT=8000
EXPOSE 8000

CMD ["./hello_rocket"]


Enter fullscreen mode Exit fullscreen mode

During the first stage:

  1. The rust:latest image is used as base and the stage is named builder
  2. The working directory is set to /app
  3. The manifest (Cargo.toml) is copied
  4. A temporary src directory and main.rs file are created
  5. The building process is started to generate a cache of the dependencies
  6. The code of the application is copied
  7. main.rs file's access and modification timestamps are updated to the current time
  8. The application is built
  9. Unnecessary information from the binary is removed, reducing its size and making it more difficult to reverse engineer

During the second stage:

  1. The alpine:latest image is used as base and the stage is named release
  2. The working directory is set to /app
  3. The binary is copied from the first stage
  4. The ROCKET_ADDRESS environment variable is set
  5. The ROCKET_PORT environment variable is set
  6. The 8000 port in the host is exposed
  7. The command to run when the container starts is specified

Now it's time to build the image. Type the following in the terminal:



$ docker build . -t hello-rocket


Enter fullscreen mode Exit fullscreen mode

After image creation, run:



$ docker image ls hello-rocket


Enter fullscreen mode Exit fullscreen mode

This is the output you'll get:



REPOSITORY     TAG       IMAGE ID       CREATED         SIZE
hello-rocket   latest    7f81a51e9b19   9 seconds ago   11.2MB


Enter fullscreen mode Exit fullscreen mode

The image size was reduced from 2GB to 11.2MB. This is how you optimize a Rust containerized application through multi-stage builds, and this feature can be used with any compiled programming language.

Note

If you try to create a container using the image built previously, you will get the following error:



exec ./hello_rocket: no such file or directory


Enter fullscreen mode Exit fullscreen mode

As mentioned here, it happens because the Rust binary that you've built is dynamically linked against libc, and it’s missing from shared libraries inside the alpine image. Alpine Linux is using musl libc instead of default libc library.

You have two options:

  • Build the Rust binary with the x86_64-unknown-linux-musl target and link it with musl library
  • Use distroless images from Google

I would recommend using a distroless image, replace the alpine:latest image, of the FROM instruction of the second stage, with the gcr.io/distroless/cc-debian12 image, and run the following command again:



$ docker build . -t hello-rocket


Enter fullscreen mode Exit fullscreen mode

Now, when you run the container, you won't get any error.


Support me on Buy Me A Coffee

💖 💪 🙅 🚩
mattdark
Mario García

Posted on December 29, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related