【令和最新版】Rust用Dockerfile 2023年6月モデル AMD64/ARM64 マルチアーキテクチャ対応 GitHub Acti

2023/06/29に公開1

RustでDockerfileを書くの地味に面倒くさいですよね。
ローカルでは開発者向けにMacで動作するようにし、CI環境ではLinuxで動作するようにし、Dockerレジストリにはマルチアーキテクチャでイメージを保管して、なおかつ効率よくキャッシュを使ってローカル、CI環境で高速にイメージをビルドできるようにしなければならないと。

_人人人人人人_
> 面倒臭い <
 ̄Y^Y^Y^Y^Y^Y^ ̄

というわけで、そのあたりをいい感じにやるためのDockerfileを作成してみました。

Dockerfile

# syntax=docker/dockerfile:1.5
FROM --platform=$BUILDPLATFORM 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 --platform=$TARGETPLATFORM alpine

COPY --from=builder /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と比べものにならないほど高速にコンパイルすることができるということです。

そこで、下記のようなマルチステージ構成でビルドできるようにします。

  1. ビルドステージとして実行環境のアーキテクチャで実行し、Rustのクロスコンパイルで作成するアーキテクチャ用のバイナリを生成する
  2. アプリケーションステージとして1で作成したバイナリをコピーして実行する

このような構成にすることで高速なマルチアーキテクチャイメージのビルドを実現します。

ビルドステージ

FROM

FROM --platform=$BUILDPLATFORM messense/rust-musl-cross:${TARGETARCH}-musl as builder

まず、--platform=$BUILDPLATFORMでビルドステージで実行するイメージを実行環境のアーキテクチャに揃えます。
$BUILDPLATFORMはDockerが提供する値で、実行環境のプラットフォームとしてlinux/amd64といった文字列を提供します。
これによりQEMUを使わないビルドにより高速なビルドが行えます。

利用するイメージとしてmessense/rust-musl-cross:${TARGETARCH}-muslと指定していますが、これはダミーです。
$TARGETARCHもDockerが提供する値で、作成するイメージのアーキテクチャとしてamd64arm64といった文字列を提供します。
この値を展開すると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 --platform=$TARGETPLATFORM alpine

COPY --from=builder /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

j5ik2oj5ik2o

マルチプロジェクトの場合、-p ${NAME}でビルドを絞れるので多少早くなると思います。

RUN CARGO_BUILD_INCREMENTAL=true cargo build -p ${NAME} --release --target $(cat /arch)-unknown-linux-musl \
    && cp target/$(cat /arch)-unknown-linux-musl/release/${NAME} target/release/app