Mario García
Posted on December 29, 2023
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.
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 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"]
- The
rust:latest
image is used as base and contains the latest version of Rust - The working directory is set to
/app
- The code and manifest (
Cargo.toml
) are copied - The application is built
- The
ROCKET_ADDRESS
environment variable is set - The
ROCKET_PORT
environment variable is set - The
8000
port in the host is exposed - 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:
- The
rust:latest
image is used as base and the stage is namedbuilder
- The working directory is set to
/app
- The manifest (
Cargo.toml
) is copied - A temporary
src
directory andmain.rs
file are created - The building process is started to generate a cache of the dependencies
- The code of the application is copied
-
main.rs
file's access and modification timestamps are updated to the current time - The application is built
- Unnecessary information from the binary is removed, reducing its size and making it more difficult to reverse engineer
During the second stage:
- The
alpine:latest
image is used as base and the stage is namedrelease
- The working directory is set to
/app
- The binary is copied from the first stage
- The
ROCKET_ADDRESS
environment variable is set - The
ROCKET_PORT
environment variable is set - The
8000
port in the host is exposed - 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, 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 withmusl
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
Now, when you run the container, you won't get any error.
Posted on December 29, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.