【令和最新版】Rust用Dockerfile 2023年6月モデル AMD64/ARM64 マルチアーキテクチャ対応 GitHub Acti
RustでDockerfile
を書くの地味に面倒くさいですよね。
ローカルでは開発者向けにMacで動作するようにし、CI環境ではLinuxで動作するようにし、Dockerレジストリにはマルチアーキテクチャでイメージを保管して、なおかつ効率よくキャッシュを使ってローカル、CI環境で高速にイメージをビルドできるようにしなければならないと。
_人人人人人人_
> 面倒臭い <
 ̄Y^Y^Y^Y^Y^Y^ ̄
というわけで、そのあたりをいい感じにやるためのDockerfile
を作成してみました。
Dockerfile
# syntax=docker/dockerfile:1.5
FROM messense/rust-musl-cross:${TARGETARCH}-musl as builder
ARG NAME=my-app
ARG TARGETARCH
RUN if [ $TARGETARCH = "amd64" ]; then \
echo "x86_64" > /arch; \
elif [ $TARGETARCH = "arm64" ]; then \
echo "aarch64" > /arch; \
else \
echo "Unsupported platform: $TARGETARCH"; \
exit 1; \
fi
COPY Cargo.toml .
COPY Cargo.lock .
RUN mkdir -p src \
&& echo 'fn main() {}' > src/main.rs \
&& cargo build --release --target $(cat /arch)-unknown-linux-musl
COPY src src
RUN CARGO_BUILD_INCREMENTAL=true cargo build --release --target $(cat /arch)-unknown-linux-musl \
&& cp target/$(cat /arch)-unknown-linux-musl/release/${NAME} target/release/app
FROM alpine
COPY /home/rust/src/target/release/app /app
CMD [ "/app" ]
利用方法は下記のようなコマンドでビルドします。
$ docker build --platform linux/amd64,linux/arm64 --build-context messense/rust-musl-cross:arm64-musl=docker-image://messense/rust-musl-cross:aarch64-musl --build-context messense/rust-musl-cross:amd64-musl=docker-image://messense/rust-musl-cross:x86_64-musl -t helloworld:latest .
このようなコマンドを覚えるのは面倒臭いため、下記のようなcompose.yml
を用意してdocker-compose build
で済ませるのも良いかと思います。
version: '3'
services:
app:
build:
dockerfile: Dockerfile
context: .
additional_contexts:
- messense/rust-musl-cross:amd64-musl=docker-image://messense/rust-musl-cross:x86_64-musl
- messense/rust-musl-cross:arm64-musl=docker-image://messense/rust-musl-cross:aarch64-musl
image: user/app
このDockerfile
は主に--platform
で複数のアーキテクチャをビルドする、GitHub Actionsのbuild-and-push
アクションで楽にビルドできることを重点に作成しています。
そのため、初見では何をしているのか大変わかりにくいものになっているので、解説をしていきたいと思います。
全体像の解説
マルチアーキテクチャイメージを作成するさいの一番の課題はQEMUによりビルド速度が遅くなることです。
経験したことのあるひどいケースでは通常のビルドで20分かかるさいに、QEMUを通すと1時間45分かかるなどビルドを待てないほどの遅さになることがあります。(GitHub Actions上でキャッシュなしで実行したケース)
このQEMUはビルド環境のアーキテクチャと作成するイメージのアーキテクチャが一致しないときに利用されます。
そのため、素直に解決するならamd64環境でamd64用のイメージを作成し、arm64環境でarm64用のイメージを作成し、最後にそれらを統合するマニフェストを作成するのが望ましいです。
しかし、メジャーなCI環境ではarm64用の環境がなく、例えばGitHub Actionでそれを行いたいならSelf Hosted Runnerでarm64環境を自前で構築しなければなりません。(この問題解決で調べたさいに見つけたDepotというサービスはこの問題を正面から解決する数少ないソリューションになりそうです。触ったことないけど)
ここで思い出して欲しいのがRustはクロスコンパイル可能な言語であり、これはQEMUと比べものにならないほど高速にコンパイルすることができるということです。
そこで、下記のようなマルチステージ構成でビルドできるようにします。
- ビルドステージとして実行環境のアーキテクチャで実行し、Rustのクロスコンパイルで作成するアーキテクチャ用のバイナリを生成する
- アプリケーションステージとして1で作成したバイナリをコピーして実行する
このような構成にすることで高速なマルチアーキテクチャイメージのビルドを実現します。
ビルドステージ
FROM
FROM messense/rust-musl-cross:${TARGETARCH}-musl as builder
まず、--platform=$BUILDPLATFORM
でビルドステージで実行するイメージを実行環境のアーキテクチャに揃えます。
$BUILDPLATFORM
はDockerが提供する値で、実行環境のプラットフォームとしてlinux/amd64
といった文字列を提供します。
これによりQEMUを使わないビルドにより高速なビルドが行えます。
利用するイメージとしてmessense/rust-musl-cross:${TARGETARCH}-musl
と指定していますが、これはダミーです。
$TARGETARCH
もDockerが提供する値で、作成するイメージのアーキテクチャとしてamd64
、arm64
といった文字列を提供します。
この値を展開するとmessense/rust-musl-cross:arm64-musl
といったイメージになりますが、messense/rust-musl-cross
イメージが提供するタグはaarch64-musl
といった形でRustのツールチェイン名に含まれるアーキテクチャ名になり、Dockerの$TARGETARCH
とは互換性がありません。
そこで--build-context
を使い、イメージの差し替えを行います。
$ docker build --platform linux/amd64,linux/arm64 --build-context messense/rust-musl-cross:arm64-musl=docker-image://messense/rust-musl-cross:aarch64-musl --build-context messense/rust-musl-cross:amd64-musl=docker-image://messense/rust-musl-cross:x86_64-musl -t helloworld:latest .
ここではmessense/rust-musl-cross:arm64-musl
というイメージ名があればDockerfile
中にあればmessense/rust-musl-cross:aarch64-musl
を使うといった形ですね。
本来であればARG
を使って解決すべきですが、そうしてしまうと--platform
で複数のアーキテクチャを指定できなくなるためこのような解決方法をとっています。
ビルド中に使うアーキテクチャ名の設定
ARG TARGETARCH
RUN if [ $TARGETARCH = "amd64" ]; then \
echo "x86_64" > /arch; \
elif [ $TARGETARCH = "arm64" ]; then \
echo "aarch64" > /arch; \
else \
echo "Unsupported platform: $TARGETARCH"; \
exit 1; \
fi
ここでは$TARGETARCH
をRustで利用するアーキテクチャ名に変更してます。
後続のRUN
で$(cat /arch)
として取り出せるようにしている形です。
Rustの依存の解決
COPY Cargo.toml .
COPY Cargo.lock .
RUN mkdir -p src \
&& echo 'fn main() {}' > src/main.rs \
&& cargo build --release --target $(cat /arch)-unknown-linux-musl
Rustの依存の解決として空のmain.rs
を作成し、ビルドを行なっています。
なぜ依存だけを解決しているかと言えば、Cargo.toml
で依存関係の変更をしたときと、コードだけ変更したときでレイヤーを分けることで依存解決をレイヤーキャッシュで解決し高速にビルドするためです。
もし、これがローカルでビルドするだけならRUN --mount=type=cache
を使えば依存とコードを一度にビルドすることは可能です。
しかし、大抵のCI環境ではこの機能は利用することができず、Docker Layer Cacheのみ利用可能なためこのようにレイヤーを分ける選択をしています。
Rustでバイナリの生成
COPY src src
RUN CARGO_BUILD_INCREMENTAL=true cargo build --release --target $(cat /arch)-unknown-linux-musl \
&& cp target/$(cat /arch)-unknown-linux-musl/release/${NAME} target/release/app
依存の解決ができたらsrc
をコピーしてコンパイルします。
CARGO_BUILD_INCREMENTAL=true
をつけることで先ほど行なった依存解決の続きからコンパイルを行うことができコンパイル時間の短縮に繋がります。
アプリケーションステージ
FROM alpine
COPY /home/rust/src/target/release/app /app
CMD [ "/app" ]
--platform=$TARGETPLATFORM
でアプリケーションステージで実行するイメージを、作成するイメージのアーキテクチャに揃えます。
$TARGETPLATFORM
はDockerが提供する値で、作成するイメージのプラットフォームとしてlinux/amd64
といった文字列を提供します。
先のビルドステージで作成したバイナリをコピーしてCMD
に設定すれば、イメージのアーキテクチャとバイナリのアーキテクチャが一致し実行できる形になります。
GitHub Actionsの設定例
name: Docker
on:
push:
branches: [main]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2
- uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v4
with:
push: true
tags: user/app:latest
file: Dockerfile
context: .
platforms: linux/amd64,linux/arm64
build-contexts: |
messense/rust-musl-cross:amd64-musl=docker-image://messense/rust-musl-cross:x86_64-musl
messense/rust-musl-cross:arm64-musl=docker-image://messense/rust-musl-cross:aarch64-musl
cache-from: type=gha
cache-to: type=gha,mode=max
より高速化を目指すための案
RUN --mount=type=cacheの利用
さきの解説ではCI環境では利用できないと書きましたが、手間をかけるならできない訳ではないようです。
私自身、まだちゃんと読めてないし、検証もしてないのですがワンチャンありそうな雰囲気です。
Cargo Workspace単位でのステージの分離
今回、作成したDockerfile
は単一のCrateをビルドするものですが、RustプロジェクトではWorkspaceを使った複数のCrate管理をすることも多いです。
そういったさいには、作成したDockerfile
ではいずれかのCrateでCargo.toml
を変更するだけですべてのCrateの依存解決からやり直しになってしまいます。
そこでCrate
単位に依存解決、コンパイルを行い、target
以下をコピーすることでDocker Layer Cacheの最適化を行うことができそうです。
ただし、これをやってしまうとCrate AとCrate Bで同じCrate Cに依存していたとしても、どちらもCrate Cダウンロードが発生するため少し無駄が多いです。
このあたりはプロジェクト次第だとは思っており、同じCrateを利用していることが多いなら分離しない方がいいですし、ほぼ違うCrateを使っているなら分離した方が効率がよくなります。
おわりに
個人的には--build-context
周りはゴリ押しした自覚がありますし、もう少しキャッシュ周りを最適化してCIを高速にしたいなという思いもあります。
何かもう少し良いDockerfile
などができましたらコメントなどで教えてください。
P.S. ローカルでコンパイルした結果をDockerfile
に突っ込めばええやんという話ももちろんあるとは思いますが、その手法は環境依存が激しく、端的に言えば再現性が低くて大嫌いです。
Discussion
マルチプロジェクトの場合、
-p ${NAME}
でビルドを絞れるので多少早くなると思います。