Multi Stage

Multi Stage Builds

Wenn wir die Größe unseres Images in Docker Desktop anschauen (oder mit dem Docker-Befehl docker images oder docker image ls auf der Kommandozeile), sehen wir, dass die Größe gewaltig ist. Auf meinem Rechner ist das Image 2.6 GB groß. Warum ist es so?

docker image ls

IMAGE                               ID             DISK USAGE   CONTENT SIZE
blazor-movies:from-sdk              e561bfaae17f       2.55GB          758MB
mcr.microsoft.com/dotnet/sdk:10.0   d1823fecac36       1.29GB          318MB

Ursachen für große Images

Schauen wir uns die Ursachen für die Größe an.

Größe des Basisimages

Einen großen Einfluss auf die endgültige Größe hat natürlich das Basisimage. Unsere App kommt ja immer “on Top” von diesem Basisimage. Aktuell nutzen wir SDK-Image als Basis, das alleine bereits 1.3 GB groß ist. Das SDK-Image bringt alles mit, was notwendig ist, um eine dotnet Applikation ausführen, aber auch bauen zu können.

Daten im Image

Da wir die Applikation in dem Image selbst bauen, hat dieser nicht nur die fertige App, sondern auch noch:

  • Quellcode, den wir kopiert haben, um die Applikation bauen zu können
  • Eventuelle “Zwischen-Dateien”, die bei einem Build-Prozess entstehen, aber nicht in die finale App reinfließen (bei dotnet z.B. die obj und bin Ordner)
  • Eventuelle “Kompilate”, die auf unserem PC bereits da sind, da wir die App ja bereits local gebaut uns ausgeführt haben

Ursachen beseitigen

Beide Probleme können wir mit sogenannten “multi stage builds” lösen. Dabei werden unterschiedliche Images für das Erstellen der App (Kompilieren) und für finale Image genutzt. Dabei bekommt das finale Image nur die fertig kompilierte App.

Dafür müssen wir nur wenige Änderungen an unserem Bauplan durchführen.

# Image für Build
- FROM mcr.microsoft.com/dotnet/sdk:10.0
+ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
# Verzeichnis für das Build

Mit AS <name> vergeben wir diesen ersten Schritt (Stage) einen Namen, so dass wir später uns auf diesen beziehen können.

Für den zweiten Schritt benötigen wir ein schlankeres Basis-Image. Da wir am Ende die Applikation nur ausführen möchten, ist SDK überdimensioniert. Uns reicht für unsere Zwecke die runtime (Ausführung) Version. Das Image dazu bei Microsoft heißt mcr.microsoft.com/dotnet/aspnet:9.0.

Alle Schritte in der Bauanleitung (Dockerfile), die mit dem finalen Image zu tun haben, werden auf dem “runtime” Basis-Image ausgeführt.

 1RUN dotnet publish BlazorWebAppMovies.csproj -c Release -o /app
 2+
 3+ # Image für Runtime
 4+ FROM mcr.microsoft.com/dotnet/aspnet:10.0
 5# Verzeichnis für die App
 6WORKDIR /app
 7+ # Kopieren des aus dem Build zu Runtime Image
 8+ COPY --from=build /app .
 9# Datenbank Connection string
10ENV CONNECTIONSTRINGS__BLAZORWEBAPPMOVIESCONTEXT=""
  • In der Zeile 4 definieren wir das Image, das als Basis für unser eigenes Image dienen soll (hier aspnet).
  • In der Zeile 8 kopieren wir die fertig kompilierte Applikation aus dem SDK-Schritt (´from=build´) und Ordner /app in den aktuellen Arbeitsordner. Damit enthält das fertige Image weder den Quellcode, noch die “Zwischen-Dateien”, die beim Kompilieren entstehen.

Bauen wir nun unser Image mit der aktuellen Version von Dockerfile und vergeben die Version from-aspnet an das Image.

# Image für Build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
# Verzeichnis für das Build
WORKDIR /build
# Kopiere das Projekt in das Arbeitsverzeichnis
COPY . ./
# Wiederherstellen der Abhängigkeiten
RUN dotnet restore
# Veröffentlichen der App in das Verzeichnis /app
RUN dotnet publish BlazorWebAppMovies.csproj -c Release -o /app

# Image für Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0
# Verzeichnis für die App
WORKDIR /app
# Kopieren des aus dem Build zu Runtime Image
COPY --from=build /app .
# Datenbank Connection string
ENV CONNECTIONSTRINGS__BLAZORWEBAPPMOVIESCONTEXT=""
# Port für Webserver
EXPOSE 8080
# App in Container starten
ENTRYPOINT ["dotnet", "BlazorWebAppMovies.dll"]
docker build -t blazor-movies:from-runtime .

Unser Image ist nun deutlich kleiner geworden. Auf meinem MacBook Air ist das Image nun 462 MB, also 2.0 GB kleiner als unser erster Versuch.

docker image ls

IMAGE                                  ID             DISK USAGE   CONTENT SIZE
blazor-movies:from-runtime             42c8d3bed245        462MB          116MB
blazor-movies:from-sdk                 e561bfaae17f       2.55GB          758MB
mcr.microsoft.com/dotnet/aspnet:10.0   eaa79205c3ad        369MB         92.4MB
mcr.microsoft.com/dotnet/sdk:10.0      d1823fecac36       1.29GB          318MB

Ein großer Teil kommt vom kleineren Basis-Image. Diese Änderung bringt bereits über 920 MB Ersparnis (von 1.3 GB runter auf 369 MB). Der Rest kommt durch das Weglassen des Quellcodes und der temporären Dateien, die beim Kompilieren entstehen.

Weitere Optimierungen

Wir können unser Image noch weiter Optimieren. Die Standard-Images von Microsoft basieren normalerweise auf Debian, zwar einer sehr schlanken Version davon, aber immer noch mit einen vollwertigen Linux als Basis. Wir kennen bereits ein minimalistisches Linux, das selbst nur 20MG groß ist, alpine Linux.

Microsoft liefert alle eigene Basis-Images auch in der Alpine-Version.

# Image für Build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
# Verzeichnis für das Build
WORKDIR /build
# Kopiere das Projekt in das Arbeitsverzeichnis
COPY . ./
# Wiederherstellen der Abhängigkeiten
RUN dotnet restore
# Veröffentlichen der App in das Verzeichnis /app
RUN dotnet publish BlazorWebAppMovies.csproj -c Release -o /app

# Image für Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
# Verzeichnis für die App
WORKDIR /app
# Kopieren des aus dem Build zu Runtime Image
COPY --from=build /app .
# Datenbank Connection string
ENV CONNECTIONSTRINGS__BLAZORWEBAPPMOVIESCONTEXT=""
# Port für Webserver
EXPOSE 8080
# App in Container starten
ENTRYPOINT ["dotnet", "BlazorWebAppMovies.dll"]
 docker build -t blazor-moviews:from-alpine .

Nun sieht unser Image noch kleiner aus. Das Basis-Image ist von 369 MB runter auf 183 MB. Und unser eigener Image ist nun von 462 MB runter auf 276 MB. Das ist 11% von unseren Ausgangspunkt.

docker image ls

IMAGE                                         ID             DISK USAGE   CONTENT SIZE
blazor-movies:from-alpine                     bb332d7cf91c        276MB           76MB
blazor-movies:from-runtime                    42c8d3bed245        462MB          116MB
blazor-movies:from-sdk                        e561bfaae17f       2.55GB          758MB
mcr.microsoft.com/dotnet/aspnet:10.0          eaa79205c3ad        369MB         92.4MB
mcr.microsoft.com/dotnet/aspnet:10.0-alpine   1be14b20e4ec        183MB         51.9MB
mcr.microsoft.com/dotnet/sdk:10.0             d1823fecac36       1.29GB          318MB

Abhängig von der Programmiersprache und deren Fähigkeiten, lässt sich die finale Applikation noch weiter verkleinern (und somit auch das Image). Das erfordert aber sehr oft deutlich mehr aufwand. Mit Asp.Net wäre es möglich bei einigen Applikationen AOT (Ahead of Time) Kompilierung durchzuführen, so dass gar keine Runtime mehr benötigt wird. Mit Trimming (Abschneiden der nicht genutzten Fähigkeiten) kann die kompilierte Applikation noch weiter verkleinert werden.

Mit den einfachen Mitteln haben wir aber bereits ein sehr kleines Image erstellt, das für die meisten Anforderungen gut genug ist.

Vorteil von Alpine Linux

Der größte Vorteil von Alpine Linux ist nicht die Größe, sondern die Sicherheit. Da dieses Linux fast nichts hat, kann es weniger kompromittiert werden. Das ist direkt sichtbar in Docker Desktop. Für SDK Image liegen aktuell 14 bekannte Schwachstellen (7 in Debian und 7 in SDK selbst), Runtime hat immer noch 8, Alpine hat nur 2 (mit niedriger Wertung).

Hinweis

Das Bild der Schwachstellen ist eine Momentaufnahme und wird bei Ihnen anders aussehen. Allgemeines Bild, dass Alpine-Images deutlich weniger Schwachstellen aufweisen, wird aber bleiben.

14 Schwachstellen in SDK Image

2 Schwachstellen in SDK Image

docs