Von einem, der auszog, Docker-Container zu verkleinern

Der Noob, die Container und warum statisch gebundene Sprachen wieder cool sind

Avatar von Torsten Wunderlich

Da hatte ich doch neulich die Chance, bei unserem Crew-Man Talex eine zweitägige Druckbetankung zum Thema Kubernetes zu bekommen. Das war viel Zeug, aber es passte alles zusammen … und ein paar Dinge aus dem Informatikstudium kamen auch wieder zurück.

Jedenfalls war ich angefixt. Lass uns mal einen Container bauen! Warum hatte ich das eigentlich vorher noch nie getan? Na ja, in der Rolle eines Agile Coaches ist es dann doch nicht mein oberstes Ziel, selbst was zu dockern.

Die „hochkomplizierte“ App (Container Payload)

Dank eines Auftrags, für den ich mich in go einarbeiten durfte, liegt es irgendwie nahe, dass es eine go-App sein wird, die ich dockern mag (der andere Grund kommt später).

Und was tut das Ding? Reichlich wenig. Es gibt auf der Console aus, dass es gerade auf Port 8080 lauscht und antwortet auf eine http-Anfrage an den Port mit einem fröhlichen „hello“.

package main
 
import (
    "fmt"
    "net/http"
)
 
// all errors are ignored, which is highly discouraged in any
// circumstances other than this tiny little demo.
// It's about the containers, not their content ;-)
 
func hello(w http.ResponseWriter, _ *http.Request) {
    _, _ = fmt.Fprintf(w, "hello\n")
}
 
func main() {
    addr := ":8080"
    http.HandleFunc("/", hello)
    fmt.Println("listening on", addr)
    _ = http.ListenAndServe(addr, nil)
}

Gut, jetzt übersetzen und testen wir das mal ohne Docker, um zu verhindern, dass wir uns nachher zu Tode suchen, weil wir glauben, irgendwas sei mit unserem Container kaputt. („Haben sie den Stecker eingesteckt?“ ist eine berechtigte und wichtige Frage ..!)

Ein go run gibt uns die Ausgabe listening on :8080. Gut, das geht schon mal. Sagt es auch hallo? Nächstes Terminal: curl localhost:8080 -> hello. Works as expected. Laßt uns den Container packen und auf den Wal aufsatteln…. oder so.

Der erste Container mit der App

Als Base-Image nehmen wir golang:1.17, machen /app zu unserem CWD im Image, kopieren unseren Code aus unserem Verzeichnis ins CWD (/app) des Images, übersetzen das Ganze im Image, machen Port 8080 nach außen zugreifbar, und sagen, dass hello-docker beim Start des Containers ausgeführt werden soll.

FROM golang:1.17
 
WORKDIR /app
 
COPY go.mod .
COPY *.go ./
 
RUN go build -o /hello-docker
 
EXPOSE 8080
 
CMD [ "/hello-docker" ]

Dockerfile

Dann sagen wir Docker mit docker build -t hello-docker:golang . mal, dass das Image gebaut werden soll.

Nach einer Minute und 32 Sekunden ist das Image gebaut. Nun wollen wir sehen, ob es auch funktioniert. Analog zum Test von oben prüfen wir mit docker run -p 8080:8080 hello-docker:golang, ob auf den Port gelauscht wird. Das Ergebnis: listening on :8080.

Das sieht schon mal nicht schlecht aus. Sagt es auch hello? curl localhost:8080 gibt uns ein nettes hello aus. Ach wie hübsch. Works as expected. Und wie groß ist das Image geworden?

> docker images

REPOSITORY     TAG       IMAGE ID       CREATED         SIZE
hello-docker   golang    fd0a7fcf0730   2 minutes ago   947MB

Geht’s noch? Der Container ist fast 1 GB groß? Für meinen kleinen Pico-, nein, Femto-Server? Das Executable von oben auf meiner Platte hat gerade mal 5,7MB. Das kann es doch nicht sein, das muss kleiner gehen!

Ab hier wurde die Reise dann interessant.

Kleiner!

