BuildJetを使ってamd64とarm64に対応したコンテナイメージを今までの8倍速く作る

三行まとめ

  • BuildJet が提供するArmマシンを用いてArmで動くイメージを作成する
  • Docker Buildx を用いると複数のイメージに同じタグを貼ることができる
  • Actionsのx86_64マシンでamd64で動くイメージを、BuildJetのArmマシンでarm64で動くイメージをそれぞれ作成してくっつけた

はじめに

Apple Silicon搭載マシンの登場、Raspberry Piの普及などArmが身近になってきた人も多いのではないでしょうか。

Raspberry Pi上で動くKubernetesなどを運用する際にはarm64とamd64の双方に対応したイメージが同一のイメージタグで存在すると手元とデプロイ先で同じようにコンテナを使えて嬉しいです。このようなイメージのことをマルチプラットフォームイメージと呼びます。

今回はlinux/amd64linux/arm64の双方に対応したマルチプラットフォームイメージをBuildJetを用いて高速に作る話をします。

なおGoやRustのようにクロスコンパイルができる仕組みのある言語で書かれたアプリケーションのマルチプラットフォームイメージは、BuildJetを使わなくてもDockerfileを工夫することによってイメージの作成を速くすることができます。

過去エントリで解説をしているのでそちらも参照ください。

ymse.hatenablog.com

タイトルの8倍という数字はdocker buildx build --platform=linux/amd64,linux/arm64 .としてNext.jsのコンテナイメージをビルドするGitHub Actionsのworkflowと比較した結果となります。

BuildJetとは

BuildJetはGitHub Actionsのworkflowを実行するためのマシンを提供する*1サービスです。マシンだけを提供しているので、BuildJetを使うからと言ってworkflow定義ファイルの変更をする必要はないです。 buildjet.com

Y Combinatorにも採択されています。

www.ycombinator.com

BuildJetには特徴が2つあると思っています。

1つ目がGitHub Actionsが管理しているランナーと比べてシングルコア性能が良いマシンをCore数を揃えて比較すると半額で提供している*2ことです。

2つ目がArmアーキテクチャのマシンを含むたくさんの種類のマシンが使える*3ことです。 GitHub Actions のManaged RunnerのラインナップになかったArmのマシンは使えるのが嬉しいです。今回はこのArmマシンが使えるという特徴を活かします。

それぞれの特徴は脚注のリンクにあるので詳しい説明は本家に任せます。

方法

実際にBuildJetを用いてマルチプラットフォームイメージを作成する方法を紹介していきます。今回紹介するコードは以下のリポジトリに置くのでそちらもぜひ参考にしてください。

github.com

BuildJetに登録する

BuildJetに登録します。GitHub Market Placeからも登録できます。

BuildJetを有効にする

リポジトリに対してBuildJetがアクセスできる権限を付与します。あたり前ですがこの権限が無いと使えないです。

runs-onにBuildJetのランナーを指定する

いつもubuntu-latestと書いているところに、ドキュメント*4を見ながらBuildJetが提供するランナーの名前を書きます。 今回はamd64イメージビルド用としてパブリックリポジトリなら無料で使えるubuntu-latest、arm64イメージビルド用としてBuildJet最安のbuildjet-2vcpu-ubuntu-2204-armを採用しました。

arm64のイメージとamd64のイメージを作るときはruns-onを変えた同じjobを定義する必要があります。runs-onを除いて共通なのでmatrixを用いると良いでしょう。

使ったリポジトリの該当箇所はここです。

https://github.com/Azuki-bar/hello-buildjet/blob/1040a07209feac57d0ae064eedd86ef0892da872/.github/workflows/release.yaml#L11

