Kilka wskazówek, jak przyspieszyć tworzenie obrazów Dockera. Na przykład do 30 sekund

Zanim funkcja trafi do środowiska produkcyjnego, w czasach złożonych orkiestratorów i CI/CD, od zatwierdzenia do testów i dostarczenia długa droga przed nami. Wcześniej można było przesyłać nowe pliki przez FTP (nikt już tego nie robi, prawda?), a proces „wdrażania” zajmował kilka sekund. Teraz musisz utworzyć żądanie połączenia i poczekać długo, aż funkcja dotrze do użytkowników.

Częścią tej ścieżki jest budowanie obrazu platformy Docker. Czasem montaż trwa kilka minut, czasem kilkadziesiąt minut, co trudno nazwać normalnym. W tym artykule zajmiemy się prostą aplikacją, którą spakujemy w obraz, zastosujemy kilka metod przyspieszających kompilację i przyjrzymy się niuansom działania tych metod.

Kilka wskazówek, jak przyspieszyć tworzenie obrazów Dockera. Na przykład do 30 sekund

Mamy duże doświadczenie w tworzeniu i obsłudze serwisów medialnych: TASS, Bell, „Nowa gazeta”, Republika…Nie tak dawno temu poszerzyliśmy nasze portfolio wypuszczając stronę produktową Przypomnienie. I choć szybko dodano nowe funkcje i naprawiono stare błędy, dużym problemem stało się powolne wdrażanie.

Wdrażamy w GitLabie. Zbieramy obrazy, wypychamy je do rejestru GitLab i wdrażamy do produkcji. Najdłuższą rzeczą na tej liście jest składanie obrazów. Na przykład: bez optymalizacji każda kompilacja backendu trwała 14 minut.

Kilka wskazówek, jak przyspieszyć tworzenie obrazów Dockera. Na przykład do 30 sekund

W końcu stało się jasne, że nie możemy już tak żyć, więc usiedliśmy, aby dowiedzieć się, dlaczego gromadzenie zdjęć trwało tak długo. Dzięki temu udało nam się skrócić czas montażu do 30 sekund!

Kilka wskazówek, jak przyspieszyć tworzenie obrazów Dockera. Na przykład do 30 sekund

Na potrzeby tego artykułu, aby nie być przywiązanym do środowiska Remindera, spójrzmy na przykład złożenia pustej aplikacji Angular. Stwórzmy więc naszą aplikację:

ng n app

Dodaj do tego PWA (jesteśmy progresywni):

ng add @angular/pwa --project app

Podczas pobierania miliona pakietów npm przyjrzyjmy się, jak działa obraz okna dokowanego. Docker umożliwia pakowanie aplikacji i uruchamianie ich w izolowanym środowisku zwanym kontenerem. Dzięki izolacji możesz uruchomić wiele kontenerów jednocześnie na jednym serwerze. Kontenery są znacznie lżejsze od maszyn wirtualnych, ponieważ działają bezpośrednio w jądrze systemu. Aby uruchomić kontener z naszą aplikacją, musimy najpierw stworzyć obraz, w którym spakujemy wszystko, co jest niezbędne do działania naszej aplikacji. Zasadniczo obraz jest kopią systemu plików. Weźmy na przykład plik Dockerfile:

FROM node:12.16.2
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build --prod

Plik Dockerfile to zestaw instrukcji; Wykonując każdą z nich, Docker zapisze zmiany w systemie plików i nałoży je na poprzednie. Każdy zespół tworzy własną warstwę. Gotowy obraz to warstwy połączone ze sobą.

Co warto wiedzieć: każda warstwa Dockera może buforować. Jeżeli od ostatniej kompilacji nic się nie zmieniło, to zamiast wykonać polecenie, doker pobierze gotową warstwę. Ponieważ główny wzrost szybkości kompilacji będzie wynikał z wykorzystania pamięci podręcznej, mierząc prędkość kompilacji, zwrócimy uwagę szczególnie na budowanie obrazu z gotową pamięcią podręczną. A więc krok po kroku:

  1. Usuwamy obrazy lokalnie, aby poprzednie uruchomienia nie miały wpływu na test.
    docker rmi $(docker images -q)
  2. Uruchamiamy kompilację po raz pierwszy.
    time docker build -t app .
  3. Zmieniamy plik src/index.html - naśladujemy pracę programisty.
  4. Uruchamiamy kompilację po raz drugi.
    time docker build -t app .

Jeśli środowisko do budowania obrazów jest poprawnie skonfigurowane (więcej na ten temat poniżej), to po rozpoczęciu kompilacji Docker będzie już miał na pokładzie kilka pamięci podręcznych. Naszym zadaniem jest nauczyć się korzystać z pamięci podręcznej, aby kompilacja przebiegła jak najszybciej. Ponieważ zakładamy, że uruchomienie kompilacji bez pamięci podręcznej odbywa się tylko raz — za pierwszym razem — możemy zignorować, jak powolny był ten pierwszy raz. W testach ważne jest dla nas drugie uruchomienie kompilacji, kiedy cache jest już rozgrzany i jesteśmy gotowi do upieczenia ciasta. Jednak niektóre wskazówki będą miały wpływ również na pierwszą kompilację.

Umieśćmy opisany powyżej plik Dockerfile w folderze projektu i rozpocznijmy kompilację. Wszystkie wykazy zostały skrócone, aby ułatwić ich czytanie.

$ 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

Zmieniamy zawartość pliku src/index.html i uruchamiamy go po raz drugi.

$ 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.000s

Aby sprawdzić, czy mamy obraz, uruchom polecenie docker images:

REPOSITORY   TAG      IMAGE ID       CREATED              SIZE
app          latest   79f335df92d3   About a minute ago   1.74GB

Przed budowaniem doker pobiera wszystkie pliki w bieżącym kontekście i wysyła je do swojego demona Sending build context to Docker daemon 409MB. Kontekst kompilacji jest określony jako ostatni argument komendy build. W naszym przypadku jest to bieżący katalog - „.”, - i Docker przeciąga wszystko, co mamy w tym folderze. 409 MB to dużo: zastanówmy się, jak to naprawić.

Ograniczanie kontekstu

Aby ograniczyć kontekst, istnieją dwie opcje. Lub umieść wszystkie pliki potrzebne do montażu w osobnym folderze i wskaż kontekst okna dokowanego na ten folder. Nie zawsze jest to wygodne, dlatego można określić wyjątki: czego nie należy przeciągać w kontekst. Aby to zrobić, umieść plik .dockerignore w projekcie i wskaż, co nie jest potrzebne do kompilacji:

.git
/node_modules

i ponownie uruchom kompilację:

$ 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.000s

607.2 KB to znacznie więcej niż 409 MB. Zmniejszyliśmy także rozmiar obrazu z 1.74 do 1.38 GB:

REPOSITORY   TAG      IMAGE ID       CREATED         SIZE
app          latest   4942f010792a   3 minutes ago   1.38GB

Spróbujmy jeszcze bardziej zmniejszyć rozmiar obrazu.

Używamy Alpine

Innym sposobem na zaoszczędzenie na rozmiarze obrazu jest użycie małego obrazu nadrzędnego. Wizerunek rodzicielski to obraz, na podstawie którego powstaje nasz wizerunek. Dolna warstwa jest określana przez polecenie FROM w Dockerfile. W naszym przypadku używamy obrazu opartego na Ubuntu, który ma już zainstalowany nodejs. I waży...

$ docker images -a | grep node
node 12.16.2 406aa3abbc6c 17 minutes ago 916MB

... prawie gigabajt. Możesz znacznie zmniejszyć głośność, używając obrazu opartego na Alpine Linux. Alpine to bardzo mały Linux. Obraz dokera dla nodejs oparty na alpine waży tylko 88.5 MB. Wymieńmy więc nasz żywy wizerunek w domach:

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

Musieliśmy zainstalować kilka rzeczy niezbędnych do zbudowania aplikacji. Tak, Angular nie buduje bez Pythona ¯(°_o)/¯

Ale rozmiar obrazu spadł do 150 MB:

REPOSITORY   TAG      IMAGE ID       CREATED          SIZE
app          latest   aa031edc315a   22 minutes ago   761MB

Pójdźmy jeszcze dalej.

Montaż wieloetapowy

Nie wszystko co jest na obrazku jest tym czego potrzebujemy 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.json

Z docker run app ls -lah uruchomiliśmy kontener bazujący na naszym wizerunku app i wykonał zawarte w nim polecenie ls -lah, po czym kontener zakończył swoją pracę.

W produkcji potrzebujemy jedynie folderu dist. W takim przypadku pliki trzeba jakoś oddać na zewnątrz. Możesz uruchomić jakiś serwer HTTP na nodejs. Ale ułatwimy to. Odgadnij rosyjskie słowo składające się z czterech liter „y”. Prawidłowy! Ynżynyksy. Zróbmy zdjęcie za pomocą Nginx i umieśćmy w nim folder dist i mała konfiguracja:

server {
    listen 80 default_server;
    server_name localhost;
    charset utf-8;
    root /app/dist;

    location / {
        try_files $uri $uri/ /index.html;
    }
}

W tym wszystkim pomoże nam wieloetapowa kompilacja. Zmieńmy nasz plik 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 FROM w pliku Dockerfile każdy z nich uruchamia inny etap kompilacji. Zadzwoniliśmy do pierwszego builder, ale zaczynając od ostatniego FROM, nasz ostateczny obraz zostanie przygotowany. Ostatnim krokiem jest skopiowanie artefaktu naszego złożenia z poprzedniego kroku do końcowego obrazu za pomocą nginx. Rozmiar obrazu znacznie się zmniejszył:

REPOSITORY   TAG      IMAGE ID       CREATED          SIZE
app          latest   2c6c5da07802   29 minutes ago   36MB

Uruchommy kontener z naszym obrazem i upewnijmy się, że wszystko działa:

docker run -p8080:80 app

Korzystając z opcji -p8080:80, przekierowaliśmy port 8080 na naszym komputerze hosta na port 80 w kontenerze, w którym działa Nginx. Otwórz w przeglądarce http://localhost:8080/ i widzimy naszą aplikację. Wszystko działa!

Kilka wskazówek, jak przyspieszyć tworzenie obrazów Dockera. Na przykład do 30 sekund

Zmniejszenie rozmiaru obrazu z 1.74 GB do 36 MB znacznie skraca czas dostarczenia aplikacji do środowiska produkcyjnego. Wróćmy jednak do czasu montażu.

$ 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.000s

Zmiana kolejności warstw

Nasze pierwsze trzy kroki zostały zapisane w pamięci podręcznej (wskazówka Using cache). W czwartym kroku wszystkie pliki projektu są kopiowane, a w piątym kroku instalowane są zależności RUN npm ci - aż 47.338s. Po co instalować zależności za każdym razem, jeśli zmieniają się bardzo rzadko? Zastanówmy się, dlaczego nie zostały zapisane w pamięci podręcznej. Chodzi o to, że Docker będzie sprawdzał warstwa po warstwie, czy polecenie i powiązane z nim pliki uległy zmianie. W czwartym kroku kopiujemy wszystkie pliki naszego projektu, a wśród nich oczywiście pojawiają się zmiany, więc Docker nie tylko nie pobiera z pamięci podręcznej tej warstwy, ale także wszystkich kolejnych! Wprowadźmy kilka drobnych zmian w pliku 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ą pakiety.json i pakiet-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.000s

46 sekund zamiast 3 minut – znacznie lepiej! Ważna jest odpowiednia kolejność warstw: najpierw kopiujemy to, co się nie zmienia, potem to, co zmienia się rzadko, a na koniec to, co zmienia się często.

Następnie kilka słów o składaniu obrazów w systemach CI/CD.

Używanie poprzednich obrazów do pamięci podręcznej

Jeśli do kompilacji użyjemy jakiegoś rozwiązania SaaS, lokalna pamięć podręczna Dockera może być czysta i świeża. Aby dać dokerowi miejsce na wypalone warstwy, przekaż mu poprzednio zbudowany obraz.

Weźmy przykład budowania naszej aplikacji w GitHub Actions. Używamy tej 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.com

Obraz jest składany i przesyłany do pakietów GitHub w ciągu dwóch minut i 20 sekund:

Kilka wskazówek, jak przyspieszyć tworzenie obrazów Dockera. Na przykład do 30 sekund

Zmieńmy teraz kompilację tak, aby pamięć podręczna była używana na podstawie wcześniej zbudowanych obrazów:

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.com

Najpierw musimy powiedzieć, dlaczego uruchamiane są dwa polecenia build. Faktem jest, że w montażu wieloetapowym powstały obraz będzie zbiorem warstw z ostatniego etapu. W takim przypadku warstwy z poprzednich warstw nie zostaną uwzględnione w obrazie. Dlatego też, korzystając z końcowego obrazu z poprzedniej kompilacji, Docker nie będzie w stanie znaleźć gotowych warstw do zbudowania obrazu za pomocą nodejs (etap konstruktora). Aby rozwiązać ten problem, tworzony jest obraz pośredni $IMAGE_NAME-builder-stage i jest przekazywany do pakietów GitHub, dzięki czemu można go użyć w kolejnej kompilacji jako źródło pamięci podręcznej.

Kilka wskazówek, jak przyspieszyć tworzenie obrazów Dockera. Na przykład do 30 sekund

Całkowity czas montażu został skrócony do półtorej minuty. Wyciąganie poprzednich obrazów zajmuje pół minuty.

Wstępne obrazowanie

Innym sposobem rozwiązania problemu czystej pamięci podręcznej Dockera jest przeniesienie niektórych warstw do innego pliku Dockerfile, zbudowanie go osobno, wypchnięcie do Container Registry i użycie go jako elementu nadrzędnego.

Tworzymy własny obraz nodejs do budowy aplikacji Angular. Utwórz plik Dockerfile.node w projekcie

FROM node:12.16.2-alpine3.11
RUN apk --no-cache --update --virtual build-dependencies add 
    python 
    make 
    g++

Zbieramy i przekazujemy publiczny obraz w Docker Hub:

docker build -t exsmund/node-for-angular -f Dockerfile.node .
docker push exsmund/node-for-angular:latest

Teraz w naszym głównym pliku Dockerfile używamy gotowego obrazu:

FROM exsmund/node-for-angular:latest as builder
...

W naszym przykładzie czas kompilacji nie skrócił się, ale gotowe obrazy mogą się przydać, jeśli masz wiele projektów i w każdym z nich musisz zainstalować te same zależności.

Kilka wskazówek, jak przyspieszyć tworzenie obrazów Dockera. Na przykład do 30 sekund

Przyjrzeliśmy się kilku metodom przyspieszania tworzenia obrazów dokerów. Jeśli chcesz, aby wdrożenie przebiegło szybko, spróbuj użyć tego w swoim projekcie:

  • ograniczanie kontekstu;
  • wykorzystanie małych obrazów nadrzędnych;
  • montaż wieloetapowy;
  • zmiana kolejności instrukcji w pliku Dockerfile w celu efektywnego wykorzystania pamięci podręcznej;
  • konfigurowanie pamięci podręcznej w systemach CI/CD;
  • wstępne tworzenie obrazów.

Mam nadzieję, że przykład wyjaśni działanie Dockera i umożliwi optymalną konfigurację wdrożenia. Aby pobawić się przykładami z artykułu, utworzono repozytorium https://github.com/devopsprodigy/test-docker-build.

Źródło: www.habr.com

Dodaj komentarz