Собираем мультиплатформенный образ в Gitlab CI на нативных раннерах

В современном мире разработки приложения должны работать на разных архитектурах процессоров — x86 (amd64) и ARM. В этой статье я подробно разберу, как настроить Gitlab CI/CD для сборки мультиплатформенного Docker-образа с автоматическим созданием релиза.

Как это сделать я подсмотрел в репозиториях Github Actions плагинов Docker. Конечно, можно по полной использовать Docker Buildx с использованием эмулятора чтобы собирать приложения, но в таком случае скорость сборки увеличивается в десятки раз. Поэтому я расскажу про способ как использовать сборку мультиплатформенного приложения на двух разных раннерах.

Перед созданием файла, убедитесь, что у Вас есть 2 раннера: один Docker DIND на amd64 и еще один идентичный Docker DIND на arm64 с тэгами по которым их можно идентифицировать.

Подготовка конвейера

Наш .gitlab-ci.yml разделён на три стадии:

  1. Prepare — подготовка к сборке
  2. Build — сборка образов для разных архитектур и их объединение
  3. 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

Что здесь происходит:

  1. Устанавливаем необходимые утилиты: jq для работы с JSON, curl для HTTP-запросов
  2. Сохраняем версию в переменную окружения через variables.env
  3. Генерируем release notes через Gitlab API
  4. Сохраняем артефакты для следующих стадий

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

Ключевые моменты:

  1. needs указывает зависимость от prepare_job
  2. В before_script настраиваем Docker Buildx для мультиархитектурных сборок
  3. Собираем образ с метаданными в соответствии со спецификацией OCI
  4. Сохраняем 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 " *)

Что происходит:

  1. Job зависит от всех предыдущих шагов сборки
  2. Используем docker buildx imagetools create для создания мультиархитектурного манифеста
  3. Добавляем три тега:
    • 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

Финал нашего пайплайна:

  1. Используем официальный образ Gitlab Release CLI
  2. Создаём тегированный релиз в репозитории
  3. Включаем в релиз сгенерированные ранее release notes
  4. Добавляем ссылку на 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