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.
Schreibe einen Kommentar