用 GitHub Actions 实现 Docker 多架构镜像构建与 Manifest 合并

本文版权归作者所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

转载自夜明的孤行灯

本文链接地址: https://www.huangyunkun.com/2025/06/04/github-actions-docker-multi-arch-manifest/



最近在折腾一个开源项目的时候,碰到了Docker 镜像构建问题。现在CPU 架构越来越多样,除了我们熟知的 amd64 (或者叫 x86_64),arm64 架构也因为苹果的 M系列芯片、各种云服务器实例以及树莓派等嵌入式设备的普及而变得越来越重要。如果我们的 Docker 镜像只支持单一架构,那显然是不行的。

最简单的方式就是在docker action中指定platform,搭配QEMU可以全自动的实现多架构镜像构建,但是QEMU很慢,会极大增加构建时间。

本文介绍依赖原生runner实现多架构镜像构建的方式。

整个 Workflow 主要包含两个核心的 Job:

  1. build-and-push:这个 Job 会并行地为我们指定的多个平台(例如 linux/amd64 和 linux/arm64)分别构建 Docker 镜像。构建完成后,它会将镜像推送到 GitHub Container Registry (GHCR),并把每个平台镜像的 digest(摘要)作为 artifact 上传。
  2. merge:这个 Job 会在所有平台的 build-and-push Job 成功完成后执行。它会下载之前上传的各个平台的 digest 文件,然后使用 docker buildx imagetools 命令创建一个 Manifest List。这个 Manifest List 会将不同架构的镜像关联到一个统一的镜像标签下(例如 your-repo/image:latest),用户拉取这个标签时,Docker 会自动根据当前系统架构选择合适的镜像。

Job 1: build-and-push – 分平台构建与推送

这个 Job 的核心在于它的 strategy.matrix 配置,它让我们能够轻松地为不同的操作系统和平台组合并行执行构建任务。

jobs:
  build-and-push:
    strategy:
      matrix:
        include:
          - os: ubuntu-latest
            platform: linux/amd64
          - os: ubuntu-24.04-arm 
            platform: linux/arm64
    runs-on: ${{ matrix.os }}
    permissions:
      contents: read
      packages: write 

这里定义了两个构建组合:一个在 ubuntu-latest (通常是 amd64)上构建 linux/amd64 镜像,另一个在 ubuntu-24.04-arm (GitHub 提供的 ARM runner)上构建 linux/arm64 镜像。

构建并推送 Docker 镜像阶段使用了 docker/build-push-action@v6

      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        id: build
        with:
          context: .
          platforms: ${{ matrix.platform }} # 指定当前矩阵的平台
          push: ${{ github.event_name != 'pull_request' }} # PR时不推送
          annotations: ${{ steps.meta.outputs.annotations }}
          labels: ${{ steps.meta.outputs.labels }}
          outputs: type=image,name=${{ env.GHCR_IMAGE }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }},oci-mediatypes=true
          cache-from: type=gha,scope=${{ github.repository }}-${{ github.ref_name }}-${{ matrix.platform }}
          cache-to: type=gha,mode=max,scope=${{ github.repository }}-${{ github.ref_name }}-${{ matrix.platform }}
  • platforms: ${{ matrix.platform }}: 明确告诉 Buildx 为当前矩阵指定的平台构建镜像。
  • push: ${{ github.event_name != 'pull_request' }}: 再次确认只有非 PR 事件才推送。
  • outputs: type=image,…,push-by-digest=true,…: 这点非常关键! push-by-digest=true 确保了镜像是基于其内容摘要 (digest) 推送的,而不是基于可变的标签。每个平台构建的镜像都会有一个唯一的、以 sha256:… 开头的 digest。这是后续合并 Manifest 的基础,因为 Manifest List 就是通过这些 digest 来引用不同平台的具体镜像。
  • cache-from 和 cache-to: 开启了 Docker 构建缓存,利用 GitHub Actions 的缓存机制来加速后续的构建,非常实用。

构建并推送完成后,steps.build.outputs.digest 会输出镜像的 digest。我们将这个 digest (去掉了 sha256: 前缀) 作为文件名,创建一个空文件,然后通过 actions/upload-artifact@v4 将其作为 artifact 上传。文件名中包含了平台信息 (${{ env.PLATFORM_PAIR }}),方便后续 Job 区分。保留时间设置为1天,足够后续 merge Job 使用了。

  - name: Export digest
    run: |
      mkdir -p /tmp/digests
      digest="${{ steps.build.outputs.digest }}"
      touch "/tmp/digests/${digest#sha256:}" # 将sha256:前缀去掉作为文件名

  - name: Upload artifact
    uses: actions/upload-artifact@v4
    with:
      name: digests-${{ env.PLATFORM_PAIR }}
      path: /tmp/digests/*
      if-no-files-found: error
      retention-days: 1

Job 2: merge – 合并 Manifest List

使用 actions/download-artifact@v4 下载之前所有平台上传的 digest 文件。这些文件会被放到 /tmp/digests 目录下。

      - name: Download digests
        uses: actions/download-artifact@v4
        with:
          path: /tmp/digests # 下载到指定路径
          pattern: digests-* # 匹配之前上传的 artifact 名称
          merge-multiple: true # 如果有多个同名 artifact (理论上这里不会),会合并

然后再次使用 docker/metadata-action@v5,但这次的目的是为最终的 Manifest List 生成合适的标签。

        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.GHCR_IMAGE }}
          annotations: | # OCI annotations for the manifest list itself
            type=org.opencontainers.image.description,value=${{ github.event.repository.description || 'No description provided' }}
          tags: | # 多种打标策略
            type=semver,pattern={{version}} # v1.2.3
            type=semver,pattern={{major}}.{{minor}} # v1.2
            type=sha,format=short # 短sha
            type=ref,event=branch # 分支名 (e.g., main)
            latest # 始终打 latest 标签

最后合并推送

      - name: Create manifest list and pushs
        working-directory: /tmp/digests # 切换到 digests 存放的目录
        id: manifest-annotate
        continue-on-error: true # 如果带注解的失败了,允许继续
        run: |
              docker buildx imagetools create \
                $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ # 从 metadata action 的输出中提取所有 tags
                --annotation='index:org.opencontainers.image.description=${{ github.event.repository.description }}' \
                --annotation='index:org.opencontainers.image.created=${{ steps.timestamp.outputs.timestamp }}' \
                --annotation='index:org.opencontainers.image.url=${{ github.event.repository.url }}' \
                --annotation='index:org.opencontainers.image.source=${{ github.event.repository.url }}' \
                $(printf '${{ env.GHCR_IMAGE }}@sha256:%s ' *) # 将 /tmp/digests 下的所有文件名(即 digests)拼接到命令中

这种方案的提升是巨大的,比如同样的项目,使用Qemu方案需要12分钟,切换到arm runner只需要4分钟。



本文版权归作者所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

转载自夜明的孤行灯

本文链接地址: https://www.huangyunkun.com/2025/06/04/github-actions-docker-multi-arch-manifest/

发表评论