Containerisierung von .NET Core Microservices – Tipps & Tricks

Es gibt bereits viele Artikel, in denen Sie Einzelheiten zur Containerisierung von .NET Core-Anwendung finden können. Trotzdem bin ich der Meinung, dass ein detaillierter Post helfen kann Best Practices für ein produktionsreifes Container-Image auf der Grundlage von Container- und .NET Core-Best Practices zu vermitteln.

Zum besseren Verständnis werde ich alles im Detail anhand einer kleinen ASP .NET-Core Beispiel-Webanwendung erklären. Mehr Details zur Anwendung selbst finden Sie hier. Natürlich sind die unten genannten Praktiken nicht auf .NET Core beschränkt. Sie können diese auf Ihre weiteren Projekte und Anwendungsfälle ausweiten.

Es gibt unzählige Anwendungsfälle, weshalb es auch keine “one fits all”-Lösung gibt. Ich möchte Ihnen zwei verschiedene Optionen vorstellen, welche ich persönlich am häufigsten verwende. Aber lassen Sie uns erst mit einigen Grundlagen beginnen.

 

Das gängige Beispiel

Dies ist ein gängiges Beispiel für ein Dockerfile, das mir recht häufig begegnet:

FROM mcr.microsoft.com/dotnet/core/aspnet:3.1
WORKDIR /app
COPY /app/output .
EXPOSE 8080
ENTRYPOINT ["dotnet", "sample-mvc.dll"]

