Zanim nowa funkcjonalność trafi na produkcję, w erze złożonych orkiestratorów i CI/CD musi przejść długą drogę od commita do testów i dostarczenia. Kiedyś wystarczyło wrzucić nowe pliki przez FTP (nikt już tak nie robi, prawda?), a proces „deployowania” zajmował sekundy. Teraz trzeba stworzyć merge request i czekać dość długo, aż nowość dotrze do użytkowników.
Częścią tej drogi jest budowanie obrazu Docker. Czasami budowa trwa minuty, czasami dziesiątki minut, co trudno nazwać normalnym. W tym artykule weźmiemy proste aplikacje, które spakujemy w obraz, zastosujemy kilka metod przyspieszających budowę i omówimy szczegóły tych metod.

Mamy całkiem niezłe doświadczenie w tworzeniu i wsparciu stron internetowych mediów: , , , … Niedawno wzbogaciliśmy nasze portfolio, uruchamiając stronę produkcyjną . I podczas gdy szybko dopracowywaliśmy nowe funkcjonalności i naprawialiśmy stare błędy, wolny proces wdrażania stał się dużym problemem.
Deploy robimy na GitLabie. Budujemy obrazy, przesyłamy do GitLab Registry i wprowadzamy na produkcję. Najdłuższe w tym procesie jest tworzenie obrazów. Dla przykładu: bez optymalizacji każda budowa backendu zajmowała 14 minut.
![]()
W końcu stało się jasne, że tak dalej nie można żyć, więc usiedliśmy, aby zrozumieć, dlaczego obrazy budują się tak długo. Ostatecznie udało się skrócić czas budowy do 30 sekund!
![]()
W tej artykule, aby nie wiązać się z otoczeniem Reminder’a, rozważymy przykład budowy pustej aplikacji na Angular. Zaczynamy tworzyć naszą aplikację:
ng n appDodajemy do niej PWA (jesteśmy przecież nowocześni):
ng add @angular/pwa --project appPodczas pobierania miliona pakietów npm, przyjrzyjmy się, jak działa obraz dockerowy. Docker umożliwia pakowanie aplikacji i uruchamianie ich w izolowanym środowisku, które nazywa się kontenerem. Dzięki izolacji można jednocześnie uruchamiać wiele kontenerów na jednym serwerze. Kontenery są znacznie lżejsze od maszyn wirtualnych, ponieważ działają bezpośrednio na jądrze systemu. Żeby uruchomić kontener z naszą aplikacją, najpierw musimy stworzyć obraz, w którym zapakujemy wszystko, co potrzebne do działania naszej aplikacji. Obraz to zasadniczo odzwierciedlenie systemu plików. Na przykład, weźmy Dockerfile:
FROM node:12.16.2
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build --prodDockerfile to zestaw instrukcji; wykonując każdą z nich, Docker będzie zapisywał zmiany w systemie plików i nakładał je na poprzednie. Każda komenda tworzy swój warstwę. A gotowy obraz to połączone razem warstwy.
Co ważne, każdy warstwa jest buforowana. Jeśli nic nie zmieniło się od ostatniego budowania, to zamiast wykonywać komendę, Docker weźmie już gotową warstwę. Ponieważ główny przyrost w szybkości budowania będzie wynikał z użycia pamięci podręcznej, w pomiarach szybkości budowania będziemy zwracać uwagę właśnie na budowanie obrazu z gotowym buforem. Zatem, krok po kroku:
- Usuwamy obrazy lokalnie, aby wcześniejsze uruchomienia nie wpływały na test.
docker rmi $(docker images -q) - Uruchamiamy budowę pierwszy raz.
time docker build -t app . - Zmiana pliku src/index.html — symulując pracę programisty.
- Uruchamiamy budowę drugi raz.
time docker build -t app .
Jeśli środowisko do budowy obrazów jest poprawnie skonfigurowane (o czym niżej), to Docker podczas uruchamiania budowy już będzie miał na pokładzie wiele buforów. Naszym zadaniem jest nauczyć się używać pamięci podręcznej, tak aby budowa przebiegła maksymalnie szybko. Ponieważ zakładamy, że uruchomienie budowy bez pamięci podręcznej występuje tylko raz — pierwszy — więc możemy zignorować, jak wolne było to pierwsze uruchomienie. W testach ważne jest dla nas drugie uruchomienie budowy, kiedy bufory są już nagrzane i jesteśmy gotowi piec nasz placek. Niemniej jednak, niektóre wskazówki wpłyną również na pierwszą budowę.
Umieszczamy wcześniej opisany Dockerfile w folderze z projektem i uruchamiamy budowę. Wszystkie podane listingi są skrócone dla wygody czytania.
$ time docker build -t app .
Wysyłanie kontekstu budowy do demona Docker 409MB
Krok 1/5 : FROM node:12.16.2
Status: Pobieranie nowszego obrazu dla node:12.16.2
Krok 2/5 : WORKDIR /app
Krok 3/5 : COPY . .
Krok 4/5 : RUN npm ci
dodano 1357 paczek w 22.47s
Krok 5/5 : RUN npm run build --prod
Data: 2020-04-16T19:20:09.664Z - Hash: fffa0fddaa3425c55dd3 - Czas: 37581ms
Pomyślnie zbudowano c8c279335f46
Pomyślnie oznaczono app:latest
czas rzeczywisty 5m4.541s
czas użytkownika 0m0.000s
czas systemowy 0m0.000sZmiana zawartości src/index.html i uruchomienie drugi raz.
$ time docker build -t app .
Wysyłanie kontekstu budowy do demona Docker 409MB
Krok 1/5 : FROM node:12.16.2
Krok 2/5 : WORKDIR /app
---> Użycie pamięci podręcznej
Krok 3/5 : COPY . .
Krok 4/5 : RUN npm ci
dodano 1357 paczek w 22.47s
Krok 5/5 : RUN npm run build --prod
Data: 2020-04-16T19:26:26.587Z - Hash: fffa0fddaa3425c55dd3 - Czas: 37902ms
Pomyślnie zbudowano 79f335df92d3
Pomyślnie oznaczono app:latest
czas rzeczywisty 3m33.262s
czas użytkownika 0m0.000s
czas systemowy 0m0.000sAby sprawdzić, czy obraz się udał, wykonujemy komendę docker images:
REPOSITORY TAG IMAGE ID CREATED SIZE
app latest 79f335df92d3 Około minuty temu 1.74GBPrzed zbudowaniem obrazu, Docker zbiera wszystkie pliki z aktualnego kontekstu i przesyła je do swojego demona. Przesyłanie kontekstu budowy do demona Docker 409 MB. Kontekst budowy podawany jest jako ostatni argument polecenia build. W naszym przypadku jest to bieżący katalog — ".", a Docker zaciąga wszystko, co jest w tym folderze. 409 MB to dużo, zastanówmy się, jak to poprawić.
Zmniejszamy kontekst
Aby zmniejszyć kontekst, są dwa warianty. Można przenieść wszystkie pliki potrzebne do budowy do osobnego folderu i wskazać kontekst dla Dockera dokładnie na ten folder. Może to nie zawsze być wygodne, dlatego istnieje możliwość określenia wyjątków: co nie powinno być dołączone do kontekstu. W tym celu umieścimy w projekcie plik .dockerignore i zaznaczymy, co nie jest potrzebne do budowy:
.git
/node_modulesi uruchomimy budowę jeszcze raz:
$ time docker build -t app .
Przesyłanie kontekstu budowy do demona Docker 607.2kB
Krok 1/5: FROM node:12.16.2
Krok 2/5: WORKDIR /app
---> Użycie pamięci podręcznej
Krok 3/5: COPY . .
Krok 4/5: RUN npm ci
dodano 1357 pakietów w 22.47s
Krok 5/5: RUN npm run build --prod
Data: 2020-04-16T19:33:54.338Z - Hash: fffa0fddaa3425c55dd3 - Czas: 37313ms
Sukces: zbudowano 4942f010792a
Pomyślnie otagowano app:latest
real 1m47.763s
user 0m0.000s
sys 0m0.000s607.2 kB — znacznie lepiej niż 409 MB. Dodatkowo zmniejszyliśmy rozmiar obrazu z 1.74 do 1.38 GB:
REPOSITORY TAG IMAGE ID CREATED SIZE
app latest 4942f010792a 3 minuty temu 1.38GBSpróbujmy jeszcze bardziej zmniejszyć rozmiar obrazu.
Używamy Alpine
Kolejnym sposobem na zaoszczędzenie na rozmiarze obrazu jest użycie małego obrazu bazowego. Obraz bazowy to obraz, na podstawie którego tworzymy nasz obraz. Dolny warstwa jest określona w poleceniu Z FROM w Dockerfile. W naszym przypadku używamy obrazu na podstawie Ubuntu, na którym już znajduje się Node.js. A jego rozmiar to …
$ docker images -a | grep node
node 12.16.2 406aa3abbc6c 17 minut temu 916MB… prawie gigabajt. Znacznie można zmniejszyć rozmiar, korzystając z obrazu na podstawie Alpine Linux. Alpine to bardzo mały Linux. Obraz Docker dla Node.js na bazie Alpine waży tylko 88.5 MB. Dlatego zamieńmy nasz tłusty obraz:
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 --prodMusieliśmy zainstalować kilka rzeczy, które są niezbędne do budowy aplikacji. Tak, Angular nie buduje się bez Pythona ¯(°_o)\/¯
Ale dzięki temu rozmiar obrazu spadł o 150 MB:
REPOSITORY TAG IMAGE ID CREATED SIZE
app latest aa031edc315a 22 minuty temu 761MBIdziemy dalej.
Budowa wieloetapowa
Nie wszystko, co jest w obrazie, jest nam potrzebne w produkcji.
$ 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.jsonDzięki docker run app ls -lah Uruchomiliśmy kontener na podstawie naszego obrazu app i wykonaliśmy w nim polecenie ls -lah, po czym kontener zakończył swoją pracę.
Na produkcji potrzebujemy tylko folderu dist. Pliki muszą być jakoś udostępnione na zewnątrz. Można uruchomić jakiś serwer HTTP na nodejs. Ale zrobimy to prościej. Zgadnijcie rosyjskie słowo, w którym są cztery litery „ы”. Dobrze! Ынжыныксы. Weźmiemy obraz z nginx, włożymy do niego folder dist i małą konfigurację:
server {
listen 80 default_server;
server_name localhost;
charset utf-8;
root /app/dist;
location / {
try_files $uri $uri/ /index.html;
}
}Wszystko to pomoże nam zbudować multi-stage build. Zmienimy nasz 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 .Teraz mamy dwie instrukcje Z FROM w Dockerfile, każda z nich uruchamia swój etap budowy. Pierwszy nazwałem buildera, którą zadamy nieco później. Nazwa w naszym przypadku będzie taka sama, jak nazwa projektu:, a od ostatniego FROM będzie przygotowywany nasz ostateczny obraz. Ostatnim krokiem jest skopiowanie artefaktu naszej budowy z poprzedniego etapu do końcowego obrazu z nginx. Rozmiar obrazu znacznie się zmniejszył:
REPOSITORY TAG IMAGE ID CREATED SIZE
app latest 2c6c5da07802 29 minut temu 36MBUruchommy kontener z naszym obrazem i upewnijmy się, że wszystko działa:
docker run -p8080:80 appOpcją -p8080:80 przekazaliśmy port 8080 na naszej maszynie hosta do portu 80 wewnątrz kontenera, gdzie działa nginx. Otwieramy w przeglądarce i widzimy naszą aplikację. Wszystko działa!

Zmniejszenie rozmiaru obrazu z 1.74 GB do 36 MB znacznie skraca czas dostarczania twojej aplikacji na produkcję. Ale wróćmy do czasu budowy.
$ time docker build -t app .
Wysyłanie kontekstu budowy do demona Docker 608.8kB
Krok 1/11 : FROM node:12.16.2-alpine3.11 as builder
Krok 2/11 : RUN apk --no-cache --update --virtual build-dependencies add python make g++
---> Używając pamięci podręcznej
Krok 3/11 : WORKDIR /app
---> Używając pamięci podręcznej
Krok 4/11 : COPY . .
Krok 5/11 : RUN npm ci
dodano 1357 pakietów w 47.338s
Krok 6/11 : RUN npm run build --prod
Data: 2020-04-16T21:16:03.899Z - Hash: fffa0fddaa3425c55dd3 - Czas: 39948ms
---> 27f1479221e4
Krok 7/11 : FROM nginx:stable-alpine
Krok 8/11 : WORKDIR /app
---> Używając pamięci podręcznej
Krok 9/11 : RUN rm /etc/nginx/conf.d/default.conf
---> Używając pamięci podręcznej
Krok 10/11 : COPY nginx/static.conf /etc/nginx/conf.d
---> Używając pamięci podręcznej
Krok 11/11 : COPY --from=builder /app/dist/app .
Pomyślnie zbudowano d201471c91ad
Pomyślnie oznaczono app:latest
czas rzeczywisty 2m17.700s
czas użytkownika 0m0.000s
czas systemowy 0m0.000sZmiana kolejności warstw
Pierwsze trzy kroki zostały zachowane w pamięci podręcznej (wskaźnik Używając pamięci podręcznej). Na czwartym kroku kopiowane są wszystkie pliki projektu, a na piątym kroku instalowane są zależności RUN npm ci — całe 47.338s. Po co za każdym razem na nowo instalować zależności, skoro zmieniają się one bardzo rzadko? Zastanówmy się, dlaczego nie zostały one zbuforowane. Chodzi o to, że Docker sprawdza warstwę po warstwie, czy polecenia i związane z nimi pliki się nie zmieniły. Na czwartym kroku kopiujemy wszystkie pliki naszego projektu, a wśród nich, oczywiście, są zmiany, dlatego Docker nie tylko nie korzysta z pamięci podręcznej dla tej warstwy, ale także dla wszystkich kolejnych! Wprowadźmy niewielkie zmiany w 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 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 .Najpierw kopiowane są package.json i package-lock.json, następnie instalowane są zależności, a dopiero potem kopiowany jest cały projekt. W rezultacie:
$ 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 sekund zamiast 3 minut — znacznie lepiej! Ważna jest właściwa kolejność warstw: najpierw kopiujemy to, co się nie zmienia, następnie to, co rzadko się zmienia, a na końcu — to, co zmienia się często.
Dalej kilka słów o budowie obrazów w systemach CI/CD.
Wykorzystanie poprzednich obrazów jako pamięci podręcznej
Jeśli używamy do budowy jakiegoś rozwiązania SaaS, lokalna pamięć podręczna Dockera może być czysta i świeża. Aby Docker miał skąd wziąć upieczone warstwy, przekaż mu poprzedni zbudowany obraz.
Rozważmy na przykład budowę naszego aplikacji w GitHub Actions. Użyjemy takiej konfiguracji
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.comObraz jest budowany i wysyłany do GitHub Packages w ciągu dwóch minut i 20 sekund:

Teraz zmienimy budowę, aby korzystać z pamięci podręcznej opartej na wcześniejszych zbudowanych obrazach:
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.comNa początek musimy wyjaśnić, dlaczego uruchamiane są dwie komendy build. Chodzi o to, że w budowie wielostanowej rezultatem będą warstwy z ostatniego stanu. W tym przypadku warstwy z poprzednich stanów nie trafią do obrazu. Dlatego korzystając z finalnego obrazu z poprzedniej budowy, Docker nie będzie mógł znaleźć gotowych warstw do zbudowania obrazu z nodejs (stan builder). Aby rozwiązać ten problem, tworzony jest obraz pośredni $IMAGE_NAME-builder-stage i jest wysyłany do GitHub Packages, aby można go było wykorzystać w kolejnej budowie jako źródło pamięci podręcznej.

Całkowity czas budowy skrócił się do półtorej minuty. Pół minuty marnowane jest na pobranie wcześniejszych obrazów.
Wstępne tworzenie obrazów
Inny sposób na rozwiązanie problemu czystej pamięci podręcznej Dockera to przeniesienie części warstw do innego pliku Dockerfile, zbudowanie go oddzielnie, wypchnięcie do Container Registry i użycie jako nadrzędnego.
Tworzymy nasz obraz nodejs do budowy aplikacji Angular. Tworzymy w projekcie Dockerfile.node
FROM node:12.16.2-alpine3.11
RUN apk --no-cache --update --virtual build-dependencies add
python
make
g++Budujemy i wypychamy publiczny obraz do Docker Hub:
docker build -t exsmund/node-for-angular -f Dockerfile.node .
docker push exsmund/node-for-angular:latestTeraz w naszym głównym Dockerfile używamy gotowego obrazu:
FROM exsmund/node-for-angular:latest as builder
...W naszym przykładzie czas budowy się nie zmniejszył, ale wstępnie utworzone obrazy mogą być przydatne, jeśli masz wiele projektów i w każdym z nich musisz zainstalować te same zależności.

Rozpatrzyliśmy kilka metod przyspieszania budowy obrazów dockera. Jeśli chcesz, aby wdrożenie było szybkie, spróbuj zastosować w swoim projekcie:
- zmniejszenie kontekstu;
- używanie małych obrazów nadrzędnych;
- budowę wieloetapową;
- zmianę kolejności instrukcji w Dockerfile, aby efektywnie wykorzystać pamięć podręczną;
- konfigurację pamięci podręcznej w systemach CI/CD;
- wstępne tworzenie obrazów.
Mam nadzieję, że na tym przykładzie stanie się jasne, jak działa Docker, i będziesz mógł optymalnie skonfigurować swoje wdrożenie. Aby móc bawić się przykładami z artykułu, stworzono repozytorium .
Źródło: habr.com
