📦

Image Index の digest をそのままに ghcr.io へ Docker image を mirror する

2024/12/06に公開

Finatextグループ - Qiita Advent Calendar 2024 - Qiita の 12/6 の記事です。

OCI Image Indexを利用したコンテナイメージの管理は、柔軟性を提供する一方で、registry 間のミラーリング時に docker cli などを利用すると digest が壊れてしまったり、manifest を上手く扱えないなどで multiarch イメージの取り扱いに関する課題が発生します。この記事では、Image Index の digest をそのままに保ちながら、Docker Hub から ghcr.io にイメージをミラーリングするために go-containerregistry/crane を使用する例として、GitHub Actions の workflow を活用した CI 環境向けの mirroring について紹介します。

Docker Hub の rate limit 問題

Docker Hub の image 利用には rate limit が付いています。帯域を確保するためにも仕方ないんですが、CI 環境などで頻繁にテストやビルドタスクを実行しているとすぐに制限が入ってしまうのが難点です。

これに対して、有料プランのユーザーとして利用するケースや、自前の他の registry に image を mirror して対処するケースが一般的です。

Image Index と multiarch 対応の Image

container の image は単なる Blob の集合というわけではなく、それらのデータ以外に manifest が registry により管理されるようになってきています。

Image Index はその manifest の一つの形式で、複数の container image をグループ化して単一の識別子で管理することに長けています。例えば golang:latest といった識別子で参照される container image を複数の CPU アーキテクチャ環境に提供する場合、golang:latest-arm64 などとタグを分解するのではなく、goalng:latest の manifest に対応する image への digest を含められるというわけです。

以下は golang@sha256:73f06be4578c9987ce560087e2e2ea6485fb605e3910542cadd8fa09fc5f3e31 manifest の例です。.manifest[].platform を見てもらうと、それぞれどの platform に対する image なのかが記載されています。image のコンシューマはこの manifest を見て自身の platform にマッチする image を download して起動します。(.manifests の中身は長さの都合上削っていることに注意してください。)

{
  "manifests": [
    {
      "annotations": {
        "com.docker.official-images.bashbrew.arch": "amd64",
        "org.opencontainers.image.base.digest": "sha256:6fbde97d2c5f38678ecc77dba69ae37004e01c5fe1060195a6fa0a03a166b6f7",
        "org.opencontainers.image.base.name": "buildpack-deps:bookworm-scm",
        "org.opencontainers.image.created": "2024-11-12T04:53:50Z",
        "org.opencontainers.image.revision": "4c0463340f0b14c2682af9d8d3bb8457a79f695d",
        "org.opencontainers.image.source": "https://github.com/docker-library/golang.git#4c0463340f0b14c2682af9d8d3bb8457a79f695d:1.23/bookworm",
        "org.opencontainers.image.url": "https://hub.docker.com/_/golang",
        "org.opencontainers.image.version": "1.23.3-bookworm"
      },
      "digest": "sha256:f61a48f4e7063c15eb9abf8cdd8077aab743fbe0d933f40c1b42d7868afe855d",
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      },
      "size": 2322
    },
    // 中略
    {
      "annotations": {
        "com.docker.official-images.bashbrew.arch": "arm64v8",
        "org.opencontainers.image.base.digest": "sha256:3351606a35eac6cc308876e2ec72862fe2ae4147581ccc4a10373c8d334c78c3",
        "org.opencontainers.image.base.name": "buildpack-deps:bookworm-scm",
        "org.opencontainers.image.created": "2024-11-13T08:08:33Z",
        "org.opencontainers.image.revision": "4c0463340f0b14c2682af9d8d3bb8457a79f695d",
        "org.opencontainers.image.source": "https://github.com/docker-library/golang.git#4c0463340f0b14c2682af9d8d3bb8457a79f695d:1.23/bookworm",
        "org.opencontainers.image.url": "https://hub.docker.com/_/golang",
        "org.opencontainers.image.version": "1.23.3-bookworm"
      },
      "digest": "sha256:15b0073131e5f3dc0cb9e94007709581e991ca21cd4b2471c5a756012f8ec98a",
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "platform": {
        "architecture": "arm64",
        "os": "linux",
        "variant": "v8"
      },
      "size": 2324
    },
  ],
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "schemaVersion": 2
}

今度はこの中の Image golang@sha256:f61a48f4e7063c15eb9abf8cdd8077aab743fbe0d933f40c1b42d7868afe855d について manifest を見てみましょう。

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:0457bb691895d9d1b47fd0d8c5328b697ad2a121234dda0f232409951232930d",
    "size": 2919
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:b2b31b28ee3c96e96195c754f8679f690db4b18e475682d716122016ef056f39",
      "size": 49575695
    },
    // 中略
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1",
      "size": 32
    }
  ],
  "annotations": {
    "com.docker.official-images.bashbrew.arch": "amd64",
    "org.opencontainers.image.base.digest": "sha256:6fbde97d2c5f38678ecc77dba69ae37004e01c5fe1060195a6fa0a03a166b6f7",
    "org.opencontainers.image.base.name": "buildpack-deps:bookworm-scm",
    "org.opencontainers.image.created": "2024-11-07T00:26:15Z",
    "org.opencontainers.image.revision": "4c0463340f0b14c2682af9d8d3bb8457a79f695d",
    "org.opencontainers.image.source": "https://github.com/docker-library/golang.git#4c0463340f0b14c2682af9d8d3bb8457a79f695d:1.23/bookworm",
    "org.opencontainers.image.url": "https://hub.docker.com/_/golang",
    "org.opencontainers.image.version": "1.23.3-bookworm"
  }
}

