Optimized Docker config for Actix Web, Diesel and Postgres

Introduction

In this article, I will cover how to write a Dockerfile that can run an Actix Web application that uses Diesel and Postgres. This also includes some findings about the docker image size optimization.

Interestingly, the resources are quite limited or rather sparse for this topic/setup currently. I learnt quite a few lessons the hard way and I hope that this article will be helpful to some of you out there 😆

The sample repo

You can find the working sample in this GitHub repo. The rest of the article is going to base upon that repo. Go ahead and give that repo a star ⭐ if it helped 😆🙏

Topic Coverage

  1. Overview
  2. A working Dockerfile (~2.5GB image size)
  3. The optimized Dockerfile (~60MB image size)
  4. .env and dotenv
  5. Lessons Learnt
  6. Things to find out next

1. Overview

The biggest challenge or confusion when setting up a Dockerfile for Rust web application is that, there's almost always some issues related to missing packages/libs during the build. And we'll have to start Googling to install some missing packages, etc. Not very fun tbh haha!

In our case, we're setting up an Actix Web application that uses Diesel as our ORM to connect to Postgres. Hmm.. Sounds pretty straightforward, but trust me it's nerve wrecking.

I'll first go through a simple setup that gets the application running successfully in Docker (~2.5GB), then followed by another setup that optimizes its image size (~60MB).

Okay, enough talking. Let's get to work!

2. A Working Dockerfile (~2.5GB image size)

To start it off, using an alpine image base is almost never gonna work out-of-box, not until we've configured all sorts of missing lib dependencies required to build our Rust app "stack" (actix-web, diesel, postgres). Trust me, I've spent hours on this.

After Googling for quite a bit, I've finally came across a repo containing a Dockerfile that builds and runs our app successfully. Yessshhh!!!

1FROM rust:slim-buster
2
3RUN apt-get update && \
4  apt-get -y upgrade && \
5  apt-get -y install libpq-dev
6
7WORKDIR /app
8COPY . /app/
9COPY .env.docker /app/.env
10
11RUN cargo build --release
12
13EXPOSE 8080
14
15ENTRYPOINT ["/bin/bash", "-c", "cargo run --release"]
16

If we pay attention to line 1, that base image is actually based on debian instead of alpine, which contains more "tools" than the lightweight counterpart.

It works fine. However, if we inspect the image size, we'd be surprised that it's actually a freaking 2.55GB. Holy cow, we're talking about Gigabytes here for a relatively simple application! There must be something that we can do to reduce the image size!

view unoptimized image size in docker desktop
Viewing image size in Docker Desktop

3. The optimized Dockerfile (~60MB image size)

There's a common build strategy to optimize a Docker image size - Muti-stage builds. It generally refers to using different images to handle build and deploy tasks respectively.

3.1 "build" vs "deploy" images

"build" - usually a bigger image, loaded with all sorts of libs/tools to build the app. In our case, it's ideally an image that's capable of performing cargo build. For that, we can just use an official Rust Docker image, such as rust:1.66.1.

"deploy" - usually a minimalist image just enough to run the app. After some explorations, I came across something called Distroless image. It's a list of bare minimum images that are language focused. In our case, we should be using gcr.io/distroless/cc-debian11 for Rust.

3.2 Resolving missing lib dependencies

However, this image alone is not enough to run our app. It'll throw some errors complaining that "libpq.so.5 missing..." or something similar along the line. The culprit is actually diesel, whom requires libpq. Not entirely sure about the details but it has something to do with Postgres as well.

The solution here is actually to install libpq during the build stage, then copy the necessary dependency lib files over to the deploy image.

Here is the GitHub issue thread that gave me a clue about the solution at first. It points to this Dockerfile here. However, I wasn't able to achieve direct success with the image suggested there "i0nw/distroless:libpq5-debian11" 🙁. Somehow, the specified path x86_64-linux-gnu was not found during my build. When I inspect the image, there's instead another directory called aarch64-linux-gnu.

Now this is a really interesting problem that you won't come across every single day! It has something to do with the machine architecture itself. I am currently using an M1 Mac, which runs on the arm64 architecture, a.k.a aarch64. On the other hand, the solution provided in that Dockerfile was targeting an amd64 architecture instead, a.k.a x86_64.

Now that we know what the issue really is, we need to come up with a way to specify the target machine architecture when we run our build. For that, we will utilize Docker's --build-arg flag to set the build argument, then consume its ARG value in the Dockerfile.

