📦

Rust でバイナリを配布する

2022/12/02に公開約13,600字

これは天久保 Advent Calendar 2022 二日目の記事です。明日は休日なのでやすみです。
https://adventar.org/calendars/8233

本記事は全部 GNU/Linux においての話で、ELF 実行可能形式のファイルのことを指してバイナリと呼びます。

Rust で書いたプログラムをビルドして配布したいことがあるでしょう。サーバーアプリケーションなどとなればコンテナイメージを配布するのがまっとうに思えますが、コマンドラインアプリケーションとなると実行の手軽さや起動オーバーヘッドへの配慮からやはりバイナリを配布する必要が出てくると思います。一方で Rust でビルドしたバイナリは[1]システムの glibc に動的リンクされており、可搬性が低い可能性があります。つまり、ビルドした環境より glibc のバージョンが低い環境でそのバイナリは動かない可能性があるということです。実例を見てみましょう。下のプログラム[2]は Debian 11 Bullseye (glibc 2.31) でビルドされており、Debian 10 Buster (glibc 2.28) で動作しません。

$ cargo new example --bin && cd example
$ cargo add libc
$ cat << EOF > src/main.rs
fn main() {
    println!("{}", unsafe { libc::gettid() });
}
EOF
$ docker run --rm -v $PWD:/work -w /work rust:bullseye cargo build
$ docker run --rm -v $PWD:/work -w /work debian:buster ./target/debug/example
./target/debug/example: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.30' not found (required by ./target/debug/example)

このようなことが起きないように様々な Linux ディストリビューション向けにそれぞれパッケージをビルドしてホストできるならばそれで問題はありません。しかし、個人開発の OSS となると、できれば多くの環境でそのまま動く、すなわち可搬性の高いバイナリを 1 つビルドするだけでリリース作業を終わらせたいものです[3]

そういった前提のもと、この記事では Rust のプログラム[4]をなるべく可搬性をもったバイナリにビルドする方法について、libc への依存をどう扱うかに着目して検討します。それぞれの手法について、可搬性そのものだけでなく、CI ワークフローに組み込むことを前提にした仕組みのセットアップ・メンテナンスのコストがどの程度なのか評価します。その中でも特に、x86_64 環境での aarch64 へのクロスビルドを前提に[5]、クロスコンパイルが容易に実現できるかどうかに注目します。

musl で静的リンク

まず思いつく[6]のがこの方法でしょう。すなわち、問題になっている glibc への動的リンクをそもそもやめるということです。Rust では Tier 2[7] のターゲットとして aarch64-unknown-linux-muslx86_64-unknown-linux-muslサポートされておりmusl libc を利用した静的リンクが可能になっています。musl libc は 2011 年に登場した新しい libc 実装で、Alpine Linux などで採用されているものです。

基本的には cargo でのビルド時に musl の target triple を指定するだけで静的リンクされたバイナリがビルドされます。例えば:

$ cargo new example --bin && cd example
$ cargo build --target=x86_64-unknown-linux-musl
$ # ldd(1) と readelf(1) で静的リンクを確認
$ ldd ./target/x86_64-unknown-linux-musl/debug/example
       statically linked
$ readelf -dW ./target/x86_64-unknown-linux-musl/debug/example | grep -c NEEDED
0

この方法の利点・欠点は静的リンクをすること自体の利点・欠点と重なる部分も多く、議論の余地があります。ここでは静的リンクの利点・欠点については議論せず、静的リンクが許容可能な場合のみこの選択肢は適用されるものとします。さて、この方法は基本的には --target フラグを指定するだけで目的が達成されるため非常に手軽であることが利点です。一方で、musl libc は glibc と挙動が異なる部分が存在し、そういった部分によって運用上の難しさが発生することがあります。
https://wiki.musl-libc.org/functional-differences-from-glibc.html

すなわち、互換性が重要でないもの、互換性が問題になりやすい機能(名前解決など)を利用しないのであればかなり有力な選択肢です。その例としてわかりやすいのが開発用途のコマンドラインツールで、実際、https://github.com/BurntSushi/ripgrephttps://github.com/sharkdp/bat といった著名な OSS でも musl でビルドされたバイナリが配布されています。

クロスコンパイル

https://github.com/cross-rs/cross は、Rust でのクロスコンパイルをセットアップなしで行うためのサードパーティーツールです。これを利用すると元からクロスコンパイル向け musl コンパイラが同梱された環境でビルドが行われるので、特にこれから述べる問題を気にする必要はないでしょう。

