この記事は 夏のブログリレー 10日目です。1日遅れてしまいました...
夢のマルチプラットフォーム対応
私たちが開発したアプリを配布する場合、マルチプラットフォームに対応することが重要になります。開発者が使用しているマシンと同じOS/CPUアーキテクチャで動作するバイナリしか配布されていなければ、どれだけ便利なアプリでも使ってもらえません。かといって開発者が様々なOS/CPUアーキテクチャのマシンを揃えるのは大変です。そこで必要になるのが、あるOS/CPUアーキテクチャのマシンから他のOS/CPUアーキテクチャで動作するバイナリを作成するクロスコンパイルです。
この記事では、特に以下の条件を満たすRust製アプリをクロスコンパイルする場合に役立つあれこれを解説します。
std
クレートが問題なく使用できる。つまり、ベアメタル環境やWASMランタイム上などでの動作を想定しない- 特にLinux OSをサポートする (筆者がWindowsないしmacOSでのアプリ開発に関して無知なため)
- Dockerを使用してビルドを行う。2024年版のDockerfileの考え方&書き方 | フューチャー技術ブログでも言及がありますが、実行可能ファイルの形式で成果物が欲しい場合は
docker build
の--output
オプションを使うことで対応可能です。 - libsslなど、libc以外で動的ライブラリへの依存が ある (もちろんなくてもいい)
キーワード
クロスコンパイルを行うにあたって押さえておきたい単語がいくつかあるのでまずはそれらを確認します。
target
プログラムを実行する環境のことです。Rustでは、targetは以下のように表記されます。
<arch><sub>-<vendor>-<sys>-<abi>
この表記をtarget tripleと呼び、それぞれの枠には以下の情報が入ります。
<arch>
: CPUアーキテクチャ。aarch64
,x86_64
,mips
など<sub>
: CPUサブアーキテクチャ。例えば<arch>
がarm
の場合にはv6
,v7a
などが入ります。<vendor>
: マシンのベンダー。apple
,pc
,unknown
,nvidia
など<sys>
: システム名。OSが搭載されているtargetに対してはOSの名前(darwin
,linux
,windows
など)が入り、ベアメタル環境ではnone
が入ります。ちなみにdarwin
はmacOSのことです。<abi>
: ABI(Application Binary Interface)。OSが搭載されている場合は使用するlibcの種類(glibc
,musl
など)、ベアメタル環境ではEmbedded ABIでeabi
などが入ります。
といってもあくまで緩い表記規則で、wasm32-wasi
など大きく異なるものもあります。rustcがサポートしているtarget tripleはrustc --print target-list
で一覧することができます。target tripleの表記はおそらくLLVM/Clangに倣ったもので、そのLLVMによるとtripleと名前についているのは歴史的経緯だそうです。(llvm::Triple
)
GoないしDockerではOSとアーキテクチャだけでtarget tripleに対応するものが定まります。特にDockerでは<os>/<arch>[/<variant>]
(<variant>
はtarget tripleの<sub>
にあたるもの)の形式で表記したものをplatformと呼ぶようです。platformの例としてlinux/amd64
やlinux/arm/v6
などがあります。
ref:
使用しているマシンのtarget tripleはrustc -vV
の出力から確認できます。例えば、M1 Macbook Airのtarget tripleはaarch64-apple-darwin
, 私の手元にあるHPラップトップのtarget tripleはx86_64-unknonwn-linux-gnu
となります。
host / target platform
ソースコードをビルドして実行可能ファイルを作成する環境(のtarget triple)と、その実行可能ファイルを使用する環境(のtarget triple)が異なるのがクロスコンパイルです。rustupのガイドでは、前者をhost platform、後者をtarget platformと呼びます。
ちなみにNixでは前者をbuild platform, 後者をhost platformと呼びます。
ref: Cross compilation — nix.dev documentation
例えばHPラップトップでビルドした成果物をUbuntu OSが載っているRaspberry Piで動かす場合は、各platformとtarget tripleの対応関係は以下のようになります。
platform | target triple |
---|---|
host platform | x86_64-unknown-linux-gnu |
target platform | aarch64-unknown-linux-gnu |
dependency
プログラムには依存関係があります。
特に気を付けるべきは、Cライブラリの存在です。ldd
コマンドでそのプログラムが依存している共有ライブラリ(shared object)の情報がわかります。cargo init
で生成されるごく単純なプログラムでも、ldd
の出力は以下のようになります。
linux-vdso.so.1 (0x0000...)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x0000...)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000...)
/lib64/ld-linux-x86-64.so.2 (0x0000...)
問題は、共有ライブラリはtarget platformにインストールされている必要があるということです(対応するヘッダファイルがコンパイル時に要求されるため、host platformでも用意されている必要があります)。
Goの場合はcgoが絡まない限りはlibcにすら依存しません。羨ましい
ただし、上記の例のように、依存している共有ライブラリがlibc(およびlibgcc)だけであれば問題はありません。先ほどのldd
出力例はtarget tripleがx86_64-unknown-linux-gnu
の状態でビルドした結果に対するものでしたが、cargo build --target x86_64-unknown-linux-musl
のようにtarget tripleでmuslを指定するとldd
の出力は以下のように変化します。
statically linked
このように、依存ライブラリが静的リンクを選択肢として提供している場合は、target platformで特別な用意を行う必要がなくなります。
ref: RustのLinux muslターゲット (その1:Linux向けのポータブルなバイナリを作る)
いざ実践
いい加減実際のアプリでのビルド例を見てみます。
サンプルのアプリ紹介
Axumでログイン機能を提供するだけのアプリです。↓のコマンドで試すことができます。多分。
docker compose -f docker/compose.yml --env-file .env.dev up -d
Cargo.toml
には以下のように依存関係が記述されています。
sqlxがnative-tlsに依存しており、特にLinuxではnative-tlsがopensslを通してlibsslに依存します。
この程度であればrustlsに切り替えてlibsslへの依存を回避できるのですが、今回はCライブラリへの依存例としてそのままにしてあります。
Dockerfile雛形
2024年版のDockerfileの考え方&書き方 | フューチャー技術ブログを参考に書いていきます。
builder
stageがhost platform, debian-slim
stageがtarget platformです。先ほども言及した通りlibsslへの依存があるため、debian-slim
のステージでlibssl-devを入れています。(builder
stageでは特に用意していませんが、rust:bookworm
イメージでは最初から用意されているためです)
また、このDockerfileの通り、host platformでもtarget platformでもdebianベースのイメージを使用します。
揃えるもの
以降はtarget platformがx86_64-unknown-linux-gnu
の場合を考えます。先ほど確認した通りlibsslへの依存があるため、host platformでは適切なrust toolchainに加えて以下のものを揃える必要があります。
- target設定が適切なCコンパイラ(ここではgccを使用します)
- 適切なtarget向けにビルドされたlibcおよびlibssl
Multiarch/HOWTO - Debian Wiki, ToolChain/Cross - Debian Wikiに記述がある通り、これらは以下のコマンドで揃えられます。
dpkg --add-architecture amd64
apt-get update
apt-get install crossbuild-essential-amd64 libssl-dev:amd64
これでインストールされたgccはx86_64-linux-gnu-gcc
として提供され, ライブラリは/usr/lib/x86_64-linux-gnu
, ヘッダーファイルは/usr/include/x86_64-linux-gnu
に配置されます。また、これらの存在をrustの各クレートに通知する必要がありますが、以下の環境変数を指定することで可能です。
- ccクレート
TARGET_CC=x86_64-linux-gnu-cc
,TARGET_CXX=x86_64-linux-gnu-g++
: target platformへ向けたバイナリを生成する際に使用するCコンパイラ、C++コンパイラの名前。
- opensslクレート
X86_64_UNKNOWN_LINUX_GNU_OPENSSL_LIB_DIR=/usr/lib/x86_64-linux-gnu
:x86_64-unknown-linux-gnu
targetへ向けたlibsslが配置されているディレクトリのパス。X86_64_UNKNOWN_LINUX_GNU_OPENSSL_INCLUDE_DIR=/usr/include/x86_64-linux-gnu
:x86_64-unknown-linux-gnu
targetへ向けたOpenSSLライブラリのヘッダーファイルが配置されているディレクトリのパス。
cargo config
上記の環境変数に加えて、target tripleに応じて使用するリンカを変える必要があります。これはCargoの設定をいじることで実現可能です。
Configuration - The Cargo Book
環境変数とリンカに加えて、ついでにbuild時のtarget tripleも設定したconfig.toml
は以下のようになります。
[build]
target = "x86_64-unknown-linux-gnu"
[env]
X86_64_UNKNOWN_LINUX_GNU_OPENSSL_LIB_DIR = "/usr/lib/x86_64-linux-gnu"
X86_64_UNKNOWN_LINUX_GNU_OPENSSL_INCLUDE_DIR = "/usr/include/x86_64-linux-gnu"
TARGET_CC = "x86_64-linux-gnu-gcc"
TARGET_CXX = "x86_64-linux-gnu-g++"
[target.x86_64-unknown-linux-gnu]
linker = "x86_64-linux-gnu-gcc"
しれっとリンカにx86_64-linux-gnu-gcc
を指定していますが、x86_64-linux-gnu-ld
だとビルドに失敗したのでこうしています。clangでクロスコンパイルしたい場合もリンカにgccを使うといいらしいんですが、ソースの記事を無くしてしまいました...
魔法のヘルパースクリプト
さて、ここまでをまとめると、クロスコンパイルの準備には以下のものが必要です。
- rust toolchainの準備(
rustup target ...
) - target platform向けのcコンパイラ、cライブラリの準備
- cargo configの整備
なかなか大変ですね。そこで、「上記の準備工程全てをよしなに行ってくれるヘルパースクリプト」を生成するための 魔法 Pythonスクリプトを作成しました。
こういう、「ちょっと複雑だけどあくまでヘルパーな作業」をやりたい時にPythonは便利ですね。このPythonスクリプトをDockerfileに組み込んで、クロスコンパイルが可能になりました。
実際にghcr.ioにマルチプラットフォーム対応のイメージをpushすることができました。
その他やってないこととか
build script!?
Build Scripts - The Cargo Book
cargo build
時にやりたい処理を走らせるのがbuild scriptです。このscriptには本当にやりたい処理をなんでも書けてしまって、なんとビルド時にインターネットから必要なファイルをダウンロードするといったこともできてしまいます。問題は、このbuild scriptがなんらかのCライブラリに依存している場合です。設定が甘いと、host platformで実行したいプログラムをtarget platform向けのコンパイラでビルドするような事態になりかねません。恐ろしや...
mips, i386対応
をやるつもりでPythonスクリプトを用意したんですが、色々とうまくいきませんでした。PRを出してくれたら泣いて喜びます。
cross-rs/cross
cross-rs/cross: “Zero setup” cross compilation and “cross testing” of Rust crates
実はここまで長ったらしく議論したあれこれは必要ないかもしれません。
pkg-config
色々調べてみたんですが、どうもbuild scriptとの兼ね合いが難しそうです。
windows, macOS向けのビルド
一体どうやるんでしょうね。
参考
- 2024年版のDockerfileの考え方&書き方 | フューチャー技術ブログ
- Platform Support - The rustc book
- Multi-platform images | Docker Docs
- Dockerfile reference | Docker Docs
- 【令和最新版】Rust用Dockerfile 2023年6月モデル AMD64/ARM64 マルチアーキテクチャ対応 GitHub Acti
- docker-buildxとmulti-platform build周りについてまとめ
- Faster Multi-Platform Builds: Dockerfile Cross-Compilation Guide | Docker
- Environment variables - The rustup book
- Target Triple - ryochack.blog
- CPUのアーキテクチャの違いまとめ(x86/x64/x86_64/AMD64/i386/i686とはなんなのか?) - フラミナル
- ToolChain/Cross - Debian Wiki
- Multiarch/HOWTO - Debian Wiki
- Ubuntu/Debian でマルチアーキテクチャを有効/無効にする備忘録 | hyt adversaria
- Cross-compilation using Clang — Clang 20.0.0git documentation
- Configuration - The Cargo Book
- Appendix: Glossary - The Cargo Book
- Codegen Options - The rustc book
- cc - Rust
- openssl - Rust
- jq/.github/workflows/ci.yml at jq-1.7.1 · jqlang/jq
おしまい
まる。
明日(今日)の担当は@masu_kouさんと@elさんと@masataroさんと@ch4tlaさん、そして@vPhosさんと@Cd_48さんです。お楽しみに!