Несколько советов о том, как ускорить сборку Docker-образов. Например, до 30 секунд

—

от автора

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

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

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

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

В конце концов стало понятно, что так жить больше нельзя, и мы сели разобраться, почему образы собираются столько времени. В итоге удалось сократить время сборки до 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/ и видим наше приложение. Всё работает!

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

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

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

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

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

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

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

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

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

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

ссылка на оригинал статьи https://habr.com/ru/company/itsumma/blog/501680/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *