Установка зависимостей приложений в сборке Docker занимает много времени? CI/CD ограничивает эффективность кэширования Docker? Используете частное репо? … Вы слышали о новых функциях кэширования BuildKit?

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

Я начну с объяснения различных вариантов кэширования, предоставляемых Docker с новым бэкендом BuildKit (Docker 18.09+), и покажушаг за шагом, как объединить их, чтобы вы не тратили ни секунды больше, чем нужно, на ожидание конвейеров сборки. Нетерпеливые могут сразу перейти к полному решению в конце статьи.

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

Соревнование

Есть несколько вещей, которые могут усложнить быструю сборку:

  • Приложение имеет множество зависимостей, загрузка и установка которых занимает много времени.
  • Задания CI/CD выполняются на машинах, над которыми у нас ограниченный контроль. В частности, мы не можем полагаться на то, какой кэш Docker присутствует.
  • Некоторые зависимости устанавливаются из частного репозитория, который требует секретных учетных данных для аутентификации.
  • Результирующий образ настолько велик, что отправка и извлечение из реестра занимает значительное время.

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

Инструменты

Кэширование слоя Docker

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

Внешние источники кэша

Что делать, если у нас нет доступного локального кеша, например, на агенте CI/CD? Менее известная функция, решающая эту проблему, — внешний источник кэша. Вы можете предоставить ранее созданный образ в своем реестре с флагом --cache-from команды build. Docker проверит манифест образа и извлечет все слои, которые можно использовать в качестве локального кеша.

Есть несколько предостережений, чтобы заставить его работать. Требуется бэкэнд BuildKit — для этого требуется версия Docker ≥18.09 и установка переменной среды DOCKER_BUILDKIT=1 перед вызовом docker build. Исходное изображение также должно быть создано с использованием --build-arg BUILDKIT_INLINE_CACHE=1, чтобы в него были встроены метаданные кеша.

Сборка маунтов

Когда дело доходит до использования каталога кеша в сборках Docker, можно подумать, что мы можем просто смонтировать его с хоста. Легко, верно? За исключением того, что не поддерживается. К счастью, в BuildKit добавлена ​​еще одна функция, которая может помочь: сборка маунтов. Они позволяют монтировать каталог из различных источников в течение одной инструкции RUN:

RUN --mount=type=cache,target=/var/cache/apt \
  apt update && apt-get install -y gcc

Существует несколько видов креплений, например,

  • bind mount позволяет смонтировать каталог из образа или из контекста сборки;
  • cache mount монтирует каталог, содержимое которого будет локально кэшироваться между сборками.

Однако содержимое монтирования недоступно для каких-либо дальнейших инструкций в файле Dockerfile.

Чтобы монтирование сборки работало, нужно включить специальную первую строку в Dockerfile # syntax=docker/dockerfile:1.3 и включить BuildKit с переменной окружения DOCKER_BUILDKIT.

Улучшение сборки шаг за шагом

Теперь, когда мы знаем, что может предложить Docker с BuildKit, давайте объединим его с некоторыми передовыми практиками, чтобы максимально сократить время сборки.

Сначала зависимости, потом код приложения

Первый трюк, часто рекомендуемый для образов Python, — переупорядочить инструкции, чтобы изменения в коде приложения не делали недействительным кеш слоя с установленными зависимостями:

Теперь слой с зависимостями будет перестраиваться только в случае изменения pyproject.toml или файла блокировки. Кстати, некоторые рекомендуют устанавливать зависимости с помощью pip, а не Poetry, но есть и причины не делать этого.

Многоэтапные сборки и виртуальные среды

Еще одна очевидная вещь — использовать многоэтапные сборки, чтобы в конечном образе были только необходимые рабочие файлы и зависимости:

Вы можете видеть, что мы использовали меньшее базовое изображение (3.8-slim) для финальной стадии и опцию --no-dev Poetry, чтобы уменьшить результат.

Мы также добавили виртуальную среду Python. Хотя это может показаться излишним в уже изолированном контейнере, он обеспечивает чистый способ переноса зависимостей между этапами сборки без ненужных системных пакетов. Все, что вам нужно для его активации, — это установить переменные PATH и VIRTUAL_ENV (некоторые инструменты используют их для определения окружения). Альтернативой venv являются файлы колеса сборки.

Одно предостережение относительно Poetry заключается в том, что вы должны быть осторожны с настройкой virtualenvs.in-project. Вот упрощенный пример того, что нельзя делать:

COPY ["pyproject.toml", "poetry.lock", "/app/"]
RUN poetry config virtualenvs.in-project true && poetry install 
COPY [".", "/app"]
FROM python:3.8-slim as final
ENV PATH="/app/.venv/bin:$PATH"
WORKDIR /app
COPY --from=build-stage /app /app

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

Передача секретов репозитория

Поэзия принимает учетные данные через переменные среды, такие как POETRY_HTTP_BASIC_<REPO>_PASSWORD. Наивным решением для передачи учетных данных репозитория PyPI является передача их с помощью --build-arg. Не делай этого.

Первая причина — безопасность. Переменные останутся встроенными в изображение, в чем вы можете убедиться с помощью docker history --no-trunc <image>. Другая причина заключается в том, что если вы используете временные учетные данные (например, предоставленные вашим CI/CD), передача учетных данных в --build-argили через COPYинструкцию сделает недействительным кэшированный слой с зависимостями!