Das Ziel ist klar. Und nun? Meine App kann nicht plötzlich fast ein Gig groß sein, das kommt von woanders. Es kann nur das Base Image sein, das so fett ist. Probieren wir mal ein anderes – die Alpine-Distro soll ja kleiner sein.

Neues Dockerfile, neues Glück:

FROM golang:1.17-alpine
 
WORKDIR /app
 
COPY go.mod .
COPY *.go ./
 
RUN go build -o /hello-docker
 
EXPOSE 8080
 
CMD [ "/hello-docker" ]

buildrunalpine.Dockerfile

Jetzt haben wir wir Alpine als Base Image. Dann mal los. Vorher schaffen wir noch mit docker system prune -a gleiche Bedingungen, um auch zu sehen, wie schnell der Container gebaut wird.

Nachdem alles geplättet ist, bauen wir ein neues Image mit dem neuen Dockerfile. Weil es jetzt nicht mehr Dockerfile heißt, sondern buildrunalpine.Dockerfile , müssen wir es explizit angeben.

docker build -t hello-docker:alpine -f buildrunalpine.Dockerfile .

Das dauerte mit 53 Sekunden deutlich weniger lang als vorher. Auch die Tests (siehe oben) zeigen an, dass die App auch weiterhin auf Port 8080 lauscht und brav ein hello ausgibt. Gut.

Und wie groß ist es jetzt geworden?

> docker images

REPOSITORY     TAG       IMAGE ID       CREATED          SIZE
hello-docker   alpine    49998fc9ff0b   14 minutes ago   321MB

Aha! Schon besser. Aber 6MB Executable gegen 321MB Image ist noch immer eher meh … Das muss doch noch besser gehen.

Noch kleiner. Distroless!

In diesem Internet habe ich was über „distroless“ Base Images gelesen. Versuchen wir die doch mal.

Da diese Images sehr minimalistisch sind, können wir unseren Server darauf zwar laufen lassen, aber nicht übersetzen, weil das Image wirklich fast nackig ist und keinen go-Compiler enthält. Was nun?

Multistage! Wir nehmen einen Container zum Übersetzen und kopieren das Ergebnis in das eigentliche Runtime-Image. Ein Dockerfile dazu sieht dann so aus:

## Build stage
FROM golang:1.17-alpine AS build
 
WORKDIR /app
 
COPY go.mod .
COPY *.go ./
 
RUN go build -o /hello-docker
 
 
## Deploy
FROM gcr.io/distroless/base-debian10
 
WORKDIR /
 
COPY --from=build /hello-docker /hello-docker
 
EXPOSE 8080
 
CMD ["/hello-docker"]

multistagedistroless.Dockerfile

Das benötigt knapp 39 Sekunden zum Bauen. Dann testen wir mal wieder:

❯ docker run -p 8080:8080 hello-docker:distroless

standard_init_linux.go:228: exec user process caused: no such file or directory

Wie, was? Gut dass wir testen! Was soll das denn?

Go baut normalerweise gegen die clib, weil das ein wenig schneller geht (developer experience und so …). So ein distroless-Image hat allerdings keine clib, daher der Crash. Testen wir das Mal und sagen unsrem go-Compiler, dass es die clib nicht nutzen soll, a. k. a. eine statisches executable erzeugen soll. Das RUN in Zeile 9 unserer Dockerfile von eben muss also so aussehen:

RUN CGO_ENABLED=0 go build -o /hello-docker

multistagedistroless.Dockerfile

Nach erneutem Bauen und testen (dieses Mal hat alles reibungslos funktioniert!), müssen wir uns jetzt natürlich noch die Größe ansehen:

❯ docker images

REPOSITORY     TAG          IMAGE ID       CREATED         SIZE
hello-docker   distroless   b5cb4c89b1ff   4 minutes ago   25.3MB

Schon viel besser. Doch Moment …

Gibt es hier auch verschiedene Base images? Schauen wir doch mal bei https://gcr.io/distroless vorbei. Was ist denn „static“? Probieren wir das mal. Dazu Copy-&-Paste des obigen Dockerfiles, um Zeile 13 wie folgt anzupassen:

