Bevor eine Funktion in die Produktion gelangt, muss sie in unserer Zeit der komplexen Orchestratoren und CI/CD einen langen Weg vom Commit über die Tests bis zur Auslieferung zurücklegen. Früher konnte man neue Dateien per FTP hochladen (das macht heutzutage niemand mehr, oder?), und der „Deployment“-Prozess dauerte Sekunden. Jetzt müssen wir einen Merge-Request erstellen und eine beträchtliche Zeit warten, bis die Funktion die Nutzer erreicht.
Ein Teil dieses Weges besteht darin, das Docker-Image zu erstellen. Manchmal dauert der Build Minuten, manchmal sogar Dutzende Minuten, was man schwer als normal bezeichnen kann. In diesem Artikel betrachten wir eine einfache Anwendung, die wir in ein Image verpacken, wenden einige Methoden zur Beschleunigung des Builds an und schauen uns die Feinheiten der Funktionsweise dieser Methoden an.

Wir haben gute Erfahrungen in der Erstellung und Wartung von Nachrichtenwebseiten: , , , … Vor nicht allzu langer Zeit haben wir unser Portfolio erweitert, indem wir die Website gelauncht haben. Und während wir schnell neue Funktionen entwickelten und alte Bugs beheben, wurde das langsame Deployment zu einem großen Problem.
Wir führen das Deployment auf GitLab durch. Wir erstellen Images, pushen sie ins GitLab Registry und rollen sie in der Produktion aus. In dieser Liste ist der zeitaufwendigste Teil die Erstellung der Images. Zum Beispiel: Ohne Optimierung dauerte jeder Build des Backends 14 Minuten.
![]()
Schließlich wurde klar, dass wir so nicht weitermachen konnten, und wir setzten uns hin, um herauszufinden, warum die Images so lange zum Bauen benötigen. Letztendlich gelang es uns, die Bauzeit auf 30 Sekunden zu reduzieren!
![]()
Für diesen Artikel, um nicht an die Umgebung von Reminder gebunden zu sein, betrachten wir ein Beispiel für den Build einer leeren Anwendung auf Angular. Lassen Sie uns also unsere Anwendung erstellen:
ng n appFügen wir PWA hinzu (wir sind schließlich fortschrittlich):
ng add @angular/pwa --project appWährend Millionen von npm-Paketen heruntergeladen werden, lassen Sie uns verstehen, wie ein Docker-Image funktioniert. Docker ermöglicht es, Anwendungen zu verpacken und sie in einer isolierten Umgebung auszuführen, die Container genannt wird. Dank der Isolierung können viele Container gleichzeitig auf einem Server ausgeführt werden. Container sind deutlich leichter als virtuelle Maschinen, da sie direkt auf dem Betriebssystemkern ausgeführt werden. Um einen Container mit unserer Anwendung zu starten, müssen wir zunächst ein Image erstellen, in dem wir alles verpacken, was zur Ausführung unserer Anwendung erforderlich ist. Im Grunde ist ein Image ein Abbild des Dateisystems. Nehmen wir zum Beispiel folgende Dockerfile:
FROM node:12.16.2
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build --prodDockerfile ist eine Reihe von Anweisungen; durch das Ausführen jeder dieser Anweisungen wird Docker Änderungen im Dateisystem speichern und sie auf die vorherigen anwenden. Jeder Befehl erzeugt seine eigene Schicht. Das fertige Image ist also eine Kombination dieser Schichten.
Wichtige Informationen: Jede Schicht kann von Docker gecacht werden. Wenn sich seit dem letzten Build nichts geändert hat, wird Docker anstelle der Ausführung des Befehls die bereits vorhandene Schicht verwenden. Da der Hauptzuwachs in der Build-Geschwindigkeit durch die Nutzung des Caches erreicht wird, werden wir bei den Geschwindigkeitsmessungen insbesondere auf den Build des Images mit dem vorhandenen Cache achten. Also folgendermassen:
- Löschen Sie die Images lokal, damit vorherige Läufe den Test nicht beeinflussen.
docker rmi $(docker images -q) - Führen Sie den Build zum ersten Mal aus.
time docker build -t app . - Ändern Sie die Datei src/index.html – simulate the work of a programmer.
- Führen Sie den Build ein zweites Mal aus.
time docker build -t app .
Wenn die Umgebung für den Build von Images richtig konfiguriert ist (darüber weiter unten mehr), wird Docker beim Start des Builds bereits eine Menge Caches an Bord haben. Unsere Aufgabe ist es, den Cache so zu nutzen, dass der Build möglichst schnell abläuft. Da wir davon ausgehen, dass der Start des Builds ohne Cache nur einmal – beim allerersten Mal – erfolgt, können wir ignorieren, wie langsam dieser erste Versuch war. In den Tests ist der zweite Build wichtig, wenn die Caches bereits vorbereitet sind und wir bereit sind, unseren Kuchen zu backen. Dennoch werden einige Tipps auch den ersten Build beeinflussen.
Legen wir das oben beschriebene Dockerfile in den Projektordner und starten den Build. Alle angeführten Listings wurden zur besseren Lesbarkeit verkürzt.
$ time docker build -t app .
Sending build context to Docker daemon 409MB
Step 1/5 : FROM node:12.16.2
Status: Neuere Image für node:12.16.2 heruntergeladen
Step 2/5 : WORKDIR /app
Step 3/5 : COPY . .
Step 4/5 : RUN npm ci
1357 Pakete in 22.47s hinzugefügt
Step 5/5 : RUN npm run build --prod
Datum: 2020-04-16T19:20:09.664Z - Hash: fffa0fddaa3425c55dd3 - Zeit: 37581ms
Erfolgreich gebaut c8c279335f46
Erfolgreich getaggt app:latest
real 5m4.541s
user 0m0.000s
sys 0m0.000sÄndern Sie den Inhalt von src/index.html und führen Sie es ein zweites Mal aus.
$ time docker build -t app .
Sending build context to Docker daemon 409MB
Step 1/5 : FROM node:12.16.2
Step 2/5 : WORKDIR /app
---> Aus dem Cache verwenden
Step 3/5 : COPY . .
Step 4/5 : RUN npm ci
1357 Pakete in 22.47s hinzugefügt
Step 5/5 : RUN npm run build --prod
Datum: 2020-04-16T19:26:26.587Z - Hash: fffa0fddaa3425c55dd3 - Zeit: 37902ms
Erfolgreich gebaut 79f335df92d3
Erfolgreich getaggt app:latest
real 3m33.262s
user 0m0.000s
sys 0m0.000sUm zu sehen, ob wir ein Image erhalten haben, führen wir den Befehl aus docker images:
REPOSITORY TAG IMAGE ID CREATED SIZE
app latest 79f335df92d3 Vor etwa einer Minute 1.74GBVor dem Bau nimmt Docker alle Dateien im aktuellen Kontext und sendet sie an seinen Daemon Build-Kontext an den Docker-Daemon senden 409MB. Der Build-Kontext wird als letztes Argument des Befehls build angegeben. In unserem Fall ist das das aktuelle Verzeichnis – „.“ – und Docker zieht alles, was wir in diesem Ordner haben. 409 MB sind viel: Lassen Sie uns überlegen, wie wir das beheben können.
Wir reduzieren den Kontext
Um den Kontext zu verringern, gibt es zwei Möglichkeiten. Entweder wir legen alle Dateien, die für den Build erforderlich sind, in einen separaten Ordner und geben Docker genau auf diesen Ordner an. Das ist nicht immer bequem, daher gibt es die Möglichkeit, Ausnahmen anzugeben: was nicht in den Kontext gezogen werden soll. Dafür legen wir eine .dockerignore-Datei in das Projekt und geben an, was nicht für den Build benötigt wird:
.git
/node_modulesund führen den Build erneut aus:
$ time docker build -t app .
Sending build context to Docker daemon 607.2kB
Step 1/5 : FROM node:12.16.2
Step 2/5 : WORKDIR /app
---> Using cache
Step 3/5 : COPY . .
Step 4/5 : RUN npm ci
added 1357 packages in 22.47s
Step 5/5 : RUN npm run build --prod
Date: 2020-04-16T19:33:54.338Z - Hash: fffa0fddaa3425c55dd3 - Time: 37313ms
Successfully built 4942f010792a
Successfully tagged app:latest
real 1m47.763s
user 0m0.000s
sys 0m0.000s607.2 KB – deutlich besser als 409 MB. Außerdem haben wir die Bildgröße von 1.74 auf 1.38 GB reduziert:
REPOSITORY TAG IMAGE ID CREATED SIZE
app latest 4942f010792a vor 3 Minuten 1.38GBLassen Sie uns versuchen, die Bildgröße weiter zu reduzieren.
Wir verwenden Alpine
Eine weitere Möglichkeit, Größe zu sparen, besteht darin, ein kleines Basisbild zu verwenden. Das Basisbild ist das Bild, auf dessen Grundlage unser Bild erstellt wird. Die untere Schicht wird im Dockerfile angegeben. In unserem Fall verwenden wir ein Bild auf Basis von Ubuntu, auf dem bereits nodejs installiert ist. Und es wiegt … FROM $ docker images -a | grep node node 12.16.2 406aa3abbc6c vor 17 Minuten 916MB
… fast ein Gigabyte. Das Volumen kann erheblich verringert werden, indem ein Bild auf Basis von Alpine Linux verwendet wird. Alpine ist ein sehr kleines Linux. Das Docker-Image für nodejs auf Basis von Alpine wiegt nur 88.5 MB. Lassen Sie uns also unser schwerfälliges Bild ersetzen:FROM node:12.16.2-alpine3.11 RUN apk --no-cache --update --virtual build-dependencies add python make g++ WORKDIR /app COPY . . RUN npm ci RUN npm run build --prod
Wir mussten einige Dinge installieren, die für den Build der Anwendung erforderlich sind. Ja, Angular lässt sich nicht ohne Python bauen ¯(°_o)\/¯Aber dafür hat sich die Bildgröße um 150 MB reduziert:
REPOSITORY TAG IMAGE ID CREATED SIZE app latest aa031edc315a vor 22 Minuten 761MB
Wir gehen noch weiter.Multistage-Build
Nicht alles, was im Bild ist, wird in der Produktion benötigt.
docker run app ls -lah
$ docker run app ls -lah
total 576K
drwxr-xr-x 1 root root 4.0K Apr 16 19:54 .
drwxr-xr-x 1 root root 4.0K Apr 16 20:00 ..
-rwxr-xr-x 1 root root 19 Apr 17 2020 .dockerignore
-rwxr-xr-x 1 root root 246 Apr 17 2020 .editorconfig
-rwxr-xr-x 1 root root 631 Apr 17 2020 .gitignore
-rwxr-xr-x 1 root root 181 Apr 17 2020 Dockerfile
-rwxr-xr-x 1 root root 1020 Apr 17 2020 README.md
-rwxr-xr-x 1 root root 3.6K Apr 17 2020 angular.json
-rwxr-xr-x 1 root root 429 Apr 17 2020 browserslist
drwxr-xr-x 3 root root 4.0K Apr 16 19:54 dist
drwxr-xr-x 3 root root 4.0K Apr 17 2020 e2e
-rwxr-xr-x 1 root root 1015 Apr 17 2020 karma.conf.js
-rwxr-xr-x 1 root root 620 Apr 17 2020 ngsw-config.json
drwxr-xr-x 1 root root 4.0K Apr 16 19:54 node_modules
-rwxr-xr-x 1 root root 494.9K Apr 17 2020 package-lock.json
-rwxr-xr-x 1 root root 1.3K Apr 17 2020 package.json
drwxr-xr-x 5 root root 4.0K Apr 17 2020 src
-rwxr-xr-x 1 root root 210 Apr 17 2020 tsconfig.app.json
-rwxr-xr-x 1 root root 489 Apr 17 2020 tsconfig.json
-rwxr-xr-x 1 root root 270 Apr 17 2020 tsconfig.spec.json
-rwxr-xr-x 1 root root 1.9K Apr 17 2020 tslint.jsonMit Hilfe von docker run app ls -lah Wir haben einen Container auf Grundlage unseres Images gestartet App und darin einen Befehl ausgeführt ls -lah, wonach der Container seine Arbeit beendet hat.
Im Prod benötigen wir nur den Ordner dist. Die Dateien müssen irgendwie nach außen bereitgestellt werden. Man kann einen HTTP-Server mit Node.js starten. Aber wir machen es einfacher. Ratet ein russisches Wort mit vier Buchstaben „ы“. Richtig! Ынжыныксы. Wir nehmen ein Image mit Nginx, legen darin den Ordner dist und eine kleine Konfiguration ab:
server {
listen 80 default_server;
server_name localhost;
charset utf-8;
root /app/dist;
location / {
try_files $uri $uri/ /index.html;
}
}Das alles können wir mit einem Multi-Stage-Build umsetzen. Ändern wir unser Dockerfile:
FROM node:12.16.2-alpine3.11 as builder
RUN apk --no-cache --update --virtual build-dependencies add
python
make
g++
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build --prod
FROM nginx:1.17.10-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx/static.conf /etc/nginx/conf.d
COPY --from=builder /app/dist/app .Jetzt haben wir zwei Anweisungen FROM im Dockerfile, jede davon startet ihre eigene Build-Phase. Die erste haben wir genannt builder, und ab dem letzten FROM wird unser endgültiges Image erstellt. Im letzten Schritt kopieren wir das Artefakt unseres Builds aus der vorherigen Phase in das endgültige Image mit Nginx. Die Größe des Images hat sich erheblich verringert:
REPOSITORY TAG IMAGE ID CREATED SIZE
app latest 2c6c5da07802 vor 29 Minuten 36MBLass uns den Container mit unserem Image starten und sicherstellen, dass alles funktioniert:
docker run -p8080:80 appMit der Option -p8080:80 haben wir den Port 8080 auf unserer Host-Maschine auf Port 80 im Container weitergeleitet, wo Nginx läuft. Öffnen wir im Browser und sehen unsere Anwendung. Es funktioniert alles!

