First steps with Docker + Rust
Tags: beginners, rust, docker, tutorial
Published: 2021-07-12 11:34:12

> 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](https://dev.to/t/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](https://docs.docker.com/docker-for-mac/install), [Linux](https://docs.docker.com/install/linux/docker-ce/ubuntu) and [Windows](https://docs.docker.com/docker-for-windows/install).

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](https://hub.docker.com/) 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](https://youtu.be/8fi7uSYlOdc).

---

## The sample project
I am going to use a REST API that I built using [warp](https://docs.rs/warp/0.3.1/warp/). If you want to build it yourself, you may check [this guide](https://dev.to/rogertorres/rest-api-with-rust-warp-1-introduction-342e); if not, feel free download it [here](https://github.com/rogertorres/dev.to/tree/main/docker/holodeck) 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](https://github.com/rogertorres/dev.to/tree/main/docker/holodeck). 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):
```dockerfile
# 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:
```dockerfile
# 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](https://docs.docker.com/glossary/#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](https://youtu.be/PdF1Rqet9E8).

---

## 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:
```dockerfile
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](https://hub.docker.com/_/rust?tab=tags&page=1&ordering=last_updated).

And all that means just replacing the second `FROM` with:
```dockerfile
FROM rust:1.49-slim-buster
```

This image tag uses Rust built upon Debian tag called [buster slim](https://hub.docker.com/_/debian) 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](https://hub.docker.com/_/debian) 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](https://doc.rust-lang.org/edition-guide/rust-2018/platform-and-target-support/musl-support-for-fully-static-binaries.html), which adds an extra layer of complexity to this beginner-intended post;
> 2. [I am not sure that MUSL is a good option]( https://andygrove.io/2020/05/why-musl-extremely-slow).

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

![Final comparison](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/75hj6rfqxhwtvocx0rbl.png)

---

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

_Cover image by [Aron Yigin](https://unsplash.com/photos/sNY6B9NsPP8)_