🔧

Dockerとbuildkitでpull through cacheを有効にする検証メモ

2023/11/02に公開

業務で運用しているGitHub Actionsのセルフホストランナー用にDockerのpull through cacheを設定するための予備調査メモです。最終的にはクラウド上に構築する予定ですが、まずは原理や設定を理解するためにローカルで構築してみた記録です。

この記事の内容はDocker Desktop + WSL2 + Ubuntu 20.04で動作確認しています。macOSやRancher Desktopなどでも同様の設定は可能だと思いますが、設定の方法やconfigファイルの場所などは異なると思われますので注意してください。

pull through cacheとは

pull through cache あるいはツールやドキュメントによってはmirror registry, registry proxyなどとも呼ばれていることがありますが、これを設定するとイメージをpullする際にDocker Hubと直接通信する前に別のレジストリに問い合わせることが可能になります。そこでイメージが見つかればDocker Hubからpullする代わりに前段のレジストリからpullをしてくれます。逆に見つからなかった場合はフォールバックしてDocker Hubからpullを行ってくれるため、仮にレジストリがダウンしていたりイメージが見つからなかったとしてもpullの最終的な挙動自体は代わりません。名前の通りキャッシュとしての振る舞いですね。

より詳しくは以下の記事などが図付きで説明しているため、こちらが参考になると思います。
https://blog.cybozu.io/entry/2021/01/26/090000

参考にした記事

まず公式でmirrorを立てるのと設定の簡易的な説明のドキュメント
https://docs.docker.com/docker-hub/mirror/

registry:2はCNCFに移管されたのでドキュメントはここになった
https://distribution.github.io/distribution/

buildkitはdockerではないので別に設定が必要
https://docs.docker.com/build/buildkit/configure/#registry-mirror

docker/setup-buildx-actionを使う場合はGHA側でミラーの設定が必要かもしれない?
https://docs.docker.com/build/ci/github-actions/configure-builder/#registry-mirror

Earthlyはまた独自のbuildkitを使っているので別途設定が必要
https://docs.earthly.dev/ci-integration/pull-through-cache#insecure-docker-hub-cache-example

コンテナ用プライベートレジストリのキャッシュを簡単構築
https://blog.jp.square-enix.com/iteng-blog/posts/00029-easy-private-container-cache-registry/

もう少し実践ぽい設定の紹介
https://blog.alexellis.io/how-to-configure-multiple-docker-registry-mirrors/

mirror.gcr.ioでpull through cacheを試す

Google Cloudは昔からpull through cacheが可能なDocker Hubのミラーレジストリを mirror.gcr.io で提供してくれています。自分で同様のレジストリを立てる前にまずはmirror.gcr.ioを使わせてもらって設定方法と動作を確認してみました。
https://cloud.google.com/container-registry/docs/pulling-cached-images

docker pull を対応させる

まずは普通のdocker pull。Docker Desktopでのdaemonの設定でregistry-mirrorsにmirror.gcr.ioを設定し、debug:trueでログも出してみます。

{
  "builder": {
    "gc": {
      "defaultKeepStorage": "20GB",
      "enabled": true
    }
  },
  "debug": true,
  "experimental": true,
  "insecure-registries": [],
  "ip": "127.0.0.1",
  "registry-mirrors": [
    "https://mirror.gcr.io"
  ]
}

WSL2でログの場所を探すのに苦労しました。
https://forums.docker.com/t/docker-desktop-wsl2-engine-log-location/93259 の場所にもたしかにログは存在していたが自分のマシンだと2021年ぐらいのログだったので場所が古い?
https://docs.docker.com/config/daemon/logs/ 公式では %LOCALAPPDATA%\Docker\log\vm\dockerd.log と書かれているが %LOCALAPPDATA% とは・・・?結局、合せ技と勘で /mnt/c/Users/{ユーザー名}/AppData/Local/Docker/log/vm/dockerd.log にて発見しました。ただwindowsのCドライブに置かれているファイルだからか、WSL2からの tail -f が効かなかった。

ログを適当にtail -1000ぐらいしてみるとこんな内容が書かれており、mirror.gcr.ioを見ていそうなことが分かります。

[2023-10-22T08:15:35.277045615Z][dockerd][I] time="2023-10-22T08:15:35.276688762Z" level=debug msg=resolving host=mirror.gcr.io
[2023-10-22T08:15:35.277085990Z][dockerd][I] time="2023-10-22T08:15:35.276777808Z" level=debug msg="do request" host=mirror.gcr.io request.header.accept="application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, */*" request.header.user-agent="docker/24.0 go/go1.20.6 git-commit/HEAD kernel/5.15.90.1-microsoft-standard-WSL2 os/linux arch/amd64 containerd-client/1.6.21+unknown storage-driver/stargz UpstreamClient(Docker-Client/20.10.21 \\(linux\\))" request.method=HEAD url="https://mirror.gcr.io/v2/library/ubuntu/manifests/20.04?ns=docker.io"
[2023-10-22T08:15:35.771479257Z][dockerd][I] time="2023-10-22T08:15:35.771191128Z" level=debug msg="fetch response received" host=mirror.gcr.io response.header.alt-svc="h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000" response.header.content-length=1133 response.header.content-type=application/vnd.oci.image.index.v1+json response.header.date="Sun, 22 Oct 2023 08:15:44 GMT" response.header.docker-content-digest="sha256:ed4a42283d9943135ed87d4ee34e542f7f5ad9ecf2f244870e23122f703f91c2" response.header.docker-distribution-api-version=registry/2.0 response.header.server="Docker Registry" response.header.x-frame-options=SAMEORIGIN response.header.x-xss-protection=0 response.status="200 OK" url="https://mirror.gcr.io/v2/library/ubuntu/manifests/20.04?ns=docker.io"

