Drei einfache Schritte, um Docker-Bilder zu reduzieren

Ursprünglicher Autor: Daniele Polencic
  • Übersetzung
Bild

Wenn Sie Docker-Container erstellen möchten, sollten Sie stets versuchen, die Größe der Bilder zu minimieren. Bilder, die dieselben Schichten verwenden und weniger wiegen, werden schneller übertragen und abgelegt.


Aber wie lässt sich die Größe steuern, wenn bei jeder Ausführung des Operators RUNeine neue Ebene erstellt wird? Außerdem brauchen wir noch Zwischenprodukte, bevor Sie das Bild selbst erstellen können ...


Vielleicht wissen Sie, dass die meisten Docker-Dateien ihre eigenen, ziemlich seltsamen Funktionen haben, zum Beispiel:


FROM ubuntu
RUN apt-get update && apt-get install vim

Warum hier &&? Ist es nicht einfacher, zwei Operatoren RUNso zu betreiben ?


FROM ubuntu
RUN apt-get update
RUN apt-get install vim

Ab Version 1.10 Docker, Operatoren COPY, ADDund RUNeine neue Ebene zum Bild hinzufügen. Im vorherigen Beispiel wurden zwei Ebenen anstelle von einer erstellt.


Bild


Schichten wie Git begeht.


Docker-Ebenen behalten die Unterschiede zwischen der vorherigen und der aktuellen Version des Images bei. Und als Git-Commits sind sie praktisch, wenn Sie sie mit anderen Repositorys oder Bildern teilen. Wenn Sie ein Bild aus der Registrierung anfordern, werden nur die fehlenden Ebenen geladen, was die Trennung von Bildern zwischen Containern vereinfacht.


Gleichzeitig findet jedoch jede Schicht statt, und je mehr davon, desto schwerer das endgültige Bild. Git-Repositorys sind in dieser Hinsicht ähnlich: Die Größe des Repositorys wächst mit der Anzahl der Layer, da alle Änderungen zwischen den Commits beibehalten werden müssen. Früher hat es sich bewährt, mehrere Operatoren RUNin einer Zeile zusammenzufassen, wie im ersten Beispiel. Aber jetzt leider nicht.


1. Führen Sie mehrere Ebenen in einem Layer zusammen, indem Sie die Docker-Images schrittweise erstellen


Wenn das Git-Repository wächst, können Sie einfach die gesamte Änderungshistorie auf ein Commit reduzieren und es vergessen. Es stellte sich heraus, dass etwas Ähnliches in Docker implementiert werden kann - durch eine schrittweise Montage.


Erstellen wir einen Node.js-Container.


Beginnen wir mit index.js:


const express = require('express')
const app = express()
app.get('/', (req, res) => res.send('Hello World!'))
app.listen(3000, () => {
 console.log(`Example app listening on port 3000!`)
})

und package.json:


{
 "name": "hello-world",
 "version": "1.0.0",
 "main": "index.js",
 "dependencies": {
   "express": "^4.16.2"
 },
 "scripts": {
   "start": "node index.js"
 }
}

Wir packen den Antrag mit folgendem Inhalt Dockerfile:


FROM node:8
EXPOSE 3000
WORKDIR /app
COPY package.json index.js ./
RUN npm install
CMD ["npm", "start"]

Erstellen Sie ein Bild:


$ docker build -t node-vanilla .

Überprüfen Sie, ob alles funktioniert:


$ docker run -p 3000:3000 -ti --rm --init node-vanilla

Nun können Sie dem Link folgen: http: // localhost: 3000 und dort "Hallo Welt!"


Wir haben Dockerfilejetzt Operatoren COPYund RUN, so dass wir eine Erhöhung von mindestens zwei Schichten gegenüber der ursprünglichen Methode festlegen:


