Сделайте ваши контейнерные среды CI по-настоящему полезными, ускорив сборку Docker

Современный цикл разработки программного обеспечения означает упаковку ваших приложений часто в виде контейнера. Эта задача может занять много времени и может значительно замедлить ваше тестирование или развертывание. Проблема особенно очевидна в контексте процесса непрерывной интеграции и развертывания, когда образы создаются при каждой модификации кода.

В этой статье мы обсудим различные способы ускорения времени сборки образов Docker в конвейере непрерывной интеграции путем реализации различных стратегий.

Локальная упаковка образца приложения

В качестве примера мы сначала возьмем приложение Python Flask. Не может быть проще:

Написание Dockerfile

Напишем соответствующий Dockerfile:

Здесь вы можете увидеть классический процесс многоэтапной сборки:

  • Мы начинаем с легкого базового образа, в котором мы устанавливаем инструменты сборки и загружаем или компилируем зависимости в виртуальную среду Python.
  • На втором этапе мы копируем виртуальный env с нашими зависимостями в целевой образ и, наконец, добавляем файлы приложения.

Почему этот двухэтапный процесс? Во-первых, потому что у вас есть защищенный процесс сборки, поскольку он выполняется в контейнере без вмешательства среды хоста. И во-вторых, у вас есть тонкий финальный образ без всех библиотек сборки, а только то, что требуется для запуска приложения.

Запуск и тестирование образа

Убедитесь, что все работает как положено:

docker build -t hello .
docker run -d --rm -p 5000:5000 hello
curl localhost:5000
Hello, World!

Если вы запустите команду docker build второй раз:

docker build -t hello .
...
Step 2/15 : RUN apk update && apk add --no-cache make gcc && pip install --upgrade pip
 ---> Using cache
 ---> 24d044c28dce
...

Как видите, эта вторая сборка выполняется намного быстрее, поскольку слои кэшируются в вашей локальной службе Docker и используются повторно, если они не вносят изменений.

Нажимаем изображение

Давайте опубликуем наш образ во внешнем реестре и посмотрим, что произойдет:

docker tag hello my-registry/hello:1.0
docker push my-registry/hello:1.0
The push refers to repository [my-registry/hello]
8388d558f57d: Pushed 
77a59788172c: Pushed 
673c6888b7ef: Pushed 
fdb8581dab88: Pushed
6360407af3e7: Pushed
68aa0de28940: Pushed
f04cc38c0ac2: Pushed
ace0eda3e3be: Pushed
latest: digest: sha256:d815c1694083ffa8cc379f5a52ea69e435290c9d1ae629969e82d705b7f5ea95 size: 1994

Обратите внимание, как каждый промежуточный уровень идентифицируется хешем. Мы насчитываем 8 уровней, потому что у нас в Dockerfile есть ровно 8 команд докеров сверх последней FROM инструкции.

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

Нет проблем с локальной сборкой, давайте теперь посмотрим, как она работает в среде CI.

Создание образа Docker в контексте конвейера CI

В реальной жизни создание и распространение образов Docker не обязательно выполняется локально, как это, но обычно выполняется внутри платформы непрерывной интеграции и развертывания. Вы хотите создавать и продвигать свой образ при каждом изменении кода перед развертыванием приложения. Конечно, время сборки имеет решающее значение, поскольку вам нужен очень быстрый цикл обратной связи.

Тестовая среда CI

Мы будем использовать среду CI, используя:

Последний пункт важен, потому что наши задания CI будут выполняться в контейнерной среде. Имея это в виду, каждая работа создается как под Kubernetes. Каждое современное решение CI использует контейнерные задания, и все они сталкиваются с одной и той же проблемой при попытке создать контейнеры Docker: вам нужно заставить команды Docker работать внутри контейнера Docker.

Чтобы все прошло гладко, у вас есть два варианта:

  • Привязка the/var/run/docker.sock, которую слушает демон Docker, эффективно делает демон хоста доступным для нашего контейнера заданий
  • Использование дополнительного контейнера, запускающего «Docker in Docker» (также известного как dind) вместе с вашей работой. Dind - это особый вариант Docker, работающий как привилегированный и настроенный для работы внутри самого Docker 😵

Мы будем использовать более поздний вариант для простоты.

Реализация конвейера GitLab

В конвейере GitLab вы обычно создаете служебные контейнеры, такие как DinD, с помощью ключевого слова службы.

В приведенном ниже фрагменте конвейера и задание docker-build, и контейнер службы dind будут выполняться в одном и том же поде Kubernetes. Когда docker используется в сценарии задания, он отправляет команды во вспомогательный контейнер dind благодаря переменной среды DOCKER_HOST.

Запуск трубопровода

Этот конвейер должен работать нормально. Запустив его один раз и проверив вывод задания, мы получаем:

$ docker build -t hello .
Step 1/15 : FROM python:3.7-alpine as builder
...
Step 2/15 : RUN apk update && apk add --no-cache make gcc && pip install --upgrade pip
---> Running in ca50f59a21f8
fetch http://dl-cdn.alpinelinux.org/alpine/v3.12/main/x86_64/APKINDEX.tar.gz
...

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