buildx (buildkitd)を対応させる

dockerd の方にpull through cacheを設定したとしてもbuildkitを別コンテナとして立ち上げている場合はbuildkitdにも同様の設定が必要になります。今どきはマルチプラットフォームのイメージをビルドするために docker buildx create でbuildkitdをコンテナで立ち上げることが多いためですが、このあたりの話は今回の主題ではないため詳しくは割愛します。

公式ドキュメントに従ってbuildkitdの設定ファイルを作ります。

debug = true
[registry."docker.io"]
  mirrors = ["mirror.gcr.io"]

次に mirror という名前でbuildxのビルダーを作成します。

docker buildx create \
  --use --bootstrap \
  --name mirror \
  --driver docker-container \
  --config $PWD/buildkitd.toml

FROM alpine を含む適当なDockerfileを作ってビルド。docker logs buildx_buildkit_mirrored0 でbuildkitdのコンテナのログを見ると確かに mirror.gcr.io という文字列が見えるのでちゃんと読んでくれてそうです。

time="2023-10-22T15:02:30Z" level=debug msg="saved 8orw13cwnlz3hdx8c4tp3dmaa as dockerfile:dockerfile:mirror_registry:bb9a0c004a4ddeef" span="[internal] load build definition from Dockerfile" spanID=5c5c37f11154a20f traceID=154b19d321101ea87727f9bfe85685ee
time="2023-10-22T15:02:30Z" level=debug msg=resolving host=mirror.gcr.io spanID=7d6012cdc1c41fe2 traceID=154b19d321101ea87727f9bfe85685ee
time="2023-10-22T15:02:30Z" level=debug msg="do request" host=mirror.gcr.io request.header.accept="application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, */*" request.header.user-agent=buildkit/v0.12 request.method=HEAD spanID=7d6012cdc1c41fe2 traceID=154b19d321101ea87727f9bfe85685ee url="https://mirror.gcr.io/v2/library/alpine/manifests/latest?ns=docker.io"
time="2023-10-22T15:02:30Z" level=debug msg="fetch response received" host=mirror.gcr.io response.header.alt-svc="h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000" response.header.content-length=1638 response.header.content-type=application/vnd.docker.distribution.manifest.list.v2+json response.header.date="Sun, 22 Oct 2023 15:02:40 GMT" response.header.docker-content-digest="sha256:eece025e432126ce23f223450a0326fbebde39cdf496a85d8c016293fc851978" response.header.docker-distribution-api-version=registry/2.0 response.header.server="Docker Registry" response.header.x-frame-options=SAMEORIGIN response.header.x-xss-protection=0 response.status="200 OK" spanID=7d6012cdc1c41fe2 traceID=154b19d321101ea87727f9bfe85685ee url="https://mirror.gcr.io/v2/library/alpine/manifests/latest?ns=docker.io"
time="2023-10-22T15:02:30Z" level=debug msg=resolved desc.digest="sha256:eece025e432126ce23f223450a0326fbebde39cdf496a85d8c016293fc851978" host=mirror.gcr.io spanID=7d6012cdc1c41fe2 traceID=154b19d321101ea87727f9bfe85685ee
time="2023-10-22T15:02:30Z" level=debug msg=fetch digest="sha256:eece025e432126ce23f223450a0326fbebde39cdf496a85d8c016293fc851978" mediatype=application/vnd.docker.distribution.manifest.list.v2+json size=1638 spanID=7d6012cdc1c41fe2 traceID=154b19d321101ea87727f9bfe85685ee
time="2023-10-22T15:02:30Z" level=debug msg="do request" digest="sha256:eece025e432126ce23f223450a0326fbebde39cdf496a85d8c016293fc851978" mediatype=application/vnd.docker.distribution.manifest.list.v2+json request.header.accept="application/vnd.docker.distribution.manifest.list.v2+json, */*" request.header.user-agent=buildkit/v0.12 request.method=GET size=1638 spanID=7d6012cdc1c41fe2 traceID=154b19d321101ea87727f9bfe85685ee url="https://mirror.gcr.io/v2/library/alpine/manifests/sha256:eece025e432126ce23f223450a0326fbebde39cdf496a85d8c016293fc851978?ns=docker.io"

ちなみにbuildkitd.tomlの設定ファイルのリファレンスはこれですが、今回やりたいことに対して他に関係ありそうな設定はなかったです。
https://docs.docker.com/build/buildkit/toml-configuration/

自分でpull through cache用のレジストリを立てる

ここまででdockerとbuildkitに正しいpull through cacheの設定ができた場合にログから動作を確認する方法が分かりました。次は実際に自分でレジストリサーバーを立ち上げてmirror.gcr.ioの代わりにそちらを参照させるようにします。

composeでregistry:2を立ち上げる