# 省略
build:
    name: Build and Publish image
    strategy:
      fail-fast: true
      matrix:
        runs-on:
          - "ubuntu-latest"
          - "buildjet-2vcpu-ubuntu-2204-arm"
    runs-on: ${{ matrix.runs-on }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v2
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2
      - name: Login to ghcr.io
        uses: docker/login-action@v2
        with:
          registry: ghcr.io
          username: "azuki-bar"
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Prepare-tag
        id: tags
        run: |
          arch=""
          # https://docs.github.com/en/actions/learn-github-actions/contexts#runner-context
          case "${{ runner.arch }}" in
            "X64" ) arch="amd64" ;;
            "ARM64" ) arch="arm64" ;;
          esac

          echo "tag=${{ github.sha }}-${arch}" >> $GITHUB_OUTPUT
      - name: Build and push
        uses: docker/build-push-action@v4
        with:
          push: true
          tags: ghcr.io/azuki-bar/hello-buildjet-nextjs:${{ steps.tags.outputs.tag }}
          provenance: false

後述のイメージを束ねるために使うdocker buildx imagetools createコマンドは対象のコンテナイメージがコンテナレジストリに存在しているほうが便利に使えます。なので今回はGitHub Container Registryに各プラットフォームのイメージをアップロードしています。

アーキテクチャごとにタグを付けて一度アップロードしていますが、この例では最終的に欲しいタグにアーキテクチャの接尾辞を付けることとしています。多分タグを付けずにコンテナイメージのsha256のままでも良いですが未検証です。

provenanceをfalseにする必要があります。そうしないとプラットフォームがunknownなイメージが最終的に作られてしまいます。

github.com

複数のイメージを束ねるマニフェストを作る

前セクションでプラットフォーム固有のタグが付いたイメージができました。しかし一つのタグで複数プラットフォーム対応イメージをダウンロードできるようにするという目的はまだ達成できていないです。ので1つのタグで複数プラットフォーム対応イメージを作成していきます。

複数のイメージタグを束ねて一つのイメージタグにするにはDocker Buildxのdocker buildx imagetools createコマンド*5を使います。

使ったリポジトリの該当個所はここです。

github.com

  merge-images:
    runs-on: "ubuntu-latest"
    needs: ["build"]
    steps:
      - name: Checkout code
        uses: actions/checkout@v2
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2
      - name: Login to ghcr.io
        uses: docker/login-action@v2
        with:
          registry: ghcr.io
          username: "azuki-bar"
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Create a New Image
        run: |
          image_tag="ghcr.io/azuki-bar/hello-buildjet-nextjs:${{ github.sha }}"

          docker buildx imagetools create \
            --tag ${image_tag} \
            ${image_tag}-arm64 \
            ${image_tag}-amd64

イメージタグを作成する段階ではarmネイティブマシンは必要ありません。なのでGitHub Actionsのマネージドランナーを使用します。

また注意が必要ですが、docker buildx imagetools createコマンドは--dry-runオプションを指定しない限りコンテナレジストリにプッシュしようとします。なので何らかのコンテナレジストリにログインすることは必須です。

めでたしめでたし

以上でマルチプラットフォームに対応したコンテナイメージを作成することができました。

実際に使ってみる

実際にイメージが対応しているのか見てみましょう。

unameカーネルとCPUアーキテクチャが何か表示してからイメージを実行してみましょう。

amd64マシンで実行した結果は次の通りです。

$ uname --kernel-name --machine
Linux x86_64
$ docker run --rm -p 3000:3000 ghcr.io/azuki-bar/hello-buildjet-nextjs:1040a07209feac57d0ae064eedd86ef0892da872
Unable to find image 'ghcr.io/azuki-bar/hello-buildjet-nextjs:1040a07209feac57d0ae064eedd86ef0892da872' locally
1040a07209feac57d0ae064eedd86ef0892da872: Pulling from azuki-bar/hello-buildjet-nextjs
a7ca0d9ba68f: Already exists

(省略)

39aea93f8f16: Pull complete
Digest: sha256:d4a9aac5ad5c9c03f5b2c16c3c879f882a7245f37f2c05e1971c70104b2a362f
Status: Downloaded newer image for ghcr.io/azuki-bar/hello-buildjet-nextjs:1040a07209feac57d0ae064eedd86ef0892da872
Listening on port 3000 url: http://afcdea87b1e1:3000