An diesem Beispiel ist nichts falsch. Es wird funktionieren, aber es ist keineswegs optimiert. Weder für Performance noch für sicherheitsrelevante Aspekte und ist daher nicht optimal für eine Produktionsumgebung. Hier einige Beispiele:

  • Der Prozess wird im Container mit Root-Rechten ausgeführt
  • Eine ineffiziente Reihenfolge der Befehle, die zu einem langsameren Build führen können
  • Eine ineffiziente Image-Layer-Verwaltung, die sich auf das finale Container Image auswirkt
  • Langsamere Builds aufgrund der fehlenden `.dockerignore’-Datei

Schauen wir uns ein weiteres Dockerfile-Beispiel etwas genauer an:

FROM mcr.microsoft.com/dotnet/core/sdk:3.1
WORKDIR /app
ADD /src .
RUN dotnet publish \
  -c Release \
  -o ./output
EXPOSE 8080
ENTRYPOINT ["dotnet", "sample-mvc.dll"]

In diesem Beispiel wird ein containerisierter Build verwendet. Dies bedeutet, dass der Anwendungs-Build selbst in den Docker-Build-Prozess verlagert wird. Dies ist ein gutes Pattern, das es Ihnen erlaubt, in einer unveränderlichen und isolierten Umgebung die Anwendung zu bauen. Ein Nachteil ist jedoch, dass Sie Ihr Image auf der Grundlage eines größeren SDK-Images erstellen müssen. Das SDK-Image stellt die für die Erstellung der Anwendung erforderlichen Abhängigkeiten bereit, wäre aber für die anschließende Ausführung nicht erforderlich. Es existiert jedoch eine Pattern, das dieses Problem löst. Multi-stage Builds.

.

Multi-stage Builds

Wenn Sie eine ähnliche Version der oben genannten Dockerfiles verwenden, haben Sie vielleicht noch nicht von Multi-stage Container-Builds gehört. Mehrstufige Builds ermöglichen es uns, unseren Image-Build-Prozess in mehrere Schritte aufzuteilen.

Die erste Stufe dient der Erstellung unserer Anwendung, für die wir die erforderlichen Abhängigkeiten bereitstellen müssen. In der zweiten Stufe kopieren wir die Anwendungsartefakte in eine kleinere Laufzeitumgebung, die dann als unser endgültiges Container Image verwendet wird. Ein entsprechendes Dockerfile sieht wie folgt aus:

FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build-env
WORKDIR /app
ADD /src .
RUN dotnet publish \
  -c Release \
  -o ./output
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1
WORKDIR /app
COPY --from=build-env /app/output .
EXPOSE 8080
ENTRYPOINT ["dotnet", "sample-mvc.dll"]

Schauen wir uns die einzelnen Schritte näher an:

FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build-env
...
RUN dotnet publish \
  -c Release \
  -o ./output
...

Unser erster Schritt basiert auf dem SDK-Image, das alle Abhängigkeiten für die Erstellung unserer Anwendung bereitstellt.

...
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1
...
COPY --from=build-env /app/output .
...

Im zweiten Scrhitt definieren wir ein neues Basis-Image, das diesmal nur unsere Laufzeitabhängigkeiten enthält. Dann kopieren wir die Anwendungsartefakte aus dem ersten Image in das zweite Image.

Damit sind wir nun in der Lage, ein kleineres und sichereres Container-Image zu erstellen, da es nur die zur Ausführung der Anwendung erforderlichen Abhängigkeiten enthält. Es gibt aber weiterhin noch Raum für Verbesserungen.

Wenn Sie mehr über die Best Practices von Dockerfile erfahren möchten, empfehle ich Ihnen einen Blick auf diese Seite oder die offizielle Docker Dokumentation.

.

Sie haben die Wahl

Wie bereits erwähnt, gibt es nicht eine Lösung für alles. Sie variiert von Anwendungsfall zu Anwendungsfall. Mit den nachfolgenden Beispielen erhalten Sie zwei Beispiele sowie deren Vor- und Nachteile. Diese können Sie natürlich nach Ihren Bedürfnissen anpassen.

Die Grundlage

Das untenstehende Dockerfile ist eine optimierte Version des obigen Multi-Stage Beispiels und dürfte für die meisten Szenarien gut geeignet sein.

ARG VERSION=3.1-alpine3.10
FROM mcr.microsoft.com/dotnet/core/sdk:$VERSION AS build-env
WORKDIR /app
ADD /src/*.csproj .
RUN dotnet restore
ADD /src .
RUN dotnet publish \
  -c Release \
  -o ./output
FROM mcr.microsoft.com/dotnet/core/aspnet:$VERSION
RUN adduser \
  --disabled-password \
  --home /app \
  --gecos '' app \
  && chown -R app /app
USER app
WORKDIR /app
COPY --from=build-env /app/output .
ENV DOTNET_RUNNING_IN_CONTAINER=true \
  ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "sample-mvc.dll"]

Auch hier schauen wir uns die einzelnen Schritte genauer an:

ARG VERSION=3.1-alpine3.10
FROM mcr.microsoft.com/dotnet/core/sdk:$VERSION AS build-env
...

Wir definieren zunächst unser Basis Image mit Hilfe einer ARG-Anweisung. Dies hilft uns, den Tag leicht zu aktualisieren, anstatt jedesmal mehrere Zeilen zu ändern. Wie Sie vielleicht bemerkt haben, verwenden wir ein anderes Tag. Das tag3.1-alpine3.10 besagt, dass dieses Image die ASP .NET-Version 3.1 enthält und auf Alpine 3.10 basiert.

Alpine Linux ist eine Linux-Distribution, die speziell für Anwendungsszenarien im Hinblick auf Sicherheit, Einfachheit und Ressourceneffizienz entwickelt wurde. In diesem Schritt kann Alpine Linux uns bereits dabei helfen, den Speicherbedarf unserer Build-Phase zu reduzieren.

...
FROM mcr.microsoft.com/dotnet/core/aspnet:$VERSION
...

Da wir Multi-Stage Builds verwenden, müssen wir auch das Image definieren, das in unserem zweiten Schritt verwendet wird. Hier werden wir die Alpine basierte ASP .NET-Laufzeitumgebung als unser Basis-Image verwenden. Wie bereits gesagt, ermöglicht uns Alpine kleinere und sicherere Container-Images zu erstellen.

ADD /src/*.csproj .
RUN dotnet restore
ADD /src .
RUN dotnet publish \
  -c Release \
  -o ./output

Anders als im oberen Beispiel teilen wir diesmal den Build-Prozess in mehrere Teile auf. Der Befehl dotnet restore verwendet NuGet zur Wiederherstellung von Abhängigkeiten sowie von projektspezifischen Tools, die in der Projektdatei angegeben sind. Das Wiederherstellen von Abhängigkeiten ist ebenfalls Teil des Befehls dotnet publish, die Trennung erlaubt uns jedoch die Abhängigkeiten in einer separaten Image-Layer zu speichern. Dies verkürzt die Zeit, die für den Aufbau des Images benötigt wird, und reduziert die Größe des Downloads, da die Abhängigkeiten der Image-Layer nur dann neu aufgebaut werden, wenn die Abhängigkeiten geändert werden.

...
RUN adduser \
  --disabled-password \
  --home /app \
  --gecos '' app \
  && chown -R app /app
USER app
...

Um die Laufzeit unserer Anwendung zu schützen, müssen wir sie ohne Root-Rechte ausführen. Aus diesem Grund legen wir einen neuen Benutzer an und ändern den Benutzerkontext unter Verwendung der USER-Definition.

...
ENV DOTNET_RUNNING_IN_CONTAINER=true \
  ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
...

Da wir unsere Anwendung ohne jegliche Root-Privilegien ausführen, müssen wir diese auch auf einem Port höher 1024 bereitstellen. In unserem Beispiel wurde 8080 gewählt. Mit der ENV-Definition können wir der Anwendung weitere Umgebungsvariablen mitgeben. DOTNET_RUNNING_IN_CONTAINER=true ist eine informelle Umgebungsvariable, um einen Entwickler/Anwendung wissen zu lassen, dass der Prozess innerhalb eines Containers läuft. ASPNETCORE_URLS=http://+:8080 wird genutzt um den konfigurierten Port mit dem Port 8080 zu überschreiben.

.

Kleiner, kleiner, kleiner

Wie bereits erwähnt, dürfte das obere Beispiel für die meisten der Szenarien geeignet sein. Das folgende Beispiel beschreibt eine Möglichkeit, ein möglichst kleines Container-Image zu erstellen. Ein möglicher Anwendungsfall könnte ein IoT Edge Szenario, oder Umgebungen die eine optimierte Startzeit benötigen, sein. Allerdings gibt es auch einige Nachteile, auf die ich im Nachhinein noch näher eingehen werde.

ARG VERSION=3.1-alpine3.10
FROM mcr.microsoft.com/dotnet/core/sdk:$VERSION AS build-env
WORKDIR /app
ADD /src .
RUN dotnet publish \
  --runtime alpine-x64 \
  --self-contained true \
  /p:PublishTrimmed=true \
  /p:PublishSingleFile=true \
  -c Release \
  -o ./output
FROM mcr.microsoft.com/dotnet/core/runtime-deps:$VERSION
RUN adduser \
  --disabled-password \
  --home /app \
  --gecos '' app \
  && chown -R app /app
USER app
WORKDIR /app
COPY --from=build-env /app/output .
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 \
  DOTNET_RUNNING_IN_CONTAINER=true \
  ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["./sample-mvc", "--urls", "http://0.0.0.0:8080"]

Noch einmal schauen wir uns die einzelnen Schritte genauer an:

...
RUN dotnet publish \
  --runtime alpine-x64 \
  --self-contained true \
  /p:PublishTrimmed=true \
  /p:PublishSingleFile=true \
  -c Release \
  -o ./output
...

Der große Unterschied zum oberen Beispiel besteht darin, dass wir eine in sich geschlossene Anwendung bauen werden. Die Angabe des Parameters --self-contained true zwingt den Build dazu, alle Abhängigkeiten in das Anwendungsartefakt einzubeziehen. Das schließt die .NET Core-Laufzeitumgebung ein. Aus diesem Grund müssen wir auch die Laufzeitumgebung definieren, in der wir die Anwendung ausführen möchten. Dies geschieht mit dem Parameter --runtime alpine-x64.

Da das fertige Image für die Größe optimiert sein soll, definieren wir das Flag /p:PubishTrimmed=true, das den Build-Prozess anweist, keine unbenutzten Bibliotheken einzubinden. Das Flag /p:PublishSingleFile=true erlaubt es uns, den Build-Prozess selbst zu beschleunigen. Ein Nachteil ist, dass Sie dynamisch geladene Assemblys im Voraus definieren müssen, um sicherzustellen, dass benötigte Bibliotheken im Image vorhanden sind. Weitere Informationen dazu finden Sie hier.

Ein zweiter Nachteil ist, dass Code-Änderungen zu einer größeren Änderung führen. Dies liegt daran, dass der Code und die Laufzeit in einem einzigen Image-Layer zusammengefasst sind. Jedes Mal, wenn sich der Code ändert, muss der gesamte Image-Layer neu erzeugt und auch neu auf die Systeme heruntergeladen werden.

...
FROM mcr.microsoft.com/dotnet/core/runtime-deps:$VERSION
...

Da das Anwendungsartefakt in sich geschlossen ist, brauchen wir keine Laufzeitumgebung mit dem Bild bereitzustellen. In diesem Beispiel habe ich das Runtime-deps-Image gewählt, das auf Alpine Linux basiert. Dieses Image ist auf die minimalen Abhängigkeiten reduziert, die zur Ausführung des Anwendungsartefakts erforderlich sind.

...
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 \
  DOTNET_RUNNING_IN_CONTAINER=true \
  ASPNETCORE_URLS=http://+:8080
...

Eine weitere Optimierung der Image-Größe ist die Verwendung des “globalization invariant” Modus. Dieser Modus ist nützlich für Anwendungen, die nicht global bekannt sind und die Formatierungskonventionen, String-Vergleich und -Sortierreihenfolge der “invariant culture” verwenden können. Dieser Modus kann durch die Umgebungsvariable DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 aktiviert werden. Wenn Ihre Anwendung eine Globalisierung erfordert, müssen Sie die ICU library installieren und die Umgebungsvariable entfernen. Dadurch wird die Größe Ihres Container-Image um etwa 28 MB zunehmen. Weitere Einzelheiten über den “globalization invariant” Modus finden Sie hier.

...
ENTRYPOINT ["./sample-mvc", "--urls", "http://0.0.0.0:8080"]

Für die in sich geschlossene Anwendungen müssen wir die ENTRYPOINT-Definition ändern, um das Artifakt selbst auszuführen.

Die Größe dieses Images wird etwa 73 MB betragen (einschließlich der Beispielapplikation). Dies ist recht klein. Hier der Vergleich zu anderen Images:

  • ein Image, das auf einem gewöhnlichen Multi-Stage Dockerfile basiert: 250 MB
  • ein Image, das auf dem oberen Multi-Stage Dockerfile basiert: 124 MB

Wie bereits oben erwähnt: Welches Dockerfile für Sie am besten geeignet ist, hängt von Ihrem Anwendungsfall ab. Kleiner ist nicht unbedingt besser.