Rust: Docker Image Optimization With Multi-stage Builds
Tags: tutorial, rust, docker
Published: 2023-12-29 22:44:27


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](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/), there's a section you can check for more information.

Each instruction in a `Dockerfile` roughly translates to an image [layer](https://docs.docker.com/build/guide/layers/).

![Image Layers](https://docs.docker.com/build/guide/images/layers.png)

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

...
```

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
```

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

Docker introduced [multi-stage builds](https://docs.docker.com/build/building/multi-stage/) 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
```

Change to the project directory:

```
$ cd hello_rocket
```

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])
}
```

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"
```

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"]
```

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
```

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

```
$ docker image ls hello-rocket
```

It will give you the following output:

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

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"]
```

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
```

After image creation, run:

```
$ docker image ls hello-rocket
```

This is the output you'll get:

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

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
```

As mentioned [here](https://dev.to/sergeyzenchenko/actix-web-in-docker-how-to-build-small-and-secure-images-2mjd), 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](https://github.com/GoogleContainerTools/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
```

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

---

[![Support me on Buy Me A Coffee](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nzx9uenqvwqv1vp8ub5d.png)](https://buymeacoffee.com/letstalkoss)