BuildKit снова в помощь. Новый рекомендуемый подход — использовать монтажные сборки secret.

  • Сначала подготовьте файл auth.toml с вашими учетными данными, например:
[http-basic]
[http-basic.my_repo]
username = "my_username"
password = "my_ephemeral_password"
  • Поместите его вне контекста Docker или исключите его из .dockerignore (в противном случае кеш все равно будет признан недействительным).
  • Обновите свой Dockerfile, включив # syntax=docker/dockerfile:1.3 в качестве самой первой строки, и измените команду poetry install на
  • Наконец, создайте образ с DOCKER_BUILDKIT=1 docker build --secret id=auth,src=auth.toml ....

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

Кэширование без локального кеша

Одним из наших требований было использование кеша Docker также в заданиях CI/CD, для которых может не быть доступного локального кеша. Вот тогда нам и могут помочь внешние источники кеша и упомянутые ранее --cache-from. Если ваш удаленный репозиторий my-repo.com/my-image, ваша команда сборки изменится на:

DOCKER_BUILDKIT=1 docker build \
  --cache-from my-repo.com/my-image \
  --build-arg BUILDKIT_INLINE_CACHE=1 \
  ...

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

Обратите внимание, что мы использовали --target в первой команде сборки, чтобы остановиться на этапе build-stage, и что вторая команда сборки ссылалась на изображения build-stage и latest как на источники кэша.

Альтернативой отправке изображений кеша в удаленный реестр является использование docker save и управление ими как файлами.

И последнее: теперь, когда вы также продвигаете свое изображение стадии сборки, рекомендуется сделать его также меньше. Установка переменной PIP_NO_CACHE_DIR=1 ENV может помочь.

Используйте .dockerignore

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

Получить каталог кеша внутри сборки докера

Я упомяну один последний трюк, хотя я не рекомендую его, если он вам действительно не нужен. До сих пор нам удавалось избегать повторной установки зависимостей с помощью кэширования уровня Docker, если вы не меняли определения зависимостей (pyproject.toml или poetry.lock). Что, если бы мы захотели повторно использовать ранее установленные пакеты даже при изменении некоторых зависимостей (как это делает Poetry при локальном запуске)? Вам нужно будет получить кэшированный каталог venv в docker buildcontainer до запуска poetry install.

Самое простое решение — использовать монтажную сборку cache. Недостатком является то, что кеш доступен только локально и не может быть повторно использован на разных машинах. Также имейте в виду, что монтирование сборки доступно только во время одной инструкции RUN, поэтому вам нужно скопировать файлы в другое место в образе до завершения инструкции RUN (например, с cp).

Если вы сами управляете каталогом кеша на своем хосте сборки, вы можете смонтировать его с помощью монтирования сборки bind. Применяется то же предостережение, что доступно только для одной инструкции RUN.

Другой подход заключается в COPY каталоге кеша из другого образа, например, из ранее созданного build-stage образа. Вы можете вытащить его как еще один этап (FROM my-image:build-stage as cache). Сложная часть заключается в решении проблемы куриного яйца: ваша сборка должна работать в самый первый раз, прежде чем будет доступен источник кеша; и в Dockerfile нет if. Решение состоит в том, чтобы параметризовать изображение, на котором будет основываться этап кэширования:

ARG VENV_CACHE_IMAGE=python:3.8
FROM $VENV_CACHE_IMAGE as cache
RUN python -m venv /venv
FROM python:3.8 as build-stage
# ...
COPY --from=cache /venv /venv
RUN poetry install --remove-untracked

Если у вас уже есть образ build-stage, укажите на него аргумент сборки VENV_CACHE_IMAGE. В противном случае используйте какой-либо другой доступный образ по умолчанию, инструкция RUN python -m venv /venv гарантирует, что будет доступен пустой каталог /venv, так что COPY не выйдет из строя.

Комплексное решение

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

  • Измените порядок инструкций Dockerfile, чтобы только COPY спецификаций зависимостей предшествовали установке зависимостей. Скопируйте файлы приложения позже.
  • Используйте многоэтапные сборки и копируйте только необходимые зависимости с виртуальной средой (или колесами) в окончательный образ, чтобы сделать его небольшим.
  • Не смешивайте код приложения с зависимостями с помощью virtualenvs.in-project = true.
  • Используйте монтирование сборки secret для передачи учетных данных репозитория.
  • Используйте --cache-from для повторного использования образов из реестра, если локальный кеш может быть недоступен. Для этого может также потребоваться отправить в реестр отдельный образ с вашей стадией сборки.
  • Если вам абсолютно необходимо получить каталог кэша для контейнера во время docker build, используйте cache или bind монтирование сборки, или COPY его из другого образа, полученного в качестве дополнительного этапа сборки. Имейте в виду, что все варианты немного сложно реализовать правильно.

В результате Dockerfile может выглядеть примерно так:

(Я также добавил инструкции, чтобы приложение не запускалось под root из соображений безопасности. Вы можете найти этот и другие полезные советы в этой отличной статье.)

Сборка и отправка образа в CI/CD может выглядеть следующим образом:

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