ククログ

株式会社クリアコード > ククログ > メンテナンス可能なfat gemエコシステム案

メンテナンス可能なfat gemエコシステム案

fat gemはやめた方がよいと思っている須藤です。やめた方がよいとは思っているのですが、どうやらfat gemが欲しい人はいなくならなそうなので、メンテナンス可能なfat gemエコシステム案を考えています。

なお、この文章を書いているのは https://github.com/ruby/rubygems/issues に英語で提案するために考えを整理したいからです。整理したら英語で提案します。

fat gemで実現したいこと・避けたいこと

fat gemに期待されているのは次のことです。

  • インストール時間の短縮(ビルドせずにコピーするだけだから)
  • プロダクション環境へビルドツールをインストールしない(セキュリティの強化とストレージ容量の削減)

fat gemを実現するにあたり、避けたいことは次のことです。

  • gem開発者のメンテナンスコストが上がる
  • 新しいCRubyで使えるようになるまでにラグがある
  • 依存ライブラリーが脆弱性に対応してもfat gemが対応してくれないと対応できない(fat gem内に依存ライブラリーが含まれているため)
  • 複数のfat gemが同じ依存ライブラリーの違うバージョンを使っているとコンフリクトすることがある

既存のパッケージ管理システムから学べること

RubyGemsのfat gemやPythonのwheelなどバイナリーパッケージを使えるパッケージ管理システムはすでにいくつもあります。これらからできるだけ学んでよりよい仕組みを設計したいです。

Python: wheel: Pythonバージョンタグで新しいPythonへの対応は楽にならない

RubyGemsやwheelにはパッケージに「タグ」というメタ情報を含めることができ、ユーザーがどのパッケージを使うかの判断材料として使われています。

RubyGemsはタグの中にRubyのバージョンは含まれていないので、1つのパッケージ内に対応しているすべてのRubyのバージョン用のバイナリーを含める必要があります。つまり、Ruby 3.2から3.4に対応しているけど、Ruby 4.0には対応していないパッケージをすでにリリースしていたら、同じバージョンでRuby 4.0にも対応したパッケージをリリースすることはできません。すでに同じタグのパッケージがあってコンフリクトするからです。

Improve precompiled binary gem supportにはRubyのバージョンもタグに含めようぜという提案も含まれています。)

一方、wheelではPythonのバージョンもタグに含まれています。つまり、Pythonのバージョンごとに別のパッケージを用意できます。新しいPythonがリリースされたら既存のバージョンの新しいPython用のパッケージを追加でリリースできます。しかし、現実的には新しいPython用のパッケージを追加でリリースすることはありません。新しいPython用のパッケージをビルドするCIを整備するなど、プロダクト側に追加の作業が必要だからです。追加の作業が必要な場合、既存のバージョン向けのパッケージを追加でリリースするよりも、新しいバージョンをリリースした方が楽でミスも少ないです。普通のリリース手順になるからです。

よって、RubyGemsでタグにRubyのバージョン情報を含めるようにしても、新しいCRubyへの対応が楽になるということはないはずです。「新しいCRubyで使えるようになるまでにラグがある」を解決するためにはなにか別のアプローチが必要そうです。

(なお、Rubyのバージョンをタグに含めることで、パッケージのサイズを小さくすることはできます。不要なRubyのバージョンのバイナリーを含める必要がなくなるからです。)

Python: wheel: Linux用のバイナリーはlibc情報だけでは十分ではない

PythonではPEP 600でglibcベースの「主要な」Linuxディストリビューション用のタグを定義し、PEP 656でmusl libcベースのLinuxディストリビューション用のタグを定義しています。

依存ライブラリーがないプロダクトの場合はlibcとPythonのバージョンだけわかれば十分ですが、バインディングの場合はそうではありません。依存しているライブラリーおよびそれらがさらに依存しているライブラリーも対象のlibcで動くようにしないといけません。wheelでは必要な依存ライブラリーを各プロダクトがビルドしてスタティックリンクすることで対応します。同じ依存ライブラリーを持つ違うwheelがなければこれでうまくいきます。

しかし、現実的には違うwheelが同じ依存ライブラリーを必要とすることがあります。その場合、各wheelが違うバージョンをスタティックリンクしていたり、同じバージョンだけど違うビルドオプションでビルドしたものをスタティックリンクしていたりすることもあります。このようなwheelを同時に使うと問題が発生することがあります。

より具体的な例は https://pypackaging-native.github.io/key-issues/native-dependencies/ などを参照してください。

なお、PEP 600には「他のwheelと一緒にいい感じに動くこと(実現方法は規定しない)」という要件もあるので、auditwheelなどのツールを使ってシンボル名をリネームしたりしてがんばる必要があります。

「複数のfat gemが同じ依存ライブラリーの違うバージョンを使っているとコンフリクトすることがある」を解決するためにwheelと同じようにauditwheelと同様の仕組みを実現することもできるでしょうが、スタティックリンクアプローチだと「依存ライブラリーが脆弱性に対応してもfat gemが対応してくれないと対応できない」を解決できません。

また、便利ツールを提供することで「gem開発者のメンテナンスコストが上がる」を軽減できるでしょうが、それで十分メンテナンスコストを下げられるのかには私は懐疑的です。(違う意見の人もいるとは思います。)そもそもたくさんのfat gemのメンテナンスをしたくないんですよ。

libcのバージョンだけで(比較的)ポータブルなLinux用のバイナリーパッケージを識別するのは困難で、なにか別のアプローチが必要そうです。

Linuxディストリビューションのパッケージ管理システム

プログラミング言語のパッケージ管理システムだけではなく、Linuxディストリビューションのパッケージ管理システムからも学んでみましょう。Linuxディストリビューションのパッケージ管理システムとはDebianのdeb/APTやFedoraのRPM/DNFです。

これらのシステムとプログラミング言語のパッケージ管理システムでは大きく違うところがいくつもあります。

その1つが、プロダクトの開発者とパッケージのメンテナーが別だという点です。たとえば、csv gemはcsv gemの開発者がリリースしていますが、Debianのruby-csvパッケージはcsv gemの開発者ではなくDebianの開発者がメンテナンスしています。

Linuxはlibcが同じでもその上の環境はディストリビューションによって様々です。gemの開発者が自分が使っていない環境用のパッケージをメンテナンスすることは大変です。では、逆に、特定の環境に詳しい人があまり詳しくないgemのパッケージをメンテナンスすることはどうでしょうか。問題が発生したときにgemの中身に詳しくなくてデバッグすることが難しいことはあるかもしれませんが、環境に固有の問題であればgemの開発者よりもメンテナンスしやすそうです。gemの中身に関わることはgemの開発者と協力してデバッグすることができれば、gemの開発者だけでメンテナンスするよりも「gem開発者のメンテナンスコストが上がる」を解決できないでしょうか。

RubyGems: rake-compiler: クロスコンパイルはつらい

fat gemを作るために使われているツールがrake-compilerです。多くの場合は、rake-compilerを使うための準備が整ったDockerイメージを提供するrake-compiler-dock経由で使われています。

rake-compilerには、初期の頃からWindows用のfat gemをLinux/macOS上でビルドするという機能がありました。そのため、Windows用以外のfat gemもクロスコンパイルっぽい感じでビルドします。現在の環境のRubyを使わずに、ビルド対象の各Rubyを別途インストールし、現在の環境のRubyと別途インストールしたRubyをいい感じに使ってfat gemをビルドします。

ただ、一般的にクロスコンパイルは普通にコンパイルするよりも難しいです。クロスコンパイルが必要だと「gem開発者のメンテナンスコストが上がる」を解決できません。

Julia: JuliaBinaryWrappers: Juliaの依存ライブラリー用パッケージ管理システム

ちょっと私はよくわかっていないのですが、Juliaには依存ライブラリーをJuliaのパッケージとして管理する仕組みがあるようです。(なんのプロダクトだったか忘れましたが、私が開発に関わっているプロダクトにissueだったかpull requestが来て、この仕組みの存在を知りました。)

Yggdrasilで各依存ライブラリーをビルドする設定を管理して、BinaryBuilderでそのレシピをもとに各種環境用のバイナリーを自動でビルドするようです。ビルドされた依存ライブラリーはJuliaのパッケージとしてインストールして他のパッケージと共有できるっぽいです。

wheelでは各パッケージでスタティックリンクするので、複数パッケージが同じ依存ライブラリーを別々に使うことがありましたが、これだと共有して「複数のfat gemが同じ依存ライブラリーの違うバージョンを使っているとコンフリクトすることがある」を解決していそうです。(ほんとに?)

また、依存ライブラリーを含んだJuliaパッケージを更新するだけで「依存ライブラリーが脆弱性に対応してもfat gemが対応してくれないと対応できない」も解決できそうです。(ほんとに?)

