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.
