Disa këshilla për të përshpejtuar ndërtimin e imazheve Docker. Për shembull, deri në 30 sekonda.

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

Часть этого пути — сборка Docker-образа. Иногда сборка длится минуты, иногда — десятки минут, что сложно назвать нормальным. В данной статье возьмём простое приложение, которое упакуем в образ, применим несколько методов для ускорения сборки и рассмотрим нюансы работы этих методов.

Disa këshilla për të përshpejtuar ndërtimin e imazheve Docker. Për shembull, deri në 30 sekonda.

У нас неплохой опыт создания и поддержки сайтов СМИ: ТАСС, The Bell, "Новая газета", Republic… Не так давно мы пополнили портфолио, выпустив в прод сайт Reminder. И пока быстро допиливали новые фичи и чинили старые баги, медленный деплой стал большой проблемой.

Деплой мы делаем на GitLab. Собираем образы, пушим в GitLab Registry и раскатываем на проде. В этом списке самое долгое — это сборка образов. Для примера: без оптимизации каждая сборка бэкенда занимала 14 минут.

Disa këshilla për të përshpejtuar ndërtimin e imazheve Docker. Për shembull, deri në 30 sekonda.

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

Disa këshilla për të përshpejtuar ndërtimin e imazheve Docker. Për shembull, deri në 30 sekonda.

Для данной статьи, чтобы не привязываться к окружению 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

Me 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/ и видим наше приложение. Всё работает!

Disa këshilla për të përshpejtuar ndërtimin e imazheve Docker. Për shembull, deri në 30 sekonda.

Уменьшение размера образа с 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 секунд:

Disa këshilla për të përshpejtuar ndërtimin e imazheve Docker. Për shembull, deri në 30 sekonda.

Теперь изменим сборку так, чтобы использовался кэш на основе предыдущих собранных образов:

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

Для начала нужно рассказать, почему запускается две команды ndërto. Дело в том, что в мультистейдж-сборке результирующим образом будет набор слоёв из последнего стейджа. При этом слои из предыдущих слоёв не попадут в образ. Поэтому при использовании финального образа с предыдущей сборки Docker не сможет найти готовые слои для сборки образа c nodejs (стейдж builder). Для того чтобы решить эту проблему, создаётся промежуточный образ $IMAGE_NAME-builder-stage и отправляется в GitHub Packages, чтобы его можно было использовать в последующей сборке как источник кэша.

Disa këshilla për të përshpejtuar ndërtimin e imazheve Docker. Për shembull, deri në 30 sekonda.

Общее время сборки сократилось до полутора минут. Полминуты тратится на подтягивание предыдущих образов.

Предварительное создание образов

Ещё один способ решить проблему чистого кэша докера — часть слоёв вынести в другой 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
...

В нашем примере время сборки не уменьшилось, но предварительно созданные образы могут быть полезны, если у вас много проектов и в каждом из них приходится ставить одинаковые зависимости.

Disa këshilla për të përshpejtuar ndërtimin e imazheve Docker. Për shembull, deri në 30 sekonda.

Мы рассмотрели несколько методов ускорения сборки докер-образов. Если хочется, чтобы деплой проходил быстро, попробуйте применить в своём проекте:

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

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

Burimi: habr.com

Bli një hosting të besueshëm për faqet me mbrojtje DDoS, VPS VDS serverë 🔥 Bli një hosting të besueshëm për faqet me mbrojtje DDoS, VPS VDS serverë | ProHoster