各種プラットフォームへのビルドのためにクロスコンパイルするみたいなので、この仕組みを真似したくはないですが、依存ライブラリーを共有するという方向性は真似したいです。

Node.js: npm: スコープ

npmでは@XXX/でスコープを使うことができます。スコープはプライベートパッケージを作るための方法でもありますが、ユーザー・オーガニゼーションごとのネームスペースでもあります。

RubyGemsやPyPIでタグが必要なのは1つのプロダクトの1つのバージョンに複数のパッケージを紐づけたいからです。

たとえば、Nokogiriの1.19.0に次の11個のパッケージを紐づけたいので、タグで実現しています。

  1. source gem
  2. fat gem: aarch64-linux-gnu
  3. fat gem: aarch64-linux-musl
  4. fat gem: arm-linux-gnu
  5. fat gem: arm-linux-musl
  6. fat gem: arm64-darwin
  7. fat gem: java
  8. fat gem: x64-mingw-ucrt
  9. fat gem: x86_64-darwin
  10. fat gem: x86_64-linux-gnu
  11. fat gem: x86_64-linux-musl

もし、npmのスコープのようにネームスペースがあったらどうでしょうか。タグが不要になり、nokogiriでsource gem、@nokogiri/aarch64-linux-gnuでaarch64-linux-gnuタグ用のfat gemを提供というようにできます。ただし、今のタグの仕組みで実現できているgem "nokogiri"と書いておけばBundlerが勝手にいい感じのプラットフォームを見つけてくれる機能は使えなくなります。次のように明示的に指定しないといけません。

if RUBY_PLATFORM.include?("linux")
  gem "@nokogiri/#{RUBY_PLATFORM}-gnu"
else
  gem "@nokogiri/#{RUBY_PLATFORM}"
end

別のネームスペースの使い方も考えてみましょう。

@#{product}/#{platform}とプロダクトごとにプラットフォームをメンテナンスするのではなく、@#{platform}/#{product}というようにプラットフォームごとにプロダクトをメンテナンスするのはどうでしょうか。Docker Hubでarm64v8/でarm64v8用のDockerイメージをメンテナンスしているように、です。

このようにすると、Linuxディストリビューションのパッケージ管理システムのようにgem開発者とfat gemのメンテナーを分けることができます。「gem開発者のメンテナンスコストが上がる」を解決できます。

@#{platform}/の部分にRubyのバージョンも入れて、@ruby4.0-#{platform}/とするとどうでしょうか。新しいRubyがリリースされたら@ruby4.0-#{platform}/#{product}にリリースするだけです。@ruby4.0-#{platform}/にリリースする仕組みはプロダクトを開発している仕組みとは別なので、プロダクトを変更・バージョンアップせずにリリースできます。@ruby4.0-#{platform}/をメンテナンスする人たちががんばれば、「新しいCRubyで使えるようになるまでにラグがある」も解決できます。たとえば、previewの段階からパッケージをリリースするとか、です。

メンテナンス可能なfat gemエコシステム案

いろいろ既存のパッケージ管理システムを参考に考えてみたので、RubyGemsの場合はどうするとよいかを整理しましょう。

「gem開発者のメンテナンスコストが上がる」のために、gem開発者とfat gemメンテナーを分離しましょう。Linuxディストリビューションのパッケージ管理システムのように、各プラットフォームごとに異なる人たちがfat gemをメンテナンスできるようにしましょう。

プラットフォームごとに分けるだけではなく、プラットフォーム+Rubyのバージョンごとにfat gemのメンテナーも分けると「新しいCRubyで使えるようになるまでにラグがある」も解決できるはずです。

プラットフォームの分け方次第で「依存ライブラリーが脆弱性に対応してもfat gemが対応してくれないと対応できない」と「複数のfat gemが同じ依存ライブラリーの違うバージョンを使っているとコンフリクトすることがある」も解決できるはずです。

wheelではlibcのバージョンで分けていますが、Linuxディストリビューションの各バージョンごとに分けるのはどうでしょう。たとえば、Ubuntu 22.04で1つのプラットフォーム、Ubuntu 24.04で1つのプラットフォームといった具合です。

プロダクション環境では基本的に使っているプロダクトのバージョンを固定して意図しない挙動の変更を避けたいです。Linuxディストリビューションのバージョンまで固定することで、gemのバージョンだけでなく、依存ライブラリーのバージョンを固定できます。

