fat gemを簡単に作れるようにするgemであるrake-compilerをメンテナンスしている須藤です。過去にfat gemの作り方をまとめたこともあります。
fat gemが有用な時代もあったのですが、今はメリットよりもデメリットの方が大きいのでfat gemをやめたらどうか、という話をします。
fat gemについて
fat gemとはビルド済みバイナリーが入ったgemのことです。Pythonで言えばwheelのようなものです。
RubyはC言語でRuby用のライブラリーを実装することができます。これを拡張ライブラリーと呼びます。拡張ライブラリーの用途は主に高速化(Rubyで実装したよりCで実装した方が速い)とバインディング(C・C++言語で実装されたライブラリーをRubyから使えるようにするライブラリー)です。
拡張ライブラリーをインストールするにはC言語のプログラムをビルドしなければいけません。ビルドするためにはインストール時にC言語のビルド環境が必要になります。ユーザーの環境にC言語のビルド環境がないとインストールできません。つまり、インストールの敷居が高くなります。
このインストールの敷居が高い問題を解決するものがfat gemです。fat gemにはビルド済みのバイナリーが含まれているためユーザーはC言語のビルド環境を用意しなくてもいいのです。やったー!
fat gemの問題点
fat gemのおかげでユーザーはハッピーになれそうですね。でも、本当にそうでしょうか?実際に、fat gemを作ってきた経験からfat gemの問題点を説明します。
新しいRubyを使えるまでにタイムラグがある
Rubyは毎年クリスマスに新しいバージョンがリリースされます。(メンテナンスリリースは必要に応じて随時行われています。)
fat gemにはビルド済みのバイナリーが含まれています。これは、だれかが事前にビルドしてくれているということです。だれかというのはgemの開発者です。
Rubyはバージョン間でのABIの互換性を保証していません。たとえば、Ruby 2.6用の拡張ライブラリーはRuby 2.7では使えません。そのため、新しいバージョンのRubyがリリースされたらそのバージョンのRuby用にビルドしないといけません。(メンテナンスリリースでは互換性があるのでRuby 2.6.0用の拡張ライブラリーはRuby 2.6.5でも使えます。)
つまり、新しいRubyがでてもgemの開発者が新しいRuby用のfat gemをビルドしてくれていなければ新しいRubyを使えません。すべてのgemがRubyのリリースにあわせて開発スケジュールを立てているわけではないので、クリスマスから数ヶ月遅れて新しいRuby用のfat gemをリリースすることも十分ありえます。
リリースされるならまだいいですが、メンテナンスモードになっているgemは重大な問題がなければ数ヶ月経ってもリリースされないかもしれません。もし、リリースされないgemが変更無しで新しいRubyでビルドできたとしてもユーザーは使えません。(RubyのC APIはそんなに劇的に変わらないのでだいたいビルドできます。)
もし、ユーザーが複数のfat gemに依存している場合はすべてのfat gemが新しいRubyに対応しなければ新しいRubyを使えません。1つでも新しいRubyに対応していないとインストールできないからです。
脆弱性対応までにタイムラグがある
バインディングをfat gemにするということはバインディング対象のライブラリーもfat gem内に入れていることになります。もし、バインディング対象のライブラリーに脆弱性があった場合は迅速に修正版をリリースするべきです。そうしないとユーザーが危険な状態が伸びてしまうからです。
しかし、すべてのfat gem開発者がそんなにタイミングよく作業できるわけではありません。そのため、脆弱性対応リリースが遅くなりがちです。
念のため補足しておくと、これはfat gemにしていない場合でも発生しえます。たとえば、Nokogiriのようにデフォルトで特定バージョンの依存ライブラリーをビルドするタイプのgemでも発生しえます。指定したバージョンのライブラリーに脆弱性があったらバージョンを更新してリリースしないと脆弱なバージョンを使ったままのユーザーが増えてしまいます。明示的に--use-system-libraries
を指定すればNokogiriのバージョンを上げなくても対応できるのですが、残念ながら多くのユーザーはそこまで頑張ってくれないでしょう。
新しい依存ライブラリーを使えるまでにタイムラグがある
これもバインディングの場合ですが、バインディング対象のライブラリーが新しいバージョンをリリースしてもfat gemを更新しなければユーザーは新しいバージョンを使えません。
fat gemに対応するとrequire
が遅くなる
fat gemに対応するには次のようなコードを入れる必要があります。2.6/io/console.so
(ビルド済みバイナリー)があればそっちを優先し、なければio/console.so
(自分でビルドしたバイナリー)を読み込むというロジックです。
begin
require "#{RUBY_VERSION[/\d+\.\d+/]}/io/console.so"
rescue LoadError
require 'io/console.so'
end
すべてのケースでfat gemを使うなら↓だけで大丈夫です。
require "#{RUBY_VERSION[/\d+\.\d+/]}/io/console.so"
しかし、それではユーザーが自分でビルドして使うという選択肢がなくなります。逆に言うと、開発者がすべてのプラットフォーム向けにfat gemを用意する覚悟を決める必要があります。
それは現実的ではないので、普通は前述のように自分でビルドしたバイナリーにフォールバックします。
そうすると、fat gemを提供していない環境では必ずfat gem用のrequire
が失敗します。この分require
が遅くなるということです。$LOAD_PATH
にたくさんのパスが入っている環境では無視できないくらい遅くなります。gemをたくさんインストールしているとその分$LOAD_PATH
も大きくなります。たとえばRuby on Railsアプリケーションではたくさんのgemを使うことになるので影響が大きいです。
これを回避するために、fat gemを提供している環境でだけフォールバックする対策をとっているgemもあります。(ありました。)
fat gemのリリースを忘れる
多くのgemはrake release
だけでgemをリリースできるようにしています。そしてこれはすぐに完了します。.gem
ファイルを作ってrubygems.orgにアップロードするだけだからです。(他にもgit tag
をするとかちょろっとしたことをしています。)
しかし、fat gemをリリースするにはもう一手間必要です。各環境用のバイナリーをビルドしてそれぞれの環境毎にfat gemを作り、それらをrubygems.orgにアップロードします。
各環境用のバイナリーは大変です。rake-compiler-dockを使えば楽になりますが、それでも面倒です。
その結果どうなるかというとリリースが億劫になったり、fat gemのリリースを忘れたりします。たとえば、Ruby-GNOMEはリリースが億劫だなと思っていました。たとえば、io-console 0.4.8はfat gemのリリースを忘れていました。
fat gemのリリースが大変
fat gemのリリースは大変なんです。特にバインディングのfat gemのリリースは大変です。
私はRuby-GNOMEでWindows用のfat gemを作っていました。バインディング対象のGTKなどのライブラリーはLinux上でMinGWを使ってクロスコンパイルしていました。これがすごく大変です。というのは、クロスコンパイルしている人がほとんどいないので、バインディング対象のライブラリーのバージョンを上げるとビルドエラーになることがよくあるからです。Ruby-GNOMEをリリースするたびにアップストリームにパッチを送っていたものです。ただ、librsvgがRustを使うようになってクロスコンパイルできなくなったときにfat gemをやめる決心をしました。
fat gemはそんなにポータブルじゃない(気がする)
fat gemは主にWindows向けに提供されていますが、Linux向けに提供している野心的なgemもあります。たとえば、sasscです。
Windowsはバージョンが限られていますし、後方互換性があるので古いWindows向けにビルドしていればいろんなWindowsでもだいたい大丈夫です。
しかし、Linuxディストリビューションはたくさんあり、使っているlibcも違います。スタティックリンクしたバイナリーを用意すれば大丈夫なのかというとそうでもない気がします。どうなんでしょうか。。。?
Pythonのwheelではmanylinuxという(だいたいの)Linux環境で動く仕組み(?)を用意しているので、このくらい頑張れば大丈夫なのかもしれません。が、私としては、この方向で頑張っちゃうの。。。?という気持ちになります。RubyGemsはそうなって欲しくないなという気持ちです。
fat gemの問題点の解決方法
ここまででfat gemの問題点をまとめました。それではfat gemの問題点を解決する方法を示します。それはfat gemをやめることです。どーん!
そもそもfat gemが必要だったのはユーザーがビルド環境を持っていないことが多かったからです。しかし、今は状況が変わっています。ユーザーがビルド環境を持っていないプラットフォームの代表はWindowsでしたが、今はRubyInstaller for Windowsがほぼ標準でDevKitを提供しています。Ruby 2.3以前はそうではなかったですが、Ruby 2.3がEOLになったので、今はWindowsユーザーでもビルド環境があるのです。
Linuxではパッケージをインストールすればすぐにビルド環境を整えられます。
macOSでもXcodeをインストールすればビルド環境を整えられます。Homebrewを使っている人はすでに整っているはずです。
他の環境(たとえば*BSD)でもビルド環境はすぐに整えられるでしょう。
つまり、今はユーザーがビルド環境を持っていると仮定してもよい状態になっています。そのため、fat gemを提供しなくてもユーザーがインストールできる状態が整っています。実際、私はWindowsユーザーがいるRuby-GNOMEでfat gemをやめましたが、最近はWindowsでのインストールトラブルはほとんど報告されていません。
それではfat gemをやめるとうれしいことをまとめます。
新しいRubyをすぐに使える
新しいバージョンのRubyは以前のバージョンのRubyとC APIが変わっている可能性があります。たとえば、Ruby 2.7ではrb_f_notimplement()
が変わります。(Ruby 2.7の対応が必要な例)
しかし、多くのC APIは互換性があるのでなにも変更しなくても新しいRubyで動くことが多いです。その場合は、特になにもしなくてもすぐに新しいRubyを使えます。ユーザーは単に新しいRubyを使ってgemをインストールすればよいだけだからです。
また、もしRuby 2.7で動かない場合でも事前にプレビュー版で動作確認し、Ruby 2.7より前にRuby 2.7対応版をリリースしておくこともできます。こうすればクリスマス後にリリースしなくてもよくなるのでgem開発者に余裕があります。
fat gemを使った場合でも、プレビュー版でバイナリーを作ってリリースしておくことができなくはありませんが、rake-compiler-dockなど各種ツールが事前に対応していないと難しいです。
脆弱性対応をシステムに任せられる
バインディングをシステムのライブラリーを使ってビルドするようにしていた場合、バインディング対象のライブラリーに脆弱性があってもシステムのライブラリーを更新すれば対策できます。gemの開発チームよりシステムのライブラリーをメンテナンスしている人たちの方が層が厚いので迅速に脆弱性に対応してもらえます。
なお、Nokogiriのようにデフォルトで依存ライブラリーを自前で管理するタイプのgemはfat gemでもそうでなくても関係ありません。
新しい依存ライブラリーをすぐに使える。。。こともある
バインディングをシステムのライブラリーを使ってビルドするようにしていた場合、バインディング対象のライブラリーの更新はシステムのパッケージシステムが面倒をみてくれます。Debian GNU/Linux sid、Fedora Rawhide、ArchLinux、Homebrew、MSYS2などのように最新のバージョンに随時アップデートされるシステムではgemの更新を待たずに新しいライブラリーを使えます。
ただし、ライブラリーのバージョンアップでAPIが変わった場合はgemの更新が必要です。
require
が遅くならない
fat gem用のrequire
がいらなくなるので失敗するrequire
を実行しなくてもよくなります。これによりrequire
が遅くなりません。
bigdecimalがfat gemのサポートをやめたのはこれが理由です。
開発コストが下がる
面倒なfat gemのリリースをしなくてよくなるので開発者は本来の開発にリソースを注力できます。
最適化ビルドできる
fat gemは事前にビルドしたバイナリーをすべてのユーザーが共通で使うことになるので最大公約数の最適化しかできません。
しかし、fat gemをやめて各ユーザーごとにインストールする場合はその環境毎に最適化できません。たとえば、速度が非常に重要な拡張ライブラリーをGCCでビルドする場合は-O3 -march=native
というオプションをつけてビルドするとその環境向けに最適化されます。たとえば、CPUがSIMDをサポートしていればSIMDを使ったバイナリーを生成することもあります。
fat gemをやめたときの問題点と解決策
fat gemをやめるとユーザーも開発者もハッピーになれそうですね。でも、本当にそうでしょうか?fat gemをやめたときの問題点とその解決策をまとめます。
インストール時間が長くなる
fat gemの場合はビルド済みのバイナリーをコピーするだけなのですぐにインストールは完了します。しかし、fat gemをやめるとインストールするたびにビルドすることになるので時間がかかります。
解決策は。。。特にありません。。。
依存ライブラリーがなくてインストールが失敗しやすくなる
バインディングはバインディング対象のライブラリーがないとインストールに失敗します。たとえば、RMagickはImageMagickがないとインストールに失敗します。Nokogiriがデフォルトで自分で依存ライブラリーをビルドするようになっているのはこの失敗を防ぐためです。
たしかに、自分でビルドしてしまうというのはこの問題の解決策の1つではあります。ただ、そんなに筋がよいとは思えません。脆弱性があったときの対応に関する問題があるからです。
私がオススメする方法はシステムのパッケージシステムを使って自動で足りない依存ライブラリーをインストールする方法です。このための便利gemがnative-package-installerです。私が開発しています。
native-package-installerはpkg-config gemと一緒に使うことを想定していて、extconf.rb
に次のように書いておけば、cairoがインストールされていなければ自動でインストールします。
require "pkg-config"
require "native-package-installer"
unless PKGConfig.have_package("cairo")
unless NativePackageInstaller.install(:arch_linux => "cairo",
:debian => "libcairo2-dev",
:homebrew => "cairo",
:macports => "cairo",
:msys2 => "cairo",
:redhat => "cairo-devel")
exit(false)
end
unless PKGConfig.have_package("cairo")
exit(false)
end
end
なお、RubyInstaller for Windows用のRubyではgemのメタデータにMSYS2のパッケージを指定しておくことで同じ機能(自動で依存ライブラリーをインストールする機能)を実現できます。以下はcairo.gemspec
での例です。
gemspec.metadata["msys2_mingw_dependencies"] = "cairo"
参考:MSYS2 library dependencies - For gem developers - onclick/rubyinstaller2 Wiki
ビルドに失敗してインストールが失敗しやすくなる
fat gemではすでにビルド済みなのでビルドが失敗することはありません。開発者が用意した環境でビルドが成功すればOKです。
一方、ユーザーの環境でビルドする場合は、開発者の環境では成功しているのにユーザーの環境では失敗することがあります。
解決策は、CIでサポートしている環境を常にテストすることです。Travis CIやGitHub Actionsなどを使えば、いろんな環境でテストできます。Linuxの亜種はDockerを使うとよいでしょう。
まとめ
検討するべき項目が他にもある気がしますが、一通りまとめたので公開します。fat gemをやめたくなりましたか?それともfat gemはやめないで!という気持ちになりましたか?
もし、これはどうなの?という項目があったらなんらかの手段で私に聞いてください。回答します。