Setting Up Docker CI for Rust with cargo-dist
A guide to building multi-arch Docker images for Rust projects using cargo-dist artifacts and GitHub Actions, without compiling inside Docker.
· 3 min read
Rust CI #
CI #
There are a few stages to get a Docker CI pipeline for Rust. The key idea here is to avoid building Rust inside Docker. The common workflow is a multi-stage Dockerfile where one stage compiles the binary and another copies it into a minimal image. This works and is usually how you can build locally, but for CI processes, it takes very long.
Instead, cargo-dist handles compilation as part of the release workflow. By the time the Docker job runs, the binaries are already built and are available as GitHub Actions artifacts. Docker just copies them in. QEMU is still needed for the final multi-arch manifest, but it’s only moving files around rather than running a compiler through emulation, so arm64 builds don’t take nearly as long.
dist #
This is usually the starting point, its quite easy to get started.
The main guide is here.
release.yml #
# the rest comes from dist generate
custom-docker-publish:
needs:
- plan
- announce
uses: ./.github/workflows/docker-publish.yml
with:
plan: ${{ needs.plan.outputs.val }}
# i added this so that its easier to automate and apply to other repos
binary_name: mock-openai
secrets: inherit
permissions:
"contents": "read"
"packages": "write"dist-workspace.toml #
Post-announce-jobs does what it says, after announcement of release, trigger the docker yaml.
github-custom-job-permission was due to an error having not enough permissions on my docker workflow.
post-announce-jobs = ["./docker-publish"]
# Permissions for docker-publish workflow
github-custom-job-permissions = { "docker-publish" = { packages = "write", contents = "read" } }
# found this needed to add the binary name
allow-dirty = ["ci"]docker workflow #
I think the pattern for this is quite generic, but use the announcement tags to help with the versioning.
name: Docker Publish
on:
workflow_call:
inputs:
plan:
required: true
type: string
description: "The dist plan JSON"
binary_name:
required: true
type: string
description: "The name of the binary produced by cargo-dist"
target_triple_suffix:
required: false
type: string
default: "unknown-linux-musl"
description: "The target triple suffix used in artifact names (e.g. unknown-linux-musl or unknown-linux-gnu)"
jobs:
docker-publish:
runs-on: ubuntu-22.04
# Only run for actual releases
if: ${{ fromJson(inputs.plan).announcement_tag != '' }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
# these steps are quite generic
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# need the version as ARG for the Docker build
- name: Extract version from plan
id: version
run: |
TAG='${{ fromJson(inputs.plan).announcement_tag }}'
echo "version=${TAG}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
# semver tags and latest tag
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}},value=${{ steps.version.outputs.tag }}
type=semver,pattern={{major}}.{{minor}},value=${{ steps.version.outputs.tag }}
type=semver,pattern={{major}},value=${{ steps.version.outputs.tag }},enable=${{ !startsWith(steps.version.outputs.tag, 'v0.') && !startsWith(steps.version.outputs.tag, '0.') }}
type=raw,value=latest,enable=${{ !fromJson(inputs.plan).announcement_is_prerelease }}
- uses: actions/download-artifact@v4
with:
name: artifacts-build-local-x86_64-${{ inputs.target_triple_suffix }}
path: artifacts/amd64
- uses: actions/download-artifact@v4
with:
name: artifacts-build-local-aarch64-${{ inputs.target_triple_suffix }}
path: artifacts/arm64
- name: Extract and Normalize Artifacts
run: |
# Extract
tar -xJf artifacts/amd64/*.tar.xz -C artifacts/amd64/
tar -xJf artifacts/arm64/*.tar.xz -C artifacts/arm64/
# Move
shopt -s globstar
mv artifacts/amd64/**/${{ inputs.binary_name }} artifacts/amd64/${{ inputs.binary_name }}
mv artifacts/arm64/**/${{ inputs.binary_name }} artifacts/arm64/${{ inputs.binary_name }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: BINARY_NAME=${{ inputs.binary_name }}Dockerfile #
So this is entirely dependent on the resources you need.
The list of images are from here: GoogleContainerTools/distroless
Other than manually testing which container to use, a good way is using ldd
For example the below binary looks like this:
linux-vdso.so.1 (0x00007ffdfb764000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x000077a805e1a000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x000077a805119000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x000077a804e00000)
/lib64/ld-linux-x86-64.so.2 (0x000077a805e50000)So I need a container with cc.
FROM gcr.io/distroless/cc-debian13:nonroot
ARG TARGETARCH
ARG BINARY_NAME
COPY --chmod=755 artifacts/${TARGETARCH}/${BINARY_NAME} /usr/local/bin/app
EXPOSE 8000
USER nonroot:nonroot
ENTRYPOINT ["/usr/local/bin/app"]