Сборка контейнерных образов через Docker Bake и Dagger
Разберем подход к сборке и публикации OCI образов в монорепозитории через Docker Buildx Bake, Dagger и GitHub Actions
Контейнерные образы часто начинают жить довольно просто. Есть Dockerfile, есть команда docker build, иногда рядом появляется docker push. Пока образ один, такой подход кажется нормальным. Но когда образов становится больше, появляются версии, несколько платформ, разные registry, автоматические обновления зависимостей и публикация из CI, простая команда постепенно превращается в набор договоренностей, которые нигде явно не описаны.
Хочется получить более предсказуемую схему:
- Метаданные сборки лежат рядом с образом.
- CI только запускает нужные проверки и публикацию.
- Логика сборки не привязана к конкретному CI-провайдеру.
- Версии зависимостей обновляются автоматически.
- После публикации остается понятный release marker в git.
Для этого можно совместить Docker Buildx Bake и Dagger. Bake будет отвечать за декларативное описание образов, а Dagger - за выполнение сборки, проверки и публикации. Дальше покажу реализацию на примере репозитория container-images.
Пример сборки hugo-autoprefixer
Первым образом в таком пайплайне будет hugo-autoprefixer. Он нужен для сборки статических сайтов, где используется Hugo extended и autoprefixer.
В этом примере рассматриваем два файла:
docker/hugo-autoprefixer/Dockerfile- описывает, из чего собирается контейнерный образ.docker/hugo-autoprefixer/docker-bake.json- описывает build target, версии зависимостей, теги, labels и platforms.
Начнем с Dockerfile. Он небольшой:
1
2
3
4
5
6
7
ARG HUGO_VERSION
FROM hugomods/hugo:exts-${HUGO_VERSION}
ARG AUTOPREFIXER_VERSION
RUN npm install --global "autoprefixer@${AUTOPREFIXER_VERSION}"
Что здесь происходит:
ARG HUGO_VERSIONобъявляется доFROM, потому что версия Hugo нужна для выбора базового образа.- Базовый образ берется из
hugomods/hugoс тегомexts-${HUGO_VERSION}. Суффиксextsозначает Hugo extended. - После
FROMобъявляетсяARG AUTOPREFIXER_VERSION, потому что эта переменная используется уже внутри build stage. - Команда
npm install --globalустанавливает конкретную версию autoprefixer внутрь образа.
Dockerfile хорошо описывает, как собрать образ, но не всегда удобно хранить в нем всю внешнюю информацию о сборке. Например:
- Какие build args используются для версии базового образа и npm-пакетов.
- В какие registry и repository нужно публиковать образ.
- Какие labels должны попасть в итоговый OCI образ.
- Для каких платформ нужно выполнять сборку.
- Какой тег считается релизным.
Все это можно передавать флагами в docker build, но тогда часть важной конфигурации начинает жить в CI. В результате GitHub Actions, GitLab CI или любой другой провайдер становятся не просто запускателем, а местом, где спрятана логика сборки.
Docker Buildx Bake решает эту проблему как стандартный формат описания build targets. В нашем случае для каждого образа будет свой docker-bake.json:
1
2
3
4
docker/
hugo-autoprefixer/
Dockerfile
docker-bake.json
Так образ становится самодостаточным: Dockerfile описывает слои, а docker-bake.json описывает контекст, аргументы, labels, platforms и итоговые теги.
Теперь посмотрим на начало docker/hugo-autoprefixer/docker-bake.json:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"variable": {
"REGISTRY": {
"default": "ghcr.io"
},
"REPOSITORY_PREFIX": {
"default": "riftonix/container-images"
},
"HUGO_VERSION": {
"default": "0.154.5",
"description": "renovate: datasource=docker depName=hugomods/hugo extractVersion=^exts-(?<version>.+)$"
},
"AUTOPREFIXER_VERSION": {
"default": "10.5.0",
"description": "renovate: datasource=npm depName=autoprefixer"
}
}
}
Здесь есть две группы переменных.
Первая группа - runtime-настройки публикации:
REGISTRYREPOSITORY_PREFIX
По умолчанию образ публикуется в ghcr.io/riftonix/container-images (эти значения при желании можно переопределить).
Вторая группа - версии зависимостей:
HUGO_VERSIONAUTOPREFIXER_VERSION
Именно эти значения попадут в Dockerfile как build args. Дополнительно у них есть description с метаданными Renovate. Благодаря этому Renovate понимает, что HUGO_VERSION нужно проверять по docker-тегам hugomods/hugo, а AUTOPREFIXER_VERSION - как npm-пакет autoprefixer.
Дальше в этом же файле описан сам target:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"target": {
"hugo-autoprefixer": {
"context": "docker/hugo-autoprefixer",
"dockerfile": "Dockerfile",
"args": {
"HUGO_VERSION": "${HUGO_VERSION}",
"AUTOPREFIXER_VERSION": "${AUTOPREFIXER_VERSION}"
},
"tags": [
"${REGISTRY}/${REPOSITORY_PREFIX}/hugo-autoprefixer:${HUGO_VERSION}-${AUTOPREFIXER_VERSION}"
],
"platforms": [
"linux/amd64",
"linux/arm64"
]
}
}
}
Этот блок связывает Dockerfile и параметры сборки:
contextговорит, какую директорию передать в build.dockerfileуказывает файл относительно context.argsпередает версии в Dockerfile.tagsописывает итоговый image reference.platformsзадает сборку подlinux/amd64иlinux/arm64.
Если подставить текущие значения переменных, тег образа получится таким:
1
ghcr.io/riftonix/container-images/hugo-autoprefixer:0.154.5-10.5.0
В полном docker-bake.json target также содержит OCI labels:
1
2
3
4
5
6
7
8
9
10
{
"labels": {
"org.opencontainers.image.title": "hugo-autoprefixer",
"org.opencontainers.image.version": "${HUGO_VERSION}-${AUTOPREFIXER_VERSION}",
"org.opencontainers.image.source": "https://github.com/riftonix/container-images",
"org.opencontainers.image.base.name": "hugomods/hugo:exts-${HUGO_VERSION}",
"io.riftonix.container-images.hugo.version": "${HUGO_VERSION}",
"io.riftonix.container-images.autoprefixer.version": "${AUTOPREFIXER_VERSION}"
}
}
Labels нужны не для сборки, а для нормальной трассируемости опубликованного образа. По ним можно понять, откуда образ был собран, какая версия Hugo использовалась как база и какая версия autoprefixer установлена внутрь.
В итоге Dockerfile отвечает только на вопрос “как собрать образ”, а docker-bake.json отвечает на вопросы “с какими версиями”, “под какие платформы”, “с какими метаданными” и “куда публиковать”.
Здесь есть важное ограничение: Dagger CI не поддерживает Docker Bake нативно как встроенный backend. Нельзя просто передать ему docker-bake.json и ожидать, что он сам выполнит target так же, как docker buildx bake.
Поэтому для этого репозитория реализуется отдельная логика в Dagger Docker module. Она читает JSON Bake manifest, резолвит переменные, выбирает нужный target и переводит поддержанные поля Bake в Dagger-native API сборки. В результате docker-bake.json остается источником правды для описания образа, но сама сборка выполняется через Dagger без зависимости от Docker socket, Buildx builder и CLI-окружения конкретного CI.
Итоговая схема
Самая важная часть подхода - правильно разделить ответственность.
Bake описывает все, что связано со сборкой.
Dagger выполняет операции:
- читает Bake manifest
- резолвит переменные
- собирает образ через Dagger-native API
- проверяет образ без публикации
- публикует все разрешенные ссылки на образы
- отдает release tag для git
GitHub Actions только решает, когда запускаться:
- на pull request - verify
- на push в master - publish
- после успешной публикации - создать git tag marker
То есть workflow остается тонким. В нем есть checkout, секреты, условия запуска и вызов Dagger. Вся логика сборки остается в переиспользуемых Dagger-модулях и scenario.
В этом пайплайне используются:
- Dagger Docker module - читает Bake manifest, резолвит target и собирает OCI образ через Dagger-native API.
- Dagger container-images scenario - дает CI функции для верификации и публикации.
- Dagger Git module - создает или подтверждает git tag после успешной публикации.
Итоговый пайплайн получается следующим.
На pull request:
- GitHub Actions вызывает
make changed-components, а тот через Git module определяет измененные директорииdocker/*и shared-файлы вроде workflow,renovate.jsonи Makefile. - Workflow вызывает Dagger verify для нужного Bake target.
- Dagger читает
docker-bake.jsonи собирает образ без публикации. - Aggregation job
CI Passedстановится единой required-проверкой.
На push в master:
- GitHub Actions запускает publish workflow.
- Dagger настраивает registry auth.
- Dagger собирает Bake target и публикует разрешенные ссылки на образы.
- Dagger получает release marker из метаданных Bake.
- Git-модуль создает или подтверждает git tag.
Локально проверить сборку можно без учетных данных registry:
1
make verify docker/hugo-autoprefixer
Для проверки publish-сценария без реальной отправки образа есть отдельный пробный запуск:
1
make publish-dry-run docker/hugo-autoprefixer
Реальная публикация требует параметры для registry и git. Для GHCR достаточно передать имя пользователя и токен через GITHUB_TOKEN:
1
2
GITHUB_TOKEN="$(gh auth token)" \
make publish docker/hugo-autoprefixer REGISTRY_USERNAME=<github-username>
По умолчанию GITHUB_TOKEN используется и как пароль для GHCR, и как токен для push release tag в git. При необходимости имена env-переменных можно переопределить через REGISTRY_PASSWORD_ENV и GIT_TOKEN_ENV.
Отмечу, запуск через make - не отдельный локальный запуск, а та же точка входа, которая используется в GitHub Actions. Локальный запуск make verify, make publish-dry-run или make publish полностью эквивалентен запуску из workflow: отличаются только источник секретов и значения переменных окружения.
Почему не просто docker buildx bake –push?
На первый взгляд можно было бы запускать docker buildx bake --push прямо в CI. Но тогда пайплайн начинает зависеть от Docker socket, buildx builder, CLI-аутентификации и особенностей окружения конкретного CI.
В этом подходе Bake используется как уже стандартизированный манифест, но не как обязательный механизм выполнения сборки. Dagger-модуль читает docker-bake.json, получает из него метаданные и переводит поддержанные поля в обычные вызовы сборки Dagger.
Здесь важно обозначить ограничение. Мы берем стандартный формат Docker Bake, но не реализуем полную поддержку всего, что умеет docker buildx bake. В Dagger-модуле поддержаны только те поля, которые нужны текущему пайплайну:
contextdockerfileargstagslabelsplatforms
То есть Bake здесь используется прежде всего как готовый формат описания образов, а не как полностью воспроизведенная реализация Buildx внутри Dagger. Если в Bake target появится неподдержанное поле, модуль должен упасть с понятной ошибкой. Это лучше, чем молча проигнорировать часть конфигурации и собрать не тот образ.
Заключение
В такой схеме Docker Bake становится стандартным manifest-форматом для описания образов, Dagger - переносимым runtime для сборки и публикации, а GitHub Actions - тонким слоем запуска. В итоге получается монорепозиторий для хранения самообновляющихся базовых образов: каждый образ живет в своей директории, версии управляются декларативно и обновляются через Renovate, публикация не размазана по CI, а успешный релиз фиксируется отдельным git tag.
Образ hugo-autoprefixer небольшой, но именно он наглядно демонстрирует всю платформу для сборки образов: сборка по метаданным Bake, публикация, релизные теги и автообновления. После этого добавление новых образов сводится к новой директории в docker/, Dockerfile и локальному docker-bake.json.