様相が全然違いますね。mediaType を見ると分かる通り、これは Image Index 形式ではなく単一 image の manifest であることがわかります。つまり Image Index は複数の Image を束ねるものであり実態は個々の manifest に記載されているということがわかります。これによって multiarch な image を registry は提供できているというわけです。

最後に一つだけ、Image Index そのものにも digest が付いています。これはその Image Index に含まれる Image の一覧が異なれば異なる digest になる値で、Image Index の同一性を確保するために付与されます。

Docker CLI での mirror の問題

前述した rate limit の回避策で image を mirror する際、簡単には docker pull && docker push を実行すれば良いんですが、その方法を使うと Image Index が付いてきません。Docker CLI は個別の platform 別の image を Image Index から解釈して pull してしまうため、例えば Apple Silicon 上で pull すると arm64 向けの image だけが手元残ります。これを registry にそのまま push するとその image は arm64 環境でしか起動できないものになってしまいます。

multiarch に対応した Image Index を作成しつつ push するには docker manifest コマンドを使って構築する必要があります。

docker manifest を使うと以下のように Image Index を作成できます。

docker pull --platform=linux/arm64 ${SRC}:${TAG}
docker tag ${SRC}:${TAG} ${DST}:${TAG}-arm64

docker pull --platform=linux/amd64 ${SRC}:${TAG}
docker tag ${SRC}:${TAG} ${DST}:${TAG}-amd64

docker manifest create ${DST}:${TAG} ${DST}:${TAG}-amd64 ${DST}:${TAG}-arm64
docker manifest annotate --os linux --arch amd64 ${DST}:${TAG} ${DST}:${TAG}-amd64
docker manifest annotate --os linux --arch arm64 ${DST}:${TAG} ${DST}:${TAG}-arm64
docker manifest push ${DST}:${TAG}

ただ、この方法でもまだ問題があります。見ての通り manifest を新規作成しているので、Docker Hub にある Image Index と digest が異なる値になることです。実際に利用する image の digest は変わりませんが、Image Index が異なるので、sha256 で image を指定するケースなどでは Docker Hub にある image と対応が取りづらくなります。こうした状況で例えば Docker Hub にある Image Index が指す image の一つがサプライチェーン攻撃に晒されるケースなどが露見した場合、その波及先の調査が難しくなる弊害があります。

go-containerregistry/crane での copy

crane は go-containerregistry パッケージに含まれる CLI の一つで、remote registry にある image を効率良く操作するのに長けたツールです。

今回の用途だと copy コマンドが mirror に利用するケースにマッチします。

copy コマンドは指定したタグに対して Image Index そのままに copy を実行します。Docker CLI で記載したさっきの例相当の操作を crane copy コマンドで実行可能です。

Actions の workflow で ghcr.io へ copy する事例

最後に Finatext で実行している crane copy を使った image の mirror 事例を紹介します。

crane copy には --all-tags というオプションがあり、これを使えば基本的に全部 tag ベースで image を copy してこれます。しかしながら利用用途の無い distribution ベースの image なども含まれてしまうことから、保存容量や転送量を削減できる余地が残ります。これに対して、現実的な落とし所として、latest や alpine など、更新によって指す image が変更されるタグに追従して定期的に image を copy してくる方法を採用しています。

name: Copy image to ghcr.io

on:
  schedule:
    - cron: "0 0 1 * *"
  workflow_dispatch:

jobs:
  copy:
    name: Copy image to ghcr.io
    runs-on: ubuntu-latest
    permissions:
      packages: write
      contents: read
    timeout-minutes: 5
    strategy:
      matrix:
        tag: [latest, alpine, bookworm]
    steps:
      - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
        with:
          go-version: 'stable'
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: imjasonh/setup-crane@31b88efe9de28ae0ffa220711af4b60be9435f6e # v0.4
      - run: |
          echo ${{ secrets.GITHUB_TOKEN }} | crane auth login ghcr.io -u owner --password-stdin

          digest=$(crane digest golang:${{ matrix.tag }})
          tags=($(curl -s "https://registry.hub.docker.com/v2/repositories/library/golang/tags?page_size=1000" | jq -r '.results[] | select(.digest=="'${digest}'") | .name'))
          for tag in ${tags[@]}
          do
            crane copy golang:${tag} ghcr.io/<YOUR_ORG>/<YOUR_REPO>/golang:${tag}
          done

基本方針は以下の通りです。https://hub.docker.com/_/golang に対して下記の処理を実行しています。

  • 定期実行をスケジュールしつつ手動実行も設定
  • mirror したい tag の一覧を指定して matrix で各々について以下を実行
    • tag に紐づく digest を取得しつつ、その digest に紐づく tag 一覧を取得
      • 大抵の場合 latest などは他に persistent なタグが付いていることがあるため
    • 取得できた tag 全てについて crane copy を実行して mirror

まとめ

  • Image Index を含む image を他 registry に copy する場合は go-containerregistry/crane を利用すると Image Index そのまま利用可能
  • ghcr.io への mirror であれば crane を使って workflow を書くだけで必要な image を mirror することが容易になる
Finatext Tech Blog

Discussion