Bevor ein neues Feature live geht, muss es in der heutigen Welt komplexer Orchestratoren und CI/CD einen langen Weg vom Commit über Tests bis zur Bereitstellung zurücklegen. Früher konnte man einfach neue Dateien per FTP hochladen (so macht das doch heute niemand mehr, oder?), und der "Deployment"-Prozess dauerte nur Sekunden. Jetzt hingegen muss man einen Merge-Request erstellen und oftmals lange warten, bis das Feature die Nutzer erreicht.
Ein Teil dieses Prozesses ist der Aufbau des Docker-Images. Manchmal dauert der Aufbau Minuten, manchmal sogar Dutzende von Minuten, was schwerlich als normal bezeichnet werden kann. In diesem Artikel nehmen wir eine einfache Anwendung, packen sie in ein Image, wenden einige Methoden an, um den Aufbau zu beschleunigen, und betrachten die Feinheiten der Funktionsweise dieser Methoden.

Wir haben gute Erfahrungen in der Erstellung und Unterstützung von Medienwebsites: , , , Vor nicht allzu langer Zeit haben wir unser Portfolio erweitert, indem wir die Website in Produktion gebracht haben. Und während wir schnell neue Features entwickelten und alte Bugs beheben, wurde das langsame Deployment zu einem großen Problem.
Das Deployment erfolgt über GitLab. Wir erstellen Images, pushen sie in das GitLab Registry und setzen sie in der Produktion ein. Der zeitaufwändigste Schritt in dieser Liste ist die Erstellung der Images. Zum Beispiel dauerte ohne Optimierung jede Backend-Bauzeit 14 Minuten.
![]()
Schließlich wurde klar, dass es so nicht weitergehen kann, und wir haben uns zusammengesetzt, um herauszufinden, warum die Images so lange zum Erstellen benötigen. Am Ende 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 Aufbau einer leeren Angular-Anwendung. Also, wir erstellen unsere Anwendung:
ng n appWir fügen PWA hinzu (wir sind schließlich progressiv):
ng add @angular/pwa --project appWährend Millionen von npm-Paketen heruntergeladen werden, lassen Sie uns klären, wie ein Docker-Image funktioniert. Docker ermöglicht es, Anwendungen zu verpacken und in einer isolierten Umgebung, dem sogenannten Container, auszuführen. Dank der Isolation können viele Container gleichzeitig auf einem Server betrieben werden. Container sind erheblich leichter als virtuelle Maschinen, da sie direkt auf dem Systemkern laufen. Um einen Container für unsere Anwendung zu starten, müssen wir zunächst ein Image erstellen, in dem wir alles verpacken, was für den Betrieb unserer Anwendung erforderlich ist. Im Grunde genommen ist ein Image ein Abbild des Dateisystems. Nehmen wir beispielsweise die folgende Dockerfile:
FROM node:12.16.2
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build --prodDie Dockerfile ist eine Sammlung von Anweisungen; bei der Ausführung jeder einzelnen speichert Docker Änderungen im Dateisystem und wendet diese auf die vorherigen an. Jeder Befehl erzeugt eine eigene Schicht. Das fertige Image ist ein Zusammenschluss dieser Schichten.
Wichtige Informationen: Jedes Docker-Layer kann gecached werden. Wenn sich seit dem letzten Build nichts verändert hat, wird Docker anstelle der Ausführung des Befehls das bereits vorhandene Layer verwenden. Der Hauptgewinn an Build-Geschwindigkeit wird durch die Nutzung des Caches erzielt, daher werden wir bei der Messung der Build-Zeiten insbesondere die Erstellung des Images mit vorhandenem Cache berücksichtigen. Also, Schritt für Schritt:
- Wir entfernen die Images lokal, damit frühere Ausführungen die Tests nicht beeinflussen.
docker rmi $(docker images -q) - Wir starten den Build zum ersten Mal.
time docker build -t app . - Wir ändern die Datei src/index.html – simulieren die Arbeit eines Programmierers.
- Wir starten den Build ein zweites Mal.
time docker build -t app .
Wenn die Umgebung zum Erstellen von Images richtig konfiguriert ist (darüber später mehr), hat Docker beim Start des Builds bereits eine Menge Caches im Gepäck. Unsere Aufgabe ist es, zu lernen, wie wir den Cache so nutzen, dass der Build möglichst schnell abläuft. Da wir davon ausgehen, dass der erste Build ohne Cache nur einmal durchgeführt wird — beim allerersten Mal — können wir ignorieren, wie langsam dieser erste Durchlauf war. Für unsere Tests ist der zweite Durchlauf des Builds wichtig, wenn die Caches bereits vorgewärmt sind und wir bereit sind, unseren Kuchen zu backen. Dennoch wird sich der eine oder andere Tipp auch auf den ersten Build auswirken.
Legen wir die oben beschriebene Dockerfile in den Projektordner und starten den Build. Alle angegebenen Listings sind zur besseren Lesbarkeit gekürzt.
$ time docker build -t app .
Sending build context to Docker daemon 409MB
Step 1/5 : FROM node:12.16.2
Status: Downloaded newer image for node:12.16.2
Step 2/5 : WORKDIR /app
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:20:09.664Z - Hash: fffa0fddaa3425c55dd3 - Time: 37581ms
Successfully built c8c279335f46
Successfully tagged app:latest
real 5m4.541s
user 0m0.000s
sys 0m0.000sÄndern wir den Inhalt von src/index.html und führen wir den Build 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
---> 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:26:26.587Z - Hash: fffa0fddaa3425c55dd3 - Time: 37902ms
Successfully built 79f335df92d3
Successfully tagged app:latest
real 3m33.262s
user 0m0.000s
sys 0m0.000sUm zu überprüfen, ob unser Image erfolgreich erstellt wurde, führen wir den Befehl aus: docker images:
REPOSITORY TAG IMAGE ID CREATED SIZE
app latest 79f335df92d3 Vor etwa einer Minute 1,74GBVor dem Build nimmt Docker alle Dateien im aktuellen Kontext und sendet sie an seinen Daemon. Sending build context to Docker daemon 409MBDer Build-Kontext wird als letztes Argument des Build-Befehls angegeben. In unserem Fall ist das das aktuelle Verzeichnis — „.“, und Docker holt alles, was wir in diesem Ordner haben. 409 MB ist viel: Lassen Sie uns darüber nachdenken, wie wir das verbessern können.
Kontext reduzieren
Um den Kontext zu reduzieren, gibt es zwei Optionen. Entweder alle für den Build benötigten Dateien in einen separaten Ordner legen und Docker genau auf diesen Ordner verweisen. Das kann nicht immer praktisch sein, daher gibt es die Möglichkeit, Ausnahmen anzugeben: was nicht in den Kontext aufgenommen werden soll. Dazu legen wir eine Datei .dockerignore im Projekt an und geben an, was nicht für den Build benötigt wird:
.git
/node_modulesund führen den Build noch einmal 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,38 GBLassen Sie uns versuchen, die Bildgröße weiter zu reduzieren.
Wir verwenden Alpine
Eine weitere Möglichkeit, die Größe des Images zu sparen, besteht darin, ein kleineres Basis-Image zu verwenden. Das Basis-Image ist das Image, auf dessen Grundlage unser Image erstellt wird. Die untere Schicht wird mit dem Befehl FROM im Dockerfile angegeben. In unserem Fall verwenden wir ein auf Ubuntu basierendes Image, das bereits nodejs installiert hat. Es wiegt …
$ docker images -a | grep node
node 12.16.2 406aa3abbc6c vor 17 Minuten 916 MB… fast ein Gigabyte. Die Größe kann erheblich reduziert werden, indem man ein auf Alpine Linux basierendes Image verwendet. Alpine ist ein sehr kleines Linux. Das Docker-Image für nodejs basierend auf Alpine wiegt nur 88,5 MB. Lassen Sie uns also unser schweres Image ersetzen:
VON 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 --prodWir mussten einige Dinge installieren, die für den Build des Applications erforderlich sind. Ja, Angular lässt sich ohne Python nicht erstellen ¯(°_o)/¯
Aber dafür haben wir die Größe des Images um 150 MB reduziert:
REPOSITORY TAG IMAGE ID CREATED SIZE
app latest aa031edc315a vor 22 Minuten 761MBLass uns noch weiter gehen.
Multi-Stage Build
Nicht alles, was im Image ist, benötigen wir in der Produktion.
$ 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 docker run app ls -lah Wir haben einen Container basierend auf unserem Image gestartet app und darin den Befehl ausgeführt ls -lah, danach hat der Container seine Arbeit beendet.
In der Produktion benötigen wir nur den Ordner dist. Die Dateien müssen irgendwie nach außen gegeben werden. Man könnte einen HTTP-Server mit Node.js starten. Aber wir machen es einfacher. Erratet das russische Wort, das vier Buchstaben „ы“ enthält. Richtig! Ынжыныксы. Wir nehmen ein Image mit nginx, fügen den Ordner dist und eine kleine Konfiguration hinzu:
server {
listen 80 default_server;
server_name localhost;
charset utf-8;
root /app/dist;
location / {
try_files $uri $uri/ /index.html;
}
}Das alles ermöglicht uns der Multi-Stage Build. Wir werden unser Dockerfile ändern:
VON node:12.16.2-alpine3.11 als builder
RUN apk --no-cache --update --virtual build-dependencies add
python
make
g++
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build --prod
VON 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 ihren eigenen Build-Schritt. Die erste haben wir genannt builder, und ab dem letzten FROM wird unser endgültiges Image vorbereitet. Im letzten Schritt kopieren wir das Artefakt unseres Builds aus dem vorherigen Schritt in das finale Image mit nginx. Die Größe des Images wurde erheblich reduziert:
REPOSITORY TAG IMAGE ID CREATED SIZE
app latest 2c6c5da07802 vor 29 Minuten 36MBLassen Sie uns den Container mit unserem Image starten und überprüfen, ob alles funktioniert:
docker run -p8080:80 appMit der Option -p8080:80 haben wir den Port 8080 auf unserem Host-Rechner zu Port 80 innerhalb des Containers weitergeleitet, wo nginx läuft. Öffnen Sie im Browser und sehen Sie unsere Anwendung. Alles funktioniert!

Die Reduzierung der Größe des Images von 1,74 GB auf 36 MB verkürzt erheblich die Zeit, die benötigt wird, um Ihre Anwendung in die Produktion zu bringen. Aber lassen Sie uns zum Build-Zeitpunkt zurückkehren.
$ time docker build -t app .
Sende Build-Kontext an den Docker-Daemon 608,8 kB
Schritt 1/11 : VON node:12.16.2-alpine3.11 als Builder
Schritt 2/11 : FÜHREN apk --no-cache --update --virtual build-dependencies hinzuzufügen python make g++ aus
---> Cache verwenden
Schritt 3/11 : WORKDIR /app
---> Cache verwenden
Schritt 4/11 : KOPIEREN . .
Schritt 5/11 : FÜHREN npm ci aus
1357 Pakete in 47,338 s hinzugefügt
Schritt 6/11 : FÜHREN npm run build --prod aus
Datum: 2020-04-16T21:16:03.899Z - Hash: fffa0fddaa3425c55dd3 - Zeit: 39948ms
---> 27f1479221e4
Schritt 7/11 : VON nginx:stable-alpine
Schritt 8/11 : WORKDIR /app
---> Cache verwenden
Schritt 9/11 : FÜHREN rm /etc/nginx/conf.d/default.conf aus
---> Cache verwenden
Schritt 10/11 : KOPIEREN nginx/static.conf /etc/nginx/conf.d
---> Cache verwenden
Schritt 11/11 : KOPIEREN --von=builder /app/dist/app .
Erfolgreich d201471c91ad gebaut
Erfolgreich mit app:latest markiert
real 2m17.700s
user 0m0.000s
sys 0m0.000sWir ändern die Reihenfolge der Schichten
Die ersten drei Schritte wurden im Cache gespeichert (Hinweis Cache verwenden). Im vierten Schritt werden alle Projektdateien kopiert und im fünften Schritt werden die Abhängigkeiten installiert FÜHREN npm ci aus — ganze 47,338s. Warum sollten wir jede Abhängigkeit von neuem installieren, wenn sie sich nur selten ändern? Lassen Sie uns herausfinden, warum sie nicht im Cache gespeichert wurden. Das liegt daran, dass Docker schichtweise überprüft, ob sich das Kommando und die damit verbundenen Dateien geändert haben. In Schritt vier kopieren wir alle Dateien unseres Projekts, und natürlich gibt es darunter Änderungen, weshalb Docker nicht nur diese Schicht nicht aus dem Cache nimmt, sondern auch alle folgenden! Lassen Sie uns kleine Änderungen in 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 .Zuerst werden package.json und package-lock.json kopiert, dann werden 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, was sich nicht ändert, dann das, was sich selten ändert, und zum Schluss das, was häufig geändert wird.
Jetzt ein paar Worte zum Erstellen von Images in CI/CD-Systemen.
Verwendung vorheriger Images für den Cache
Wenn wir ein SaaS-Produkt zum Bauen verwenden, kann der lokale Docker-Cache leer und frisch sein. Um Docker die Möglichkeit zu geben, gebaute Schichten zu übernehmen, geben Sie ihm das vorherige gebaute Image.
Betrachten wir als Beispiel den Aufbau unserer Anwendung in GitHub Actions. Wir verwenden eine solche 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 Image wird in GitHub Packages in zwei Minuten und 20 Sekunden erstellt und gepusht:

Lassen Sie uns das Build anpassen, damit der Cache auf der Grundlage der zuvor erstellten Images 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.comZuerst ist es wichtig zu erklären, warum zwei Befehle ausgeführt werden. build. Es ist so, dass in einem Multi-Stage-Build als Ergebnis eine Schicht aus der letzten Stufe erstellt wird. Dabei gelangen die Schichten aus den vorherigen Stufen nicht ins Image. Deshalb kann Docker beim Einsatz des finalen Images mit der vorherigen Build-Version die erforderlichen Schichten für den Build des Node.js-Images (Stadium Builder) nicht finden. Um dieses Problem zu lösen, wird ein Zwischen-Image erstellt. $IMAGE_NAME-builder-stage und in GitHub Packages hochgeladen, damit es in der folgenden Build-Phase als Cache-Quelle verwendet werden kann.

Die gesamte Build-Zeit wurde auf anderthalb Minuten reduziert. Eine halbe Minute entfällt auf das Herunterladen der vorherigen Images.
Vorab-Bilder erstellen
Eine weitere Möglichkeit, das Problem des sauberen Docker-Caches zu lösen, besteht darin, einige Schichten in eine andere Dockerfile auszulagern, sie separat zu bauen, in das Container Registry zu pushen und sie als übergeordnetes Image zu verwenden.
Wir erstellen unser Node.js-Image zum Bauen einer Angular-Anwendung. Wir erstellen eine Dockerfile.node in unserem Projekt.
FROM node:12.16.2-alpine3.11
RUN apk --no-cache --update --virtual build-dependencies add
python
make
g++Wir bauen und pushen das öffentliche Image in Docker Hub:
docker build -t exsmund/node-for-angular -f Dockerfile.node .
docker push exsmund/node-for-angular:latestJetzt verwenden wir das fertige Image in unserem Haupt-Dockerfile:
FROM exsmund/node-for-angular:latest as builder
...In unserem Beispiel blieb die Build-Zeit gleich, jedoch können vorab erstellte Images nützlich sein, wenn Sie viele Projekte haben und in jedem von ihnen dieselben Abhängigkeiten installieren müssen.

Wir haben verschiedene Methoden zur Beschleunigung des Docker-Image-Builds betrachtet. Wenn Sie einen schnellen Deployment-Prozess wünschen, probieren Sie in Ihrem Projekt Folgendes aus:
- Reduzierung des Kontextes;
- Verwendung kleinerer Basis-Images;
- Multi-Stage-Builds;
- Änderung der Anweisungsreihenfolge im Dockerfile, um den Cache effektiv zu nutzen;
- Konfiguration des Caches in CI/CD-Systemen;
- Vorab-Erstellung von Images.
Ich hoffe, das Beispiel macht klarer, wie Docker funktioniert, und Sie können Ihr Deployment optimal konfigurieren. Um mit den Beispielen aus dem Artikel zu experimentieren, wurde ein Repository erstellt. .
Quelle: habr.com
