Minimale Images mit Docker + Go
Von Julian Sauer
- 4 Minuten - 830 WörterInhaltsverzeichnis
Ausgangslage
Wir haben ein tolles Programm in Go geschrieben und wollen es zum Beispiel durch eine CI-Pipeline bei neuen Commits kompilieren, in ein Docker Image verpacken und das für unsere Anwender bereitstellen. Als Beispiel wird eine Hello World-Anwendung herhalten und soll bei unseren Anwendern folgendes Ergebnis liefern:
▶ docker run helloworld
Hello World!
Wir starten mit einem Image, das unseren Quellcode von Github auscheckt und kompiliert:
FROM alpine:3.13.6
RUN apk add go git
RUN git clone https://gist.github.com/e74367f8716bf53e188de4be30b4d3f7.git /helloworld
RUN cd /helloworld \
&& go build -o /go/bin/helloworld -ldflags="-w -s" helloworld.go
CMD ["/go/bin/helloworld"]
Wenn wir es mit docker build -t helloworld .
bauen und mit docker run helloworld
ausführen, liefert es die gewünschte Ausgabe. Mit einem halben Gigabyte ist es aber noch recht groß:
▶ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
helloworld latest 3998bcecdd81 2 seconds ago 458MB
alpine 3.13.6 12adea71a33b 7 weeks ago 5.61MB
Überflüssige Abhängigkeiten entfernen
Eigentlich interessiert uns nur die kompilierte Datei /go/bin/helloworld
, welche ungefähr 1MB groß ist. Demgegenüber werden die installierten Pakete und unser Quellcode zur Laufzeit nicht mehr benötigt. Um also keine unnötigen Dateien mit auszuliefern, sollten wir sie aus dem finalen Image entfernen. Wir müssen dabei allerdings aufpassen, denn Docker verwendet Copy-on-write. Folgendes Image ist nicht wie eventuell erwartet kleiner:
FROM alpine:3.13.6
RUN apk add go git
RUN git clone https://gist.github.com/e74367f8716bf53e188de4be30b4d3f7.git /helloworld
RUN cd /helloworld \
&& go build -o /go/bin/helloworld -ldflags="-w -s" helloworld.go
RUN apk del go git
CMD ["/go/bin/helloworld"]
▶ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
helloworld latest 9d1e50b95f4d 2 seconds ago 458MB
golang 1.17.2 9f8b89ee4475 7 days ago 941MB
Docker legt ein Layer pro Befehl (RUN, ADD, COPY etc.) in unserem Image an. Wird dabei eine Datei einer vorherigen Schicht verändert, wird sie zuvor kopiert. Die Änderungen werden anschließend auf der Kopie durchgeführt. In unserem Fall kopiert Docker für uns also die Installationen der Pakete Go und Git sowie unseren Quellcode, nur um diese Dateien dann zu löschen.
Weiteres Beispiel/Anekdote aus einem früheren Projekt: Wir hatten mehrere Gigabyte an Skripten in einem Image, welchen die Unix-Rechte fehlten, um ausgeführt zu werden. Deshalb wurden diese beim Bauen angepasst. Im folgenden Beispiel wird ein 5MB großes Skript erzeugt und in einem zweiten Schritt ausführbar gemacht:
FROM alpine:3.13.6
RUN truncate -s 5M foo.sh
RUN chmod +x foo.sh
# CMD ["foo.sh"]
▶ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
foo-script latest 088b9a646b72 2 seconds ago 16.1MB
alpine 3.13.6 12adea71a33b 7 weeks ago 5.61MB
▶ docker history foo-script
IMAGE CREATED CREATED BY SIZE COMMENT
5fd9417c1226 7 seconds ago RUN /bin/sh -c chmod +x foo.sh # buildkit 5.24MB buildkit.dockerfile.v0
<missing> 7 seconds ago RUN /bin/sh -c truncate -s 5M foo.sh # build… 5.24MB buildkit.dockerfile.v0
<missing> 7 weeks ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B
<missing> 7 weeks ago /bin/sh -c #(nop) ADD file:ecdfb91a737d6c292… 5.61MB
Durch den Befehl docker history <image>
sehen wir die einzelnen Schichten und ihre Größe. Auffälligerweise sind die letzten beiden Schichten gleich groß. Wir können schlussfolgern:
5,61MB Base Image
+ 5MB Skript
+ 5MB ausführbare Skript-Kopie
≈ 16MB finales Image
…oder eben 5GB in unserem damaligen Projekt, die wir regelmäßig ausliefern mussten.
Als schnellen Workaround können Images gesquasht werden: Alle Schichten werden verschmolzen und nur die neuste Version einer Datei wird behalten. Stand Oktober 2021 ist die Flag --squash
aber noch ein experimentelles Features.
Alternativ können wir auch einfach den Best Practices folgen und unser Dockerfile so anpassen, dass Initialisieren, Kompilieren und Aufräumen in einem Schritt erfolgt:
FROM alpine:3.13.6
RUN apk add go git \
&& git clone https://gist.github.com/e74367f8716bf53e188de4be30b4d3f7.git /helloworld \
&& cd /helloworld \
&& go build -o /go/bin/helloworld -ldflags="-w -s" helloworld.go \
&& apk del go git \
&& rm -rf /helloworld
CMD ["/go/bin/helloworld"]
Wir nähern uns der Größe unseres Base Images:
▶ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
helloworld latest ca6c3669eb4b 4 seconds ago 9.24MB
alpine 3.13.6 12adea71a33b 7 weeks ago 5.61MB
Allerdings enthält unser Image immer noch einiges, was wir nicht benötigen. Zum Beispiel brauchen wir den Paketmanager (hier apk) in unserem fertigen Image nicht mehr. Wir wollen ja nur unser Programm ausführen.
Multistage Build
Als Lösung verwenden wir zwei getrennte Images. Zum Kompilieren benutzen wir mit golang:1.17.2
ein Image, das bereits alle dafür nötigen Abhängigkeiten besitzt. Mit knapp 1GB ist es zwar recht groß, für die spätere Ausführung ist das jedoch egal. Denn dafür verwenden wir scratch
, ein 0MB großes Image, in das wir unsere ausführbare Datei kopieren:
FROM golang:1.17.2 as build
RUN git clone https://gist.github.com/e74367f8716bf53e188de4be30b4d3f7.git /helloworld
RUN cd /helloworld \
&& go build -o /go/bin/helloworld -ldflags="-w -s" helloworld.go
FROM scratch
COPY --from=build /go/bin/helloworld /
CMD ["/helloworld"]
In den ersten paar Zeilen wird unser Quellcode heruntergeladen und kompiliert. Zu beachten ist der Name build
, den wir in Zeile 1 vergeben. Durch ihn können wir mit dem COPY
-Befehl unsere ausführbare Datei aus unserem ersten Image in das zweite kopieren. Das erste Image dient als Zwischenschritt, der nicht im finalen Image auftaucht:
▶ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
helloworld latest 40ec972260ed 2 seconds ago 1.2MB
- Docker
- Container
- Virtualisierung
- DevOps
- Automatisierung
- Microservices
- Dockerfile
- Speicher
- Speicherreduzierung
- Imagegröße
- Dateigröße