Skip to content

Latest commit

 

History

129 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Multi-Architecture Container Image w/.NET

Building a .NET 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 ⭐ ... 😉

Introduction

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.

Sibling Repositories

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

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.

Goals

  • Construct a .NET multi-architecture container image via a single Dockerfile using the docker buildx command.

  • 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

Project Structure

  • docker-compose.yml - builds and runs all four sibling images together, see Run All Four Side By Side.
  • src/multi-arch-container-dotnet/ - console application source.
    • Program.cs - entry point; configuration, logging and DI wiring only.
    • Models/_AppConfig.cs - application configuration bound from the app section.
    • Models/_BuildInfo.cs - build provenance bound from the flat GIT_*/GITHUB_* variables.
    • Models/_Enums.cs - all enums for the project.
    • Services/WorkerService.cs - the BackgroundService worker loop.
    • appsettings.json - base configuration.
  • Dockerfile - two-stage, cross-compiling, multi-architecture build.
  • .github/workflows/ci.yml - CI/CD using reusable workflows from f2calv/gha-workflows.
  • build.sh / build.ps1 - local build scripts for manual testing.
  • Directory.Build.props / Directory.Packages.props - central MSBuild properties and NuGet versions.

Technology Stack

  • Language: C# 14 / .NET 10.0
  • Hosting: Microsoft.Extensions.Hosting generic host, BackgroundService worker
  • Logging: Serilog owns the Microsoft.Extensions.Logging pipeline; application code depends only on ILogger<T>
  • Configuration: Microsoft.Extensions.Configuration (appsettings.json, then environment variables), bound to validated IOptions<T> records
  • Container: Docker (multi-stage, chiseled Ubuntu final image, non-root)
  • CI/CD: GitHub Actions (reusable workflows from f2calv/gha-workflows)
  • Versioning: GitVersion (MainLine mode)

Platform Mapping

RID is short for Runtime Identifier. docker buildx injects TARGETARCH and TARGETVARIANT into the build, and the Dockerfile maps them onto a RID:

Docker platform TARGETARCH TARGETVARIANT .NET RID Typical hardware
linux/amd64 amd64 (empty) linux-x64 Most desktop/server distributions
linux/arm64 arm64 (empty) linux-arm64 Raspberry Pi 3+ on 64-bit Ubuntu/Debian, Apple Silicon, AWS Graviton
linux/arm/v7 arm v7 linux-arm Raspberry Pi 2+ on 32-bit Raspberry Pi OS

Anatomy of the Dockerfile

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
Loading

The five ideas worth stealing:

  1. 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.
  2. Split dependency resolution from compilation. dotnet restore runs against a layer containing only *.csproj and Directory.*.props, so editing a .cs file reuses the cached restore. Restore is platform-agnostic and deliberately happens before TARGETARCH is introduced, so all three architectures share it.
  3. Switch on TARGETARCH + TARGETVARIANT, not TARGETPLATFORM. Concatenating the two produces a single flat token (amd64, arm64, armv7) that a case statement handles in three lines, instead of comparing full linux/arm/v7-style strings.
  4. Use BuildKit cache mounts. --mount=type=cache keeps the NuGet package cache outside the image layers - it survives across builds without bloating the result.
  5. Ship a minimal, non-root final image. The chiseled base has no shell and no package manager, and the container runs as $APP_UID (1654).

Logging

Structured logging is provided by Serilog, which takes ownership of the Microsoft.Extensions.Logging pipeline. Application code therefore only ever depends on ILogger<T> - Serilog could be swapped out without touching a single service.

logger.LogInformation("{ClassName} git provenance, Repository={GitRepository} Branch={GitBranch}",
    nameof(WorkerService), buildInfo.Value.GitRepository, buildInfo.Value.GitBranch);

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-dotnet

OpenTelemetry

Set OTEL_EXPORTER_OTLP_ENDPOINT to enable batched logs, metrics and traces over OTLP/HTTP with Protocol Buffers. Serilog 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-dotnet \
  -e OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=development \
  ghcr.io/f2calv/multi-arch-container-dotnet

The exporter honors 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

Configuration is layered by Microsoft.Extensions.Configuration, in ascending order of precedence:

  1. Property defaults on the AppConfig record.
  2. appsettings.json.
  3. An optional appsettings.${DOTNET_ENVIRONMENT}.json file.
  4. Environment variables.
  5. Command line arguments.

Step 3 is the one place where this repository intentionally has a capability its siblings lack. Host.CreateApplicationBuilder provides it for free, keyed off the host's own DOTNET_ENVIRONMENT variable, so removing it would mean fighting the framework. Reimplementing it in Go, Rust and Python would mean hand-rolling file resolution and merge semantics in three languages to match a built-in, which is not a trade worth making in a reference repository.

Values are bound to validated IOptions<T> records with ValidateDataAnnotations().ValidateOnStart(), so a bad value fails fast at startup 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, not PascalCase, and mapped onto idiomatic C# property names with [ConfigurationKeyName]. That is deliberate: the Go and Rust configuration libraries lower-case environment keys, so snake_case is the only casing where the file key and the environment key resolve identically across all four languages.

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 Container Image

#Run pre-built image on Docker
docker run --pull always --rm -it ghcr.io/f2calv/multi-arch-container-dotnet

#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-dotnet

#Inspect the multi-architecture manifest list
docker buildx imagetools inspect ghcr.io/f2calv/multi-arch-container-dotnet

Run on Kubernetes with Helm

The public universal workload chart deploys this .NET worker through the same framework-neutral values used for Go, Rust, 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-dotnet.values.yaml with the pinned image and worker configuration:

kind: Deployment
replicaCount: 1

fullnameOverride: multi-arch-container-dotnet

image:
  repository: ghcr.io/f2calv/multi-arch-container-dotnet
  tag: 1.3.1
  pullPolicy: IfNotPresent

service:
  enabled: false

startupProbe: false
readinessProbe: false
livenessProbe: false

envVars:
  APP__GREETING: Hello from .NET on Kubernetes
  APP__INTERVAL_SECONDS: "5"
  APP__LOG_FORMAT: json

Install or upgrade the Deployment with version 1.1.0 of the universal workload chart:

helm upgrade --install multi-arch-container-dotnet oci://ghcr.io/f2calv/charts/workload \
  --version 1.1.0 \
  --values multi-arch-container-dotnet.values.yaml

kubectl logs --follow deployment/multi-arch-container-dotnet
helm uninstall multi-arch-container-dotnet

Self-Build Container Image Locally

The .NET 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 and then, via a terminal window from the root of the repository, execute;

#demo script PowerShell version
./build.ps1

Or

#demo script Shell version
./build.sh

Both 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

Build & Test Commands

A devcontainer is provided so the repository can be built without installing the .NET SDK on the host; installing the SDK natively (Visual Studio 2026 / dotnet CLI) works equally well.

# Restore, build and run
dotnet restore
dotnet build
dotnet run --project src/multi-arch-container-dotnet

# Format check
dotnet format --verify-no-changes

Run All Four Side By Side

This repository carries a docker-compose.yml that builds and runs all four sibling images together, which is the quickest way to confirm that configuration, environment variables and log output behave identically across the languages. It expects the siblings to be cloned alongside this repository:

source/github/
├── multi-arch-container-dotnet/   <- docker-compose.yml lives here
├── multi-arch-container-go/
├── multi-arch-container-rust/
└── multi-arch-container-python/
# Build all four in parallel, then run them together
docker compose up --build

# Same, but with real git provenance baked in and JSON logging
GIT_COMMIT=$(git rev-parse HEAD) APP__LOG_FORMAT=json docker compose up --build

# Prove the configuration override reaches all four identically
APP__GREETING="hello from compose" APP__INTERVAL_SECONDS=1 docker compose up --build

docker compose down

Build provenance (GIT_*, GITHUB_*) is passed as build args and baked into each image, so changing one needs --build. Application configuration (APP__*) is passed as runtime environment, so it takes effect on the next up.

The telemetry profile adds an OpenTelemetry Collector so the OTLP output of all four can be compared too. It prints every log, metric and trace it receives to its own stdout:

OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 docker compose --profile telemetry up --build

docker compose logs -f otel-collector

Without OTEL_EXPORTER_OTLP_ENDPOINT the collector is not started and none of the four initialises an OpenTelemetry provider, which is the default path.

Deployment Flow

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/>(dotnet build)"]
    A --> I["image<br/>(docker buildx)"]
    I --> R["release<br/>(tag + GitHub release)"]
    I --> G[("ghcr.io/f2calv/multi-arch-container-dotnet")]
    class L,V,A,I,R f2calv
Loading

Docker, Container & .NET Resources

Further Resources

About

Multi-architecture container build for amd64, arm64, and arm/v7 using .NET.

Topics

Resources

Contributing

Stars

13 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages