A while ago, I published an app that I originally made for myself but thought it would be useful for others: Kindly RSS Reader. Kindly RSS Reader is a self-hosted RSS aggregator designed for e-ink devices and optimized for low-end computers such as the Raspberry Pi. In fact, I run it on a Raspberry Pi 3B powered by a USB port.
The only way to deploy it at the moment is via Docker (or compiling the source and running it by manually). I’ve uploaded the image in Docker Hub. In the future, I plan to create a .deb
package and other formats to make deployment more flexible.
Issues requesting support for other architectures
I was pleasantly surprised by the attention the project received. After a week of publishing the repository I got received two issues asking to support older arm architectures and for x86_64.
Supporting more architectures was something I had in mind from the beginning of the project. There are a lot of single-board and low end computers out there that could run the project, so I was happy to see people interested in running it on different architectures.
The approach
At the moment I got two computers: A MacBook running an ARMv8 and an old desktop running x86_64. I wanted the build process to be as portable as possible, so I could use either machine, and I usually do not like to install things in the OS unless is absolutely necessary. Using docker for building met both my requirements.
The final images
I wanted the final images to be as lightweight as possible. That’s why I opted for Alpine Linux docker images. Alpine Linux is a small1 linux distribution focused on security.
One thing that differentiates Alpine from other distributions is that it uses musl C library instad of GNU C Library. This is an important detail for cross-compilation.
The linkers image
To be able to cross compile projects, we need to use different linkers: one for each target architecture. The target architectures are armv6
, armv7
, arm64v8
(or aarch64
) and x86_64
.
The first step was to create a Docker image containing all the necessary linkers. To download and install the them I used the musl-cross-make project.
# This image will be used for building the project in different platforms
FROM rust:1.84-bullseye AS builder
WORKDIR /home
RUN git clone https://github.com/richfelker/musl-cross-make.git --depth 1
# armv6
RUN cd musl-cross-make \
&& echo 'TARGET = arm-linux-musleabihf' > config.mak \
&& echo 'OUTPUT = /build/cross-armv6' >> config.mak \
&& make \
&& make install
# armv7
RUN cd musl-cross-make \
&& echo 'TARGET = armv7-linux-musleabihf' > config.mak \
&& echo 'OUTPUT = /build/cross-armv7' >> config.mak \
&& make \
&& make install
# arm64v8
RUN cd musl-cross-make \
&& echo 'TARGET = aarch64-linux-musl' > config.mak \
&& echo 'OUTPUT = /build/cross-armv8' >> config.mak \
&& make \
&& make install
# x86_64
RUN cd musl-cross-make \
&& echo 'TARGET = x86_64-linux-musl' > config.mak \
&& echo 'OUTPUT = /build/cross-x86_64' >> config.mak \
&& make \
&& make install
ENTRYPOINT ["/bin/bash"]
As base image, I used rust:1.84-bullseye
because it includes all the tools I need for building the project (with the exception of the additional targets, which will be installed manually in the respective Dockerfile).
The command used for building this image is:
docker build \
--tag nicoan/kindly-rss-builder \
-f ./dockerfiles/Dockerfile.build \
.
Building the images
To build the project, I use four different Dockerfiles: One for each architecture. I could have created one Dockerfile with parameters but I wanted to take advantage of the Docker’s layer caching mechanism. Here’s the Dockerfile for the armv6
architecture:
FROM --platform=$BUILDPLATFORM nicoan/kindly-rss-builder AS builder
WORKDIR /home
ENV PATH=$PATH:/build/cross-armv6/bin
ENV CARGO_TARGET_ARM_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-linux-musleabihf-gcc
RUN rustup target add arm-unknown-linux-musleabihf
COPY . ./
RUN cargo build --target arm-unknown-linux-musleabihf --release
FROM alpine:3 AS run
RUN mkdir -p /home/kindlyrss/static_data \
&& mkdir -p /home/kindlyrss/data
EXPOSE 3000/tcp
COPY --from=builder /home/target/arm-unknown-linux-musleabihf/release/kindle-rss-reader /usr/local/bin/kindlyrss
COPY --from=builder /home/templates/ /home/kindlyrss/static_data/templates/
COPY --from=builder /home/migrations/ /home/kindlyrss/static_data/migrations/
COPY --from=builder /home/static/ /home/kindlyrss/static_data/static/
COPY --from=builder /home/config/config.json /home/kindlyrss/data/config.json
ENV RUST_LOG=info
ENV MAX_ARTICLES_QTY_TO_DOWNLOAD=0
ENV STATIC_DATA_PATH=/home/kindlyrss/static_data
ENV DATA_PATH=/home/kindlyrss/data
CMD ["kindlyrss"]
The idea is to use a multi-stage build with two stages: build
and run
.
In the build
stage:
- Line 1: We use the linkers image with the
--platform=$BUILDPLATFORM
parameter to ensure that no emulation will be used in this stage. - Line 5: We add to the
$PATH
environment variable the path where the linker’s binaries are located. - Line 6: We tell cargo which linker we are going to use for compiling
armv6
. This can be done through environment variables or with a configuration file. The shape of the environment variables specifying the linker isCARGO_TARGET_<triple>_LINKER
. The format of the<triple>
is<arch><sub>-<vendor>-<sys>-<abi>
. The four environment variables used are:CARGO_TARGET_ARM_UNKNOWN_LINUX_MUSLEABIHF_LINKER
forarmv6
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER
forarmv7
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER
forarm64v8
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER
forx86_64
- Line 8: We add the
arm-unknown-linux-musleabihf
target to be able to build the project for thearmv6
architecture using the musl C library. The four targets used are:arm-unknown-linux-musleabihf
forarmv6
armv7-unknown-linux-musleabihf
forarmv7
aarch64-unknown-linux-musl
forarm64v8
x86_64-unknown-linux-musl
forx86_64
- Line 9: We copy the source.
- Line 11: We build the project in release mode targeting the
armv6
architecture.
The run
stage will be built using alpine
as base. This stage is the last one and will output the final image for armv6
. Here, we copy the binary file and all the files needed by it to run the application correctly from the builder
stage, expose the port 3000
and set some configurations using environment variables. After that we execute the binary to run the application.
Note that in the Dockerfile is never specified the platform to be used. This is done using the --platform
argument in the docker build
command.
The command used to build this image is:
docker build \
--tag nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-armv6 \
--platform linux/arm/v6 \
-f ./dockerfiles/Dockerfile.armv6 \
.
where $(PACKAGE_VERSION)
is the app version.
Publishing the images
To publish the images we just need a Docker Hub account and use the docker push
command. Since we are supporting more than one architecture, it would be nice to have all the images targeting different platforms grouped in the same tag. This can be achieved by pushing all the different images separately and then creating a manifest pointing at them under the same tag.
For example, for version 0.1.0
, we first push the four images:
docker push nicoan/kindly-rss-reader:0.1.0-x86_64
docker push nicoan/kindly-rss-reader:0.1.0-arm64v8
docker push nicoan/kindly-rss-reader:0.1.0-armv7
docker push nicoan/kindly-rss-reader:0.1.0-armv6
After that we create a manifest file for the tag 0.1.0
attaching all the images that we just pushed to the hub:
docker manifest create nicoan/kindly-rss-reader:0.1.0 \
--amend nicoan/kindly-rss-reader:0.1.0-x86_64 \
--amend nicoan/kindly-rss-reader:0.1.0-arm64v8 \
--amend nicoan/kindly-rss-reader:0.1.0-armv7 \
--amend nicoan/kindly-rss-reader:0.1.0-armv6
And push it:
docker manifest push nicoan/kindly-rss-reader:0.1.0
The same thing can be done with the latest
tag with an extra step: we first need to delete its manifest and then re-create it with the images we want to group. If we don’t do the delete step, the new images will be grouped with all the previous ones.
Doing all this process semi-automatically
I am not good at remembering all the arguments and details of the commands I use. That’s why I usually try to create scripts to automate the processes. In this case I opted to use a good old Makefile
:
# Extract the version from Cargo.toml
PACKAGE_VERSION=$(shell cat Cargo.toml | grep version | head -n 1 | awk '{print $$3}' | sed -e 's/"//g')
# Build for different archs
# I opted to use multiple Dockerfiles to take advantage of the layer caching mechanism.
docker-build:
docker build \
--tag nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-x86_64 \
-f ./dockerfiles/Dockerfile.x86_64 \
--platform linux/amd64 \
.
docker build \
--tag nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-arm64v8 \
-f ./dockerfiles/Dockerfile.armv8 \
--platform linux/arm64/v8 \
.
docker build \
--tag nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-armv6 \
--platform linux/arm/v6 \
-f ./dockerfiles/Dockerfile.armv6 \
.
docker build \
--tag nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-armv7 \
--platform linux/arm/v7 \
-f ./dockerfiles/Dockerfile.armv7 \
.
# Builds an image with different linkers to be able to build
# for different architectures
docker-prepare-build-image:
docker build \
--tag nicoan/kindly-rss-builder \
-f ./dockerfiles/Dockerfile.build \
.
# Push new versions
docker-push:
# Push different architecture images
docker push nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-x86_64
docker push nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-arm64v8
docker push nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-armv7
docker push nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-armv6
# Create manifest for the package version and push
docker manifest create nicoan/kindly-rss-reader:$(PACKAGE_VERSION) \
--amend nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-x86_64 \
--amend nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-arm64v8 \
--amend nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-armv7 \
--amend nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-armv6
docker manifest push nicoan/kindly-rss-reader:$(PACKAGE_VERSION)
# Create manifest for the latest tag and push
docker manifest rm nicoan/kindly-rss-reader:latest
docker manifest create nicoan/kindly-rss-reader:latest \
--amend nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-x86_64 \
--amend nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-arm64v8 \
--amend nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-armv7 \
--amend nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-armv6
docker manifest push nicoan/kindly-rss-reader:latest
git-tag-and-push:
git tag v$(PACKAGE_VERSION)
git push origin v$(PACKAGE_VERSION)
.PHONY: build-docker docker-push docker-prepare-build-image git-tag-and-push
This way, I just have to write in the terminal
make docker-prepare-build-image
make docker-build
make docker-push
Conclusion
Using Docker to cross-compile Rust projects ensures portability and ease of deployment. This process could be used in some CI/CD pipeline fully automate the process.
Resources
- https://docs.docker.com/build/building/multi-platform/
- https://doc.rust-lang.org/cargo/reference/config.html
- https://www.docker.com/blog/multi-arch-build-and-images-the-simple-way/
Compressed docker images are around 3.5 MBs while Debian stable slim are around 25MBs. ↩︎