昔はregistry:2のドキュメントはDocker公式に存在したのですが、今はCNCFに移管されたのでドキュメントはこちらになったようです。これを参考にdocker composeでレジストリを立ち上げます。
https://distribution.github.io/distribution/

こういうcompose.ymlで立ち上げます。

 services:
     registry:
         image: registry:2
         restart: always
         environment:
             - REGISTRY_LOGLEVEL=debug
             - REGISTRY_PROXY_REMOTEURL="https://registry-1.docker.io"
             - REGISTRY_PROXY_USERNAME=$DOCKER_USER
             - REGISTRY_PROXY_PASSWORD=$DOCKER_PAT
         ports:
             - "50000:5000"

ローカル側のポートを5000ではなく50000にしていることに深い意味はないです。自分は開発用に5000ポートを使うことが多いため、被らないように50000にしました。

docker pullを自前のレジストリサーバーに対応させる

Docker desktopのdaemonの設定の registry-mirrorsmirror.gcr.io から http://172.0.0.1:50000 に変更します。ローカルで立ち上げるため通信がHTTPSではないので insecure-registries も追加しておきます。

"insecure-registries": [
  "http://127.0.0.1:50000"
],
"registry-mirrors": [
  "http://127.0.0.1:50000"
]

docker pull node:20-slim したときのcomposeで立ち上げたコンテナのログ。
GetImageManifest を127.0.0.1:50000に問い合わせていたり、filesystem.PutContent でローカルに書き込んでいるのでキャッシュとしての動作をしてくれていることが分かります。

mirror_registry-registry-1  | time="2023-10-22T15:59:42.462089665Z" level=debug msg="authorizing request" go.version=go1.20.8 http.request.host="127.0.0.1:50000" http.request.id=7474f35f-17dc-41f4-94d7-6859c258f0c4 http.request.method=HEAD http.request.remoteaddr="172.19.0.1:58866" http.request.uri="/v2/library/node/manifests/20-slim?ns=docker.io" http.request.useragent="docker/24.0 go/go1.20.6 git-commit/HEAD kernel/5.15.90.1-microsoft-standard-WSL2 os/linux arch/amd64 containerd-client/1.6.21+unknown storage-driver/stargz UpstreamClient(Docker-Client/20.10.21 \(linux\))" vars.name="library/node" vars.reference=20-slim
mirror_registry-registry-1  | time="2023-10-22T15:59:42.462992704Z" level=debug msg=GetImageManifest go.version=go1.20.8 http.request.host="127.0.0.1:50000" http.request.id=7474f35f-17dc-41f4-94d7-6859c258f0c4 http.request.method=HEAD http.request.remoteaddr="172.19.0.1:58866" http.request.uri="/v2/library/node/manifests/20-slim?ns=docker.io" http.request.useragent="docker/24.0 go/go1.20.6 git-commit/HEAD kernel/5.15.90.1-microsoft-standard-WSL2 os/linux arch/amd64 containerd-client/1.6.21+unknown storage-driver/stargz UpstreamClient(Docker-Client/20.10.21 \(linuxse/base.go" trace.func="github.com/docker/distribution/registry/storage/driver/base.(*Base).Stat" trace.id=a4369b37-687f-44cd-974a-191d5f23bd9d trace.line=155 version=2.8.3
mirror_registry-registry-1  | time="2023-10-22T15:59:36.892183366Z" level=debug msg="filesystem.Stat("/")" go.version=go1.20.8 instance.id=43f17ef7-33e7-41f3-8b5f-3cedf112a931 service=registry trace.duration=37.931µs trace.file="github.com/docker/distribution/registry/storage/driver/base/base.go" trace.func="github.com/docker/distribution/registry/storage/driver/base.(*Base).Stat" trace.id=e97eb0e9-98b6-4f34-982c-a76ffa6fa8a6 trace.line=155 version=2.8.3
mirror_registry-registry-1  | time="2023-10-22T15:59:42.462089665Z" level=debug msg="authorizing request" go.version=go1.20.8 http.request.host="127.0.0.1:50000" http.request.id=7474f35f-17dc-41f4-94d7-6859c258f0c4 http.request.method=HEAD http.request.remoteaddr="172.19.0.1:58866" http.request.uri="/v2/library/node/manifests/20-slim?ns=docker.io" http.request.useragent="docker/24.0 go/go1.20.6 git-commit/HEAD kernel/5.15.90.1-microsoft-standard-WSL2 os/linux arch/amd64 containerd-client/1.6.21+unknown storage-driver/stargz UpstreamClient(Docker-Client/20.10.21 \(linux\))" vars.name="library/node" vars.reference=20-slim
mirror_registry-registry-1  | time="2023-10-22T15:59:42.462992704Z" level=debug msg=GetImageManifest go.version=go1.20.8 http.request.host="127.0.0.1:50000" http.request.id=7474f35f-17dc-41f4-94d7-6859c258f0c4 http.request.method=HEAD http.request.remoteaddr="172.19.0.1:58866" http.request.uri="/v2/library/node/manifests/20-slim?ns=docker.io" http.request.useragent="docker/24.0 go/go1.20.6 git-commit/HEAD kernel/5.15.90.1-microsoft-standard-WSL2 os/linux arch/amd64 containerd-client/1.6.21+unknown storage-driver/stargz UpstreamClient(Docker-Client/20.10.21 \(linux\))" vars.name="library/node" vars.reference=20-slim
mirror_registry-registry-1  | time="2023-10-22T15:59:43.588262769Z" level=debug msg="filesystem.PutContent("/docker/registry/v2/repositories/library/node/_manifests/tags/20-slim/index/sha256/4fa1430cd19507875e65896fdf3176fc1674bc5bbf51b5f750fa30484885c18d/link")" go.version=go1.20.8 http.request.host="127.0.0.1:50000" http.request.id=7474f35f-17dc-41f4-94d7-6859c258f0c4 http.request.method=HEAD http.request.remoteaddr="172.19.0.1:58866" http.request.uri="/v2/library/node/manifests/20-slim?ns=docker.io" http.request.useragent="docker/24.0 go/go1.20.6 git-commit/HEAD kernel/5.15.90.1-microsoft-standard-WSL2 os/linux arch/amd64 containerd-client/1.6.21+unknown storage-driver/stargz UpstreamClient(Docker-Client/20.10.21 \(linux\))" trace.duration=4.852152ms trace.file="github.com/docker/distribution/registry/storage/driver/base/base.go" trace.func="github.com/docker/distribution/registry/storage/driver/base.(*Base).PutContent" trace.id=15453ef3-0344-447d-93b5-ea4be172e72a trace.line=110 vars.name="library/node" vars.reference=20-slim