イメージをプルして実行できていることが分かります。

同様にarm64マシンでも実行してみましょう。

$ uname --kernel-name --machine
Linux aarch64
$ docker run --rm -p 3000:3000 ghcr.io/azuki-bar/hello-buildjet-nextjs:1040a07209feac57d0ae064eedd86ef0892da872
Unable to find image 'ghcr.io/azuki-bar/hello-buildjet-nextjs:1040a07209feac57d0ae064eedd86ef0892da872' locally
1040a07209feac57d0ae064eedd86ef0892da872: Pulling from azuki-bar/hello-buildjet-nextjs
0b41f743fd4d: Pull complete

(省略)

b399976cc073: Pull complete
Digest: sha256:d4a9aac5ad5c9c03f5b2c16c3c879f882a7245f37f2c05e1971c70104b2a362f
Status: Downloaded newer image for ghcr.io/azuki-bar/hello-buildjet-nextjs:1040a07209feac57d0ae064eedd86ef0892da872
Listening on port 3000 url: http://19f0a027d7b1:3000

こちらも同様にイメージをプルして実行できています。

amd64マシンとarm64マシンの両方で同じイメージタグを使っていることに注目してください。 またイメージレイヤごとのハッシュ値が異なっていることにも注目してください。

速度比較

このセクションではQEMUを用いた、おそらく一番メジャーな方法でマルチプラットフォームイメージを作成したときと比較します。

使用したマシンはGitHub Actionsが提供しているubuntu-latest*6とBuildJetが提供しているbuildjet-2vcpu-ubuntu-2204-arm*7です。

またビルドするイメージはNext.jsのドキュメント*8にて紹介されているDockerfileを修正したものとします。アプリケーションの中身はnpx create-next-appをしただけとなっています。

中身が見たい人はこのエントリで何度も参照しているリポジトリ*9を見てください。

runs-on: ubuntu-latestとして QEMU を用いてマルチプラットフォームイメージを作成した場合、トータルで21m46s掛かっています。

https://github.com/Azuki-bar/hello-buildjet/actions/runs/5209520747/jobs/9399463926

今回の記事にした方法でマルチプラットフォームイメージを作成した場合、2m36sかかります。 内訳としてはイメージのビルドにamd64,arm64のそれぞれで1m21s,1m5sかかっていて、マージするのに10sかかっています。

グラフです。

マルチプラットフォームイメージの作成にかかる時間のグラフ

1度しか検証していないので何とも言えないですが、今回の計測ではイメージの作成時間が1/8になったことが分かります。

まとめ

BuildJetが提供するArmマシンを使用することでarm64に対応したイメージをQEMUを使って作成するよりも高速に作成することができます。これがエミュレータを介さない力です。

以上です。

*1:libvirtを用いたオレオレVM管理ツールを作ってベアメタルで運用しているらしい。 https://buildjet.com/for-github-actions/blog/why-we-want-to-fix-github-actions

*2:https://buildjet.com/for-github-actions/docs/getting-started/how-is-buildjet-for-github-actions-different に記述がある。サーバ用CPUではなくゲーミングCPUを使っているらしい。

*3:https://buildjet.com/for-github-actions/docs/runners/hardware に記述がある。2vCPUのマシンから32vCPUのマシンがある。

*4:https://buildjet.com/for-github-actions/docs/runners/hardware にruns-onに指定すべき値が書いてある。

*5:コマンドのドキュメントは https://docs.docker.com/engine/reference/commandline/buildx_imagetools_create/ を参照してください。

*6:2コア7GB

*7:2vCPU3GB

*8:https://nextjs.org/docs/app/building-your-application/deploying#docker-imagehttps://github.com/vercel/next.js/tree/canary/examples/with-docker-multi-env が例として紹介されている

*9:https://github.com/Azuki-bar/hello-buildjet/blob/1040a07209feac57d0ae064eedd86ef0892da872/Dockerfile