$ cross build --target=aarch64-unknown-linux-musl
$ # readelf(1) で静的リンクを確認
$ readelf -dW ./target/aarch64-unknown-linux-musl/debug/example | grep -c NEEDED
0

cross を利用せず素の環境でクロスコンパイルを行う場合は注意が必要になります。musl でのクロスコンパイルに使えるリンカ・コンパイラ[8]が一般に配布されていないからです。リンクだけなら[9]GNU 向けのリンカを使っても何ら問題はないと思われますが、C のコードを含むクレートを利用しているなどコンパイルが必要な場面では[10]glibc のヘッダでコンパイルして musl にリンクすることになってしまい、それはいただけません。クロスコンパイルに使えるツールチェインを自前でビルドするか https://musl.cc/ などサードパーティーから入手する必要があり、セットアップコスト・メンテナンスコストが増大します。

glibc で静的リンク

musl を利用せずとも、rustc の -C target-feature=+crt-static フラグで glibc との静的リンクを行うことが可能です[11]

https://github.com/rust-lang/rust/issues/65447

$ RUSTFLAGS="-C target-feature=+crt-static" cargo build
$ # ldd(1) と readelf(1) で静的リンクを確認
$ ldd ./target/debug/example
       statically linked
$ readelf -dW ./target/debug/example | grep -c NEEDED
0

この方法は RUSTFLAGS などで rustc のフラグを設定するだけでよく非常に手軽で、またクロスコンパイルも通常のビルドと特に変わらない方法で行うことができます。

しかし glibc を静的リンクする場合、dlopen(3) の使用がサポート外になります。そして glibc はいくつかの機能で dlopen(3) を利用しているため、結果的にそれらの機能も利用できなくなることになります。特に Name Service Switch (NSS) が利用できなくなることによって、ドメイン名の名前解決を行うプログラムでの利用が難しくなります[12]
https://sourceware.org/glibc/wiki/FAQ#Even_statically_linked_programs_need_some_shared_libraries_which_is_not_acceptable_for_me.__What_can_I_do.3F

古いディストリでビルド

glibc は後方互換性があり[13]、古いディストリでビルドすればそれより新しいディストリで動くバイナリができる[14]、ということになります。Debian 11 Bullseye (glibc 2.31) でビルドされたプログラムは Ubuntu 22.04 Jammy (glibc 2.35) で動作します。

$ docker run --rm -v $PWD:/work -w /work rust:bullseye cargo build
$ docker run --rm -v $PWD:/work -w /work ubuntu:jammy ./target/debug/example
1

そのため、各プロジェクトのサポート要件に応じてビルド環境に古いディストリを用いてビルドすることで、柔軟に可搬性を向上させることができます。

多くの Rust プロジェクトでこの方法が採用されているのを見かけます。例えば rust-analyzer は Ubuntu 18.04 Bionic のコンテナを GitHub Actions で使用してビルドを行っています。この場合は Ubuntu 18.04 Bionic (glibc 2.27) 以上の環境、Debian でいうと Debian 10 Buster (glibc 2.28) 以上で動作するバイナリが生成されていることになります。
https://github.com/rust-lang/rust-analyzer/blob/ad633db4935a220f77becfab55300492cfab239d/.github/workflows/release.yaml#L37

さて、Rust にはサポートされる glibc バージョンの下限が存在し、Rust 1.64 からは glibc 2.17 以上がサポート対象となっています。
https://blog.rust-lang.org/2022/08/01/Increasing-glibc-kernel-requirements.html

そのため glibc 2.17 を用いている[15]ディストリでビルドすれば、Rust でビルドする限り最大限の可搬性があるバイナリが生成できると言えるでしょう。rustup や rustc は CentOS 7 (glibc 2.17) ベースのイメージを利用してバイナリのビルドを行い、これを達成しています。
https://github.com/rust-lang/rustup/blob/b3d53252ec06635da4b8bd434a82e2e8b6480485/.github/workflows/linux-builds-on-master.yaml#L96-L124

このように古いディストリを用いてビルドを行う方法は、特殊なツールを利用する必要がないという点で優れています。コンテナ技術が手軽に利用できる現状、単に古いディストリのコンテナの中で標準的なワークフローを実行するだけでよいため、クロスコンパイルについても[16]特に通常のビルドと相違なく行うことができます。欠点としてはやはり利用するディストリが古いため、サポートが止まっている場合はその環境で動くコードについてセキュリティ的に気を使う必要があることが挙げられます。依存パッケージは Build Scripts 経由で任意のコードをビルド環境上で実行できることを考えると、サポートが切れた[17]環境の上で行うビルドについて安心するのは簡単ではないでしょう。さらに Rust でのビルドのみを古いディストリで行うことができればまだ良いのですが、「Rust の環境が入った古いディストリのイメージ」として手軽なものがほとんど存在しません。そのため、結果的に古いディストリで Rust のインストールから行うか自前でイメージをメンテナンスするかいずれかが必要となることが多く、あまり快いとはいえません。

cross

CentOS 7 でのビルドに限って言えば、先ほども紹介した cross がちょうど良い解決策を用意してくれています。
https://github.com/cross-rs/cross#supported-targets

cross でのビルドに使うことができるイメージとして、CentOS 7 ベースのものが用意されています。これを使えば通常のビルドと同じような手順で CentOS 7 (glibc 2.17) でのビルドが可能で、また aarch64 へのクロスコンパイルもイメージが用意されているので同様に通常と同じ手順で可能です。

$ cat << EOF > Cross.toml
[target.aarch64-unknown-linux-gnu]
image = "ghcr.io/cross-rs/aarch64-unknown-linux-gnu:main-centos"

[target.x86_64-unknown-linux-gnu]
image = "ghcr.io/cross-rs/x86_64-unknown-linux-gnu:main-centos"
EOF
$ cross build
$ cross build --target=aarch64-unknown-linux-gnu  # cross compilation

古い glibc でビルド

一方で、古いディストリではない通常のビルド環境でも古い glibc に対してリンクできれば(当然)古い glibc の環境でも動作するバイナリを作ることができるでしょう。そのためには古い glibc のシステムをあるパス以下に用意し、そのパスをツールチェインの sysroot に設定する方法がまず考えられます。これは https://github.com/denoland/deno の CI で使われています。
https://github.com/denoland/deno/blob/1848c7e361f1a3a33487b60ab6fcb61ed1f62273/.github/workflows/ci.yml#L169-L181

この方法は仕組みが単純である(= 何が起きているか見通しが良い)という点で優れていますが、セットアップが煩雑だという欠点があります。また sysroot を変更しているため Rust 外のライブラリを利用したくなった際に普段以上の複雑なセットアップを要求されるでしょう。クロスコンパイルを行うなら尚更です。

zig cc

ここで zig cc を使うとこのセットアップ、すなわち「特定の glibc のバージョンに対してビルドすること」が非常に簡単に実現できます。zig cc は C コンパイラドライバとして clang, gcc の drop-in replacement として使うことができつつ、ホストと別バージョンの glibc や別アーキテクチャへのコンパイルを特別な準備なしで行うことができる優れものです。
https://andrewkelley.me/post/zig-cc-powerful-drop-in-replacement-gcc-clang.html

実際に zig cc を用いて古い glibc をターゲットにしたバイナリをビルドしてみます。Rust のプロジェクトをビルドする際には主に zig cc をリンカとして使いますが、ビルドに C コンパイラを利用するプロジェクトではコンパイラとしても使うことになるでしょう。ただし、Cargo[18]はリンカへのパスを設定することはできますが、コマンドラインを指定できるわけではないので、一枚ラッパースクリプトを噛ませることになります。ラッパースクリプトでは glibc のバージョンを含めたターゲットの指定と、gcc との挙動の互換性のために -lunwind を追加で指定する必要があります[19]

zig_cc_wrapper_x86_64-linux-gnu.2.17
#!/bin/sh
zig cc -target x86_64-linux-gnu.2.17 "$@" -lunwind
zig_cc_wrapper_aarch64-linux-gnu.2.17
#!/bin/sh
zig cc -target aarch64-linux-gnu.2.17 "$@" -lunwind

CARGO_TARGET_<triple>_LINKER でリンカの場所を指定します。C コンパイラを利用するプロジェクトではさらに CC を同様にこのラッパースクリプトへ設定する必要があります。

$ CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=./zig_cc_wrapper_x86_64-linux-gnu.2.17 \
    CC=./zig_cc_wrapper_x86_64-linux-gnu.2.17 \
    cargo build
$ CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=./zig_cc_wrapper_aarch64-linux-gnu.2.17 \
    CC=./zig_cc_wrapper_aarch64-linux-gnu.2.17 \
    cargo build --target=aarch64-unknown-linux-gnu  # cross compilation

Cargo はリンカとして指定されたファイルの中身の変更については検知せずリビルドが行われないため、ターゲットごとに別名のラッパースクリプトを用意する必要があります。ローカルでの開発だけならともかく、CI でのビルド環境のメンテナンスは煩雑になるでしょう。

cargo-zigbuild

このような zig cc を用いたビルド手順を簡略化できるサードパーティーの cargo サブコマンドが開発されています。
https://github.com/messense/cargo-zigbuild

これを用いると Cargo に対するリンカやコンパイラの指定を --target に合わせてうまく行ってくれるほか、先ほどの -lunwind のような互換性の問題を隠蔽できます。結果として、ほぼ通常の cargo build と変わらない使い心地でビルドが可能になります。

$ cargo zigbuild --target=x86_64-unknown-linux-gnu.2.17
$ cargo zigbuild --target=aarch64-unknown-linux-gnu.2.17  # cross compilation

まとめ

それぞれのアプローチについて、下の表の通り評価しました。

可搬性 コスト クロスコンパイル そのほか
static (musl) glibc との挙動差異あり
static (cross, musl) glibc との挙動差異あり
static (glibc) 利用できる機能に制限あり
old distro (supported)
old distro (unsupported) サポート外のディストリの利用によるセキュリティリスク
old distro (cross, CentOS 7) サードパーティーのビルドツール
old glibc (sysroot)
old glibc (zig cc)
old glibc (cargo-zigbuild) サードパーティーのビルドツール

glibc との挙動の互換性、サードパーティーツールの利用可否や外部の C ライブラリへの依存など、それぞれの要件に合わせて最適な選択肢を検討する一助になれば幸いです。

脚注
  1. ほとんどのプログラム言語のエコシステムと同様に ↩︎

  2. 意味もなく gettid(2) を頑張って呼び出していますし、別段これは Rust である必要もないのですが、Rust でこのようなことが起きる可能性がある、という例として ↩︎

  3. インストール者の利便性のためにパッケージを用意するとしても、それぞれのパッケージマネージャ・ディストリビューション向けに共通のバリナリが利用できるのは良い性質でしょう ↩︎

  4. Cargo のバイナリパッケージ ↩︎

  5. M1 Mac の登場や各クラウドベンダーの ARM 対応に伴って aarch64 アーキテクチャへの対応の重要性が増してきています。一方でビルドに用いる CI インフラでの aarch64 対応はあまり進んでいるとはいえません。GitHub Actions は未対応、CircleCI は Machine Executor で対応しているが Docker Executor では利用できない、それぞれ 2022/9 時点 ↩︎

  6. というより、よく知られている ↩︎

  7. guaranteed to build ↩︎

  8. GNU 環境における gcc-aarch64-linux-gnu みたいなものです ↩︎

  9. どちらにせよ -nodefaultlibs して Rust のツールチェインに同梱されている musl にリンクするようなので ↩︎

  10. なんとなく動いてしまうとはいえ ↩︎

  11. -unknown-linux-gnu ターゲットのまま動的リンクのないバイナリを生成することが可能ということ ↩︎

  12. 手元で試すと何となく動いてしまうこともありますが、あくまでサポート外です。またそもそも動的ライブラリに依存しているので静的リンクでやりたかったことは達成できていませんし、結局使っている NSS の共有オブジェクトが glibc にリンクされているので同時に 2 つの glibc を使うことになってマズそうです。実際よくわからない SEGV が出たこともあるのでやはりやめておいたほうがいいのでしょう ↩︎

  13. どのように?という話は自分もあまり詳しくないので、この辺りを https://developers.redhat.com/blog/2019/08/01/how-the-gnu-c-library-handles-backward-compatibility ↩︎

  14. もちろん、動的リンクの対象が glibc だけである場合 ↩︎

  15. glibc 2.17 以下ではビルドもサポート外であるため glibc 2.17 より古い glibc のディストリを使うことはできない ↩︎

  16. クロスコンパイルツールチェインがそのディストリに存在すれば、ですが ↩︎

  17. なお、CentOS 7 それ自体は 2024 年までサポートがありますが、CentOS 7 の Docker Hub のイメージはサポートが切れています https://hub.docker.com/_/centos ↩︎

  18. そして rustc ↩︎

  19. Rust が C ランタイム + Unwind Library として libgcc_s を使っている一方で zig が compiler-rt (Unwind Library を含まない) を使っており、zig が -lgcc_s を無視します。そのために手動で libunwind をリンクしてやる必要があります https://github.com/ziglang/zig/issues/10050#issuecomment-956204098 ↩︎

Discussion

ログインするとコメントできます