この状態で再度pullをすれば今度はDocker Hubに問い合わせる必要なくローカルのレジストリからイメージを取得するので早いはずです。ただ、dockerは一度取得したイメージが手元に残っているうちはpullしても実際にはpullされないため、まずは docker image rm node20-slim で削除しておきます。この状態で再度pullしたときに体感できるぐらい早くなったので、やはりちゃんと動いているようです。

一応コンテナの中に入って中身も調べます。debianubuntu でも実験していたのですが、ちゃんとpullしてきたイメージの実体のデータが保存されていますね。

$ docker compose exec registry sh

/ # ls /var/lib/registry/docker/registry/v2/repositories/library/
debian  node    ubuntu

# du -sh /var/lib/registry/docker/
410.1M  /var/lib/registry/docker/

buildkitdを自前のレジストリサーバーに対応させる(失敗)

次はdockerと同様にbuildkitdも mirror.gcr.io から書き換えて以下の設定にしてみましたが、自前のレジストリに接続されていないようでした。よく考えてみたらdocker composeだとdockerのネットワークが別に作成されるのでコンテナの中から127.0.0.1では疎通できない?

debug = true
[registry."docker.io"]
  mirrors = ["127.0.0.1:50000"]
  http = true
  insecure = true

[registry."127.0.0.1:50000"]
  http = true
  insecure = true

自分も今までdockerのネットワークを意識したことがなかったのですが、どうやら普通に docker run した場合はbridgeというデフォルトのネットワーク内にコンテナが立ち上がるようです。
https://qiita.com/toshihirock/items/f5b9f7799ec8bf8c328e

一方でdocker composeの場合はbridgeではなく、compose.ymlに書かれているコンテナが全て含まれる専用のネットワークに作成されるのがデフォルトの挙動になっています。つまり、現在はこのようなネットワーク構成になっているため、buildkitdのコンテナからregistryのコンテナに疎通できていない状態と考えられます。

buildkitdはbridge、registryはcomposeのネットワークに属している図
buildkitdとregistryが異なるネットワークに属しているため疎通できない

compose.ymlにネットワークの設定を書くことでbridgeネットワーク内に立ち上げることも可能なようですのでこれで解決できる・・・?
https://qiita.com/toshihirock/items/f5b9f7799ec8bf8c328e

なお、この指定を行った際は、compose のプロジェクト内でのサービス名を指定したサービス間の通信ができなくなることに注意してください。他のサービスとの通信が必要なツールの場合は、この指定は避けます。

記事中に言及されているようにbridgeではコンテナ間の通信を http://registry:5000 のようにサービス名で名前解決できないようです。これはDockerの公式ドキュメントでも説明されています。 --link を用いれば可能だが、これは古い方法であるとも記載されています。

Containers on the default bridge network can only access each other by IP addresses, unless you use the --link option, which is considered legacy. On a user-defined bridge network, containers can resolve each other by name or alias.

https://docs.docker.com/network/drivers/bridge/#differences-between-user-defined-bridges-and-the-default-bridge

また、bridgeネットワークにコンテナを立ち上げた場合、自分の環境では 172.17.0.0/16 のIPアドレスがコンテナに割り振られるのですが、このIPがコンテナごとに固定されている保証もなさそうです。先ほどのbuilkitdの設定を例えば mirrors = ["172.17.0.1:5000"] のようにしたとしても自分のPCを再起動した後にIPアドレスが変わっていたら意味がありません。何とかして mirrors = ["registry:5000"] のような設定でコンテナ間の名前解決をしてもらって疎通できるようにしたいです。

dockerのネットワークについて原理はだいたい分かりました。つまりは以下の図のようにdocker composeで立ち上げるregistry:2とdocker buildx createで立ち上げるbuildkitdのそれぞれのコンテナが同じネットワークに所属していれば名前解決が可能になるので疎通できるようになるはずです。

buildkitdとregistryが両方ともcomposeのネットワークに属している図
buildkitdとregistryの両方ともcomposeのネットワークに属しているため名前解決と疎通が可能