Если вы запустите конвейер во второй раз, ничего не меняя, вы должны увидеть то же самое: каждый слой перестраивается! Когда мы запускали наши команды сборки локально, кешированные слои использовались повторно, но не здесь. Для такого простого изображения это не имеет особого значения, но в реальной жизни, где для создания некоторых изображений могут потребоваться десятки минут, это может стать настоящей проблемой.

Это почему? Просто потому что в этом случае dind - это временный контейнер, который создается вместе с заданием и умирает после его завершения, поэтому все кэшированные данные теряются . К сожалению, вы не можете легко сохранить данные между запусками двух конвейеров.

Как мы можем извлечь выгоду из кеша и по-прежнему использовать Dind-контейнер?

Использование кеша Docker при запуске Docker в Docker

Одно решение: танцы на вытягивании / толчке

Первое решение довольно простое: мы будем использовать наш удаленный реестр (тот, который мы вставляем) в качестве удаленного кеша для наших слоев.

Точнее:

  1. Мы начинаем с извлечения самого последнего образа (т.е. latest) из удаленного реестра, который будет использоваться в качестве кеша для следующей команды docker build.
  2. Затем мы создаем изображение, используя извлеченное изображение в качестве кеша (аргумент --cache-from), если он доступен.
  3. Мы помечаем эту новую сборку меткойlatest и SHA фиксации.
  4. Наконец, мы помещаем оба изображения с тегами в удаленный реестр, чтобы их также можно было использовать в качестве кеша для последующих сборок.

Если вы запустите этот новый конвейер два раза, использование кеша по-прежнему будет неудовлетворительным:

  • Все слои из базового builder изображения перестраиваются.
  • Только первые 2 уровня (8 и 9) последнего этапа используют кэш, но следующие уровни перестраиваются.
...
Step 8/15 : ENV VENV="/venv"
---> Using cache
---> 8b5e90958c41
Step 9/15 : ENV PATH="${VENV}/bin:$PATH"
---> Using cache
---> 840e974fb7c1
Step 10/15 : COPY --from=builder ${VENV} ${VENV}
---> 9c9696d7cbc4
Step 11/15 : WORKDIR /app
---> Running in 786fe0a0193d
Removing intermediate container 786fe0a0193d
---> bb5e7565442e
...

Как мы видели ранее, при отправке нашего образа локально слои базового образа компоновщика не помещаются в удаленный реестр и фактически теряются. Следовательно, когда мы извлекаем самые свежие образы, их там нет, и их нужно восстанавливать.

Затем, когда наше финальное изображение этапа построено (шаги с 8 по 15), первые два слоя присутствуют в изображении, которое мы извлекли и использовали в качестве кеша. Но на шаге 10 мы получаем зависимости от образа компоновщика, которые изменились, поэтому все последующие шаги также строятся заново.

Подводя итог, можно сказать, что использование кеша ограничено: 2 шага из 15 получают пользу от кеша! Чтобы улучшить его, нам нужно отправить образ промежуточного компоновщика в удаленный реестр, чтобы сохранить его слои. :

Мы создаем промежуточный этап нашего сборщика как правильный образ докера, используя параметрtarget. После этого мы помещаем его в удаленный реестр, в конечном итоге извлекая его в качестве кеша для создания нашего окончательного образа. При запуске конвейера наше время сократилось до 15 секунд!

Как видите, сборка постепенно усложняется. Если вы заблудились, просто подумайте об изображении с 3 или 4 промежуточными стадиями! Хотя это действительно работает. Другой недостаток заключается в том, что вам придется каждый раз загружать и скачивать все эти слои, что может быть довольно дорогостоящим с точки зрения затрат на хранение и передачу.

Другое решение: внешний сервис Dind

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

Почему бы не сделать Dind гражданином первого класса, создав сервис Dind в нашем кластере Kubernetes? Он будет работать с подключенным PersistentVolume для обработки кэшированных данных, и каждое задание может отправлять свои команды докеров в эту общую службу.

Создать такой сервис в Kubernetes несложно:

Затем мы немного изменим наш исходный конвейер GitLab, чтобы он указывал на эту новую внешнюю службу, и удалили встроенную службу dind:

Если вы запустите конвейер дважды, второй раз сборка должна занять 10 секунд, что даже лучше, чем наше предыдущее решение. Для «большого» изображения, для построения которого требуется около 10 минут, эта стратегия также сокращает время построения до нескольких секунд, если слои не изменились.

Последний вариант: использование Канико

Последним вариантом может стать использование Канико. С его помощью вы можете создавать образы Docker без использования демона Docker, делая все, что мы видели, без проблем.

Однако обратите внимание, что при этом вы не можете использовать расширенные параметры BuildKit, такие как, например, ввод секретов при создании вашего изображения. По этой причине я не выбрал это решение.

Заключение

Поскольку при разработке программного обеспечения контейнеры широко используются повсюду, их эффективное создание является ключевым моментом в конвейере выпуска. Как мы видели, проблема может стать довольно сложной, и у каждого решения есть свой компромисс. Предлагаемые здесь решения проиллюстрированы с использованием GitLab, но имейте в виду, что они по-прежнему верны в любой другой контейнерной среде CI.