ぽよメモ

レガシーシステム考古学専攻

マルチアーキテクチャ対応イメージのビルドをどうにか早くしたかった

マルチアーキテクチャ対応イメージって何?

 最近ではApple Siliconの登場や、Oracle CloudのAmpere A1 Computeインスタンスなど、ARMアーキテクチャ採用のプロセッサに実際の開発などで触れる機会も増えてきました。 現在広く流通するx86_64に対応するIntelAMD製のプロセッサと比べて、ARM系のCPUには高い省電力性能などに期待が集まっています。趣味開発レベルの話をすると、Oracle CloudのAlways FreeなA1インスタンスは非常に魅力的であり、是非活用したいところです。

 ただしARM系CPUではx86_64向けにビルドされたバイナリをそのまま動かすことはできません。当然Dockerイメージについてもx86_64向けのイメージをarm64のマシンで動かすことはできない*1ため、arm向けにビルドされたDockerイメージを別で用意する必要があります。簡単に思いつく方法としては

  • イメージごと分けてしまう方法
    • 例:hoge/fuga-arm64:latest, hoge/fuga-amd64:latest
  • タグごとに分けてしまう方法
    • 例:hoge/fuga:latest-arm64, hoge/fuga:latest-amd64

などがあると思います。しかし、これは利便性を大きく損ないます。docker-compose.ymlだったり、コンテナを起動するスクリプトだったり、Kubernetesマニフェストだったりには基本的にイメージ名やタグがハードコードされています。環境ごとにこれを書き換えるのは非常に面倒でしょう。

 マルチアーキテクチャ対応イメージとは、ある単一のイメージおよびそのタグに、複数のアーキテクチャ向けのイメージを紐付けたものを指します。利用者側で自身のアーキテクチャに合わせて最適なイメージが選択されて使用されるため、アーキテクチャを意識してイメージ・タグを書き換える仕組みを用意する必要がありません。例えばDocker Hubで配布されているベースイメージ( docker.io/library/* )などが多数のアーキテクチャに対応したマルチアーキテクチャ対応イメージになっています*2

どのアーキテクチャに対応しているか、Dockerとしてのサポートの範囲などは以下に記述があります。 https://github.com/docker-library/official-images#architectures-other-than-amd64

どうやって作るか

単に別アーキテクチャでビルドして同じイメージ・タグでレジストリにpushすると、後からpushされた方で上書きされて単一のアーキテクチャ向けのイメージになってしまいます。正しいマルチアーキテクチャ対応イメージの作り方には大きく分けて2つの方法があります。

docker manifestコマンドを使う

 どうにかして各アーキテクチャで動く環境を手に入れ、その上でdocker buildしたイメージを別々のイメージ名ないしタグでレジストリにpushしておきます*3。ここでは例えば amd64/image:latestarm64/image:latest としておきます。これらをまとめて一つの my/image:latest としたい場合、下記の様にコマンドを実行します。

docker manifest create my/image:latest \
    amd64/image:latest \
    arm64/image:latest

これで my/image:latestマニフェストができたのでこれをレジストリにpushします。

docker manifest push my/image:latest

 この方法は(おそらく)Docker Hubのオフィシャルイメージで使用されています。例えばUbuntuのイメージは対応アーキテクチャごとにそれぞれ別々のorgに存在します。

利用者側では単にubuntu:20.04などのイメージを指定するだけで、適したアーキテクチャのイメージがpullされます。

docker buildxコマンドでビルドする

 buildxとは、BuildKit*4に基づいてより発展的なビルド方式を提供するdockerコマンドのプラグインです。

github.com

 buildxではQEMUを利用した他アーキテクチャのエミュレーション環境や、実際にそのアーキテクチャで動作するノードを仮想的な Builder に紐付け、それらを使ってビルドを行い、最初からマルチアーキテクチャに対応したマニフェストを用意することが出来ます。 ただし、ローカルのDocker環境からマルチアーキテクチャマニフェストをそのまま取り扱うことはできないため、一度レジストリにpushした後に自身のアーキテクチャに対応するイメージをpullすることでしかそのビルドしたイメージを使うことは出来ません。

 試した環境の情報は以下の通りです。

 まずはBuilderを用意します。ここではDocker Desktop for Macを使っています。WindowsのDocker Desktopではおよそ同じ手順で出来るとは思いますが、Linuxでは追加の手順が必要になると思います*5。本当はLinuxでも確かめるつもりだったのですが、諸事情でデスクトップが死んでいるので諦めました*6

 ローカルでマルチアーキテクチャ対応イメージをビルドする場合、docker-container ドライバの Builder を作ることが必要です。

docker buildx create \
     --name multi-arch-builder \
     --driver docker-container \
     --platform linux/arm64,linux/amd64

今回はサンプルとしてGo製のcowsayであるNeo-cowsay*7をビルドするだけのDockerfileを用意してみました。 golangイメージはマルチアーキテクチャ対応しているので、Dockerfile自体に工夫がなくともamd64/arm64両方に向けてビルドできます。

FROM golang:1.17.1 as builder

ARG VERSION=latest

RUN go install github.com/Code-Hex/Neo-cowsay/cmd/cowsay@${VERSION}

FROM gcr.io/distroless/static

COPY --from=builder /go/bin/cowsay /usr/bin/cowsay

ENTRYPOINT ["/usr/bin/cowsay"]

先ほどの Builder を指定して、linux/amd64linux/arm64 向けにビルドしてみます。

$ docker buildx build \
    --builder multi-arch-builder \
    --platform linux/amd64,linux/arm64 \
    -o type=image,push=false \
    -t cowsay:latest \
    --no-cache .
[+] Building 91.3s (15/15) FINISHED
 => [internal] load build definition from Dockerfile                                                                                                    0.4s
 => => transferring dockerfile: 274B                                                                                                                    0.0s
 => [internal] load .dockerignore                                                                                                                       0.3s
 => => transferring context: 2B                                                                                                                         0.0s
 => [linux/arm64 internal] load metadata for gcr.io/distroless/static:latest                                                                            1.0s
 => [linux/arm64 internal] load metadata for docker.io/library/golang:1.17.1                                                                            1.4s
 => [linux/amd64 internal] load metadata for docker.io/library/golang:1.17.1                                                                            1.3s
 => [linux/amd64 internal] load metadata for gcr.io/distroless/static:latest                                                                            1.4s
 => CACHED [linux/amd64 stage-1 1/2] FROM gcr.io/distroless/static@sha256:912bd2c2b9704ead25ba91b631e3849d940f9d533f0c15cf4fc625099ad145b1              0.0s
 => => resolve gcr.io/distroless/static@sha256:912bd2c2b9704ead25ba91b631e3849d940f9d533f0c15cf4fc625099ad145b1                                         1.0s
 => CACHED [linux/amd64 builder 1/2] FROM docker.io/library/golang:1.17.1@sha256:285cf0cb73ab995caee61b900b2be123cd198f3541ce318c549ea5ff9832bdf0       0.0s
 => => resolve docker.io/library/golang:1.17.1@sha256:285cf0cb73ab995caee61b900b2be123cd198f3541ce318c549ea5ff9832bdf0                                  1.1s
 => CACHED [linux/arm64 stage-1 1/2] FROM gcr.io/distroless/static@sha256:912bd2c2b9704ead25ba91b631e3849d940f9d533f0c15cf4fc625099ad145b1              0.0s
 => => resolve gcr.io/distroless/static@sha256:912bd2c2b9704ead25ba91b631e3849d940f9d533f0c15cf4fc625099ad145b1                                         1.1s
 => CACHED [linux/arm64 builder 1/2] FROM docker.io/library/golang:1.17.1@sha256:285cf0cb73ab995caee61b900b2be123cd198f3541ce318c549ea5ff9832bdf0       0.0s
 => => resolve docker.io/library/golang:1.17.1@sha256:285cf0cb73ab995caee61b900b2be123cd198f3541ce318c549ea5ff9832bdf0                                  1.1s
 => [linux/amd64 builder 2/2] RUN go install github.com/Code-Hex/Neo-cowsay/cmd/cowsay@latest                                                          17.1s
 => [linux/arm64 builder 2/2] RUN go install github.com/Code-Hex/Neo-cowsay/cmd/cowsay@latest                                                          80.5s
 => [linux/amd64 stage-1 2/2] COPY --from=builder /go/bin/cowsay /usr/bin/cowsay                                                                        1.0s
 => [linux/arm64 stage-1 2/2] COPY --from=builder /go/bin/cowsay /usr/bin/cowsay                                                                        0.9s
 => exporting to image                                                                                                                                  5.5s
 => => exporting layers                                                                                                                                 4.0s
 => => exporting manifest sha256:d1ee75cd239140bd0210554ada9707162750d4766635e6abc17725aa70bac5d3                                                       0.3s
 => => exporting config sha256:afe4e4a473376655e0377c92aa75ef7bdcddc3a3fffd95e285fda7d0db4ce1df                                                         0.3s
 => => exporting manifest sha256:df3198ab96b70b1e5b0584d5cc39065772c1268946e088e0bb2f1f038ff81400                                                       0.3s
 => => exporting config sha256:af3de61b4d4202ee4c8fa513b189e620443a462b5487d39cc491fee08776d39d                                                         0.3s
 => => exporting manifest list sha256:e1e7b18094b953f584370083971a13fdcc12290239e575739e0b7f91a9d83ca1                                                  0.3s

無事イメージがビルドできたようです。とはいえ、これを使うためにはレジストリにpushする必要があるのですが…… 。現状生成されたマニフェストdocker manifest inspect等で見ることもできないような気がします。見る方法を知っている方、教えてください🙏

 ちなみに docker ドライバーのBuilderを使い、単一のplatformを指定し、-o type=image,push=false ではなく --load というオプションを組み合わせると、 docker image ls などで表示されるイメージ一覧にビルドされたイメージが追加されます。動作確認したい場合は試してみてください。

buildxとQEMUによるビルドは遅い

 非常に簡単にマルチアーキテクチャ対応イメージをビルドすることができました。しかし、実行時間を見てみると、 go install してcowsayをビルドしている部分の時間は以下の様になっていました。

およそ5倍近い差が開いています。複数回実行しても、おおよそ4〜6倍程度、arm64向けのビルドが遅いことがわかりました。QEMUの分のオーバーヘッドがあるため仕方ないのですが、ビルド対象の規模が大きくなるほど遅くなる他、apt-getなど他の操作も有意に遅くなりました。場合によっては10倍、20倍と差が開くこともあり、CIなどで長時間待たされることになりました。

CIでのビルドを速くしたい

 当初、プライベートリポジトリでマルチアーキテクチャ対応イメージをCIでビルド・pushしたいと考えていました。このリポジトリではCIにGitHub Actionsを用いています。GitHub Actionsは従量課金で毎月2000分のビルドまでは無料ですが、色々複雑なことをしていたら一回のビルド時間が容易に15分、20分と伸びていってしまいました。できれば課金はしたくないですし、手作業でビルド・pushもしたくありません。何より一度コードをpushしてから結果が分かるまで20分もかかるというのはかなり苦痛です。

 どうにか高速化できないかなと少し足掻いてみました。試したリポジトリ・アップロードしたイメージは以下にあります。

github.com

https://hub.docker.com/repository/docker/pddg/multi-arch-image-sample

アーキテクチャごとに別ジョブでビルドする

 buildx自体はビルドをリモートマシンに委譲できるなどスケーラブルな仕組みになっているのですが、CIの仕組みとは非常に相性が悪いです。CIでは定義された複数のジョブを決められたスペックのインスタンスで実行します。スケールする単位はジョブやワークフローごとであり、ある一つのジョブ内で計算リソースをスケールさせるためには動的にクラウドのリソースを作るようなことが必要になります*8。そんな面倒なことはしたくないので、対象アーキテクチャごとにジョブを分けて並列にビルド・レジストリにpushして、それらが全て完了した後に後続のジョブで docker manifest コマンドを叩く方法を検討してみました。

ただ今回は各アーキテクチャごとに別ジョブでビルドしても、ワークフロー全体に占める時間はほとんど変わりませんでした。より大規模なプロジェクトや、多数のアーキテクチャをサポートなどするなどすると差が出てくると思います。

クロスビルドを活用する

 Goは幸いクロスビルドできる言語です。QEMUなんて挟まなくとも、Pure Goで記述されていればx86_64のマシンからarm向けのバイナリをビルドすることが出来ます。これを使えば、わざわざ重い環境でビルドしなくてもよくなるはずです。

github.com

実際に今回はこの方法で2分近くかかっていたCIを40秒程度まで短縮することが出来ました。

 ただし嬉しくない点がいくつかあります。

  1. Dockerfile内でアーキテクチャを認識する必要があるかもしれない
  2. docker buildで完結しないため、開発者の個々の環境に左右される可能性がある

 まず1についてですが、Goのアーキテクチャの指定方法と、Dockerのアーキテクチャの指定方法に差異があるため、これをどこかで吸収する必要があります。各アーキテクチャ向けにビルドしたい場合、ビルド時に以下の様に環境変数を設定します*9。arm64で GOARM=8 を付けると Invalid GOARM value. Must be 5, 6, or 7. というエラーが出るので注意が必要です。

GOOS=linux GOARCH=amd64 go build .
GOOS=linux GOARCH=arm GOARM=7 go build .
GOOS=linux GOARCH=arm64 go build .

Dockerfile内からこれらの値について知ることができる特殊な引数は以下の通りです。

# linux
ARG TARGETOS

# amd64, arm, arm64
ARG TARGETARCH

# linux/arm/v7 のみ v7 。他は空文字。
ARG TARGETVARIANT

arm/v7の場合は TARGETVARIANT に値が存在しますが、amd64やarm64の場合は現状 TARGETVARIANT は空文字列です。もしかしたら将来的にarm64/v9とかが出てくるかもしれないと思うと、結構嬉しくない状態だなと思っています(ARMの事情に明るくないので適当言っています)。x86とarm以外については今回検討しなかったため、もしかしたら他にも厄介な点があるかも知れません。

 2については言うまでも無く、これまではmulti stage buildによってDockerさえあればビルド出来る状態でした。しかしクロスビルドによってDockerイメージの生成とバイナリの生成を分離したことで、DockerだけでなくGoの環境が必要になってしまいました。幸いGoはインストールも簡単なので、他言語と比べると非常に楽な方ではあると思います。

buildxに対する雑感

 buildx周りのコマンドは洗練されていない印象が強いです。buildx のサブコマンドは対応するリソースとアクションが分かりづらく、また未だ知見も少ないことからかなり手探りでした。一方でGitHub Actionsで使えるように用意されているactionは広く使われることを意識しているのか、かなりわかりやすく整えられていると感じました。とはいえスケールする構成にするためにはdocker manifestコマンドなどを知っていなければいけないなど、まだ発展途上な仕組みであることには違いないようです。

 また、複数プラットフォームのイメージをラップトップで一度にビルドするのは計算リソースの問題から厳しいことが分かってきました。検証しているとひっきりなしにMacBook Proのファンが唸るのでかなり厳しい気持ちになってしまいました。力こそパワー、ラップトップを捨ててつよつよワークステーションを買いましょう。

結論

  • 強い計算リソースを買えば問題は解決

こうすると良いよ的な情報があればどしどし寄せて頂けると幸いです。

参考

  • www.docker.com
    • docker manifestによる方法を「The hard way」と言っていますが、正直こっちの方がわかりやすいかつスケーラブルな仕組みにしやすいと思います。

 

*1:QEMUなどによってパフォーマンスを犠牲にして動かすことはできます

*2:www.docker.com

*3:実際には後述するbuildxを使って別プラットフォームを指定しても良いです

*4:github.com

*5:www.yukkuriikouze.com

*6:どうもメモリがぶっ壊れたようでメモリテストで異常が検知されるのですが、32GBモジュール×2の交換品は果たしてちゃんと確保されているのでしょうか……

*7:github.com

*8:もしかしたら将来的にはGitHub Actionsで動的にbuildxのBuilderノードを追加して叩くようなAPI/actionができるかもしれませんが、わざわざそこまでしてくれるかは微妙だろうと自分は思っています

*9:github.com