Since Docker ushered in a wave of container hype several years ago, it has become somewhat standard, especially in the cloud, to package web applications as Docker containers.
This approach has some nice benefits for both local development and server deployments. Once an image has been built, it doesn’t change and can be executed on any platform that has a running Docker engine with a minimal performance penalty (on Linux).
In this tutorial, we’ll demonstrate how to put a Rust web application inside a Docker container. We’ll walk through two approaches with different base images and tradeoffs.
First, let’s build a very basic web application with warp to test the different docker setups.
In this case, the only dependencies you’ll need are tokio and warp itself:
[dependencies] tokio = { version = "0.2", features = ["macros", "rt-threaded"] } warp = "0.2"
You’ll also need a running web server with a /health
endpoint to call to see if everything works.
use warp::{Filter, Rejection, Reply}; type Result<T> = std::result::Result<T, Rejection>; #[tokio::main] async fn main() { let health_route = warp::path!("health").and_then(health_handler); let routes = health_route.with(warp::cors().allow_any_origin()); println!("Started server at localhost:8000"); warp::serve(routes).run(([0, 0, 0, 0], 8000)).await; } async fn health_handler() -> Result<impl Reply> { Ok("OK") }
The code for the basic web app isn’t particularly exciting. However, it’s important to note the criticality of the 0.0.0.0
when binding the server to an IP and port. Using 127.0.0.1
or localhost
here won’t work from inside docker.
Running cargo run
with this in place will start a local server on http://localhost:8000
with a /health
endpoint, which returns OK
when it’s called.
Now it’s time to put the whole thing inside a Docker container.
A good practice when building Docker images to be run in the cloud is to use a multibuild setup where the executable is created in a builder
step and then copied into a different, slimmer image.
This is useful because to build the application, you need Rust, Cargo, and many other software packages. If you were to just keep the executable within this container and run it there, the resulting image would be very large (~1.5 GB in this case).
With a multistage build, however, you can use a very slim base image to actually run the created executable.
First, let’s look at using a slim Debian Buster base image. The following would be a working Dockerfile
to do that.
FROM rust:1.43 as builder RUN USER=root cargo new --bin rust-docker-web WORKDIR ./rust-docker-web COPY ./Cargo.toml ./Cargo.toml RUN cargo build --release RUN rm src/*.rs ADD . ./ RUN rm ./target/release/deps/rust_docker_web* RUN cargo build --release FROM debian:buster-slim ARG APP=/usr/src/app RUN apt-get update \ && apt-get install -y ca-certificates tzdata \ && rm -rf /var/lib/apt/lists/* EXPOSE 8000 ENV TZ=Etc/UTC \ APP_USER=appuser RUN groupadd $APP_USER \ && useradd -g $APP_USER $APP_USER \ && mkdir -p ${APP} COPY --from=builder /rust-docker-web/target/release/rust-docker-web ${APP}/rust-docker-web RUN chown -R $APP_USER:$APP_USER ${APP} USER $APP_USER WORKDIR ${APP} CMD ["./rust-docker-web"]
That’s quite a bit of text, so let’s go through it step by step.
As mentioned above, there are two build steps: the builder
, which simply uses a Rust 1.43 base image, and a second step, which uses Debian.
In the builder step, a simple pseudodependency-caching mechanism is used. First, an empty Rust project is created using cargo
and then all dependencies (in the form of Cargo.toml
) are copied into that project.
Then a release-build is triggered before removing everything in the src
folder. With Docker, for each command inside the Dockerfile
, a layer is created. On subsequent builds, only layers where the inputs have changed need to be rebuilt.
This is why you build the dependencies independent of your own code — because the dependencies will usually change a lot less often than dependencies. If you only change the code in src
, none of dependency layer will need to be rebuilt. Since Rust’s compile times are unfortunately still rather long, especially with complex web applications, this can save you quite some time.
A after removing all files in src
, copy all files of the project into the Docker working directory, remove the binary built from the dependencies, and trigger another release build — in this case, with the whole code.
Now let’s look at the second part of the Dockerfile
, where we create a runnable container image.
Most of the commands in this part are cookie-cutter commands you would use for web applications within Docker in any other language.
First, we’ll install some required packages using apt
, ca-certificates
and tzdata
, which is a good baseline for web applications.
Next, expose the application port and create a nonroot user, which is used to run the executable. The most interesting part is the COPY
--from-builder
command, which enables us to actually copy the executable built within the builder
step into this container.
At the end, the executable is simply started with the newly created user.
To build this container, simply execute the following.
docker build -t rust-debian -f ./debian/Dockerfile .
You can then execute it using:
docker run -p 8000:8000 rust-debian
This will run the Rust web application, and calling http://localhost:8000/health
will return OK. Success!
Looking at the output of docker images
, we can see the following image size:
rust-debian latest d5ae1c61b310 About a minute ago 84.7MB
Under 90MB — not too bad. But we can do a lot better. Let’s see how we can do the same with an Alpine base image.
Most of the Alpine-based Dockerfile
is the same, but there are some important differences.
FROM ekidd/rust-musl-builder:stable as builder RUN USER=root cargo new --bin rust-docker-web WORKDIR ./rust-docker-web COPY ./Cargo.lock ./Cargo.lock COPY ./Cargo.toml ./Cargo.toml RUN cargo build --release RUN rm src/*.rs ADD . ./ RUN rm ./target/x86_64-unknown-linux-musl/release/deps/rust_docker_web* RUN cargo build --release FROM alpine:latest ARG APP=/usr/src/app EXPOSE 8000 ENV TZ=Etc/UTC \ APP_USER=appuser RUN addgroup -S $APP_USER \ && adduser -S -g $APP_USER $APP_USER RUN apk update \ && apk add --no-cache ca-certificates tzdata \ && rm -rf /var/cache/apk/* COPY --from=builder /home/rust/src/rust-docker-web/target/x86_64-unknown-linux-musl/release/rust-docker-web ${APP}/rust-docker-web RUN chown -R $APP_USER:$APP_USER ${APP} USER $APP_USER WORKDIR ${APP} CMD ["./rust-docker-web"]
In the builder
, the most important change is the base image, where instead of the official Rust image, the rust-musl-builder image is used.
The reason for this is that we want to build a static Rust binary that we can just copy over to an Alpine base image. Because Alpine is also built around musl-libc
, an executable using the official Docker image won’t work.
Besides that, the builder stage is very similar. The only thing that changes is the path to the executable.
Within the runner stage, we now use alpine:latest
as a base image and have to use apk
and some different commands to create the user to run the application. The steps themselves are basically the same, but the commands and paths are a bit different.
The basic structure of this Dockerfile
is very similar to the Debian-based one, so building and running it also works the same way.
docker build -t rust-alpine -f ./alpine/Dockerfile . docker run -p 8000:8000 rust-alpine
Again, upon opening http://localhost:8000/health
, you’ll get a successful response.
The reason we switched to Alpine in the first place was the image size, so let’s see where we land here.
rust-alpine latest 492d0305ebcb About a minute ago 16MB
16MB — that’s an 80 percent decrease in image size. However, while this decrease in size is quite nice, what are the tradeoffs involved here? Are there any disadvantages to using the Alpine image versus the Debian one?
Let’s have a look at the implications involved when choosing which image to use as a base.
The clear benefit of Alpine is the lower image size. However, as described in the rust-musl-builder
docs, there have been issues in the past with making OpenSSL work. While that has been fixed, it’s possible that you’ll run into trouble if you depend on C libraries, which will be easier to handle with the standard toolchain.
There are also severe performance issues related to memory allocation using the Alpine-based image. Andy Grove tracked this back to the use of musl. In some cases, it helped to switch to the jemalloc allocator.
I personally didn’t run into the aforementioned issues, but I did notice a lower memory footprint when running the app inside the Alpine container compared to Debian. These metrics will be different depending on the workload you want to run, so it’s impossible to give silver-bullet advice here.
In general, the decision of which stack to use is yours and yours alone. It needs to be based on the tradeoffs that are important to your use case.
As with any other ops decision, there is no one-size-fits all for production web services. You need to test, benchmark, and profile for your use case and then decide what is most suitable.
In this guide, we walked through how to containerize a Rust web application in two different ways that have distinctly different trade-offs.
The two approaches outlined above are not the only ways you can go about this and, as mentioned before, the approach you take for a production service will have to be based on your own unique needs and data. However, the examples in this post are a good starting point to get your feet wet using Docker with Rust.
You can find the full code for this example on GitHub.
Debugging Rust applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking the performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Rust application. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Modernize how you debug your Rust apps — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
3 Replies to "Packaging a Rust web service using Docker"
nice thanks for sharing with us…
https://packagingblogger.blogspot.com/2020/09/why-do-you-need-custom-cddvd-boxes-for.html
Thanks for documenting this.
Why do you need to set USER=root?
Perfect article with very easy to understand example and excellent explanation. Thank you! By the way, inside Dockerfile I was required to change “copy ./Cargo.lock ./Cargo.lock” to “RUN cargo generate-lockfile” and place it two lines lower.