$ docker history node-vanilla
IMAGE          CREATED BY                                      SIZE
075d229d3f48   /bin/sh -c #(nop)  CMD ["npm" "start"]          0B
bc8c3cc813ae   /bin/sh -c npm install                          2.91MB
bac31afb6f42   /bin/sh -c #(nop) COPY multi:3071ddd474429e1…   364B
500a9fbef90e   /bin/sh -c #(nop) WORKDIR /app                  0B
78b28027dfbf   /bin/sh -c #(nop)  EXPOSE 3000                  0B
b87c2ad8344d   /bin/sh -c #(nop)  CMD ["node"]                 0B
<missing>      /bin/sh -c set -ex   && for key in     6A010…   4.17MB
<missing>      /bin/sh -c #(nop)  ENV YARN_VERSION=1.3.2       0B
<missing>      /bin/sh -c ARCH= && dpkgArch="$(dpkg --print…   56.9MB
<missing>      /bin/sh -c #(nop)  ENV NODE_VERSION=8.9.4       0B
<missing>      /bin/sh -c set -ex   && for key in     94AE3…   129kB
<missing>      /bin/sh -c groupadd --gid 1000 node   && use…   335kB
<missing>      /bin/sh -c set -ex;  apt-get update;  apt-ge…   324MB
<missing>      /bin/sh -c apt-get update && apt-get install…   123MB
<missing>      /bin/sh -c set -ex;  if ! command -v gpg > /…   0B
<missing>      /bin/sh -c apt-get update && apt-get install…   44.6MB
<missing>      /bin/sh -c #(nop)  CMD ["bash"]                 0B
<missing>      /bin/sh -c #(nop) ADD file:1dd78a123212328bd…   123MB

Wie wir sehen, ist das endgültige Bild um fünf neue Ebenen gewachsen: eine für jeden unserer Bediener Dockerfile. Versuchen wir jetzt einen schrittweisen Docker-Build. Wir verwenden dasselbe Dockerfile, bestehend aus zwei Teilen:


FROM node:8 as build
WORKDIR /app
COPY package.json index.js ./
RUN npm install
FROM node:8
COPY --from=build /app /
EXPOSE 3000
CMD ["index.js"]

Der erste Teil Dockerfileerstellt drei Ebenen. Dann werden die Ebenen kombiniert und in die zweite und letzte Stufe kopiert. Zwei weitere Ebenen werden über dem Bild hinzugefügt. Als Ergebnis haben wir drei Schichten.


Bild


Lass es uns versuchen. Erstellen Sie zuerst den Container:


$ docker build -t node-multi-stage .

Verlauf überprüfen:


$ docker history node-multi-stage
IMAGE          CREATED BY                                      SIZE
331b81a245b1   /bin/sh -c #(nop)  CMD ["index.js"]             0B
bdfc932314af   /bin/sh -c #(nop)  EXPOSE 3000                  0B
f8992f6c62a6   /bin/sh -c #(nop) COPY dir:e2b57dff89be62f77…   1.62MB
b87c2ad8344d   /bin/sh -c #(nop)  CMD ["node"]                 0B
<missing>      /bin/sh -c set -ex   && for key in     6A010…   4.17MB
<missing>      /bin/sh -c #(nop)  ENV YARN_VERSION=1.3.2       0B
<missing>      /bin/sh -c ARCH= && dpkgArch="$(dpkg --print…   56.9MB
<missing>      /bin/sh -c #(nop)  ENV NODE_VERSION=8.9.4       0B
<missing>      /bin/sh -c set -ex   && for key in     94AE3…   129kB
<missing>      /bin/sh -c groupadd --gid 1000 node   && use…   335kB
<missing>      /bin/sh -c set -ex;  apt-get update;  apt-ge…   324MB
<missing>      /bin/sh -c apt-get update && apt-get install…   123MB
<missing>      /bin/sh -c set -ex;  if ! command -v gpg > /…   0B
<missing>      /bin/sh -c apt-get update && apt-get install…   44.6MB
<missing>      /bin/sh -c #(nop)  CMD ["bash"]                 0B
<missing>      /bin/sh -c #(nop) ADD file:1dd78a123212328bd…   123MB

Prüfen Sie, ob sich die Dateigröße geändert hat:


$ docker images | grep node-
node-multi-stage   331b81a245b1   678MB
node-vanilla       075d229d3f48   679MB

Ja, es ist kleiner geworden, aber noch nicht signifikant.


2. Wir reißen den gesamten Überschuss aus dem Behälter mit hilflos ab


Aktuelles Bild liefert uns Node.js, yarn, npm, bashund viele andere nützliche Binärdateien. Es basiert auch auf Ubuntu. Bei der Bereitstellung erhalten wir ein vollwertiges Betriebssystem mit vielen nützlichen Binärdateien und Dienstprogrammen.


Sie benötigen sie jedoch nicht, um den Container auszuführen. Die einzige notwendige Abhängigkeit ist Node.js.


Docker-Container sollten die Arbeit eines Prozesses bereitstellen und die Mindestmenge an Tools enthalten, die für den Start erforderlich sind. Ein gesamtes Betriebssystem ist dafür nicht erforderlich.


Daher können wir alles außer Node.js daraus entfernen.


Aber wie?


Google hat bereits eine ähnliche Entscheidung getroffen - GoogleCloudPlatform / Distroless .


Die Beschreibung für das Repository lautet wie folgt:


Distroless-Images enthalten nur die Anwendung und die Abhängigkeiten für ihre Arbeit. Es gibt keine Paketmanager, Shells und andere Programme, die normalerweise in der Standard-Linux-Distribution enthalten sind.


Das brauchen Sie!


Führen Sie aus Dockerfile, um ein neues Bild zu erhalten:


FROM node:8 as build
WORKDIR /app
COPY package.json index.js ./
RUN npm install
FROM gcr.io/distroless/nodejs
COPY --from=build /app /
EXPOSE 3000
CMD ["index.js"]

Wir sammeln das Bild wie gewohnt:


$ docker build -t node-distroless .

Die Anwendung sollte normalerweise verdienen. Um dies zu überprüfen, führen Sie den Container aus:


$ docker run -p 3000:3000 -ti --rm --init node-distroless

Und wir gehen zu http: // localhost: 3000 . Ist das Bild ohne zusätzliche Binärdateien einfacher geworden?


$ docker images | grep node-distroless
node-distroless   7b4db3b7f1e5   76.7MB

Wie sonst! Jetzt wiegt es nur noch 76,7 MB, also 600 MB weniger!


Alles ist cool, aber es gibt einen wichtigen Punkt. Wenn der Container ausgeführt wird und Sie ihn überprüfen müssen, können Sie eine Verbindung herstellen mit:


$ docker exec -ti <insert_docker_id> bash

Das bashHerstellen einer Verbindung zu einem laufenden Container ist dem Erstellen einer SSH-Sitzung sehr ähnlich.


Da es sich bei distroless jedoch um eine abgespeckte Version des ursprünglichen Betriebssystems handelt, gibt es weder zusätzliche Binaries noch eine Shell!


Wie stellt man eine Verbindung zu einem laufenden Container her, wenn keine Shell vorhanden ist?


Das Interessanteste ist das.


Dies ist nicht sehr gut, da nur Binärdateien in einem Container ausgeführt werden können. Und die einzige, die ausgeführt werden kann, ist Node.js:


$ docker exec -ti <insert_docker_id> node

In der Tat ist dies ein Plus, denn wenn ein Angreifer plötzlich Zugriff auf den Container erhält, wird er wesentlich weniger Schaden anrichten, als wenn er Zugriff auf die Shell hätte. Mit anderen Worten, kleinere Binärdateien - weniger Gewicht und höhere Sicherheit. Aber um ehrlich zu sein, kostet das komplexere Debugging.


Hier müssten Sie eine Reservierung vornehmen, dass Sie Container nicht in der Produktumgebung verbinden und debuggen sollten. Es ist besser, sich auf korrekt konfigurierte Protokollierungs- und Überwachungssysteme zu verlassen.


Was aber, wenn wir noch ein Debugging benötigen und gleichzeitig möchten, dass das Docker-Image die kleinste Größe hat?


3. Basisbilder mit Alpine reduzieren


Sie können das verzweifelte Alpenbild ersetzen.


Alpine Linux ist eine sicherheitsorientierte, leichte Distribution, die auf musl libc und busybox basiert . Aber lassen Sie uns das Wort nicht glauben, sondern prüfen Sie es.


Ausführen Dockerfilemit node:8-alpine:


FROM node:8 as build
WORKDIR /app
COPY package.json index.js ./
RUN npm install
FROM node:8-alpine
COPY --from=build /app /
EXPOSE 3000
CMD ["npm", "start"]

Erstellen Sie ein Bild:


$ docker build -t node-alpine .

Überprüfen Sie die Größe:


$ docker images | grep node-alpine
node-alpine   aa1f85f8e724   69.7MB

