Rust + GitHub Actionsでマルチアーキテクチャ対応のイメージをなるべく早く作る

マルチアーキテクチャ対応のイメージとは

詳しいことは書かないが、複数種類のアーキテクチャ上で実行できるイメージのことを指す。amd64 や arm64 など、異なる種類の環境でも docker run --rm hello-world として実行できるのはこれに対応しているため。

このようなビルドをGitHub Actionsで行いたくなるが、これがかなり遅い。

愚直にやると遅い

docker buildx build --platform linux/amd64,linux/arm64 -t naari3/testtest . のように実行すると、必要なタイミングでQEMUが起動して対象のアーキテクチャで実行してくれるんだけど、まあ勿論のように遅い。

Googleで調べてみると、遅くならないようにする工夫がいくつかヒットする。

Rustのクロスコンパイルを利用する

Rustは比較的簡単にクロスコンパイルができるので、これを利用したい。

ホストと異なるアーキテクチャを対象にビルドする場合であっても、ベースイメージはホストと同じアーキテクチャを選択することでQEMUの起動を回避することができる。↓こんな感じ

# linux/amd64の環境で docker buildx build --platform linux/arm64 としたとき
# BUILDPLATFORM = linux/amd64
# TARGETPLATFORM = linux/arm64
FROM --platform=$BUILDPLATFORM rust:1.60 as builder
# snip
RUN cargo build --release --target $TARGETPLATFORM

これを利用して、最近amd64とarm64向けのイメージをビルドするためのDockerfileを作る。

# ホストと同じアーキテクチャのイメージが使用される
FROM --platform=$BUILDPLATFORM rust:1.60 as builder

# arm64向けにリンカを入れておく。
RUN apt update -y && apt install llvm clang -y

# ここから依存ライブラリのためのレイヤーを用意する
RUN cargo new --bin app
WORKDIR /app

COPY ./Cargo.lock ./Cargo.lock
COPY ./Cargo.toml ./Cargo.toml

# リンカの指定
ENV CC_aarch64_unknown_linux_musl=clang
ENV AR_aarch64_unknown_linux_musl=llvm-ar
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS="-Clink-self-contained=yes -Clinker=rust-lld"

ENV CC_x86_64_unknown_linux_musl=clang
ENV AR_x86_64_unknown_linux_musl=llvm-ar
ENV CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS="-Clink-self-contained=yes -Clinker=rust-lld"

# Docker側のアーキテクチャの名前とRust側のアーキテクチャの名前を対応付ける
ARG TARGETPLATFORM
RUN case "$TARGETPLATFORM" in \
  "linux/arm64") echo aarch64-unknown-linux-musl > /rust_target.txt ;; \
  "linux/amd64") echo x86_64-unknown-linux-musl > /rust_target.txt ;; \
  *) exit 1 ;; \
esac
RUN rustup target add $(cat /rust_target.txt)
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/app/target \
    cargo build --release --target $(cat /rust_target.txt)
RUN rm src/*.rs

# 依存以外の実装のビルド
COPY ./src ./src
RUN touch ./src/main.rs
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/app/target \
    cargo install --locked --path . --target $(cat /rust_target.txt)

# 実行時のイメージはplatformを指定しない(arm64向けのイメージが使用される)
FROM alpine
COPY --from=builder /usr/local/cargo/bin/app .
CMD ["./app"]

勿論規模にも依るが、雑なDiscord botを対象にしたアプリを手元のマシンでビルドした結果を下に並べている。QEMU経由でビルドする場合と比べ、28分ほど早くなった。

# qemu
$ docker buildx build --platform linux/arm64 -f Dockerfile.on-qemu -t test1 . --no-cache
[+] Building 1875.1s (17/17) FINISHED
$ docker buildx build --platform linux/arm64 -f Dockerfile -t test1 . --no-cache
[+] Building 182.6s (22/22) FINISHED

アーキテクチャごとにビルドを並列で実行する

GitHub Actionsなのでビルドを並列で実行したくなる。割り方としてはアーキテクチャ毎にビルドを走らせることが考えられる。実現方法としては、まず複数Jobで各アーキテクチャごとにイメージのbuild/pushを実行し、後続Jobで新規manifestを作成することで実現できる。

$ docker manifest create naari3/testtest:v1.0.0 naari3/testtest:v1.0.0-amd64 naari3/testtest:v1.0.0-arm64
$ docker manifest push naari3/testtest:v1.0.0

これについても雑なGitHub Actionsを作成し、別のJobから叩けるようにした。ただ、都合のためにghcr.ioのみを対象にしている。他のレジストリに対して投げたい場合は少し弄ればいけるはずなので、そのようにしてほしい。

workflows/build-multiarch-images-ghcr.yaml at main · naari3/workflows · GitHub

使用例。

name: build-image

on:
  release:
    types:
      - created

jobs:
  build:
    uses: naari3/workflows/.github/workflows/build-multiarch-images-ghcr.yaml@main
    with:
      tag_name: ${{ github.event.release.tag_name }}
      target_arch: linux/amd64,linux/arm64
    secrets:
      github-token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}

これの作成にはいくつかのコツが必要だった。まず、複雑になるのでgoogle/zxを利用した。

GitHub Actionsはjobを跨ぐと別マシン相当での実行となるため、各ビルド後はイメージをリポジトリにpushしておく必要がある。でないとイメージにアクセスすることができないため。その後、RegistryのAPIを叩いて降ってきたタグ一覧からpushされているべきタグを集めて一つのmanifestを作成する。そしてこれを完成形としてpushしている。この手を取る場合、各アーキテクチャに限定されたイメージがpushされることになるが、自分は特に気にしないことで解決している。何か問題が思い当たるようになれば対処したいと思う。

ビルド時に渡された inputs を元に並列に実行するJob(matrixの構成)を決めているが、JSONの文字列を matrix に渡してやることでこれが実現できる。以下のようなイメージ。配列を作るためのjq力が試される。僕は一発では書けなかった。

      - id: matrix_targets
        run: |
          targets=$(echo ${{ inputs.target_arch }} | jq -sRc 'gsub("\n"; "")|split(",")')
          echo "::set-output name=value::$targets"

    strategy:
      fail-fast: false
      matrix:
        target: ${{ fromJSON(needs.info.outputs.matrix_targets) }}

publicなリポジトリなのにRegistry APIからタグ一覧を取得するのにPATが必要でたまげた。なにかもっと良い方法はないのだろうか。

とにかく、これでアーキテクチャごとの並列ビルドが実現できた。めでたしめでたし。

おわりに

今回、はじめてマルチアーキテクチャイメージを意識することになった。きっかけとしては、OCIのarm64サーバー上で動かすMinecraftのサーバーにDiscord用のbotを生やしたかったため。これについては後日詳しく書きたい→書いた。制作する側が少し頑張ることで、同じ名前のイメージが様々な環境で動くようになる、というのはとても良い仕組みだと思う。今後マルチアーキテクチャイメージを頑張る必要に駆られた場合には、この記事を思い出すことで対応したい。