Die Reduzierung der Image-Größe von 1,74 GB auf 36 MB verkürzt erheblich die Bereitstellungszeit Ihrer Anwendung im Prod. Aber lassen Sie uns zu der Build-Zeit zurückkehren.
$ time docker build -t app .
Sending build context to Docker daemon 608.8kB
Step 1/11 : FROM node:12.16.2-alpine3.11 as builder
Step 2/11 : RUN apk --no-cache --update --virtual build-dependencies add python make g++
---> Using cache
Step 3/11 : WORKDIR /app
---> Using cache
Step 4/11 : COPY . .
Step 5/11 : RUN npm ci
added 1357 packages in 47.338s
Step 6/11 : RUN npm run build --prod
Date: 2020-04-16T21:16:03.899Z - Hash: fffa0fddaa3425c55dd3 - Time: 39948ms
---> 27f1479221e4
Step 7/11 : FROM nginx:stable-alpine
Step 8/11 : WORKDIR /app
---> Using cache
Step 9/11 : RUN rm /etc/nginx/conf.d/default.conf
---> Using cache
Step 10/11 : COPY nginx/static.conf /etc/nginx/conf.d
---> Using cache
Step 11/11 : COPY --from=builder /app/dist/app .
Successfully built d201471c91ad
Successfully tagged app:latest
real 2m17.700s
user 0m0.000s
sys 0m0.000sWir ändern die Reihenfolge der Schichten
Die ersten drei Schritte wurden bei uns zwischengespeichert (Hinweis Using cache). Im vierten Schritt werden alle Projektdateien kopiert und im fünften Schritt werden die Abhängigkeiten installiert RUN npm ci — ganze 47,338s. Warum sollten wir jedes Mal die Abhängigkeiten neu installieren, wenn sie sich sehr selten ändern? Lassen Sie uns herausfinden, warum sie nicht im Cache gespeichert wurden. Das liegt daran, dass Docker die Schichten schichtweise überprüft, um festzustellen, ob sich der Befehl oder die damit verbundenen Dateien geändert haben. Im vierten Schritt kopieren wir alle Dateien unseres Projekts, und unter ihnen gibt es natürlich Änderungen, weshalb Docker nicht nur diese Schicht nicht aus dem Cache nimmt, sondern auch alle nachfolgenden! Lassen Sie uns einige kleine Änderungen an der Docker-Datei vornehmen.
FROM node:12.16.2-alpine3.11 as builder
RUN apk --no-cache --update --virtual build-dependencies add
python
make
g++
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build --prod
FROM nginx:1.17.10-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx/static.conf /etc/nginx/conf.d
COPY --from=builder /app/dist/app .Zunächst werden package.json und package-lock.json kopiert, dann die Abhängigkeiten installiert und erst danach wird das gesamte Projekt kopiert. Das Ergebnis:
$ time docker build -t app .
Sending build context to Docker daemon 608.8kB
Step 1/12 : FROM node:12.16.2-alpine3.11 as builder
Step 2/12 : RUN apk --no-cache --update --virtual build-dependencies add python make g++
---> Using cache
Step 3/12 : WORKDIR /app
---> Using cache
Step 4/12 : COPY package*.json ./
---> Using cache
Step 5/12 : RUN npm ci
---> Using cache
Step 6/12 : COPY . .
Step 7/12 : RUN npm run build --prod
Date: 2020-04-16T21:29:44.770Z - Hash: fffa0fddaa3425c55dd3 - Time: 38287ms
---> 1b9448c73558
Step 8/12 : FROM nginx:stable-alpine
Step 9/12 : WORKDIR /app
---> Using cache
Step 10/12 : RUN rm /etc/nginx/conf.d/default.conf
---> Using cache
Step 11/12 : COPY nginx/static.conf /etc/nginx/conf.d
---> Using cache
Step 12/12 : COPY --from=builder /app/dist/app .
Successfully built a44dd7c217c3
Successfully tagged app:latest
real 0m46.497s
user 0m0.000s
sys 0m0.000s46 Sekunden statt 3 Minuten — das ist deutlich besser! Die richtige Reihenfolge der Schichten ist wichtig: Zuerst kopieren wir das, was sich nicht ändert, dann das, was sich selten ändert, und zuletzt das, was häufig geändert wird.
Nun ein paar Worte über die Erstellung von Images in CI/CD-Systemen.
Verwendung vorheriger Images zum Caching
Wenn wir eine SaaS-Lösung für den Build verwenden, kann der lokale Docker-Cache sauber und frisch sein. Damit Docker aus dem Cache gebaute Schichten entnehmen kann, geben Sie ihm das vorherige erstellte Image.
Betrachten wir als Beispiel den Build unserer Anwendung in GitHub Actions. Wir verwenden folgende Konfiguration
on:
push:
branches:
- master
name: Test docker build
jobs:
deploy:
name: Build
runs-on: ubuntu-latest
env:
IMAGE_NAME: docker.pkg.github.com/${{ github.repository }}/app
IMAGE_TAG: ${{ github.sha }}
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Login to GitHub Packages
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
docker login docker.pkg.github.com -u $GITHUB_ACTOR -p $TOKEN
- name: Build
run: |
docker build
-t $IMAGE_NAME:$IMAGE_TAG
-t $IMAGE_NAME:latest
.
- name: Push image to GitHub Packages
run: |
docker push $IMAGE_NAME:latest
docker push $IMAGE_NAME:$IMAGE_TAG
- name: Logout
run: |
docker logout docker.pkg.github.comDas Abbild wird in GitHub Packages in zwei Minuten und 20 Sekunden erstellt und gepusht:

Jetzt ändern wir den Build so, dass der Cache auf der Grundlage der vorherigen erstellten Abbilder verwendet wird:
on:
push:
branches:
- master
name: Test docker build
jobs:
deploy:
name: Build
runs-on: ubuntu-latest
env:
IMAGE_NAME: docker.pkg.github.com/${{ github.repository }}/app
IMAGE_TAG: ${{ github.sha }}
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Login to GitHub Packages
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
docker login docker.pkg.github.com -u $GITHUB_ACTOR -p $TOKEN
- name: Pull latest images
run: |
docker pull $IMAGE_NAME:latest || true
docker pull $IMAGE_NAME-builder-stage:latest || true
- name: Images list
run: |
docker images
- name: Build
run: |
docker build
--target builder
--cache-from $IMAGE_NAME-builder-stage:latest
-t $IMAGE_NAME-builder-stage
.
docker build
--cache-from $IMAGE_NAME-builder-stage:latest
--cache-from $IMAGE_NAME:latest
-t $IMAGE_NAME:$IMAGE_TAG
-t $IMAGE_NAME:latest
.
- name: Push image to GitHub Packages
run: |
docker push $IMAGE_NAME-builder-stage:latest
docker push $IMAGE_NAME:latest
docker push $IMAGE_NAME:$IMAGE_TAG
- name: Logout
run: |
docker logout docker.pkg.github.comZunächst muss erklärt werden, warum zwei Befehle ausgeführt werden. buildDer Grund ist, dass in einer Multi-Stage-Build der resultierende Image ein Satz von Schichten aus der letzten Stage sein wird. Dabei gelangen die Schichten aus vorherigen Stufen nicht in das Abbild. Daher kann Docker beim Verwenden des finalen Abbilds aus dem vorherigen Build keine fertigen Schichten für den Build des Node.js-Images (Stage Builder) finden. Um dieses Problem zu lösen, wird ein Zwischenabild erstellt, $IMAGE_NAME-builder-stage und in GitHub Packages hochgeladen, sodass es in einem nachfolgenden Build als Cache-Quelle verwendet werden kann.

Die Gesamtbauzeit wurde auf anderthalb Minuten verkürzt. Eine halbe Minute wird für das Abrufen der vorherigen Abbilder benötigt.
Vorab generierte Abbilder
Ein weiterer Weg, das Problem des sauberen Docker-Caches zu lösen, besteht darin, Teile der Schichten in eine andere Docker-Datei auszulagern, sie separat zu erstellen, im Container-Registry zu pushen und als Elternteil zu verwenden.
Wir erstellen unser eigenes Node.js-Image zum Bauen einer Angular-Anwendung. Im Projekt erstellen wir Dockerfile.node
FROM node:12.16.2-alpine3.11
RUN apk --no-cache --update --virtual build-dependencies add
python
make
g++Wir erstellen und pushen ein öffentliches Image in Docker Hub:
docker build -t exsmund/node-for-angular -f Dockerfile.node .
docker push exsmund/node-for-angular:latestJetzt verwenden wir im Haupt-Dockerfile das fertige Image:
FROM exsmund/node-for-angular:latest as builder
...In unserem Beispiel hat sich die Build-Zeit nicht verringert, aber vordefinierte Images können nützlich sein, wenn Sie viele Projekte haben und in jedem von ihnen die gleichen Abhängigkeiten installieren müssen.

Wir haben mehrere Methoden zur Beschleunigung des Docker-Image-Baus betrachtet. Wenn Sie möchten, dass das Deployment schnell erfolgt, versuchen Sie in Ihrem Projekt anzuwenden:
- Reduzierung des Kontexts;
- Verwendung kleinerer Basis-Images;
- Multi-Stage-Bau;
- Änderung der Anweisungsreihenfolge in Dockerfile, um den Cache effizient zu nutzen;
- Cache-Einstellungen in CI/CD-Systemen;
- Vorab-Erstellung von Images.
Ich hoffe, dass anhand des Beispiels klarer wird, wie Docker funktioniert, und dass Sie Ihr Deployment optimal konfigurieren können. Um mit den Beispielen aus dem Artikel zu experimentieren, wurde ein Repository erstellt. .
Quelle: habr.com
