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, theCargo.toml
file; -
Image: When we run a command
build
we create an image that contains everything we specified in theDockerfile
. Running an image result in a container. E.g., if ourDockerfile
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 rundocker ps -a
to list all the containers and then usedocker 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 asbuild
, except that it places the binary on the indicated path, in this case, theWORKDIR
.
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:
- 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;
- 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
Top comments (23)
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.
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.
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.
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 :)
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.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 š
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.
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 :)
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!
To say that Go is "more readable" depends on who you ask. Also, Rust has an excellent concurrency model.
When running
if you ran
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.
You have to use "&&" instead of "&", if you use a single & then the rm command would run before cargo builds, additionally you can remove this line too
and change that previous line too
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
Is caching dependencies even supported when working with workspaces?
Thanks sharing your experience. Consider using Google Distroless as base image in Docker 5. Haven't seen more secure base image so far.
As said earlier, I would use "scratch" as base image (empty image) with only the binary inside ;)
You need to run
docker run hello-world
withsudo
for it to work correctly on Linux. Is this expected?Hi Matt! Please take a look at this: docs.docker.com/engine/install/lin...
Thanks!
What is this line for?
RUN rm ./target/release/deps/holodeck*
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.
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?Very helpful. I now understand how to make my images smaller! Thanks so much and great article!
I can't see the fifth docker file anywhere in this post. Am I the only one who can't see it?