Декілька порад про те, як прискорити складання 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

Додати коментар або відгук