Actix-Web in Docker: How to Build Small and Secure Images

Sergey ZenchenkoApril 09, 2023

Share:

At AppSpector, we recently deployed our first Rust service to production. Actix-Web and Rust were a pleasure to work with, but the Docker image-building process can be a little confusing. In this post, we’ll walk you through how to build small and secure docker images for Rust services. The information is pretty basic, but if you are doing it for the first time, it might save you several hours searching for answers.

Official docker images for Rust

Rust now supports docker images! They contain everything you need to build and run a typical Rust project. However, you may want to install additional system dependencies for your project.


FROM rust:1.43.1
WORKDIR /usr/src/api-service
COPY . .
RUN cargo install --path .
CMD [“api-service”]

You can put this docker file directly into your project, update the project name and run.

docker build -t api-service . docker run -it —rm —name api-service-instance api-service

You can also deploy it to production using other tools such as Kubernetes, Docker Compose, Swarm, or any other tools you use for deployment. That said, just because you can deploy it doesn’t mean you should. Right now, there are several issues with this docker image that we need to address before we deploy.

The image size

The resulting image is pretty big (~1.2 Gb). That means every time you deploy this image, the server will need to pull it from the Docker images registry.

api-service latest a72004cb9a35 2 seconds ago 1.24GB

You can use smaller image like rust:1.43.1-slim, which is smaller, but still it’s 624 Mb. ‌ api-service latest ada242f40855 46 seconds ago 624MB

Docker can cache images locally, but still, it will slow down deployment and it’s bad practice to use such large images for deployments.

Security

Official images contain the whole Rust development toolset (cargo, rustc, bash shell, etc.). You don’t really need all of that to run your web service.

‌All these tools available inside your container will significantly increase the attack surfaces of your system. If intruders get access to a running container, they will be very happy to see all these tools available. Restricting what’s in your runtime container to precisely what’s necessary for your app is a best security practice.

Multi-stage docker builds

Docker allows you to separate the image build process into different stages. You can use the official Rust image to build the app and another image to run it.

FROM rust:1.43.1 as build
WORKDIR /usr/src/api-service
COPY . .
RUN cargo install --path .
FROM alpine:latest
COPY —from=cargo-build /usr/local/cargo/bin/api-service /usr/local/bin/api-service
CMD [“api-service”]

You won’t have any components of the Rust development toolchain in the final image. It has a clear separation between the build process and the runtime container.‌

At AppSpector, we like to use Alpine images. It’s widely used for Docker deployments because it’s small and secure. ‌

The resulting image is just 35.4 MB in size. Don’t forget it also includes the size of your service binary.

api-service latest 96d575188ba9 5 minutes ago 35.4MB

Making it work with Rust

If you try to run the image created in the example above using docker run-rm-t API service, you should get an error:

standard_init_linux.go:187: exec user process caused “no such file or directory”

This is because Rust binary that you’ve built is dynamically linked with libc. It’s missing from shared libraries inside the Alpine image. ‌Alpine Linux is using MUSL Libc instead of the default Libc library. Its an alternative Libc implementation.

MUSL is an implementation of the C standard library built on top of the Linux system call API, including interfaces defined in the base language standard, POSIX, and widely agreed-upon extensions. MUSL is lightweight, fast, simple, and free and strives to be correct in standards-conformance and safety.

You can build Rust binary with x86_64-unknown-linux-musl target and link it with the MUSL library.

FROM rust:1.43.1 as build

RUN apt-get update
RUN apt-get install musl-tools -y
RUN rustup target add x86_64-unknown-linux-musl

WORKDIR /usr/src/api-service
COPY . .

RUN RUSTFLAGS=-Clinker=musl-gcc cargo install -—release —target=x86_64-unknown-linux-musl

FROM alpine:latest

COPY —from=cargo-build /usr/local/cargo/bin/api-service /usr/local/bin/api-service

CMD [“api-service”]

Everything works fine until you start linking with system libraries linked to Libc. OpenSSL is a great example. For Actix-Web-based service, you need OpenSS.

‌There are a few Docker images (like this one), that are specially designed for MUSL support.

They are tested for popular system libraries like OpenSSL, sqlite3, curl, zlib, and pg.

However, introducing third-party docker images into our infrastructure so we can build in Rust with MUSL seems messy. It’s less secure because third-party images can lead to additional attack areas. Therefore, we chose to stick with official images.

Distroless

Let’s use something else instead of Alpine so we don’t have to build with MUSL support. Again, we need something small and secure. ‌

A great alternative is Distroless images. These images are designed to contain only your application and its runtime dependencies. ‌Check out this video for more info on Distroless.

‌They don’t have package managers, shells, or any other program you might find in a standard Linux. These are the most secure docker images we have found. Best practices for production usage at Google are applied to them.

Distroless images support many languages, and we must find the best image for Rust. ‌We’ve tested several, and it looks like this is the one we need. This image contains a minimal Linux glibc runtime for mostly-statically-compiled languages like Rust.

How to use it with Rust:

FROM rust:1.43.1 as build

WORKDIR /usr/src/api-service
COPY . .

RUN cargo install --path .

FROM gcr.io/distroless/cc-debian10

COPY —from=cargo-build /usr/local/cargo/bin/api-service /usr/local/bin/api-service

CMD [“api-service”]

Just replace alpine with gcr.io/distroless/cc-debian10 and nothing else. No need to use MUSL target. This image contains Libc.

api-service latest d8c818e1e1e1 19 hours ago 50.9MB

The size is 15 Mb larger than alpine, but still small enough. It’s a good practice to use Distroless images in production, even if you don't have issues with MUSL builds, simply because they eliminate messy builds and unnecessary additional attack areas. We hope this article helps you to deploy better services.