Deploying Rust in a Docker/Podman container on ARM64 with GitHub Packages and Portainer

2024-05-10Updated on 2024-05-11

This post describes how to deploy a Rust application in a Docker/Podman container on an ARM64 device using GitHub Packages and Portainer. The post assumes that you have a Rust application that you want to deploy and that you have a GitHub account.

After all this manipulations, we should get this:

  • Rust application is built on a self-hosted ARM64 Github Actions runner
  • Rust application is pushed to Github Packages
  • Rust application is pulled from Github Packages and run in a Docker/Podman container
  • Portainer is used to manage the container
  • Github Actions Workflow is configured to automatically build and deploy the application
  • Whenever you push a new commit to the main branch of the repository, the application is automatically built and deployed

Update 2024-05-11: I had a lot of issues with Podman, so eventually I switched to Docker. The main reason is the Podman issues with cgroupv2 - the more I solved, the more appeared. So, most of the article is still relevant, but I recommend to skip the Podman part and use Docker instead.

Dockerfile#

I invite you to read Rust small Docker image to get some context and see all options. Below is a Dockerfile that worked for me:

####################################################################################################
## Builder
####################################################################################################
FROM rust:latest AS builder

# Add target for ARM64
RUN rustup target add aarch64-unknown-linux-gnu

# Install cross-compilation tools and ARM64 libraries for OpenSSL
RUN apt-get update && apt-get install -y crossbuild-essential-arm64 pkg-config
RUN dpkg --add-architecture arm64
RUN apt-get update && apt-get install -y openssl:arm64 libssl-dev:arm64
RUN apt-get update && apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
RUN apt-get update && apt-get install -y binutils-aarch64-linux-gnu

# Environment for pkg-config to support cross-compilation
ENV PKG_CONFIG_ALLOW_CROSS=1
ENV PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig

# Set environment variables for cross-compilation
ENV CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc
ENV CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc

####################################################################################################
## Create appuser
####################################################################################################
ENV USER=myappuser
ENV UID=10001

RUN adduser \
    --disabled-password \
    --gecos "" \
    --home "/nonexistent" \
    --shell "/sbin/nologin" \
    --no-create-home \
    --uid "${UID}" \
    "${USER}"

# Set up a working directory
WORKDIR /usr/src/myapp

# Copy the source code into the Docker image
COPY . .

# Configure environment variables for openssl-sys
ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr/lib/aarch64-linux-gnu
ENV OPENSSL_LIB_DIR=/usr/lib/aarch64-linux-gnu
ENV OPENSSL_INCLUDE_DIR=/usr/include/aarch64-linux-gnu

# Build your application for ARM64
RUN cargo build --release --target=aarch64-unknown-linux-gnu

# Strip the binary to reduce size
RUN aarch64-linux-gnu-strip -s target/aarch64-unknown-linux-gnu/release/myappbin

RUN chmod +x target/aarch64-unknown-linux-gnu/release/myappbin

####################################################################################################
## Final image
####################################################################################################
FROM gcr.io/distroless/cc

# Import from builder.
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group

# Copy the built executable
COPY --from=builder /usr/src/myapp/target/aarch64-unknown-linux-gnu/release/myappbin /myapp/myappbin

WORKDIR /myapp

# Use an unprivileged user.
USER myappuser:myappuser

CMD ["/myapp/myappbin"]

Github Workflow#

I encourage you to read Working with the container registry, and below is a Github Workflow (.github/workflows/main.yml) that worked for me:

#
name: Build and Push Docker Dev Image

on:
  push:
    branches:
      - main
env:
  IMAGE_NAME: myappbin-server
