Привет, Хабр! С вами эксперты ИнфоТеКС. В современной ИТ-индустрии контейнеризация стала неотъемлемой частью разработки и эксплуатации систем. Docker, как один из ключевых инструментов, прочно вошёл в повседневную практику. Однако с ростом сложности проектов, особенно в микросервисной архитектуре, возникает проблема, которая может существенно замедлить процесс разработки — скорость сборки Docker-образов.

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

Содержание

Вкратце про подходы

Всё больше компаний и команд разработчиков переходят на использование контейнеров для создания и развёртывания новых сервисов. Это связано с тем, что контейнеры:

  • повышают гибкость и масштабируемость

  • упрощают управление зависимостями

Однако, несмотря на все преимущества, проблемой остаётся скорость сборки Docker-образов. Она является ключевым фактором в процессах CI/CD, особенно в проектах, основанных на микросервисной архитектуре. В условиях частых обновлений и необходимости быстрого внесения изменений разработчики сталкиваются с задачей оптимизации времени сборки.

На сегодняшний день существует два подхода к хранению кода:

Подход

Отдельные репозитории для каждого компонента/микросервиса, со своим набором данных и процессом сборки

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

Плюсы

Скорость сборки.

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

Общие инструменты и зависимости, а также общая основа в виде базовых образов позволяют снизить количество проблем с совместимостью

Минусы

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

Время на сборку.

Все образы собираются одновременно, время сборки значительно увеличивается.

О решении этой проблемы мы и расскажем далее

Эволюция сборки Docker: от хаоса к оптимизации

ИнфоТеКС занимается разработкой ПО и аппаратных средств защиты информации. Проект, на примере которого мы рассмотрим работу с Docker BuildX, — управляющее ПО, комплекс приложений.

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

  • Каждый сервис имел свой файл, что приводило к громоздкости и усложняло сопровождение.

  • Потенциально разные зависимости в проектах усложняли управление версиями и совместимостью.

  • Из-за одной ошибочной сборки в одном сервисе приходилось пересобирать приложение с нуля.

  • Добавление нового сервиса требовало значительных временных и ресурсных затрат.

Эта устаревшая структура не только замедляла процесс разработки, но и увеличивала ошибки, что в конечном итоге влияло на качество продукта.

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

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

  1. Bazel. Сборка системы от Google, оптимизирована для крупных проектов со множеством зависимостей.

  2. Встроенное решение GitHub CI/CD, поддерживающее кеширование слоёв образов.

  3. Kaniko. Альтернатива для сборки образов в Kubernetes, подходящая для облачных сред с ограниченными правами.

Почему мы выбрали Docker Buildx?

Docker Buildx — это расширение системы Docker Build, которое значительно улучшает процесс сборки. Изначально мы сомневались в его применимости, так как переход на новый запрос требовал времени на изучение и доработку кода. Но после первого опыта использования стало понятно, что без него не обойтись, если нужно собрать что-то более сложное, чем один образ.

Плюсы процесса, которые значительно упростили работу:

  • количество файлов конфигураций

  • единообразие процесса для всех составляющих (минимизация ошибки человеческого фактора)

  • скорость внесения изменений

Ключевые преимущества Docker Buildx перед Docker Build:

  1. Мультиархитектурные сборки

    Даёт возможность создавать образцы для нескольких архитектур (например, x86 и ARM) в одной команде, что критично для современных микросервисов.

  2. Расширенное кеширование

    Ускоряет процессы CI/CD, умеет сохранять и использовать кеш локально, в docker-реестре и в S3-совместимых сервисах. Это особенно полезно, когда сборка может происходить на разных сборочных агентах (runners, workers) из общего пула.

  3. Инкрементальные сборки

    Уменьшает время сборки и делает процесс более эффективным, особенно при небольших, но частых изменениях кода.

  4. Сборки в распределённых средах

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

Эти преимущества делают Docker Buildx эффективным средством оптимизации процессов сборки и развёртывания, что в конечном итоге приводит к более быстрой и качественной разработке ПО.

Пример использования BuildX Bake

В примере будет три файла:

  1. Dockerfile – единый файл для всех образов/микросервисов. Он выглядит так же, как если бы мы собирали single image, разве что содержит большее количество переменных.

  2. docker-bake.hcl – в нём будем описывать общие переменные (variable), функции (function) и группы.

  3. env.hcl – в нём хранятся значения переменных для конкретной конфигурации сборки. Название файла может быть любое.

docker-bake.hcl

В файле docker-bake.hcl хранятся все переменные, группы сервисов и сами сервисы (таргеты проекта).

Переменные

variable "version" {}
variable "languages" {}
variable "registry" {}
...

