DEV Community

Cover image for First steps with Docker + Rust
Roger Torres (he/him/ele)
Roger Torres (he/him/ele)

Posted on • Updated on

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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

$ docker build -t holodeck .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

To stop the container:

$ docker stop holodeck1
holodeck1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

Final comparison


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

Cover image by Aron Yigin

Top comments (21)

Collapse
 
metal3d profile image
Patrice Ferlet

For languages than can be statically compiled like Go or Rust, the best is to use "FROM scratch" base and only append the binary in the image. Your images will only be a few Mb sized.

Collapse
 
my profile image
Mindaugas Sharskus

I wanted to suggest scratch too. I once had made Rocket web app image as small as ~10MB. Probably it could be optimized even more.

Collapse
 
rogertorres profile image
Roger Torres (he/him/ele)

Hi! Thank you both for your comments. I understand what you say and I think it is correct. The problem—specific to this tutorial—is that scratch would not work with the project I am using here; at least not without some extra work, which would lead me to fix a few things and explain them (e.g., I would need a static build, a similar problem that I would have with Alpine), which in turn would cross the threshold of a "first steps" beginner post.

Hopefully, people will read this and be aware of this alternative for their particular projects.

Thread Thread
 
metal3d profile image
Patrice Ferlet

Ho, sorry, I thought that Rust makes static bainary by default. I just checked and I understand that I was wrong.

Reading this zderadicka.eu/static-build-of-rust... is a nice complement :)

Thread Thread
 
rsalmei profile image
Rogério Sampaio de Almeida

Hey, Rust does make static binaries by default. But when you use a dependency, it'll bring their own dependencies, and you may end up using one that happens to load something dynamically... Like libc for example.

Collapse
 
cjsmocjsmo profile image
Charlie J Smotherman

Thanks for this. I to am just starting with rust. Going to rewrite a server of mine I wrote in py 2.7 so its time to update it. This looks a lot like the double build Dockerfile I use with my golang containers. Thanks again 😄

Collapse
 
metal3d profile image
Patrice Ferlet

Using rust to rewrite a server made in Python is, IMHO, not the best way. Go is made for this kind of work, easier to use threads (and concurrency) and a lot more readable. It's closer to Python in syntax and you will have more or less the same performances than Rust for this kind of project.

Collapse
 
cjsmocjsmo profile image
Charlie J Smotherman

You are correct and I agree with everything you are saying. To be a little clearer I plan to use Rust during the music server setup process and not necessarily the server itself. Is it a good design choice, probable not. Is it a good way to learn Rust , hopefully :)

Thread Thread
 
rogertorres profile image
Roger Torres (he/him/ele)

If you feel encouraged to do that (and have the time), please share your results (and maybe even the process) of developing such a server :) I would love to read!

Collapse
 
damianesteban profile image
Damian Esteban

To say that Go is "more readable" depends on who you ask. Also, Rust has an excellent concurrency model.

Collapse
 
tomasfejfar profile image
Tomáš Fejfar

When running

RUN cargo build --release
RUN rm src/*.rs
Enter fullscreen mode Exit fullscreen mode

if you ran

RUN cargo build --release & rm src/*.rs
Enter fullscreen mode Exit fullscreen mode

you'd actually get smaller and cleaner layer as the rs files in src would not be part of it. By separating to two different layers you actually create one layer with deps+rs files and another layer that removes the rs files IMHO.

Collapse
 
vbocan profile image
Valer BOCAN

This is interesting read. It would be useful to add a section related to building a Docker image for a Rust workspace as opposed to a regular project. For instance, my app consists of a workspace with three different projects, such that the Cargo.toml file is this:
[workspace]
members = [
"gooblen_lib",
"gooblen_cli",
"gooblen_api"
]

In turn, I have a corresponding Cargo.toml in each project folder (gooblen_lib, gooblen_cli, gooblen_api).

When building the Docker image, the system complains that

no targets specified in the manifest
either src/lib.rs, src/main.rs, a [lib] section, or [[bin]] section must be present
in the child Cargo.toml

Is caching dependencies even supported when working with workspaces?

Collapse
 
cthutu profile image
Matt Davies

You need to run docker run hello-world with sudo for it to work correctly on Linux. Is this expected?

Collapse
 
rogertorres profile image
Roger Torres (he/him/ele)

Hi Matt! Please take a look at this: docs.docker.com/engine/install/lin...

Collapse
 
cthutu profile image
Matt Davies

Thanks!

Collapse
 
pranasb profile image
Pranas Baliuka

Thanks sharing your experience. Consider using Google Distroless as base image in Docker 5. Haven't seen more secure base image so far.

Collapse
 
metal3d profile image
Patrice Ferlet

As said earlier, I would use "scratch" as base image (empty image) with only the binary inside ;)

Collapse
 
stuartwk profile image
Stuart • Edited

When I'm running this, I'm getting the error cannot remove './target/release/deps/holodeck*': No such file or directory. Anyone else getting this issue?

Collapse
 
afonsojramos profile image
Afonso Jorge Ramos

What is this line for?

RUN rm ./target/release/deps/holodeck*

Collapse
 
rogertorres profile image
Roger Torres (he/him/ele) • Edited

Hi Afonso! This removes the binary previously built, so when the last build for release is executed, Docker uses all the cached dependencies and only the application itself (which, in this scenario is the only thing that was changed) is rebuilt.

Collapse
 
pxlmastrxd profile image
Pxlmastr

Very helpful. I now understand how to make my images smaller! Thanks so much and great article!