Am Ausgang haben wir 69,7 MB - es ist sogar weniger als ein Distroless-Image.


Prüfen Sie, ob eine Verbindung zu einem laufenden Container möglich ist (im Fall des Distrolles-Images könnten wir dies nicht tun).


Wir starten den Container:


$ docker run -p 3000:3000 -ti --rm --init node-alpine
Example app listening on port 3000!

Und verbinden:


$ docker exec -ti 9d8e97e307d7 bash
OCI runtime exec failed: exec failed: container_linux.go:296: starting container process caused "exec: \"bash\": executable file not found in $PATH": unknown

Nicht erfolgreich Aber vielleicht hat der Container sh'ell ...:


$ docker exec -ti 9d8e97e307d7 sh / #

Großartig! Es ist uns gelungen, eine Verbindung zu dem Container herzustellen, und gleichzeitig ist das Image ebenfalls kleiner. Aber hier war es nicht ohne Nuancen.


Alpine Images basieren auf muslc - einer alternativen Standardbibliothek für C. Während die meisten Linux-Distributionen wie Ubuntu, Debian und CentOS auf glibc basieren. Es wird angenommen, dass diese beiden Bibliotheken dieselbe Schnittstelle für die Arbeit mit dem Kernel bieten.


Sie haben jedoch andere Ziele: Glibc ist das häufigste und schnellste, Muslc nimmt weniger Platz ein und schreibt Sicherheit. Wenn eine Anwendung kompiliert wird, wird sie in der Regel für eine bestimmte C-Bibliothek kompiliert. Wenn Sie sie mit einer anderen Bibliothek verwenden müssen, müssen Sie sie erneut kompilieren.


Mit anderen Worten, das Zusammenstellen von Containern auf alpinen Bildern kann zu unerwarteten Entwicklungen führen, da die darin verwendete Standard-C-Bibliothek unterschiedlich ist. Der Unterschied wird beim Arbeiten mit vorkompilierten Binärdateien, wie z. B. den Erweiterungen Node.js für C ++, auffällig.


Zum Beispiel funktioniert das PhantomJS-Paket nicht auf Alpine.


Welches Grundbild soll man wählen?


Alpines, verzweifeltes oder Vanille-Image - es ist natürlich besser, die Situation zu bestimmen.


Wenn es sich um einen Anstoß handelt und Sicherheit wichtig ist, wäre vielleicht am besten geeignet, sich zu distanzieren.


Jedes zu einem Docker-Image hinzugefügte Binärprogramm stellt ein gewisses Risiko für die Stabilität der gesamten Anwendung dar. Dieses Risiko kann reduziert werden, wenn nur ein Binärprogramm im Container installiert ist.


Wenn ein Angreifer beispielsweise eine Sicherheitsanfälligkeit in einer Anwendung finden kann, die auf der Grundlage eines Image ohne Abbruch ausgeführt wird, kann er keine Shell im Container starten, da diese nicht vorhanden ist!


Wenn Ihnen aus irgendeinem Grund die Größe des Docker-Images extrem wichtig ist, sollten Sie sich unbedingt die auf Alpine basierenden Bilder ansehen.


Sie sind wirklich klein, aber wahr, auf Kosten der Kompatibilität. Alpine verwendet eine etwas andere Standard-C-Muslc-Bibliothek, so dass manchmal Probleme auftauchen. Beispiele finden Sie unter den Links: https://github.com/grpc/grpc/issues/8528 und https://github.com/grpc/grpc/issues/6126 .


Vanillebilder sind ideal zum Testen und Entwickeln.


Ja, sie sind groß, aber sie ähneln einer vollwertigen Maschine, auf der Ubuntu installiert ist. Darüber hinaus sind alle Binärdateien im Betriebssystem verfügbar.


Fassen wir die Größe der empfangenen Docker-Bilder zusammen:


node:8681 MB
node:8mit schrittweiser Montage 678
gcr.io/distroless/nodejsMB
node:8-alpine76,7 MB 69,7 MB


Übersetzung vom Übersetzer


Lesen Sie weitere Artikel in unserem Blog:


Stateful-Backups in Kubernetes


Sichern Sie eine große Anzahl heterogener Webprojekte


Telegramm-Bot für Redmine. Wie Sie das Leben von sich und den Menschen vereinfachen können


Jetzt auch beliebt: