Несколько советов о том, как ускорить сборку Docker-образов. Например, до 30 секунд
Иногда сборка docker-образов длится минуты, иногда — десятки минут, что сложно назвать нормальным. В конце концов стало понятно, что так жить больше нельзя, и мы сели разобраться, почему образы собираются столько времени. В итоге удалось сократить время сборки до 30 секунд!
Прежде чем фича попадет на прод, в наше время сложных оркестраторов и CI/CD предстоит пройти долгий путь от коммита до тестов и доставки. Раньше можно было кинуть новые файлы по FTP (так больше никто не делает, верно?), и процесс «деплоя» занимал секунды. Теперь же надо создать merge request и ждать немалое время, пока фича доберётся до пользователей.
Часть этого пути — сборка Docker-образа. Иногда сборка длится минуты, иногда — десятки минут, что сложно назвать нормальным. В данной статье возьмём простое приложение, которое упакуем в образ, применим несколько методов для ускорения сборки и рассмотрим нюансы работы этих методов.
У нас неплохой опыт создания и поддержки сайтов СМИ: ТАСС, 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 будет сохранять изменения в файловой системе и накладывать их на предыдущие. Каждая команда создаёт свой слой. А готовый образ — это объединённые вместе слои.
Что важно знать: каждый слой докер умеет кэшировать. Если ничего не изменилось с прошлой сборки, то вместо выполнения команды докер возьмёт уже готовый слой. Поскольку основной прирост в скорости сборки будет за счет использования кэша, в замерах скорости сборки будем обращать внимание именно на сборку образа с готовым кэшем. Итак, по шагам:
- Удаляем образы локально, чтобы предыдущие запуски не влияли на тест.
docker rmi $(docker images -q)
- Запускаем билд первый раз.
time docker build -t app .
- Меняем файл src/index.html — имитируем работу программиста.
- Запускаем билд второй раз.
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)/¯
Но зато размер образа сбросил 619 Мбайт:
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.