Blog · Build & Deploy

Schneller .NET-Build mit Dockerfile Caching

Wie wir unsere Dockerfiles für .NET‑Anwendungen so strukturiert haben, dass Restore, Build und Publish sauber gecached werden – statt bei jedem Build alles neu zu kompilieren.

Ausgangslage: Jeder Build dauert „gefühlt ewig“

Wer .NET‑Anwendungen in Docker packt, kennt das Problem: Eine kleine Codeänderung, und der Build rennt wieder durch Restore, Build und Publish – selbst wenn sich an den NuGet‑Dependencies nichts geändert hat. Lokal ist das nervig, in CI/CD summieren sich die Minuten schnell.

Unser Ziel war daher, Dockerfile‑Caching so zu nutzen, dass:

  • NuGet‑Restore nur dann läuft, wenn sich die Projektdateien ändern
  • Build‑Artefakte wiederverwendet werden können, wenn sich nur Kleinigkeiten ändern
  • das Ganze trotzdem gut lesbar bleibt

Schritt 1: Projekte & NuGet-Config separat kopieren

Der erste Schritt ist, Restore von den eigentlichen Sources zu entkoppeln. Im Build‑Stage kopieren wir nur die Projektdatei(en) und die nuget.config, führen dann Restore aus – und erst danach kopieren wir den restlichen Code.

FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src

COPY nuget.config ./nuget.config
COPY MyApp/MyApp.csproj MyApp/

RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \
    dotnet restore "MyApp/MyApp.csproj" --configfile ./nuget.config

COPY . .
WORKDIR /src/MyApp

Solange sich MyApp.csproj und nuget.config nicht ändern, kann Docker die Restore‑Schicht komplett aus dem Cache ziehen – selbst wenn wir später Source‑Files oft austauschen.

Schritt 2: Build & Publish trennen

Im nächsten Schritt führen wir Build und Publish getrennt aus. Das hilft, Artefakte in CI leichter zu analysieren und macht das Dockerfile lesbarer:

FROM build AS build-app

ARG BUILD_CONFIGURATION=Release

RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \
    dotnet build "MyApp.csproj" -c $BUILD_CONFIGURATION -o /app/build --no-restore

FROM build-app AS publish

ARG BUILD_CONFIGURATION=Release

RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \
    dotnet publish "MyApp.csproj" -c $BUILD_CONFIGURATION -o /app/publish --no-restore /p:UseAppHost=false

Auch hier gilt: Solange die Inputs gleich bleiben (Projektdatei, Restore‑Schicht, Sources), kann Docker Build‑ und Publish‑Layer wiederverwenden.

Schritt 3: Runtime-Image schlank halten

Im finalen Image brauchen wir nur die veröffentlichten Artefakte. Der Runtime‑Stage bleibt daher bewusst minimal:

FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyApp.dll"]

Das Build‑Image kann lokal oder in CI gecached werden, das Runtime‑Image ist schlank und unabhängig von SDK‑Versionen.

Schritt 4: Typische Stolpersteine

Ein paar Dinge, über die wir selbst gestolpert sind:

  • Wenn man COPY . . zu früh macht, invalidiert jede Code‑Änderung den Restore‑Cache.
  • Wer mehrere Projekte in einem Repo hat, sollte überlegen, pro Service ein eigenes Dockerfile zu pflegen – sonst wird das Caching unübersichtlich.
  • Environment‑abhängige Restore‑Optionen (z.B. andere Feeds) können Caches trennen – BuildKit‑Cache‑Mounts helfen, das zu entschärfen.

Schritt 5: BuildKit-Caching sinnvoll nutzen

In CI/CD nutzen wir BuildKit und Cache‑Mounts (--mount=type=cache), damit der NuGet‑Cache nicht bei jedem Build bei Null anfängt. Das haben wir im Dockerfile bereits angedeutet – in den Pipelines setzen wir zusätzlich die passenden Flags (DOCKER_BUILDKIT=1 etc.).

Fazit

Kein magischer Trick, aber in Summe machen diese Schritte einen großen Unterschied: Builds laufen deutlich schneller, Images bleiben übersichtlich und wir können sehr klar erklären, welcher Schritt wann neu ausgeführt werden muss. Für uns fühlt sich das nach einer guten Balance aus Performance und Transparenz an.