Собираем мультиплатформенный образ в Gitlab CI на нативных раннерах
В современном мире разработки приложения должны работать на разных архитектурах процессоров — x86 (amd64) и ARM. В этой статье я подробно разберу, как настроить Gitlab CI/CD для сборки мультиплатформенного Docker-образа с автоматическим созданием релиза.
Как это сделать я подсмотрел в репозиториях Github Actions плагинов Docker. Конечно, можно по полной использовать Docker Buildx с использованием эмулятора чтобы собирать приложения, но в таком случае скорость сборки увеличивается в десятки раз. Поэтому я расскажу про способ как использовать сборку мультиплатформенного приложения на двух разных раннерах.
Перед созданием файла, убедитесь, что у Вас есть 2 раннера: один Docker DIND на amd64 и еще один идентичный Docker DIND на arm64 с тэгами по которым их можно идентифицировать.
Подготовка конвейера
Наш .gitlab-ci.yml
разделён на три стадии:
- Prepare — подготовка к сборке
- Build — сборка образов для разных архитектур и их объединение
- Release — создание релиза в Gitlab
Рассмотрим каждую стадию подробно.
Prepare: подготовка переменных
prepare_job:
stage: prepare
inherit:
default: false
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- apt-get update && apt-get install -y jq curl
- echo "APP_VERSION=$APP_VERSION" >> variables.env
- 'curl -H "PRIVATE-TOKEN: $CI_API_TOKEN" "$CI_API_V4_URL/projects/$CI_PROJECT_ID/repository/changelog?version=$APP_VERSION" | jq -r .notes > release_notes.md'
artifacts:
paths:
- release_notes.md
reports:
dotenv: variables.env
Что здесь происходит:
- Устанавливаем необходимые утилиты:
jq
для работы с JSON,curl
для HTTP-запросов - Сохраняем версию в переменную окружения через
variables.env
- Генерируем release notes через Gitlab API
- Сохраняем артефакты для следующих стадий
Build: мультиплатформенная сборка
Сборка выполняется в двух отдельных jobs — для amd64 и arm64 архитектур.
Сборка для amd64
build_job_amd:
stage: build
needs:
- job: prepare_job
artifacts: true
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
before_script:
- apk add --no-cache jq
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
- docker context create tls-environment
- docker buildx create --name docker-builder --driver docker-container --use tls-environment
script:
- docker buildx build
--build-arg "APP_VERSION=$APP_VERSION"
--label "org.opencontainers.image.title=$CI_PROJECT_TITLE"
--label "org.opencontainers.image.description=$CI_PROJECT_DESCRIPTION"
--label "org.opencontainers.image.vendor=$GITLAB_USER_LOGIN"
--label "org.opencontainers.image.authors=$CI_COMMIT_AUTHOR"
--output "type=image,name=$CI_REGISTRY_IMAGE,push-by-digest=true,name-canonical=true,push=true"
--metadata-file metadata.json
--file Dockerfile-prod
.
- mkdir -p digests/
- digest="$(jq -r '.["containerimage.digest"]' metadata.json)"
- touch digests/${digest#sha256:}
artifacts:
paths:
- digests/*
expire_in: 1 day
Ключевые моменты:
needs
указывает зависимость от prepare_job- В
before_script
настраиваем Docker Buildx для мультиархитектурных сборок - Собираем образ с метаданными в соответствии со спецификацией OCI
- Сохраняем digest образа в файл, который будет использоваться для создания манифеста
Сборка для arm64
build_job_arm:
stage: build
tags:
- arm64
needs:
- job: prepare_job
artifacts: true
# ... остальное аналогично build_job_amd
Отличия:
- Используем другой тег раннера (
arm64
) для нативной сборки под ARM - В остальном процесс идентичен сборке для amd64
Объединение образов в манифест
manifest_merge:
stage: build
needs:
- job: prepare_job
artifacts: true
- job: build_job_arm
artifacts: true
- job: build_job_amd
artifacts: true
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
script:
- cd digests
- docker buildx imagetools create
--tag $CI_REGISTRY_IMAGE:latest
--tag $CI_REGISTRY_IMAGE:$APP_VERSION
--tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
$(printf "$CI_REGISTRY_IMAGE@sha256:%s " *)
Что происходит:
- Job зависит от всех предыдущих шагов сборки
- Используем
docker buildx imagetools create
для создания мультиархитектурного манифеста - Добавляем три тега:
latest
— всегда указывает на последнюю версию$APP_VERSION
— версия приложения$CI_COMMIT_SHORT_SHA
— тэг с короткой версией хэша коммита
Release: публикация релиза
release_job:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
needs:
- job: prepare_job
artifacts: true
- job: manifest_merge
release:
name: "Release v.$APP_VERSION"
description: release_notes.md
tag_name: "$APP_VERSION"
ref: "$CI_COMMIT_SHA"
assets:
links:
- name: "Container Image Tag $APP_VERSION"
url: "https://$CI_REGISTRY_IMAGE:$APP_VERSION"
link_type: image
Финал нашего пайплайна:
- Используем официальный образ Gitlab Release CLI
- Создаём тегированный релиз в репозитории
- Включаем в релиз сгенерированные ранее release notes
- Добавляем ссылку на Docker-образ в ассеты релиза
Итог
Такую конфигурацию можно адаптировать под разные языки программирования, подогнав способ извлечения версии приложения из файлов и более тщательной настройке шагов пайплайна.
Полный код файла выглядит так:
stages:
- prepare
- build
- release
variables:
APP_VERSION: 1.0.0
default:
tags:
- docker
prepare_job:
stage: prepare
inherit:
default: false
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- apt-get update && apt-get install -y jq curl
- echo "APP_VERSION=$APP_VERSION" >> variables.env
- 'curl -H "PRIVATE-TOKEN: $CI_API_TOKEN" "$CI_API_V4_URL/projects/$CI_PROJECT_ID/repository/changelog?version=$APP_VERSION" | jq -r .notes > release_notes.md'
artifacts:
paths:
- release_notes.md
reports:
dotenv: variables.env
build_job_amd:
stage: build
needs:
- job: prepare_job
artifacts: true
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
before_script:
- apk add --no-cache jq
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
- docker context create tls-environment
- docker buildx create --name docker-builder --driver docker-container --use tls-environment
script:
- docker buildx build
--build-arg "APP_VERSION=$APP_VERSION"
--label "org.opencontainers.image.title=$CI_PROJECT_TITLE"
--label "org.opencontainers.image.description=$CI_PROJECT_DESCRIPTION"
--label "org.opencontainers.image.vendor=$GITLAB_USER_LOGIN"
--label "org.opencontainers.image.authors=$CI_COMMIT_AUTHOR"
--output "type=image,name=$CI_REGISTRY_IMAGE,push-by-digest=true,name-canonical=true,push=true"
--metadata-file metadata.json
--file Dockerfile-prod
.
- mkdir -p digests/
- digest="$(jq -r '.["containerimage.digest"]' metadata.json)"
- touch digests/${digest#sha256:}
artifacts:
paths:
- digests/*
expire_in: 1 day
build_job_arm:
stage: build
tags:
- arm64
needs:
- job: prepare_job
artifacts: true
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
before_script:
- apk add --no-cache jq
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
- docker context create tls-environment
- docker buildx create --name docker-builder --driver docker-container --use tls-environment
script:
- docker buildx build
--build-arg "APP_VERSION=$APP_VERSION"
--label "org.opencontainers.image.title=$CI_PROJECT_TITLE"
--label "org.opencontainers.image.description=$CI_PROJECT_DESCRIPTION"
--label "org.opencontainers.image.vendor=$GITLAB_USER_LOGIN"
--label "org.opencontainers.image.authors=$CI_COMMIT_AUTHOR"
--output "type=image,name=$CI_REGISTRY_IMAGE,push-by-digest=true,name-canonical=true,push=true"
--metadata-file metadata.json
--file Dockerfile-prod
.
- mkdir -p digests/
- digest="$(jq -r '.["containerimage.digest"]' metadata.json)"
- touch digests/${digest#sha256:}
artifacts:
paths:
- digests/*
expire_in: 1 day
manifest_merge:
stage: build
needs:
- job: prepare_job
artifacts: true
- job: build_job_arm
artifacts: true
- job: build_job_amd
artifacts: true
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
script:
- cd digests
- docker buildx imagetools create
--tag $CI_REGISTRY_IMAGE:latest
--tag $CI_REGISTRY_IMAGE:$APP_VERSION
--tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
$(printf "$CI_REGISTRY_IMAGE@sha256:%s " *)
release_job:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
needs:
- job: prepare_job
artifacts: true
- job: manifest_merge
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- echo "running release_job for $APP_VERSION"
release:
name: "Release v.$APP_VERSION"
description: release_notes.md
tag_name: "$APP_VERSION"
ref: "$CI_COMMIT_SHA"
assets:
links:
- name: "Container Image Tag $APP_VERSION"
url: "https://$CI_REGISTRY_IMAGE:$APP_VERSION"
link_type: image