## Deploy
FROM gcr.io/distroless/static

multistagedistrolessstatic.Dockerfile

Bauen … testen … Größe?

❯ docker images

REPOSITORY     TAG                 IMAGE ID       CREATED          SIZE
hello-docker   distroless-static   c7eafedfba34   16 seconds ago   8.43MB

Na, das geht doch schon in die richtige Richtung. Aber …

Geht das noch kleiner?

Ja, bestimmt! Warum brauchen wir eigentlich ein Base Image, wenn unsere executable in sich alles enthält, was zum Laufen nötig ist? Warum nicht FROM scratch? Kein Image, nur unsere App.

Los, das probieren wir mal. Copy-&-Paste vom vorherigen Multi Stage File und Zeile 13 ändern wir in FROM scratch:

## Deploy
FROM scratch

multistagescratch.Dockerfile

Bauen … testen … Größe?

❯ docker images

REPOSITORY     TAG       IMAGE ID       CREATED         SIZE
hello-docker   scratch   2f28e7ce5f45   3 minutes ago   6.07MB

Na also! Jetzt lohnt sich noch noch ein Blick in die go-Eingeweide, um noch ein paar MB wegzuschneiden. Bis jetzt hatte sich das nicht gelohnt, weil es andere Optimierungen gab.

RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /hello-docker

multistagescratchstripped.Dockerfile

Damit streichen wir alles Debugging-Infos aus der Executable.

  • -s Omit the symbol table and debug information.
  • -w Omit the DWARF symbol table.

Und wieder: Bauen … testen … Größe?

❯ docker images

REPOSITORY     TAG                IMAGE ID       CREATED          SIZE
hello-docker   scratch-stripped   19421556b755   14 seconds ago   4.28MB

Na also! Von fast 1GB zu weit unter 5MB. So kann ich damit leben.

Bottom line

Hier der direkte Vergleich von allem, das wir gerade gebaut haben. Die Größe unser letztes Images (hello-docker:scratch-stripped) ist tatsächlich nur 0,5 Prozent unserer Ausgangsbasis (hello-docker:golang)! Das nenne ich ein erfolgreiches Experiment!

❯ docker images

REPOSITORY     TAG                 IMAGE ID       CREATED              SIZE
hello-docker   scratch-stripped    407358a970c0   26 seconds ago       4.28MB
hello-docker   scratch             3b0ef5f5ebe2   41 seconds ago       6.07MB
hello-docker   distroless-static   32d35185bcf7   43 seconds ago       8.43MB
hello-docker   distroless          49318b4098bd   48 seconds ago       25.3MB
hello-docker   alpine              0d0bcf093cdc   About a minute ago   321MB
hello-docker   golang              84f659d3454f   About a minute ago   947MB

Das Base Image braucht ein paar Experimente, damit man nicht gleich zu fett startet und Multistage ist definitiv ein gute Idee (falls die Sprache es hergibt).

Wenn deine Sprache nicht statically linked ist, musst du leider in Kauf nehmen, dass dein Image fett wird, weil du die ganze Umgebung mit dir herumschleppen darfst und es nicht FROM scratch läuft.

Aus Entwicklersicht mag ich, dass das Ding schnell gebaut und getestet werden kann. Wenn ich das alles irgendwo in in einen Cluster ausliefere, mag ich auch, dass es möglichst schnell geht. Wenn mein Image 1GB groß ist, dauert es sehr viel länger, als wenn das Image 4MB groß ist. Das ist weder Rocket Science, noch irgendwie überraschend.

Also: Je kleiner das Image, desto schneller kann ich es ausliefern, desto schneller kann ich testen, desto schneller kann ich sagen, ob mein code funktioniert.

Wenn ihr das mal selbst ausprobieren möchtet, solltet ihr einen Blick in mein Repo dazu werfen.

Avatar von Torsten Wunderlich

Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert


Für das Handling unseres Newsletters nutzen wir den Dienst HubSpot. Mehr Informationen, insbesondere auch zu Deinem Widerrufsrecht, kannst Du jederzeit unserer Datenschutzerklärung entnehmen.