zum Inhalt springen
Kontakt

Anleitung für die Reduktion der Grösse von Docker Images

Michael Ingold
Docker_Images_Beitragsbild 1200x600

Wir verwenden Docker Container, um die Installation und den Betrieb von Softwarelösungen zu vereinfachen. Nun haben wir aber das Problem, dass unsere Docker Images zu gross sind, viel zu gross. Stellen Sie sich vor, dass Ihre Applikation kompiliert gerade mal 5Mb gross ist, warum würden Sie dann mehr als 900Mb grosse Images in die Produktion deployen? Wahrscheinlich haben viele Entwickler dieses Problem, sodass wir hier eine Anleitung veröffentlichen, wie die Grösse von Docker Images drastisch reduziert werden kann.

Docker Images bildlich dargestellt

Warum grosse Docker Images ein echtes Problem werden können?

Docker Images können gross werden, sehr gross:

microsoft/dotnet 2.1 -runtime 180MB
mongo latest 394MB
microsoft/mssql-server-linux latest 1.35 GB
confluentinc/cp-enterprise-kafka 5.1 . 0 619MB
node latest 900MB
microsoft/dotnet 2.1-runtime 180MB mongo latest 394MB microsoft/mssql-server-linux latest 1.35GB confluentinc/cp-enterprise-kafka 5.1.0 619MB node latest 900MB
microsoft/dotnet 2.1-runtime 180MB
mongo latest 394MB
microsoft/mssql-server-linux latest 1.35GB
confluentinc/cp-enterprise-kafka 5.1.0 619MB
node latest 900MB

Oft wählen Entwickler `Ubuntu` als Basisimage für ihre Applikationscontainer. Dies ist verständlich, da es durchaus sinnvoll ist, ein OS als Basis zu nehmen, das man gut kennt! Die Grösse der resultierenden Docker Images dann jedoch zu einem grossen Problem werden. Ganz besonders, wenn diese in einem Orchestrierungssystem wie Kubernetes gestartet werden. Wenn nun einzelne Instanzen fehlschlagen sollten, starten die Systeme mehrere Instanzen unserer Container um Verfügbarkeit zu gewährleisten. Das bedeutet, dass die Images immer wieder aus der Container Registry heruntergeladen werden müssen, was die Netzwerke teils kurzzeitig stark belasten kann. Belastet werden aber nicht nur die Netzwerke. Die Container Registry, Storage, etc. leiden mit.

Zu grosse Container sind nicht nur für produktive Cluster und Orchestrierungssysteme ein Problem. Auf den Notebooks der Entwickler ist bekannterweise Speicherplatz stets eine knappe Ressource. Da hilft es nicht, wenn konstant mehrere Gigabytes an Basisimages auf teuren SSD Speicherplatz gecasht werden müssen. Es gibt also genügend Gründe, die Grösse unserer Docker Images zu reduzieren.

Praktikable Lösungsansätze

In der Softwareentwicklung setzt sich die Praxis kleiner in sich abgeschlossener funktionaler Softwareeinheiten (aka „Microservices“) durch. Die Binaries eines solchen Services sind meist wenige Megabytes gross. Weshalb soll das daraus resultierende Image mehrere Gigabytes gross sein? Warum paketieren wir ein ganzen OS oder eine ganze Toolchain wie NodeJs in einen Container, nur um dann ein minimales Subset dessen Inhaltes zu verwenden? Als Beispiel:

FROM node
WORKDIR /app
COPY package. json /app/package. json
RUN npm install --production
COPY server. js /app/server. js
EXPOSE 8080
CMD npm start
FROM node WORKDIR /app COPY package.json /app/package.json RUN npm install --production COPY server.js /app/server.js EXPOSE 8080 CMD npm start
FROM node
WORKDIR /app
COPY package.json /app/package.json
RUN npm install --production
COPY server.js /app/server.js
EXPOSE 8080
CMD npm start

Viele Tutorials lehren, dass Node Applikationen so verteilt werden sollten. Dies ist jedoch ein sehr unbeholfener und schlecht überlegter Ansatz der vielfach von Entwicklern eingesetzt wird, die zu wenig Einblick in die verwendete Technologie besitzen. Wenn wir `docker images | grep node` ausführen kann man erkennen, dass das `node` Basisimage (von dessen Verwendung sogar das Node Projekt selbst abrät) ganze **900Mb** gross ist! Wie soll das in produktive Systeme ausgerollt werden? Zu diesem Punkt, haben wir die Performance Implikationen, die durch das gegebene Beispiel zum Start und der Laufzeit des Containers entstehen, noch gar nicht in Betracht bezogen!

Aber es gibt auch gute Nachrichten: Meistens besteht keine Notwendigkeit diesen eher negativen Ansatz zu wählen. Die meisten Sprachen (oder genauer deren *Compiler*) ermöglichen es dem Entwickler, seinen Code in sogenannte **self-contained binaries** zu kompilieren, die nicht viel mehr als einen Kernel brauchen um ausgeführt zu werden. Es gibt drei Variante, diese Möglichkeiten mit Docker auszuschöpfen:

  1. Kleinere Basisimages verwenden.
  2. Das Builder-Pattern anwenden.
  3. Zero-Waste Images!

So funktioniert die Verwendung von kleineren Basisimages

Der erste Ansatz kann ein super Quick-Win sein, denn obwohl das `Node`-Basisimage 900Mb gross ist, ist das `node:alpine`-Image gerade mal **70Mb** gross und funktional dem `Node`-Image gleich:

node latest 900MB
node alpine 73.7 MB
node latest 900MB node alpine 73.7MB
node latest 900MB
node alpine 73.7MB

Dies ist bereits eine massgebliche Verbesserung – ein super Quick-Win! Aber unsere Images könnten durchaus noch kleiner sein, wenn das **Builder-Pattern** eingesetzt wird:

Das Builder-Pattern

In Docker gibt es ein bisher kaum bekanntes Feature, dass sogenannte Multi-Stage Builds von Containern zulässt: Einer oder mehrere Container (Vorsicht: *NICHT* Images) dienen nur dem Zweck, den Code zu kompilieren. Die kompilierten Artefakte werden dann zum Erstellen eines weiteren *Images* verwendet. Dieses Image ist das Einzige, was wir benötigen. Alle anderen Images können entsorgt werden

Zur Abwechslung verwenden wir ein weiteres Beispiel in einer anderen Sprache: GO. (Keine Sorge: das Prinzip ist auch direkt auf NodeJs  anwendbar)

FROM golang: 1.11 . 5 -alpine3. 7 as builder
WORKDIR /go/src/github. com /test/repo
RUN go get -d -v golang. org /x/net/html
COPY app. go .
RUN CGO_ENABLED= 0 GOOS=linux go build -a -installsuffix cgo -o app .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /go/src/github. com /test/repo/app .
CMD [ "./app" ]
FROM golang:1.11.5-alpine3.7 as builder WORKDIR /go/src/github.com/test/repo RUN go get -d -v golang.org/x/net/html COPY app.go . RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app . FROM alpine:latest RUN apk --no-cache add ca-certificates WORKDIR /root/ COPY --from=builder /go/src/github.com/test/repo/app . CMD ["./app"]
FROM golang:1.11.5-alpine3.7 as builder
WORKDIR /go/src/github.com/test/repo
RUN go get -d -v golang.org/x/net/html
COPY app.go .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /go/src/github.com/test/repo/app .
CMD ["./app"]

In diesem Beispiel sind zwei Image-Deklarationen vorhanden. Die Erste ist der Builder-Container, der aus der Basisimagedeklaration `as builder` einfach ersichtlich ist. Der Builder-Container basiert auf `golang:1.11.5-alpine3.7`, welcher wiederum direkt auf dem `alpine` image aufsetzt. Zusammen sind beide Images ungefähr 100Mb[^2] gross. Dieser Container enthält alle, für das Erstellen eines statisch gelinkten Binaries, notwendigen Tools wie den Compiler, Linker, Depdendency-Management, etc. Als erstes werden alle `RUN` Befehle dieses Containers ausgeführt und die resultierenden Artefakte (lediglich ein File: `./app`) werden nun in den nächsten Container kopiert. Alle Inhalte des Builder-Containers gehen nach seiner Ausführung wieder verloren und werden auch nie in einer Container-Registry zu finden sein.

Das zweite deklarierte Image ist das, was wir gerne deployen und ausführen möchten. Es basiert direkt auf `alpine` und enthält ausschliesslich das ausführbare Binary (und einige Zertifikate – aber was würden wir ohne die bloss tun…).

Diese Herangehensweise reduziert die Grösse unseres Containers auf knappe 10Mb. Das ist in Anbetracht der Tatsache, dass wir nun eine Applikation verteilen die eine komplett gemanagte Laufzeit und Speicherzugriffssicherheit mitbringt und zur Ausführung bloss einen funktionierenden Kernel benötigt, recht annehmbar.

Der Builder-Ansatz ist dem letzten Ansatz sehr nahe:

Zero-Waste Images!

Während das Builder-Pattern einfach ist und durchaus seine Berechtigung hat, gibt es noch eine konsequentere und saubere Umsetzung, wie Images eigentlich gebaut werden sollten. Hier wird auf die Prämisse gebaut, dass Docker keine Build-Systeme sind. Natürlich kann man Docker über das Builder-Pattern durchaus als Build-System missbrauchen, das ist aber nicht unbedingt Sinn und Zweck. Docker ist in seinem Kern lediglich ein praktisches Container-Image Format. Natürlich ist Docker auch recht gut für den Betrieb dieser Images als Container gemacht. Aber Docker setzt eher auf bestehende und zum Teil wesentlich ältere Technologien welche von *RKT* und vielen anderen Containerruntimes verwendet werden.

Das Image-Format hingegen ist breit adaptiert worden und ist auch sehr Intuitiv und einfach erlernbar. Aus diesem Grund sollte Docker nicht als Build-System missbraucht werden. Das Builder-Pattern ist genau das: *ein behelfsmässiges CI System* (und kein besonders Gutes…).

Die meisten etablierten CI/CD Plattformen wie GitLab, Github, Azure Devops etc. haben etablierte mächtige Buildsysteme, die nicht nur unseren Code kompilieren sondern auch noch unzählige weitere Informationen aus unserem Code destillieren (Code-Metrics etc.) oder für uns Tests ausführen und rapportieren können. Diese Systeme produzieren bereits genau das, was wir in unseren Docker-Container packen sollten. Warum benutzen wir diese Artefakte dann nicht?

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY /go/src/github. com /test/repo/app .
CMD [ "./app" ]
FROM alpine:latest RUN apk --no-cache add ca-certificates WORKDIR /root/ COPY /go/src/github.com/test/repo/app . CMD ["./app"]
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY /go/src/github.com/test/repo/app .
CMD ["./app"]

Dieser Lösungsansatz verfeinert das Builder-Pattern nur im Hinblick auf die Builddauer. Es bringt uns aber zusätzlich in unseren CI/CD Pipelines viel Freiheit für Testausführung, Code-Analysen, Release-Prozesse etc. mit. Diese können wir mit dem Builder-Pattern nicht oder nur über mühsehlige Umwege bekommen. Zudem gelten sie als gute Praxis für professionelle Softwareentwickler.

[^1]: full list of layers for the `node` image 
[^2]: full list of layers for the `golang:1.11.5-alpine3.7` image