Building a Rust application container image that targets linux/amd64, linux/arm64 and linux/arm/v7 - all from a single Dockerfile.
If you find this repository useful then give it a ⭐ ... 😉
I've been developing a service orientated smart home system which consists of a number of containerised workloads running on an edge Kubernetes cluster (via k3s), the "cluster" comprises two Raspberry Pi 4b (ARMv8).
As well as running multiple workloads on the Pi 4b I also run workloads on another Raspberry Pi 2b (ARMv7) which is much older (but very power efficient). And finally I also need to run general tests of the workloads on my local Windows development machine prior to deployment to my "Production cluster", and at a later date I may even want to run these workloads on Azure Kubernetes Service.
Although I could achieve my goal of deploying the same application to multiple architectures using separate Dockerfiles (i.e. Dockerfile.amd64, Dockerfile.arm64, etc...) in my view that is messy and makes the CI/CD more complex. I think the single Dockerfile is the elegant approach keeping all build instructions in one place.
The same trivial worker application is implemented four times, once per language. The repository layout, file names, CI workflow and even the Dockerfile comments are kept as close to identical as possible - so a developer fluent in one language can learn another language's containerisation story simply by diffing two repositories.
| Repository | Language | Build image | Final image | Cross-compilation mechanism |
|---|---|---|---|---|
| multi-arch-container-dotnet | C# / .NET 10 | mcr.microsoft.com/dotnet/sdk:10.0 |
mcr.microsoft.com/dotnet/runtime:10.0-noble-chiseled |
dotnet publish -r <RID> |
| multi-arch-container-go | Go | golang:1-trixie |
gcr.io/distroless/static-debian13:nonroot |
GOOS / GOARCH / GOARM |
| multi-arch-container-rust | Rust | rust:1-trixie |
gcr.io/distroless/cc-debian13:nonroot |
rustup target + GNU cross linker |
| multi-arch-container-python | Python 3.14 | python:3.14-slim-trixie |
python:3.14-slim-trixie |
Architecture-neutral wheel + target-native runtime |
Rust is the most involved compiled implementation: it needs a real cross linker because the binary links natively against the target's glibc.
These repositories are application code only - Kubernetes packaging lives in the standalone f2calv/helm-charts repository, which provides a single multi-purpose chart used by all four.
-
Construct a Rust multi-architecture container image via a single Dockerfile using the
docker buildxcommand. -
Demonstrate idiomatic structured logging and layered configuration in each language, wired identically.
-
Create a single GitHub Actions workflow ci.yml to handle all tasks and host the reusable workflows in an external gha-workflows repository.
- Auto-Semantic Versioning
- Build App
- Build Container + Push To GitHub Packages
- GitHub Release
src/main.rs- entry point; configuration, logging and shutdown wiring only.src/config.rs-AppConfig/Settingstypes and the layered loader.src/telemetry.rs-tracingsubscriber installation.src/worker.rs- the worker loop.appsettings.json- base configuration.Cargo.toml/Cargo.lock- package manifest and lockfile (both committed).Dockerfile- two-stage, cross-compiling, multi-architecture build..github/workflows/ci.yml- CI/CD using reusable workflows from f2calv/gha-workflows..devcontainer/- VS Code devcontainer (Rust toolchain + Docker-outside-of-Docker). All Rust tooling runs in the container; nothing is installed on the host.build.sh/build.ps1- local build scripts for manual testing.
- Language: Rust (edition 2021)
- Async runtime: Tokio (minimal feature set -
macros,rt-multi-thread,sync,time) - Logging:
tracing+tracing-subscriber, with a text or JSON layer selected by configuration - Configuration: the
configcrate (appsettings.json, then environment variables), deserialised withserde - Container: Docker (multi-stage, distroless final image, non-root)
- CI/CD: GitHub Actions (reusable workflows from f2calv/gha-workflows)
- Versioning: GitVersion (MainLine mode)
docker buildx injects TARGETARCH and TARGETVARIANT into the build, and the Dockerfile maps them onto a Rust target triple plus the matching GNU cross toolchain:
| Docker platform | TARGETARCH |
TARGETVARIANT |
Rust target triple | Cross toolchain |
|---|---|---|---|---|
linux/amd64 |
amd64 |
(empty) | x86_64-unknown-linux-gnu |
g++-x86-64-linux-gnu |
linux/arm64 |
arm64 |
(empty) | aarch64-unknown-linux-gnu |
g++-aarch64-linux-gnu |
linux/arm/v7 |
arm |
v7 |
armv7-unknown-linux-gnueabihf |
g++-arm-linux-gnueabihf |
The mapping is resolved exactly once and written to /etc/rust-target.env, which the later layers source - so the case statement is never repeated.
All four sibling repositories share the same two-stage shape:
flowchart LR
subgraph build["Stage 1: build - runs on $BUILDPLATFORM"]
direction TB
A["toolchain / SDK base image"] --> B["dependency layer<br/>(restore / fetch / download)"]
B --> C["compile for $TARGETPLATFORM"]
end
subgraph final["Stage 2: final - image for $TARGETPLATFORM"]
direction TB
D["minimal base image"] --> E["copy compiled artefact"]
E --> F["provenance ARG/ENV<br/>+ OCI labels"]
F --> G["USER non-root"]
end
C --> E
The five ideas worth stealing:
- Cross-compile, don't emulate. The build stage is pinned with
FROM --platform=$BUILDPLATFORM, so it always runs natively on the builder and produces output for the target. Letting buildx run the whole build under QEMU emulation instead is often an order of magnitude slower. - Split dependency resolution from compilation.
cargo fetchruns against a layer containing onlyCargo.tomlandCargo.lock, so editing a.rsfile reuses the cached download. - Switch on
TARGETARCH+TARGETVARIANT, notTARGETPLATFORM. Concatenating the two produces a single flat token (amd64,arm64,armv7) that acasestatement handles in three lines, instead of comparing fulllinux/arm/v7-style strings. - Use BuildKit cache mounts.
$CARGO_HOMEandtarget/are--mount=type=cachemounts, so incremental rebuilds are fast without any of the artefacts bloating the image. Thetarget/cache is keyed per-architecture so the three platform legs do not thrash it, and the finished binary isinstalled out of the mount inside the sameRUN. - Ship a minimal, non-root final image.
distroless/cchas no shell and no package manager, and the container runs as uid/gid 65532.
Why
distroless/ccand notscratch? The*-unknown-linux-gnutargets link dynamically against glibc. Switching to a*-unknown-linux-musltarget would produce a fully static binary suitable forgcr.io/distroless/static-debian13or evenscratch- at the cost of a musl cross toolchain and slightly slower allocator performance.
Structured logging is provided by tracing and tracing-subscriber, the de-facto standard for instrumentation in the Rust async ecosystem.
info!(
git_repository = %settings.git_repository,
git_branch = %settings.git_branch,
"git provenance"
);The equivalent in the sibling repositories:
| .NET | Go | Rust | Python | |
|---|---|---|---|---|
| Library | Serilog (behind ILogger<T>) |
log/slog (standard library) |
tracing + tracing-subscriber |
logging (standard library) |
| Text/JSON switch | app:log_format |
app.log_format |
app.log_format |
app.log_format |
| Verbosity | Serilog:MinimumLevel in appsettings.json |
LOG_LEVEL env var |
RUST_LOG env var |
LOG_LEVEL env var |
Set APP__LOG_FORMAT=json to emit newline-delimited JSON instead of human-readable console output:
docker run --rm -e APP__LOG_FORMAT=json ghcr.io/f2calv/multi-arch-container-rustSet OTEL_EXPORTER_OTLP_ENDPOINT to enable batched logs, metrics and traces over OTLP/HTTP with Protocol Buffers. Console logging remains enabled in the selected text or JSON format. The worker emits a worker.iteration span and increments the worker.iterations counter on every cycle.
docker run --rm \
-e OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \
-e OTEL_SERVICE_NAME=multi-arch-container-rust \
-e OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=development \
ghcr.io/f2calv/multi-arch-container-rustThe exporters honor signal-specific OTEL_EXPORTER_OTLP_* variables for endpoints, headers, compression, certificates and timeouts. When the base endpoint is absent, no OpenTelemetry provider or exporter is initialized.
Configuration is layered by the config crate, in ascending order of precedence:
- Struct defaults from
impl Default for AppConfig. appsettings.json- optional, so the binary runs unchanged outside a container.- Environment variables.
The sibling .NET repository layers one extra source, an optional appsettings.${DOTNET_ENVIRONMENT}.json, because Host.CreateApplicationBuilder provides it for free. It is deliberately not reimplemented here - hand-rolling file resolution and merge semantics to match a built-in is not a trade worth making in a reference repository.
Values are deserialised into a typed Settings struct with serde, so a malformed value aborts startup with a clear message rather than surfacing later.
| Key | Environment variable | Default | Description |
|---|---|---|---|
app.greeting |
APP__GREETING |
Hello from a multi-architecture container |
Message logged each iteration |
app.interval_seconds |
APP__INTERVAL_SECONDS |
3 |
Delay between iterations, from 1 to 3600 seconds |
app.log_format |
APP__LOG_FORMAT |
text |
text or json |
Keys are snake_case in both the file and the environment. The config crate lower-cases environment keys but preserves file keys verbatim, so snake_case is the only casing where both sources resolve to the same key - and it is what the sibling .NET, Go and Python repositories use.
Build provenance is a second, flat set of variables baked into the image by the ARG/ENV block of the Dockerfile (populated by CI, or by build.sh/build.ps1 locally). The same names are used by all four sibling repositories.
| Environment Variable | Description |
|---|---|
GIT_REPOSITORY |
Git repository name |
GIT_BRANCH |
Git branch name |
GIT_COMMIT |
Git commit SHA |
GIT_TAG |
Git tag |
GITHUB_WORKFLOW |
GitHub Actions workflow name |
GITHUB_RUN_ID |
GitHub Actions run ID |
GITHUB_RUN_NUMBER |
GitHub Actions run number |
#Run pre-built image on Docker
docker run --pull always --rm -it ghcr.io/f2calv/multi-arch-container-rust
#Override configuration at runtime
docker run --pull always --rm -it -e APP__GREETING="hello world" -e APP__INTERVAL_SECONDS=1 ghcr.io/f2calv/multi-arch-container-rust
#Inspect the multi-architecture manifest list
docker buildx imagetools inspect ghcr.io/f2calv/multi-arch-container-rustThe public universal workload chart deploys this Rust worker through the same framework-neutral values used for .NET, Go, and other containerised runtimes. Sensible defaults keep the worker configuration small while retaining opt-in access to scheduling, networking, storage, autoscaling, and disruption controls.
Create multi-arch-container-rust.values.yaml with the pinned image and worker configuration:
kind: Deployment
replicaCount: 1
fullnameOverride: multi-arch-container-rust
image:
repository: ghcr.io/f2calv/multi-arch-container-rust
tag: 1.2.1
pullPolicy: IfNotPresent
service:
enabled: false
startupProbe: false
readinessProbe: false
livenessProbe: false
envVars:
APP__GREETING: Hello from Rust on Kubernetes
APP__INTERVAL_SECONDS: "5"
APP__LOG_FORMAT: json
RUST_LOG: debugInstall or upgrade the Deployment with version 1.1.0 of the universal workload chart:
helm upgrade --install multi-arch-container-rust oci://ghcr.io/f2calv/charts/workload \
--version 1.1.0 \
--values multi-arch-container-rust.values.yaml
kubectl logs --follow deployment/multi-arch-container-rust
helm uninstall multi-arch-container-rustThe Rust workload is an ultra simple worker process (i.e. a console application) which loops outputting a number of environment variables passed in during the CI process and then baked into the container image.
Clone the repository (ideally opening it as a vscode devcontainer, so no Rust toolchain is installed on the host) and then, via a terminal window from the root of the repository, execute;
#demo script PowerShell version
./build.ps1Or
#demo script Shell version
./build.shBoth scripts are byte-identical across the four sibling repositories - every value they need is derived from git rather than hard-coded. They emulate the image job of ci.yml.
A multi-platform image cannot be loaded into the local Docker image store, so by default the scripts build a single platform (linux/amd64) with --load. To exercise all three architectures locally, export an OCI archive instead:
PLATFORM=linux/amd64,linux/arm64,linux/arm/v7 OUTPUT=--output=type=oci,dest=multi-arch-container.tar ./build.sh# Format (rustfmt is authoritative)
cargo fmt --all
# Lint and test
cargo clippy -- -D warnings
cargo test
# Run
cargo run
# Cross-compile by hand, exactly as the Dockerfile does
rustup target add armv7-unknown-linux-gnueabihf
cargo build --release --target armv7-unknown-linux-gnueabihfA docker-compose.yml in the sibling .NET repository builds and runs all four images together, which is the quickest way to confirm that configuration, environment variables and log output behave identically across the languages. Clone the four repositories alongside each other and run docker compose up --build from the .NET repository.
flowchart LR
classDef f2calv fill:#dbeafe,stroke:#2563eb,color:#1e3a5f
P(["push / pull_request"]) --> L["lint"]
P --> V["versioning<br/>(GitVersion)"]
V --> A["app<br/>(cargo fmt/clippy/build)"]
A --> I["image<br/>(docker buildx)"]
I --> R["release<br/>(tag + GitHub release)"]
I --> G[("ghcr.io/f2calv/multi-arch-container-rust")]
class L,V,A,I,R f2calv
-
I highly recommend reading the official Docker blog posts about multi-arch images;
-
Official Docker documentation about support/implementation for multi-arch images;
-
Official Rust documentation useful for multi-arch builds;