variable "IMAGE_SDK" {}
variable "IMAGE_RUNTIME" {}
variable "IMAGE_SIGNING" {}
variable "IMAGE_ASTRA_SMOLENSK" {}
...

Цели (Targets)

Для каждого сервиса создаётся таргет со своими параметрами: название, теги будущего образа и т.д. Также можно создать общий базовый таргет с дефолтными настройками образа. Таргеты могут выстраиваться в цепочку и наследовать параметры от родителя, при необходимости подменяя и дополняя их.

target "base" {
    args = {
        version = "${version}
        languages = "${languages}"
        registry = "${registry}"

        IMAGE_SDK = "${IMAGE_SDK}"
        IMAGE_RUNTIME = "${IMAGE_RUNTIME}"
        IMAGE_SIGNING = "${IMAGE_SIGNING}"
        IMAGE_ASTRA_SMOLENSK = "${IMAGE_ASTRA_SMOLENSK}"
    }
}

target "license-service" {
    inherits = ["base"]
    args = {
        project = "LicenseService"
        service = "license-service"
        edition = "${edition}"
    }
    tags = ["${registry}/prod-smp/smp-license:${edition}-${version}"]
}

Группы

Группы — это объединённые по каким-либо критериям таргеты. Например, в default входят все таргеты проекта. Сюда мы складываем таргеты, которые будут собираться, если иное не указано явным образом. Также можно формировать любые группы по необходимому набору критериев. Например, весь продукт (все модули); все компоненты выбранного модуля; выбранный компонент (для тестов, отладки и т.д.).

group "default" {
    targets = ["transport-service", "backup-service", "billing-service", "import-service", "licence-service", "log-service", "messaging-service", "smp-service", "storage-service", "nginx"]
}

Файлы с переменными (env.hcl)

Мы можем объявить отдельный файл с переменными, которые будут использованы при сборке. Это удобно, когда нужны разные конфигурации сборки, например, версии SDK.

variable "IMAGE_SDK" {
    default ="dep-mngmob-net/net-sdk:6.0.424-astra-smolensk1.7.5uu1-36"
}

variable "IMAGE_SIGNING" {
    default = "dep-mngmob-net/signing:2.10.1.190-debian11-26
}

variable "IMAGE_ASTRA_SMOLENSK" {
    default = "dep-mngmob-net/astra-smolenks:1.7.5.uu1-12"
}

Dockerfile

Разберём основные элементы структуры Dockerfile, которая применяется для всех наших бэкенд-микросервисов.

Это мультистейдж-процесс, где в рамках одного Dockerfile происходит цепочка действий над проектом. Мы опустили часть кода из соображений конфиденциальности, но постарались сохранить общий смысл.

Первым этапом на основе базового SDK-образа копируется исходный код и восстанавливаются все зависимости проекта. Это происходит на основе базового образа SDK. Как результат работы этапа – получаем слой restore, включающий в себя весь исходный код и все скачанные зависимости.

ARG registry = "harbor"
ARG IMAGE_SDK
ARG IMAGE_RUNTIME
ARG IMAGE_SIGNING
ARG IMAGE_CRG_TOOLS

#============================================#
#=                 SDK                      =#
#============================================#
FROM ${registry}/${IMAGE_SDK} AS sdk

#============================================#
#=                 RESTORE                  =#
#============================================#
FROM sdk AS restore

ARG edition
ARG languages

ENV EDITION=${edition}

WORKDIR /build

COPY Source/BackupService/...
COPY Source/StorageService/...
...

RUN --mount=type=cache,target=/root/.nuget/packages \
    dotnet restore --configfile nuget.config

COPY Source/ Source/

COPY ./scripts/ci/build/installer/manage-localizations.sh ./manage-localizations.sh
RUN ./manage-localizations.sh --languages "${languages}"

Вторым этапом,  запускаем процесс непосредственно сборки проекта. Принимая за основу артефакты предыдущего слоя – restore as build, мы собираем конкретный dotnet-проект. Все параметры для его сборки — название, версия и т.д. — формируются на основе bake-файла.

#============================================#
#=                 PROD BUILD               =#
#============================================#
FROM restore AS prod-build

ARG version
ARG project
ARG edition

RUN --mount=type=cache,target=/root/.nuget/packages \
    dotnet publish "Source/${project}.Host/${project}.Host.csproj" \
    -c Release -f linux-x64 --no-restore --self-contained -o /out \
    /p:Version="${version}"

Так как мы поддерживаем работу ПО на Astra Linux в режиме ЗПС (замкнутой программной среды), все компоненты приложения должны иметь соответствующую подпись. Это следующий этап, где для работы базового образа подписи используются артефакты, созданные на стадии Build.

#============================================#
#=                 PROD SIGN                =#
#============================================#

FROM ${registry}/${IMAGE_SIGNING} AS prod-build-sign

ARG ... # Аргументы, необходимые для подписи

COPY --link --from=prod-build /out /out
RUN ... # Запуск скрипта подписи

Можно также выделить этап тестирования приложения. Здесь используется результат работы restore, как и для этапа build. Это позволяет значительно сократить время сборки, выполняя операцию restore только один раз для всех задач.

#============================================#
#=                 TEST                     =#
#============================================#
FROM restore AS test

RUN --mount=type=cache,target=/root/.nuget/packages \
    dotnet build -C Release --no-restore

ENTRYPOINT ["dotnet", "test", "--logger", "console;verbosity=normal", "./**.Tests.dll"]

Запуск сборки

Для запуска процесса сборки будем использовать следующую команду.

version="${version:?}" \
edition="${edition:?}" \
languages="$languages" \
docker buildx bake \
-f "docker-bake-hcl" \
-f "env.hcl" \
"${target:-'default'}" \
"${args[@]}"

Значения, генерируемые CI/CD, передаём как переменные среды, остальные – из файла env.hcl.

Результат

В итоге получается Dockerfile и два hcl-файла:

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

  2. Bake-файл содержит общие параметры всех образов и индивидуальные параметры каждого сервиса.

  3. env.hcl содержит параметры, необходимые для конкретной конфигурации сборки.

Из плюсов:

  1. Больше не нужно вручную менять значение переменной в нескольких местах: замена в одном файле сразу применяется ко всему проекту.

  2. Стало легче масштабировать количество сервисов в проекте: теперь для этого достаточно добавить несколько строк кода.

Кеширование

Каждый образ состоит из последовательности слоёв, которые создаются на основе шагов, описанных в Dockerfile. Например, команды COPY, RUN, ADD и другие генерируют новые слои.

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

Может ли кеширование вызывать проблемы? У него, помимо очевидных плюсов, есть и недостатки. Одна из распространённых проблем — применение устаревших слоёв образов. Это может привести к некорректной работе приложения, особенно при изменении зависимостей. Некорректная работа кеширования может замедлить процесс разработки и усложнить отладку.

Когда кеширование в Docker Buildx следует отключить?

Есть случаи, когда кеширование Docker-образов не применяется. Чтобы гарантировать, что финальный образ будет содержать все актуальные изменения и обновления, мы соглашаемся с тем, что сборка может занять больше времени. Это предотвращает возможные проблемы, связанные с использованием устаревших данных.

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

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

Безопасность в Docker

Нельзя не остановиться и на безопасности процесса сборки образов.

Согласно последним отчётам более 44% всех контейнеров, которые находятся в коммерческой эксплуатации, содержат уязвимости. Угроза не ограничивается выявленными недостатками — хакеры беспрерывно ищут новые векторы атак на системы, построенные с использованием Docker. Поэтому даже своевременное обновление не гарантирует долгосрочную защищённость систем.

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

Рекомендации по безопасности для Dockerfile и Docker Compose

При создании и развёртывании контейнеров защищённость должна закладываться на этапе конфигурации. Использование безопасных практик при написании Dockerfile поможет избежать распространённых уязвимостей.

Обновляйте базовый образ

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

Удаляйте временные файлы и неиспользуемые зависимости

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

Запускайте контейнер от имени служебного пользователя

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

Ищите и устраняйте уязвимости

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

Подводим итоги

Перечислим ещё раз основные шаги по оптимизации процесса мультистейдж-сборки на Docker BuildX:

  • Анализ сборки

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

  • Настройка Dockerfile и CI/CD

    Внедряйте кеширование постепенно, анализируйте и вносите коррективы на каждом этапе.

  • Обучение команды

    Введите стандарты эффективной работы и обучите команду их применению, чтобы повысить стабильность и скорость разработки.

  • Мониторинг и корректировка

    Анализируйте метрики времени сборки и ресурсоёмкость для корректировки стратегий кеширования.

  • Своевременно обновляйте базовые компоненты

    При использовании сторонних библиотек – следите за их актуальностью. Используйте только актуальные версии, и что важно – поддерживаемые компоненты, в которых все найденные уязвимости своевременно устраняются.

  • Внедряйте в CI/CD-конвейер сканирование образов

    Своевременное обнаружение проблем на стадии разработки/сборки проекта позволит минимизировать время реакции на замечания анализаторов, а также время их устранения.

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

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

На этом всё. Желаем успешного внедрения и ускорения сборки на Docker BuildX!

P.S. Чтобы не пропустить наши новые материалы, подписывайтесь на блог! И заходите в TG-канал, там мы рассказываем о технических мероприятиях и конференциях, делимся выступлениями экспертов, обсуждаем подборки на технические и ИБ темы.