First steps with Docker + Rust

TL;DR: We are going to install Docker and create five different containers for a Rust program, each one a little more complex than the other.

Hi! In this post, I will show you how to dockerize your Rust program. We will begin with a very simple container and will build from that to more sophisticated ones, where we take care of compile-time and image size.

I thought about just jumping to the last one instead of granularizing so much, but I prefer to be clear rather than concise with these #beginners posts. Feel free to jump whatever is too obvious for you and ask me what remained too obscure.

Install Docker

First of all, install Docker. The getting started guide has instructions for Mac OS, Linux and Windows.

Once installed, run and test it by issuing this command:

$ docker run hello-world

It should pull the hello-world image from the Docker Hub and return a text block explaining in detail what happened behind the scene.

Glossary

If you are completely new to Docker, it will help to have a clear understanding of what I mean when I use the following terms:

  • Docker: The application we just installed (or, to be more precise, the Docker daemon we use to deal with our images and containers);
  • Dockerfile: A file named Dockerfile that contains the commands that Docker will run to build the image. In a Rust project, it lies alongside the manifest, that is, the Cargo.toml file;
  • Image: When we run a command build we create an image that contains everything we specified in the Dockerfile. Running an image result in a container. E.g., if our Dockerfile has instructions to build and run a Web Server, the image will contain the program (that is, the Web Server itself) which will be accessible when we run the image, thus creating the container;
  • Base image: It is an image that we use to base ours. E.g., for us, Rust will be a base image (which we don't build manually; we download it ready to use);
  • Container: The result of running an image. The container is a process running on your computer that contains everything that is needed to run the application. For a better understanding, I recommend this presentation by Liz Rice.

The sample project

I am going to use a REST API that I built using warp. If you want to build it yourself, you may check this guide; if not, feel free download it here or even use your own project; I believe you will have no problem mapping the commands.

There are a few differences between the code you'll find in the guide and the one I am using here:

  • I am now using IP 0.0.0.0 instead of 127.0.0.1. I changed this so I don't have to tell Docker which IP to bind;
  • The old version has two crates: binary (main.rs) and library (lib.rs). That made things harder for Docker (and would make my explanations here too complex) so I just maintained the binary crate and moved the library crate content to a module.

The first Dockerfile

This and all other files are available here. You will identify them by their numbers.

I will start with a very simple version and improve upon it a few times. Here I am working with the project I mentioned above, named holodeck (so that is the name you will have to change if you're using your project):

# 1. This tells docker to use the Rust official image
FROM rust:1.49

# 2. Copy the files in your machine to the Docker image
COPY ./ ./

# Build your program for release
RUN cargo build --release

# Run the binary
CMD ["./target/release/holodeck"]

With this, I just run the command below where the Dockerfile is.

$ docker build -t holodeck .

This will create the image. To see that, you may either look at your Docker app or use the command docker images.

$ docker images
REPOSITORY  TAG       IMAGE ID       CREATED          SIZE
holodeck    latest    aad6ff7c3b4d   47 seconds ago   2.42GB

To run it, all we have to do is to issue a command like this:

$ docker run -p 8080:3030 --rm --name holodeck1 holodeck
Warp 6, Engage!

Let me expand on the parameters:

  • -p maps the port, so what is 3030 inside Docker (the port our warp server is using) will be accessible through 8080 outside, i.e., your machine (if you don't do this, Docker will map a random port);
  • --rm is here to remove the container after we close it (to visualize this, you may run without --rm and then run docker ps -a to list all the containers and then use docker rm containername to remove it).

Now it is possible to test it in localhost:8080.

$ curl --location --request POST 'localhost:8080/holodeck' \
--header 'Content-Type: application/json' \
--header 'Content-Type: text/plain' \
--data-raw '{
    "id": 2,
    "name": "Bride Of Chaotica!"
}'

Simulation #1 created.

To stop the container:

$ docker stop holodeck1
holodeck1

If you ran your image like me, your terminal will be frozen. To avoid this, run in detached mode, by just adding the parameter -d:

$ docker run -dp 8080:3030 --rm --name holodeck1 holodeck

Great! This is fine... Until you have to build again and again (and maybe even again because you're writing a guide like this and have to tweak things often). Why is this a problem? Compile time. Every time we run docker build, Rust does the entire building process all over again; and the fact we're building for release just makes it worse.

Let's fix it.

The second Dockerfile

This is a more elaborated alternative:

# Rust as the base image
FROM rust:1.49

# 1. Create a new empty shell project
RUN USER=root cargo new --bin holodeck
WORKDIR /holodeck

# 2. Copy our manifests
COPY ./Cargo.lock ./Cargo.lock
COPY ./Cargo.toml ./Cargo.toml

# 3. Build only the dependencies to cache them
RUN cargo build --release
RUN rm src/*.rs

# 4. Now that the dependency is built, copy your source code
COPY ./src ./src

# 5. Build for release.
RUN rm ./target/release/deps/holodeck*
RUN cargo install --path .

CMD ["holodeck"]

I am using install, but this is the same as build, except that it places the binary on the indicated path, in this case, the WORKDIR.

This is how we avoid the compiler aeon. By building only the dependencies attached to a new program (steps 1 through 3) and then, with a new command inside the Dockerfile, build our program (commands 4 and 5), we stop Docker from ignoring the cache. Why does it work? Because every command in our Dockerfile creates a new layer, which is a modification to the image. When we run docker build, only the modified layers are updated, the rest is retrieved from the local cache. To put it in practical terms, as long as we don't change the manifest, the dependencies will not have to be rebuilt.

In my case, after a first build that took 323.6 seconds, the second one (where I just changed the main.rs) took only 33.9 seconds. Great!

However... there is another problem: image size. For example, mine has 1.65 GB, which better than the very first one, which had 2.42 GB, but still too large. Let's put a little fixin' on it.

The third Dockerfile

If you visited Isaac's post, you'll see he managed this by "chaining" builds by using two base images. He did everything after a first FROM rust, which is the build where everything is built, and then called another FROM rust, copying only the required files from the first build. That allows the final image to retain only these last copied files, therefore decreasing the image size.

This is how we do it:

FROM rust:1.49 as build

# create a new empty shell project
RUN USER=root cargo new --bin holodeck
WORKDIR /holodeck

# copy over your manifests
COPY ./Cargo.lock ./Cargo.lock
COPY ./Cargo.toml ./Cargo.toml

# this build step will cache your dependencies
RUN cargo build --release
RUN rm src/*.rs

# copy your source tree
COPY ./src ./src

# build for release
RUN rm ./target/release/deps/holodeck*
RUN cargo build --release

# our final base
FROM rust:1.49

# copy the build artifact from the build stage
COPY --from=build /holodeck/target/release/holodeck .

# set the startup command to run your binary
CMD ["./holodeck"]

That reduced the image size from 1.66 GB to 1.26 GB. But I promised five versions of the Dockerfile, so you already know we can do better.

The fourth Dockerfile

What we'll now do is to change the Rust image tag we are using. A tag is another way to say an "image variant", that is, an alternative image designed to meet certain goals. So, if we want a space-saving image, it is a tag that we're looking for.

And all that means just replacing the second FROM with:

FROM rust:1.49-slim-buster

This image tag uses Rust built upon Debian tag called buster slim to create a more compact image. Now, instead of 1.26 GB, we have 642.3 MB.

Some people might wonder why I didn't use Alpine. Well, I did, and buster-slim was 10MB smaller. But the real reason why I avoided Alpine will be clear in the next step.

The fifth (and final) Dockerfile

I don't think this hunt for the minimal image size is always needed; you got to have a reason.

As I do have reason (show it to you), I will do one final change that will give a really small Docker image. For this, we need an image that has no Rust whatsoever, only the binary that will be executed (that's one of the beauties of a compiled language after all).

To achieve such a thing, we use a Rust base image to build our binary and just move the binary to a Linux image without Rust. And to do that, we will use debian:buster-slim itself.

Again, regarding Alpine. I didn't use it here either for two reasons:

  1. To run a Rust code in Alpine it has to be compiled with MUSL, which adds an extra layer of complexity to this beginner-intended post;
  2. I am not sure that MUSL is a good option.

The result: 75.39 MB. That's a long way from 2.42 GB.
Let's make an overall comparison:

That's it! Thank you for reading so far. Bye!

Cover image by Aron Yigin

13