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 is CARGO_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 for armv6
    • CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER for armv7
    • CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER for arm64v8
    • CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER for x86_64
  • Line 8: We add the arm-unknown-linux-musleabihf target to be able to build the project for the armv6 architecture using the musl C library. The four targets used are:
    • arm-unknown-linux-musleabihf for armv6
    • armv7-unknown-linux-musleabihf for armv7
    • aarch64-unknown-linux-musl for arm64v8
    • x86_64-unknown-linux-musl for x86_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

  1. https://docs.docker.com/build/building/multi-platform/
  2. https://doc.rust-lang.org/cargo/reference/config.html
  3. https://www.docker.com/blog/multi-arch-build-and-images-the-simple-way/

  1. Compressed docker images are around 3.5 MBs while Debian stable slim are around 25MBs. ↩︎