After tweaking the configs to accommodate all these hassles, voila! Finally, we get a working version of the Dockerfile!

3.3 The final Dockerfile

Below is the working Dockerfile with multi-stage build.

Notice that I am setting a default value for the ARCH argument at line 30, ARG ARCH=aarch64. This means that it will work without passing in the additional --build-arg if you happen to be on an arm64 machine.

If you are on an x86_64 machine, just run the build by passing in the --build-arg value accordingly, e.g. docker build . --build-arg="ARCH=x86_64". Similarly, if you are using docker compose, then this should work for you docker-compose build --build-arg "ARCH=x86_64".

For more details, you can refer to this README section of the sample repo.

1# ---------------------------------------------------
2# 1 - Build Stage
3#
4# Use official rust image to for application build
5# ---------------------------------------------------
6FROM rust:1.66.1 as build
7
8# Setup working directory
9WORKDIR /usr/src/codefee-works-api
10COPY . .
11COPY .env.docker .env
12
13# Install dependency (Required by diesel)
14RUN apt-get update && apt-get install libpq5 -y
15
16# Build application
17RUN cargo install --path .
18
19# ---------------------------------------------------
20# 2 - Deploy Stage
21#
22# Use a distroless image for minimal container size
23# - Copy `libpq` dependencies into the image (Required by diesel)
24# - Copy application files into the image
25# ---------------------------------------------------
26FROM gcr.io/distroless/cc-debian11
27
28# Set the architecture argument (arm64, i.e. aarch64 as default)
29# For amd64, i.e. x86_64, you can append a flag when invoking the build `... --build-arg "ARCH=x86_64"`
30ARG ARCH=aarch64
31
32# libpq related (required by diesel)
33COPY --from=build /usr/lib/${ARCH}-linux-gnu/libpq.so* /usr/lib/${ARCH}-linux-gnu/
34COPY --from=build /usr/lib/${ARCH}-linux-gnu/libgssapi_krb5.so* /usr/lib/${ARCH}-linux-gnu/
35COPY --from=build /usr/lib/${ARCH}-linux-gnu/libldap_r-2.4.so* /usr/lib/${ARCH}-linux-gnu/
36COPY --from=build /usr/lib/${ARCH}-linux-gnu/libkrb5.so* /usr/lib/${ARCH}-linux-gnu/
37COPY --from=build /usr/lib/${ARCH}-linux-gnu/libk5crypto.so* /usr/lib/${ARCH}-linux-gnu/
38COPY --from=build /usr/lib/${ARCH}-linux-gnu/libkrb5support.so* /usr/lib/${ARCH}-linux-gnu/
39COPY --from=build /usr/lib/${ARCH}-linux-gnu/liblber-2.4.so* /usr/lib/${ARCH}-linux-gnu/
40COPY --from=build /usr/lib/${ARCH}-linux-gnu/libsasl2.so* /usr/lib/${ARCH}-linux-gnu/
41COPY --from=build /usr/lib/${ARCH}-linux-gnu/libgnutls.so* /usr/lib/${ARCH}-linux-gnu/
42COPY --from=build /usr/lib/${ARCH}-linux-gnu/libp11-kit.so* /usr/lib/${ARCH}-linux-gnu/
43COPY --from=build /usr/lib/${ARCH}-linux-gnu/libidn2.so* /usr/lib/${ARCH}-linux-gnu/
44COPY --from=build /usr/lib/${ARCH}-linux-gnu/libunistring.so* /usr/lib/${ARCH}-linux-gnu/
45COPY --from=build /usr/lib/${ARCH}-linux-gnu/libtasn1.so* /usr/lib/${ARCH}-linux-gnu/
46COPY --from=build /usr/lib/${ARCH}-linux-gnu/libnettle.so* /usr/lib/${ARCH}-linux-gnu/
47COPY --from=build /usr/lib/${ARCH}-linux-gnu/libhogweed.so* /usr/lib/${ARCH}-linux-gnu/
48COPY --from=build /usr/lib/${ARCH}-linux-gnu/libgmp.so* /usr/lib/${ARCH}-linux-gnu/
49COPY --from=build /usr/lib/${ARCH}-linux-gnu/libffi.so* /usr/lib/${ARCH}-linux-gnu/
50COPY --from=build /lib/${ARCH}-linux-gnu/libcom_err.so* /lib/${ARCH}-linux-gnu/
51COPY --from=build /lib/${ARCH}-linux-gnu/libkeyutils.so* /lib/${ARCH}-linux-gnu/
52
53# Application files
54COPY --from=build /usr/local/cargo/bin/codefee-works-api /usr/local/bin/codefee-works-api
55COPY --from=build /usr/src/codefee-works-api/.env /.env
56
57CMD ["codefee-works-api"]
58