また、Linuxディストリビューションが提供する依存ライブラリーを使えます。これにより「依存ライブラリーが脆弱性に対応してもfat gemが対応してくれないと対応できない」と「複数のfat gemが同じ依存ライブラリーの違うバージョンを使っているとコンフリクトすることがある」も解決できます。前者はLinuxディストリビューションのパッケージ管理システムが脆弱性に対応したバージョンにアップデートすれば解決できますし、後者は各fat gemが依存ライブラリーをそれぞれビルドしてスタティックリンクする必要がなくなるからです。Linuxディストリビューションが提供する共有ライブラリーを使えるからです。

現状の https://rubygems.org/ にはスコープ機能はありませんが、GitHubのRubyGemsレジストリー機能を使えば同じようなことは試せるはずです。

ruby4.0-ubuntu-24.04オーガニゼーションを作って、Gemfilesource "https://rubygems.pkg.github.com/ruby4.0-ubuntu-24.04"を使います。

source "https://rubygems.org/"

source "https://rubygems.pkg.github.com/ruby4.0-ubuntu-24.04" do
  # fat gemはこの中で指定する
  gem "nokogiri"
end

# 他のgemはここで指定してデフォルトソースのhttps://rubygems.org/を使う
gem "csv"

fat gemはGitHub ActionsとRubyGems.orgのwebhookを使うことで自動でビルド・公開できるはずです。

ちなみに、RubyGems.orgのwebhookは https://slide.rabbit-shocker.org/ で使っています。新しいgemがpushされるごとに通知をもらってrabbit-slide-から始まるgemなら自動でダウンロードしてHTMLを更新しています。

fat gemのビルドも同じような感じでgemのpushを検知してGitHub Actionsのjobを動かすとかできるはずです。

案を検証するために足りないこと

この案がうまく動くかを検証するにあたり、足りない機能があるはずです。

とりあえず、今の環境用のfat gemを作るためのビルドコマンドが欲しいです。rake-compilerも使えなくはないですが、準備が面倒なのでgem build --nativeとかでビルドできるとうれしいです。

そのビルドコマンドを使ってfat gemをビルドしてpushする仕組み(RubyGems.orgのwebhookとGitHub Actionsを使ったやつ)も必要です。

Gemfileの中でユーザーがプラットフォームごとに処理を振り分けるのが面倒ならBundlerも改良しないといけませんが、それはこの方向でメンテナンス可能なfat gemエコシステムを構築できそうかを検証できてからでよさそうな気がします。

想定質問

こんなことを気になるだろうなぁということを想像してあらかじめ私の考えを書いておきます。

プラットフォームを細かく分割すると対象プラットフォーム数が爆発しない?

wheelのmanylinuxみたいに複数のプラットフォームで1つのバイナリーを共有するようにしてプラットフォーム数を減らしたほうがいいんじゃない?

気持ちはわかるんですが、複数のプラットフォームで使えるバイナリーを作るのがすごく大変だし、その方向にいくと「スタティックリンクするか!」となって「依存ライブラリーが脆弱性に対応してもfat gemが対応してくれないと対応できない」と「複数のfat gemが同じ依存ライブラリーの違うバージョンを使っているとコンフリクトすることがある」を解決できないんですよねぇ。

プラットフォーム数が増えても、ニーズがないプラットフォームはメンテナンスする人が現れないので、現実的にはニーズが多いプラットフォームだけサポートすればよい状態に収束する気がします。

あるいは、自分が必要なプラットフォームのfat gemをメンテナンスする人たちが増えて、メンテナンスの負荷をエコシステム全体で分散してある状態に収束するかもしれません。

SIMDを有効にしたビルドとかCUDAのバージョンに依存したビルドとかはどうするの?

https://pypackaging-native.github.io/key-issues/simd_support/ とか https://pypackaging-native.github.io/key-issues/gpus/ とかのユースケースの話。

ubuntu-24.04-simd-avx512とかubuntu-24.04-cuda-13.1.0とかプラットフォームをさらに細かく分けて対応かなぁ。

まとめ

もし、fat gemの流れを止められないなら、メンテナンス可能なfat gemエコシステムにしたいと思っているので、それはどうしたらよいかを考えてみました。まだ仕上がり切っていないので、コメントをお待ちしています。コメントの受付先は、とりあえず https://x.com/ktou 宛で、英語で提案できるくらいになったら https://github.com/ruby/rubygems/issues にissueを作るので、その後はそちらで。