buildxのドキュメントを調べたところ、--driver-optというオプションにnetwork=NETMODEを渡せるようなので、これでdocker composeのネットワーク内にコンテナを立ち上げることができそうです。
https://docs.docker.com/engine/reference/commandline/buildx_create/#docker-container-driver-1

buildkitdを自前のレジストリサーバーに対応させる(リベンジ)

compose.ymlにcontainer_nameを追加してレジストリのコンテナ名を固定し、networksを追加してネットワーク名も固定します。

services:
    registry:
        container_name: registry
        image: registry:2
        restart: always
        environment:
            - REGISTRY_LOGLEVEL=debug
            - REGISTRY_PROXY_REMOTEURL="https://registry-1.docker.io"
            - REGISTRY_PROXY_USERNAME=$DOCKER_USER
            - REGISTRY_PROXY_PASSWORD=$DOCKER_PAT
            - REGISTRY_PROXY_TTL=48h
        ports:
            - "50000:5000"
        networks:
            - docker_registry
networks:
    docker_registry:
        name: docker_registry

buildkitd.tomlは以下のようにレジストリのURLを registry:5000 に変更します。同じネットワークに所属されるのでこれで名前解決できるはずです。

debug = true
[registry."docker.io"]
  mirrors = ["registry:5000"]
  http = true
  insecure = true

[registry."registry:5000"]
  http = true
  insecure = true

buildkitdを docker_registry のネットワーク内に立ち上げます。

$ docker buildx create \
  --use --bootstrap \
  --name mirrored \
  --driver docker-container \
  --driver-opt network=docker_registry \
  --config $PWD/buildkitd.toml

この状態でネットワークを見てみるとちゃんと2つのコンテナが同じネットワークに属しています。

$ docker network inspect docker_registry
# 一部を抜粋 

        "Containers": {
            "1019b9711bc630662f68e372abf31e5e98c4aa403ddf51f4d74a00004d89c136": {
                "Name": "registry",
                "IPv4Address": "172.18.0.2/16",
            },
            "fa0382714f0906daad63e47e738a528009c0cb748c135e7a171c2b00de5e94e5": {
                "Name": "buildx_buildkit_mirrored0",
                "IPv4Address": "172.18.0.3/16",
            }
        },

docker compose logs -fregistryのログを監視しつつ、適当にalpineを使うビルドコマンドを流してみるとログが大量に流れたので成功してそうです。

$ docker buildx build --load . -f - <<EOF
FROM alpine
RUN echo "hello world"
EOF

buildkit側のログを詳しく見てみます。registry:5000に対してresolveでdigestを問い合わせ、解決したdigestをfetchしてる様子が分かります。

$ docker logs buildx_buildkit_mirrored0

time="2023-10-23T12:29:09Z" level=debug msg=resolving host="registry:5000" spanID=4e209cd26423471f traceID=0f76e161ee7706ee44328fe9e1967fa2
time="2023-10-23T12:29:09Z" level=debug msg="do request" host="registry:5000" request.header.accept="application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, */*" request.header.user-agent=buildkit/v0.12 request.method=HEAD spanID=4e209cd26423471f traceID=0f76e161ee7706ee44328fe9e1967fa2 url="https://registry:5000/v2/library/alpine/manifests/latest?ns=docker.io"
time="2023-10-23T12:29:11Z" level=debug msg="fetch response received" host="registry:5000" response.header.content-length=1638 response.header.content-type=application/vnd.docker.distribution.manifest.list.v2+json response.header.date="Mon, 23 Oct 2023 12:29:11 GMT" response.header.docker-content-digest="sha256:eece025e432126ce23f223450a0326fbebde39cdf496a85d8c016293fc851978" response.header.docker-distribution-api-version=registry/2.0 response.header.etag="\"sha256:eece025e432126ce23f223450a0326fbebde39cdf496a85d8c016293fc851978\"" response.header.x-content-type-options=nosniff response.status="200 OK" spanID=4e209cd26423471f traceID=0f76e161ee7706ee44328fe9e1967fa2 url="https://registry:5000/v2/library/alpine/manifests/latest?ns=docker.io"
time="2023-10-23T12:29:11Z" level=debug msg=resolved desc.digest="sha256:eece025e432126ce23f223450a0326fbebde39cdf496a85d8c016293fc851978" host="registry:5000" spanID=4e209cd26423471f traceID=0f76e161ee7706ee44328fe9e1967fa2
time="2023-10-23T12:29:11Z" level=debug msg=fetch digest="sha256:eece025e432126ce23f223450a0326fbebde39cdf496a85d8c016293fc851978" mediatype=application/vnd.docker.distribution.manifest.list.v2+json size=1638 spanID=4e209cd26423471f traceID=0f76e161ee7706ee44328fe9e1967fa2

レジストリサーバーに自分のローカルから v2/_catalog にcurlすることでキャッシュされているイメージ一覧を取得できるので、これでalpineが増えている様子も確認できました。

$ curl http://localhost:50000/v2/_catalog
{"repositories":["cimg/base","library/alpine","library/node"]}

これでdockerに加えてbuildkitでも無事にpull through cacheを使えるようになりました。

レジストリのストレージをminioに変更する

registry:2 のデフォルト設定だとキャッシュとして保存されるイメージはローカルのファイルシステムに保存されます。自分のローカルマシンだけで運用するので使用される容量もたかが知れているので全く問題ないですが、registry:2 はS3に保存する機能もあるようなのでこれも試してみます。ただ今回は実験用なので本物のS3の代わりにS3互換のminioをdocker composeで立ち上げて使用します。

https://distribution.github.io/distribution/about/configuration/

https://distribution.github.io/distribution/storage-drivers/s3/

これらのドキュメントを見ながらcompose.ymlに環境変数を追加していきます。ドキュメントに従ってYAMLで設定するとYAML自体をコンテナにマウントさせる手間がかかるため、代わりに環境変数で全て設定します。環境変数で設定する場合は設定のキー名を全てを大文字にした上でYAMLの階層区切りを _ に変換するので、例えば storage.s3.accesskey に対応する環境変数は STORAGE_S3_ACCESSKEY となります。

ただし例外として REGISTRY_STORAGEs3 という値を設定する必要があります。これを設定しないとfilesystemとs3を両方とも設定している判定となり、storage設定は排他的なので重複はダメというエラーで registry:2 が立ち上がらなくなります。あと、minioにリージョンの概念は無いのですが STORAGE_S3_REGION は必須なので適当な値を入れます。SECUREとSKIPVERIFYはminioコンテナがHTTPなので必要かと思っていたのですが無くても大丈夫でした(よくわかっていないけど少なくともローカルで使う分にはまあいいか)。

services:
    registry:
        container_name: registry
        image: registry:2
        restart: always
        environment:
            - REGISTRY_LOGLEVEL=debug
            - REGISTRY_PROXY_REMOTEURL="https://registry-1.docker.io"
            - REGISTRY_PROXY_USERNAME="kesin"
            - REGISTRY_PROXY_PASSWORD=$DOCKER_PAT
            - REGISTRY_PROXY_TTL=48h
            - REGISTRY_STORAGE=s3
            - REGISTRY_STORAGE_S3_BUCKET=registry
            - REGISTRY_STORAGE_S3_ACCESSKEY=root
            - REGISTRY_STORAGE_S3_SECRETKEY=password
            - REGISTRY_STORAGE_S3_REGION=minio
            - REGISTRY_STORAGE_S3_REGIONENDPOINT=http://minio:9000
            # - REGISTRY_STORAGE_S3_SECURE=false
            # - REGISTRY_STORAGE_S3_SKIPVERIFY=true
            - REGISTRY_STORAGE_S3_LOGLEVEL=debug
        ports:
            - "50000:5000"
        networks:
            - docker_registry
   minio:
       container_name: minio
       image: minio/minio
       restart: always
       command: server --console-address ":9001" /data
       environment:
          - MINIO_ROOT_USER=root
          - MINIO_ROOT_PASSWORD=password
       ports:
          - "9000:9000"
          - "9001:9001"
       networks:
          - docker_registry
       volumes:
          - minio_data:/data
networks:
    docker_registry:
        name: docker_registry
 volumes:
     minio_data:

まずはminioのバケットを作成する必要があります。docker compose up でminioが立ち上がったらブラウザからlocalhost:9001でコンソールにアクセスし、registry という名前でバケットを作成します。自分はブラウザでやりましたが、minioやawsのCLIからバケットを作成しても良いでしょう。

最終的に docker compose logsregistry のログを見て起動時にエラーを出さなければ正しく設定できています。docker pull debian で試してみたところ、registryのログ中にs3aws.PutContentが見えるのでminioに接続できていることが分かります。

registry  | time="2023-10-24T00:50:01.973578309Z" level=debug msg=GetImageManifest go.version=go1.20.8 http.request.host="127.0.0.1:50000" http.request.id=9ca8ed6f-fd59-49aa-bdfc-8db8890b3f31 http.request.method=HEAD http.request.remoteaddr="172.18.0.1:56782" http.request.uri="/v2/library/debian/manifests/latest?ns=docker.io" http.request.useragent="docker/24.0 go/go1.20.6 git-commit/HEAD kernel/5.15.90.1-microsoft-standard-WSL2 os/linux arch/amd64 containerd-client/1.6.21+unknown storage-driver/stargz UpstreamClient(Docker-Client/20.10.21 \(linux\))" vars.name="library/debian" vars.reference=latest
registry  | time="2023-10-24T00:50:03.14447878Z" level=debug msg="s3aws.PutContent("/docker/registry/v2/repositories/library/debian/_manifests/tags/latest/index/sha256/7d3e8810c96a6a278c218eb8e7f01efaec9d65f50c54aae37421dc3cbeba6535/link")" go.version=go1.20.8 http.request.host="127.0.0.1:50000" http.request.id=9ca8ed6f-fd59-49aa-bdfc-8db8890b3f31 http.request.method=HEAD http.request.remoteaddr="172.18.0.1:56782" http.request.uri="/v2/library/debian/manifests/latest?ns=docker.io" http.request.useragent="docker/24.0 go/go1.20.6 git-commit/HEAD kernel/5.15.90.1-microsoft-standard-WSL2 os/linux arch/amd64 containerd-client/1.6.21+unknown storage-driver/stargz UpstreamClient(Docker-Client/20.10.21 \(linux\))" trace.duration=11.454148ms trace.file="github.com/docker/distribution/registry/storage/driver/base/base.go" trace.func="github.com/docker/distribution/registry/storage/driver/base.(*Base).PutContent" trace.id=ffb10e61-2bd4-4b09-b213-e06c08fdc637 trace.line=110 vars.name="library/debian" vars.reference=latest

レジストリにキャッシュされたデータのお掃除(未検証)

自前で立てたレジストリにはキャッシュとしてイメージのデータが保存されていくため、データ量が無限に増え続けます。キャッシュという性質上、新しいイメージに比べて古いイメージデータが今後参照される可能性は低いため、ディスクの節約のために可能ならば古いデータを定期的に削除したいです。

ドキュメントにGarbage collectionのページがあるのでこれっぽいのですが、registry のコンテナ内に garbage-collect という別のコマンドが存在しており、それを実行することで削除してくれそう?

https://distribution.github.io/distribution/about/garbage-collection/

ただドキュメント上の deleted の話がよく分からないのと、設定のリファレンスに時折見える delete の項目に関係があるのかもよく分からなかったです。さらに言えばファイルシステムではなくS3などの外部ストレージを使用している場合のGCがどうなるのかも一切記載がなかったため、挙動がいまいち分かりません。

別のアプローチとしてminio側のライフサイクル機能で古いデータを削除する方法が可能かもしれません。ライフサイクルを1日で削除の設定で一度実験してみた結果では、一応問題なく再びpull through cacheの挙動でDocker Hubからイメージを取得してキャッシュを保存するという挙動をしてくれました。ただ本当に問題がないかどうかは長期に使ってみないと分からないので、最終的にクラウド上にレジストリサーバーを構築する場合に採用するかどうかは検討中です。

本物のS3ならば容量単価が安いため、リスクを取らずにGCやライフサイクルを一切設定せずに永続的にデータを持ち続けるという選択でもいいかもしれません。

最終的な各種設定ファイル

最終的にこのような設定ファイルになりました。これを使用すれば自分以外の環境でもローカルにレジストリとminioを立ち上げ、docker pulldocker buildx build でpull through cacheを使えるようになるはずです。

compose.yml

services:
    registry:
        container_name: registry
        image: registry:2
        restart: always
        environment:
            # - REGISTRY_LOGLEVEL=debug
            - REGISTRY_PROXY_REMOTEURL="https://registry-1.docker.io"
            - REGISTRY_PROXY_USERNAME=$DOCKER_USER
            - REGISTRY_PROXY_PASSWORD=$DOCKER_PAT
            - REGISTRY_PROXY_TTL=168h
            - REGISTRY_STORAGE=s3
            - REGISTRY_STORAGE_S3_BUCKET=registry
            - REGISTRY_STORAGE_S3_ACCESSKEY=root
            - REGISTRY_STORAGE_S3_SECRETKEY=password
            - REGISTRY_STORAGE_S3_REGION=minio
            - REGISTRY_STORAGE_S3_REGIONENDPOINT=http://minio:9000
            # - REGISTRY_STORAGE_S3_LOGLEVEL=debug
            # レイヤーのメタデータをインメモリでキャッシュするらしいが効果のほどはよくわからない
            - REGISTRY_STORAGE_CACHE_BLOBDESCRIPTOR=inmemory
        ports:
            - "50000:5000"
        networks:
            - docker_registry
        # docker composeのデフォルト挙動を忘れたので一応ログの容量にキャップをかけておく
        logging:
            driver: json-file
            options:
                max-size: "10m"
                max-file: "3"

    minio:
        container_name: minio
        image: minio/minio
        restart: always
        command: server --console-address ":9001" /data
        environment:
            - MINIO_ROOT_USER=root
            - MINIO_ROOT_PASSWORD=password
        ports:
            - "9000:9000"
            - "9001:9001"
        networks:
            - docker_registry
        volumes:
            - minio_data:/data
        logging:
            driver: json-file
            options:
                max-size: "10m"
                max-file: "3"
networks:
    docker_registry:
        name: docker_registry
volumes:
    minio_data:

buildkitd.toml

# debug = true
[registry."docker.io"]
  mirrors = ["registry:5000"]
  http = true
  insecure = true

[registry."registry:5000"]
  http = true
  insecure = true

これで自分のローカルでは docker pull, docker buildx build のどちらかでpullしてきたイメージはキャッシュされるので、別のツールで同じイメージをpullする場合にもDocker Hubに通信が不要となり高速化されました。

ただ、初回のイメージのpullは自前のレジストリを経由するため、何も設定していなかった頃に比べると逆に若干遅くなりました。そもそもpull through cacheを使わずとも一度pullしていればdockerdやbuildkitdのキャッシュ残っている限りは再度pullしても一瞬で終わりますし、自分しか利用者がいないローカルのレジストリではスケールメリットもありません。

正直に言えば自分も若干期待していたところはあるのですが、残念ながらローカルのdockerを高速化するような効果は見込めないでしょう。今回の記事の内容はあくまでpull through cacheの理解を深めるための検証としての価値しかないと思います。

番外編1 buildkitdのデフォルト設定にpull through cacheを導入する(失敗)

ここから先は番外編なので興味がない方は読み飛ばしてください。

docker buildx create するたびに --config で設定ファイルを指定するのは面倒なので、buildkitdのデフォルトのconfigファイルとして設定できるかを試してみました。

https://docs.docker.com/build/buildkit/toml-configuration/

The file path is /etc/buildkit/buildkitd.toml for rootful mode, ~/.config/buildkit/buildkitd.toml for rootless mode.

これを見る限り今まで使っていたbuildkitd.tomlをどちらかのパスに配置してやれば使ってくれそうなのですが、自分の環境ではどちらのパスに配置しても有効になってくれませんでした。WSL2なのでdockerdの本体はDocker Desktopが関している別のVMに存在していることが関係しているのでしょうか・・・?

理由は分からないですが、どのみち --driver-opt network=docker_registry のオプションも必要なので多少の面倒さは受け入れても大差はない、ということで自分のローカル環境では諦めることにしました。

番外編2 Earthlyにpull through cacheを導入する

自分は docker buildx 以外にEarthlyを使うこともあるのでこちらにもpull through cacheを導入します。Earthlyについて興味がある方は過去に紹介記事を書いたのでこちらを御覧ください。

https://zenn.dev/kesin11/articles/7f4eed7cabf38d

Earthlyもbuildkitdを利用したビルドツールなのですが、buildkitdを独自にForkしたカスタマイズ版を使用しているためかpull through cacheのための設定もbuildkitd.tomlではなく独自のconfigファイルの方に書く必要があるようです。

https://docs.earthly.dev/ci-integration/pull-through-cache#insecure-docker-hub-cache-example

# ドキュメントのサンプルを引用
global:
  buildkit_additional_config: |
    [registry."docker.io"]
      mirrors = ["192.168.0.80:5000"]
    [registry."192.168.0.80:5000"]
      insecure = true

サンプルを見る限り、Earthly用の設定のyamlの中にbuildkitd.tomlの設定を無理やり入れるようです。ということはここまで使用してきたbuildkitd.tomlの中身をコピペでいけそうです。

$ cat ~/.earthly/config.yml
global:
    buildkit_additional_config: |
        [registry."docker.io"]
          mirrors = ["registry:5000"]
          http = true
          insecure = true

        [registry."registry:5000"]
          http = true
          insecure = true

earthly bootstrap をするとEarthlyが専用のbuildkitdをコンテナで立ち上げてくれるので、どのネットワーク上に立ち上がったのかを確認してみます。やはりbridgeネットワーク上にコンテナが立ち上がっていました。

$ earthly bootstrap
           buildkitd | Starting buildkit daemon as a docker container (earthly-buildkitd)...
           buildkitd | ...Done
           bootstrap | Bootstrapping successful.

$ docker container inspect earthly-buildkitd
            "Networks": {
                "bridge": {
                    "IPAMConfig": null,
                    "Links": null,
                    "Aliases": null,
                    "NetworkID": "06558ce17ed3b48138e6d4652f3e9de3cb80a5db37650b789d58332fef9bd029",
                    "EndpointID": "c471c5fa1eb30b2b26b364577e786831c5fe46ab0a994c18f1ba87b3f8abf5df",
                    "Gateway": "172.17.0.1",
                    "IPAddress": "172.17.0.2",
                    "IPPrefixLen": 16,
                    "IPv6Gateway": "",
                    "GlobalIPv6Address": "",
                    "GlobalIPv6PrefixLen": 0,
                    "MacAddress": "02:42:ac:11:00:02",
                    "DriverOpts": null
                }
            }

ということはdocker buildx create--driver-opt network=docker_registrを渡したのと同様にearthly-buildkitdのコンテナを立ち上げる際にオプションを渡してネットワークを変更できれば解決できるはずです。Earthlyのドキュメントを再度見直してみると buildkit_additional_args というオプションがあったので、これに --network を渡します。

$ cat ~/.earthly/config.yml
global:
    buildkit_additional_args: ["--network", "docker_registry"]
    buildkit_additional_config: |
        [registry."docker.io"]
          mirrors = ["registry:5000"]
          http = true
          insecure = true

        [registry."registry:5000"]
          http = true
          insecure = true

earthly bootstrap やり直して、再度コンテナが属しているネットワークを調べてみます。registry, minio, buildkitdと同じネットワークにearthly-buildkitdも立ち上がったので大丈夫でしょう。

 $ docker network inspect docker_registry

 "Containers": {
     "1b2e5fda5a94c30438df7d84554b790fdbd6e758de0fb9a06ba8f": {
         "Name": "buildx_buildkit_mirrored0",
         "IPv4Address": "172.18.0.3/16",
     },
     "c61964704efb464c0c64e6dd6412eb67e2e5ad6fcd94e1db5aa4f": {
         "Name": "registry",
         "IPv4Address": "172.18.0.2/16",
     },
     "d78b0ebd1aba99f730d078e5f6d69f4cc0fcb97cb5bb0bc88cab7": {
         "Name": "minio",
         "IPv4Address": "172.18.0.5/16",
     },
     "e64911ab6d2da334c3f610ebac33e51219655b007830d65dd7e9d": {
         "Name": "earthly-buildkitd",
         "IPv4Address": "172.18.0.4/16",
     }
 },

この状態で適当なEarthfileを用意してビルドし、 registry のログを見るとちゃんとminioにキャッシュしているログが出ていたのでEarthlyでもpull through cacheの設定ができたようです。

サイボウズ 生産性向上チーム 💪

Discussion