The result is mind-blowing. Our image size is now down to only 62.68MB, way smaller from the earlier 2.55GB. That's almost a 98% in size reduction. Woohoooo~!!!

view optimized image size in docker desktop
Viewing image size in Docker Desktop

Not sure if we can further reduce the size here. 62.68MB still sounds a lil bit big to me tbh. However, I'll leave it at that for now. If any of you readers found out some ways to reduce it, please do not hesitate to leave me a message! I'd be really grateful about it ❤️

4. ".env" and "dotenv"

If your application uses a .env file to configure environment variables, REMEMBER to copy over the .env file into your final image. Otherwise, dotenv is gonna panic.

You can just copy the intended .env file into the image's root. Yep, easy peasy. How or why? Well, if you inspect the dotenv crate's source, you'll notice that there is a function called try_parent in there. It works by recursively finding its parent directory until a .env file is found. Therefore, leaving the .env file at root directory will still work for this.

In the sample repo, you'll see that we just copy the .env file from build image into the deploy image's root directory.

5. Lessons Learnt

  1. Use a distroless image to minimize your image size
  2. If you use diesel, you need to copy the required libpq files into the final image as well
  3. If you use .env file and dotenv, remember to copy your intended .env file into the image. Otherwise, your dotnet().ok().expect(...) will panic

6. Things to find out next

Here are some of the things that I felt can be improved and requires further research:

  1. Incremental build (running fresh cargo install everytime is super slow)
  2. Further image size reduction (an even smaller final image)

Conclusion

I find that the resources are generally quite sparse on using Docker with the Rust stack covered here (Actix Web, Diesel and Postgres). It took me some time to consolidate the different moving parts and finally nailing down the configurations.

From my perspective, it tells us one thing, i.e. there aren't too many people who are actively doing this in comparison to node, golang, etc. other popular ecosystems for web service. Learning curve is probably one of the major factors here. After all, Rust is not the easiest language to pick up 😆 Well, I'm still struggling hard too! haha..

I decided to first check out Actix Web instead of Rocket, solely due to the promising performance benchmarks being displayed by the former. However, it gives me a feeling that the latter might have a larger community around it (might be easier to find "answers"). Not entirely sure though, just a feeling.

Hopefully, more and more folks will start writing web services in Rust. Definitely hoping and looking forward to the time when Rust finally enters the "mainstream" for this area of programming!

After working with Rust on and off for a few months, I think this language is quite verbose, yet beautiful at the same time. It's definitely low level and reminds me of my university days, studying data structures and algorithms with C++ and C haha! Well, it feels akin to C# too (feels at home 😆)!

Since there's a big wave around authoring Frontend toolings with more performant languages, e.g. Golang and Rust, it really excites me and motivated me to pick up either one of them. I tried Go some time ago, but sadly did not find the syntax enjoyable in contrast to many other folks. Rust on the other hand, feels kinda natural (perhaps more akin to C#).


Man, it's been quite a while since my last article. So many stuffs happened in the past "almost one year". Since Covid has finally tuned down a lil, I've finally gotten the chance to travel back to my hometown, visiting my parents, some old friends, etc.

Took the chance to go for Registration of Marriage too! 😆 Haha.. yep, officially married with my beloved partner.

I have also started working at ByteDance recently too. Definitely one of the wildest ride in my life haha.. For me, the journey from preparations, tryouts, failing interviews, failing lesser, burnout... till I finally landed this job has truly shaped me into a better engineer through and through. Thank God I'm still alive! Perhaps, I should dedicate an article to demystify the entire interview journey as well 😆

The coffee? It's the legendary Kopi O Kosong today! 😆

Kopi O Kosong at Choy On Yoon
Wonderful "Kopi O Kosong" at Choy On Yoon, Ipoh, Malaysia

Resources

  1. https://github.com/DriLLFreAK100/codefee-works-api
  2. https://github.com/mihai-dinculescu/rust-graphql-actix-juniper-diesel-example
  3. https://docs.rs/crate/dotenv/0.10.0/source/src/lib.rs
  4. https://github.com/GoogleContainerTools/distroless
  5. https://github.com/GoogleContainerTools/distroless/issues/673