#
jobs:
  # This pushes the image to GitHub Packages.
  push:
    runs-on: self-hosted
    permissions:
      packages: write
      contents: read
      #
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Build image
        run: docker build . --file Dockerfile --tag $IMAGE_NAME --label "runnumber=${GITHUB_RUN_ID}"

      - name: Log in to registry
        run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
        #
      - name: Push image
        run: |
          IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME
          # This changes all uppercase characters to lowercase.
          IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
          # This strips the git ref prefix from the version.
          VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
          # This strips the "v" prefix from the tag name.
          [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
          # This uses the Docker `latest` tag convention.
          [ "$VERSION" == "main" ] && VERSION=latest
          echo IMAGE_ID=$IMAGE_ID
          echo VERSION=$VERSION
          docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
          docker push $IMAGE_ID:$VERSION

      - name: Trigger Portainer Webhook
        run: curl -X POST ${{ secrets.DEV_PORTAINER_WEBHOOK_URL }}

Configuring nginx#

I am assuming that you have a domain name and that you have configured nginx to serve your domain. If you have not done this, you can follow the instructions in my post on setting up a web server with nginx.

Configuring Github Actions#

Go to Managing your presonal access token and create a personal access token (classic) with the following permissions:

  write:packages
    read:packages
  delete:packages

You will need this token in section Adding Github Packages to Portainer below.

Install self-hosted Github Actions runner#

Github currently (at the time I am writing this) does not support ARM64 runners. That means that you should use emulation and/or cross-platform compilation to build your Rust application. I played with various scenarios with emulation and cross-platform compilation and I found that they all are drastically slow comparing to using an x86 runner: on x86 it took 4 minutes, on x86/qemu it took 40 minutes and I just cancelled the job. So, I decided to install a self-hosted ARM64 runner in Hetzner Cloud.

In Github, go to Repository -> Settings -> Actions -> Self-hosted runners -> Add runner and follow the instructions - you will see a set of command (including a token) that you need to run on your server.

Then you need to start the runner as a service:

sudo ./svc.sh install
sudo ./svc.sh start

Installing Portainer#

Portainer is a lightweight management UI that allows you to easily manage your Docker/Podman containers, images, networks, and volumes. It is available in both a free and a paid version. The free version is sufficient for most use cases. Although, I use Github Packages as container registry, and Portainer supports Github registry only in their Enterprise Edition. You can get a free license key for the Enterprise Edition by registering on the Portainer website.

We are going to install Podman and use it for running Portainer in a container exposing port 9000 and listening on localhost - Nginx then is supposed to server HTTPS to the external world and proxy requests to the container.

sudo yum install podman
sudo podman volume create portainer_data
sudo systemctl enable --now podman.socket
sudo podman run -d -p 127.0.0.1:9000:9000 --privileged --name portainer --restart=always -v /run/podman/podman.sock:/var/run/docker.sock:Z -v portainer_data:/data docker.io/portainer/portainer-ee:latest
sudo podman ps

Setting up Portainer service#

Docker usually is running as a daemon, so when you restart your server all your containers start automatically. Podman does not have a daemon, so you need to create a systemd service to start your container automatically after server restart.

Create /etc/systemd/system/portainer.service and edit it as follows:

[Unit]
Description=Portainer container
Wants=syslog.service

[Service]
Restart=always
ExecStart=/usr/bin/podman start -a portainer
ExecStop=/usr/bin/podman stop -t 2 portainer
RestartSec=5s

[Install]
WantedBy=multi-user.target

Then run the following commands:

sudo systemctl daemon-reload
sudo systemctl enable portainer.service
sudo systemctl start portainer.service

systemctl status portainer.service

You should see the Portainer container running.

Configuring Portainer#

  • go to portainer.<YOUR HOST NAME>
  • create user/password
  • log in
  • enter license key

Adding Github Packages to Portainer#

So, now we need to add Github Packages to Portainer. Go to Settings -> Registry -> Create Registry -> Github. Enter your registry name, Github username, and personal access token you have created earlier in the Configuring Github Actions section.

Create a new container in Portainer#

By default, there is a bridge network where portainer is running. Add a new network where yor containers wil be running.

Create a new container in Portainer, select the image from Github Packages, and configure the container as you need. Specify the network you have created in the previous step.

Enable the Web Hook in the container settings and copy the URL. This URL you then need to use in the Github Workflow - for that, create a new repository secret DEV_PORTAINER_WEBHOOK_URL and paste the URL.

Changing crun to runc#

I had an issue with crun and memory swappiness ("crun: cannot set memory swappiness with cgroupv2: OCI runtime error"). If you experience the same issue when starting/re-creating a container, please see the solution that worked for me.

Testing#

After you have completed all the steps above, you can push a new commit to the main branch of your repository. This should trigger the Github Actions Workflow, which will build your Rust application in a Docker/Podman container on an ARM64 device, push the container to the registry (Github Packages), trigger the webhook at Portainer, and re-create/re-deploy the container on the server.

Switching to Docker#

Update 2024-05-11: it did not go well with podman. I had a lot of issues with it, I tried to fix them, but more and more appeared. So I decided to go with Docker, eventually.

So, here are some notes specific to the Docker setup.

Portainer#

By some reason, Portainer didn't want to work on an HTTP port, constantly replying with HTTP error 404 on it. So, I run it on HTTPS 9443 with certificates from Letsencrypt.

docker run -d -p 9443:9443 --name portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ee:latest

Now navigate to https://<IP_ADDRESS>:9443 and create admin account. I also recommend to generate proper TLS certificates with Letsencrypt and upload them to Portainer, so next time you can use the domain name instead of IP address.

Docker#

I experienced weird issues with permissions when deploying containers via webhook. This is the fix:

chmod o+w /var/run/docker.sock

Conclusion#

This post described how to deploy a Rust application in a Docker/Podman container on an ARM64 device using GitHub Packages and Portainer. The post assumed that you have a Rust application that you want to deploy and that you have a GitHub account. After following the steps in this post, you should have a Rust application that is built on a self-hosted ARM64 Github Actions runner, pushed to GitHub Packages, pulled from GitHub Packages and run in a Docker/Podman container, managed by Portainer. You should also have a GitHub Actions Workflow that is configured to automatically build and deploy the application whenever you push a new commit to the main branch of the repository.

Backlinks: