Некалькі парад аб тым, як паскорыць зборку Docker-вобразаў. Напрыклад, да 30 секунд

Перш чым фіча патрапіць на прод, у наш час складаных аркестратараў і CI/CD маецца быць мінуць доўгі шлях ад коммита да тэстаў і дастаўкі. Раней можна было кінуць новыя файлы па FTP (так больш так ніхто не робіць, праўда?), і працэс "дэплоя" займаў секунды. Цяпер жа трэба стварыць merge request і чакаць немалы час, пакуль фіча дабярэцца да карыстальнікаў.

Частка гэтага шляху - зборка Docker-выявы. Часам зборка доўжыцца хвіліны, часам - дзясяткі хвілін, што складана назваць нармальным. У дадзеным артыкуле возьмем простае прыкладанне, якое пакуе ў выяву, ужыем некалькі метадаў для паскарэння зборкі і разгледзім нюансы працы гэтых метадаў.

Некалькі парад аб тым, як паскорыць зборку Docker-вобразаў. Напрыклад, да 30 секунд

У нас нядрэнны досвед стварэння і падтрымкі сайтаў СМІ: ТАСС, Bell, "Новая газета", Рэспубліка… Не так даўно мы папоўнілі партфоліё, выпусціўшы ў прад сайт Напамін. І пакуль хутка дапілоўвалі новыя фічы і чынілі старыя багі, павольны дэплой стаў вялікай праблемай.

Дэплой мы робім на GitLab. Збіраны выявы, пушым у GitLab Registry і раскочваем на продзе. У гэтым спісе самае доўгае - гэта зборка вобразаў. Для прыкладу: без аптымізацыі кожная зборка бэкэнда займала 14 хвілін.

Некалькі парад аб тым, як паскорыць зборку Docker-вобразаў. Напрыклад, да 30 секунд

Урэшце стала зразумела, што так жыць больш нельга, і мы селі разабрацца, чаму вобразы збіраюцца столькі часу. У выніку ўдалося скараціць час зборкі да 30 секунд!

Некалькі парад аб тым, як паскорыць зборку Docker-вобразаў. Напрыклад, да 30 секунд

Для дадзенага артыкула, каб не прывязвацца да асяроддзя Reminder'а, разгледзім прыклад зборкі пустога прыкладання на Angular. Такім чынам, ствараем наша дадатак:

ng n app

Дадаем у яго PWA (мы ж прагрэсіўныя):

ng add @angular/pwa --project app

Пакуль спампоўваецца мільён npm-пакетаў, давайце разбярэмся, як уладкованы docker-выява. Docker дае магчымасць пакаваць прыкладання і запускаць іх у ізаляваным асяроддзі, якое называецца кантэйнер. Дзякуючы ізаляцыі можна запускаць адначасова шмат кантэйнераў на адным сэрвэры. Кантэйнеры значна лягчэй віртуальных машын, паколькі выконваюцца напроста на ядры сістэмы. Каб запусціць кантэйнер з нашым дадаткам, нам трэба спачатку стварыць вобраз, у якім мы спакуем усё, што неабходна для працы нашага прыкладання. Па сутнасці вобраз - гэта злепак файлавай сістэмы. Напрыклад, возьмем Dockerfile:

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

Dockerfile - гэта набор інструкцый; выконваючы кожную з іх, Docker будзе захоўваць змены ў файлавай сістэме і накладваць іх на папярэднія. Кожная каманда стварае свой пласт. А гатовы вобраз - гэта аб'яднаныя разам пласты.

Што важна ведаць: кожны пласт докер умее кэшаваць. Калі нічога не змянілася з мінулай зборкі, то замест выканання каманды докер возьме ўжо гатовы пласт. Паколькі асноўны прырост у хуткасці зборкі будзе за рахунак выкарыстання кэша, у замерах хуткасці зборкі будзем зважаць менавіта на зборку выявы з гатовым кэшам. Такім чынам, па кроках:

  1. Выдаляем выявы лакальна, каб папярэднія запускі не ўплывалі на тэст.
    docker rmi $(docker images -q)
  2. Запускаем білд першы раз.
    time docker build -t app .
  3. Змяняем файл src/index.html - які імітуецца працу праграміста.
  4. Запускаем білд другі раз.
    time docker build -t app .

Калі асяроддзе для зборкі выяў наладзіць правільна (пра што крыху ніжэй), то докер пры запуску зборкі ўжо будзе мець на борце купку кэшаў. Наша задача - навучыцца выкарыстоўваць кэш так, каб зборка прайшла максімальна хутка. Паколькі мы мяркуем, што запуск зборкі без кэша адбываецца ўсяго адзін раз – самы першы, – такім чынам, можам ігнараваць тое, наколькі павольным быў гэты першы раз. У тэстах нам важны другі запуск зборкі, калі кэшы ўжо прагрэты і мы гатовы печ наш пірог. Тым не менш, некаторыя парады адаб'юцца на першай зборцы таксама.

Пакладзем Dockerfile, апісаны вышэй, у тэчку з праектам і запусцім зборку. Усе прыведзеныя лістынгі скарочаныя для зручнасці чытання.

$ 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

Змяняем змесціва src/index.html і запускаем другі раз.

$ 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

Каб паглядзець, ці атрымаўся ў нас вобраз, выканаем каманду docker images:

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

Перад зборкай докер бярэ ўсе файлы ў бягучым кантэксце і адпраўляе іх свайму дэману Sending build context to Docker daemon 409MB. Кантэкст для зборкі паказваецца апошнім аргумэнтам каманды build. У нашым выпадку гэта бягучая дырэкторыя - «.», - і докер цягне ўсё, што ёсць у нас у гэтай тэчцы. 409 Мбайт - гэта шмат: давайце думаць, як гэта выправіць.

Памяншаем кантэкст

Каб зменшыць кантэкст, ёсць два варыянты. Або пакласці ўсе файлы, патрэбныя для зборкі, у асобную тэчку і паказваць кантэкст докеру менавіта на гэтую тэчку. Гэта можа быць не заўсёды зручна, таму ёсць магчымасць указаць выключэнні: што не трэба цягнуць у кантэкст. Для гэтага пакладзем у праект файл .dockerignore і пакажам, што не трэба для зборкі:

.git
/node_modules

і запусцім зборку яшчэ раз:

$ 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 Кбайт - нашмат лепш, чым 409 Мбайт. А яшчэ мы паменшылі памер выявы з 1.74 да 1.38Гбайт:

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

Давайце паспрабуем яшчэ паменшыць памер выявы.

Выкарыстоўваны Alpine

Яшчэ адзін спосаб зэканоміць на памеры выявы - выкарыстоўваць маленькі бацькоўскі вобраз. Бацькоўскі вобраз - гэта вобраз, на аснове якога рыхтуецца наш вобраз. Ніжні пласт паказваецца камандай FROM у Dockerfile. У нашым выпадку мы выкарыстоўваем выяву на аснове Ubuntu, у якім ужо варта nodejs. І важыць ён…

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

… амаль гігабайт. Ладна скараціць аб'ём можна, выкарыстоўваючы выяву на аснове Alpine Linux. Alpine - гэта вельмі маленькі лінукс. Докер-выява для nodejs на аснове alpine важыць усяго 88.5 Мбайт. Таму давайце заменім наш жыіірны ў дамах вобраз:

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

Нам прыйшлося ўсталяваць некаторыя штукі, якія неабходныя для зборкі прыкладання. Так, Angular не збіраецца без пітона ¯(°_o)/¯

Але затое памер выявы скінуў 150 Мбайт:

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

Ідзем яшчэ далей.

Мультыстэйдж зборка

Не ўсё, што ёсць у выяве, трэба нам у прадакшэне.

$ 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

З дапамогай docker run app ls -lah мы запусцілі кантэйнер на аснове нашага ладу app і выканалі ў ім каманду ls -lah, пасля чаго кантэйнер завяршыў сваю працу.

На продзе нам патрэбна толькі тэчка dist. Пры гэтым файлы неяк трэба аддаваць вонкі. Можна запусціць які-небудзь HTTP-сервер на nodejs. Але мы зробім прасцей. Адгадайце рускае слова, у якім чатыры літары "ы". Правільна! Ынжыныксы. Возьмем выяву з nginx, пакладзем у яго тэчку dist і невялікі канфіг:

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

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

Гэта ўсё пракруціць нам дапаможа multi-stage build. Зменім наш 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 .

Цяпер у нас дзве інструкцыі FROM у Dockerfile, кожная з іх запускае свой этап зборкі. Першы мы назвалі builder, а вось пачынаючы з апошняга FROM будзе рыхтавацца наш выніковы лад. Апошнім крокам які капіюецца артэфакт нашай зборкі ў папярэднім этапе ў выніковую выяву з nginx. Памер выявы істотна зменшыўся:

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

Давайце запусцім кантэйнер з нашым чынам і пераканаемся, што ўсё працуе:

docker run -p8080:80 app

Опцыяй -p8080:80 мы пракінулі порт 8080 на нашай хаставой машыне да порта 80 усярэдзіне кантэйнера, дзе круціцца nginx. Адкрываем у браўзэры http://localhost:8080/ і бачым наша дадатак. Усё працуе!

Некалькі парад аб тым, як паскорыць зборку Docker-вобразаў. Напрыклад, да 30 секунд

Памяншэнне памеру выявы з 1.74 Гбайт да 36 Мбайт значна скарачае час дастаўкі вашага прыкладання ў прад. Але давайце вернемся да часу зборкі.

$ 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

Змяняем парадак пластоў

Першыя тры крокі ў нас былі закэшаваныя (падказка Using cache). На чацвёртым кроку капіююцца ўсе файлы праекту і на пятым кроку ставяцца залежнасці RUN npm ci - Цэлых 47.338s. Навошта кожны раз зноўку ставіць залежнасці, калі яны мяняюцца вельмі рэдка? Давайце разбяромся, чаму яны не закэшаваліся. Справа ў тым, што докер правераць пласт за пластом, ці не памянялася каманда і файлы, звязаныя з ёй. На чацвёртым кроку мы капіюем усе файлы нашага праекта, і сярод іх, вядома ж, ёсць змены, таму докер не толькі не бярэ з кэша гэты пласт, але і ўсе наступныя! Давайце занясем невялікія змены ў 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 .

Спачатку капіююцца package.json і package-lock.json, затым ставяцца залежнасці, а толькі пасля гэтага капіюецца ўвесь праект. У выніку:

$ 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 секунд замест 3 хвілін - значна лепш! Важны правільны парадак пластоў: спачатку які капіюецца тое, што не змяняецца, затым тое, што рэдка змяняецца, а ў канцы - тое, што часта.

Далей трохі слоў аб зборцы выяў у CI/CD сістэмах.

Выкарыстанне папярэдніх выяў для кэша

Калі мы выкарыстоўваем для зборкі нейкае SaaS-рашэнне, то лакальны кэш докера можа апынуцца чысты і свежы. Каб докеру было адкуль узяць выпечаныя пласты, дайце яму папярэднюю сабраную выяву.

Разгледзім для прыкладу зборку нашага прыкладання ў GitHub Actions. Выкарыстоўваны такі канфіг

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

Выява збіраецца і пушыцца ў GitHub Packages за дзве хвіліны і 20 секунд:

Некалькі парад аб тым, як паскорыць зборку Docker-вобразаў. Напрыклад, да 30 секунд

Цяпер зменім зборку так, каб выкарыстоўваўся кэш на аснове папярэдніх сабраных выяў:

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

Для пачатку трэба расказаць, чаму запускаецца дзве каманды build. Справа ў тым, што ў мультыстэйдж-зборцы выніковай выявай будзе набор пластоў з апошняга стэйджа. Пры гэтым пласты з папярэдніх пластоў не патрапяць у выяву. Таму пры выкарыстанні фінальнай выявы з папярэдняй зборкі Docker не зможа знайсці гатовыя пласты для зборкі выявы c nodejs (стэйдж builder). Для таго каб вырашыць гэтую праблему, ствараецца прамежкавы вобраз. $IMAGE_NAME-builder-stage і адпраўляецца ў GitHub Packages, каб яго можна было выкарыстоўваць у наступнай зборцы як крыніца кэша.

Некалькі парад аб тым, як паскорыць зборку Docker-вобразаў. Напрыклад, да 30 секунд

Агульны час зборкі скараціўся да паўтары хвіліны. Паўхвіліны траціцца на падцягванне папярэдніх вобразаў.

Папярэдняе стварэнне вобразаў

Яшчэ адзін спосаб вырашыць праблему чыстага кэша докера - частка пластоў вынесці ў іншы Dockerfile, сабраць яго асобна, запушыць у Container Registry і выкарыстоўваць як бацькоўскі.

Ствараем свой лад nodejs для зборкі Angular-прыкладанні. Ствараем у праекце Dockerfile.node

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

Збіраны і пушым публічны вобраз у Docker Hub:

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

Зараз у нашым асноўным Dockerfile выкарыстоўваем гатовую выяву:

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

У нашым прыкладзе час зборкі не зменшылася, але папярэдне створаныя выявы могуць быць карысныя, калі ў вас шмат праектаў і ў кожным з іх даводзіцца ставіць аднолькавыя залежнасці.

Некалькі парад аб тым, як паскорыць зборку Docker-вобразаў. Напрыклад, да 30 секунд

Мы разгледзелі некалькі метадаў паскарэння зборкі докер-вобразаў. Калі жадаецца, каб дэплой праходзіў хутка, паспрабуйце ўжыць у сваім праекце:

  • памяншэнне кантэксту;
  • выкарыстанне невялікіх бацькоўскіх вобразаў;
  • мультыстэйдж-зборку;
  • змена парадку інструкцый у Dockerfile, каб эфектыўна выкарыстоўваць кэш;
  • наладу кэша ў CI/CD-сістэмах;
  • папярэдняе стварэнне вобразаў.

Спадзяюся, на прыкладзе стане больш зразумела, як працуе Docker, і вы зможаце аптымальна наладзіць ваш дэплой. Для таго, каб пагуляцца з прыкладамі з артыкула, створаны рэпазітар. https://github.com/devopsprodigy/test-docker-build.

Крыніца: habr.com

Дадаць каментар