Bundler: ネイティブgemのクラッシュやコンテナでの不要な再コンパイルを解決する(翻訳)
Rubyアプリでbundle install
を実行中に、ネイティブ拡張gemが原因でクラッシュすることが結構あります。特に、nokogiri
やffi
などの、壊れやすいことで有名なC拡張gemを使っていると起きやすくなります。
この問題は、複数のOSにまたがって作業する場合や、Rubyバージョンをアップグレードしている場合に深刻になります。こうした問題を一気に解決しましょう。
🔗 私がネイティブgemで踏んだ問題
私たちArkencyでは、さまざまなプロジェクトでRubyやRailsのアップグレード作業を山ほど実行しているので、上述の問題を何度となく経験しています。
私がネイティブgemで主に気がかりな点は、開発やデプロイのワークフローで不要な摩擦を引き起こすことです。
bundle install
を実行するたびにコンパイルで待たされる- Rubyバージョンをアップグレードするたびに、すべてを再コンパイルする準備が必要になる
- チームメンバーと異なるOSを使っていると、自分の環境でしか起きないエラーに苦しめられる
- CIのパイプライン実行がどうも遅いと思ったら、C拡張が原因だったりする
- YJITのパフォーマンスがC拡張によって足を引っ張られる
特に最後の点は見落とされがちです。RubyのYJIT(Yet Another Just--In-Timeコンパイラ)はアプリケーションを大幅に高速化してくれますが、YJITが最も効果を発揮するのは、純粋にRubyだけで書かれたコードです。C拡張はRuby VMをバイパスするため、YJITによる最適化が効きません。アプリケーションが依存するネイティブ拡張が増えれば増えるほど、YJITのメリットが目減りしてしまいます。
なお、Ruby 3.3は--yjit
フラグを指定することでYJITが有効になるので、それに気づかないとパフォーマンスを上げ損ねる可能性がありますが、RailsでRuby 3.3が使われていれば代わりにYJITを有効にしてくれます(#49947)。
ただし、以下の素晴らしい記事では、CのコードをRubyで書き直すことで高速化する方法を解説しています。
参考: Speeding up Ruby by rewriting C… in Ruby - JP Camara
特に、デプロイ時にコンパイルで待たされるとイライラします。せっかくコンテナで美しく環境を構築しても、同じネイティブgemを何度も何度もコンパイルするためだけにビルド依存関係をインストールしなければならなくなります。このようにして、production環境のDockerイメージは、無意味なコンパイラやdevヘッダーファイルなどで肥大化することになります。
🔗 今に始まった問題ではない
このソリューションを本記事で皆さんと共有しようと思ったきっかけは、過去記事で友人と雑談したことで、特に以下の部分がきっかけとなりました。
最近小さなバックエンドアプリを実装しなければならなくなったことがあったんで久しぶりにRailsでやってみたんだけど、何もかもあの当時と変わっていないね。コマンドも同じならgemも同じ、
bundle install
でnokogiriがクラッシュするのも同じ、10年前そのまんまだね。
こんなクラッシュを我慢する必要などないと思ったのです。
🔗 我らがヒーロー、bundler參上
そこで颯爽と登場するのがbundle lock --add-platform
です。
このコマンドは、現在のプラットフォームとは別のプラットフォーム向けの依存関係を解決して、その情報をGemfile.lock
に保存します。指定のプラットフォーム向けにプリコンパイル済みバージョンが提供されている場合は、bundlerはgemをソースからコンパイルするのではなく、プリコンパイル済みのバージョンを使うようになります。
この--add-platform
オプションはbundler 2.2.0以降で利用可能なので、実行するbundlerのバージョンがこれより新しいことを確認しておきましょう(bundle -v
で手軽にチェックできます)。
使っているGemfile.lock
にPLATFORMS
セクションがまだない場合(古いRailsアプリを更新中の場合など)は、この後で説明する手順に沿って進めてください。
Gemfile.lock
にPLATFORMS
セクションがあっても、アプリを実行するプラットフォーム固有の記述がPLATFORMS
セクションに存在しない場合は、本記事の手順に沿って進めてください。
🔗 ここで言うプラットフォームとは?
その答えは、以下のように皆さんの環境でgem help platform
コマンドを実行すればわかります。
▶gem help platform
の実行結果(クリックで展開)
➜ gem help platform
RubyGems platforms are composed of three parts, a CPU, an OS, and a
version. These values are taken from values in rbconfig.rb. You can view
your current platform by running `gem environment`.
RubyGems matches platforms as follows:
* The CPU must match exactly unless one of the platforms has
"universal" as the CPU or the local CPU starts with "arm" and the gem's
CPU is exactly "arm" (for gems that support generic ARM architecture).
* The OS must match exactly.
* The versions must match exactly unless one of the versions is nil.
For commands that install, uninstall and list gems, you can override what
RubyGems thinks your platform is with the --platform option. The platform
you pass must match "#{cpu}-#{os}" or "#{cpu}-#{os}-#{version}". On mswin
platforms, the version is the compiler version, not the OS version. (Ruby
compiled with VC6 uses "60" as the compiler version, VC8 uses "80".)
For the ARM architecture, gems with a platform of "arm-linux" should run on a
reasonable set of ARM CPUs and not depend on instructions present on a limited
subset of the architecture. For example, the binary should run on platforms
armv5, armv6hf, armv6l, armv7, etc. If you use the "arm-linux" platform
please test your gem on a variety of ARM hardware before release to ensure it
functions correctly.
Example platforms:
x86-freebsd # Any FreeBSD version on an x86 CPU
universal-darwin-8 # Darwin 8 only gems that run on any CPU
x86-mswin32-80 # Windows gems compiled with VC8
armv7-linux # Gem complied for an ARMv7 CPU running linux
arm-linux # Gem compiled for any ARM CPU running linux
When building platform gems, set the platform in the gem specification to
Gem::Platform::CURRENT. This will correctly mark the gem with your ruby's
platform.
RubyGemsのプラットフォーム(platform)は、「CPU」「OS」「バージョン」という3つの要素の組み合わせでできています。これらの値はrbconfig.rbファイルから取得されます。これらの値は
gem enviroment
コマンドを実行することで確認できます。RubyGemsは、プラットフォームを以下のように照合します。
- CPU名は、完全に一致しなければならない。ただし「プラットフォームのいずれかのCPUが
universal
1である場合」または「ローカルCPUがarm
で始まる名前で、かつgemのCPUがarm
と完全に一致する場合」を除く。- OS名は、完全に一致しなければならない。
- バージョンは、いずれかのバージョンが
nil
でない限り、完全に一致しなければならない。gemを「インストール」「アンインストール」「一覧表示」するコマンドでは、
--platform
オプションでプラットフォームを指定することで、RubyGemsが認識するプラットフォームリストを上書きできます。指定するプラットフォーム名は、
#{cpu}-#{os}
または#{cpu}-#{os}-#{version}
と一致しなければなりません。
mswin
(Windows)プラットフォームの場合、バージョンはOSのバージョンではなくコンパイラのバージョンを意味する点に注意が必要です(VC6でコンパイルされたRubyのコンパイラバージョンは"60"、VC8の場合は"80"になります)。ARMアーキテクチャの場合、
arm-linux
プラットフォームを指定されたgemは、ARMアーキテクチャの特定のサブセットにのみ存在する命令に依存することなく、ARM CPUの適切なセットで動作しなければなりません。たとえば、バイナリはarmv5
、armv6hf
、armv6l
、armv7
などのプラットフォームで動作する必要があります。
arm-linux
プラットフォームを使う場合は、リリース前にさまざまなARMハードウェアでgemをテストして、正常に動作することを確認してください。プラットフォームの例:
x86-freebsd
# x86 CPU上の任意のFreeBSDバージョンで動作するuniversal-darwin-8
# Darwin 8でのみ動作するgem(CPUは任意)x86-mswin32-80
# VC8でコンパイルされたWindows向けgemarmv7-linux
# Linuxを実行するARMv7 CPU向けにコンパイルされたgemarm-linux
# Linuxを実行する任意のARM CPU向けにコンパイルされたgemプラットフォームgemをビルドする場合は、gemspecファイルの
platform
をGem::Platform::CURRENT
に設定してください。これにより、gemにRubyのプラットフォームが正しく設定されます。
🔗 必要なもの
🔗 最新のツール
問題を避けて改善のメリットを得るために、bundlerとrubygemを新しいものにしておきましょう。
gem install bundler
gem update --system
新しいbundlerのバージョンがインストール済みであることを確かめたら、以下を実行してGemfile.lock
にそのことを反映します。
bundle update --bundler
git add Gemfile.lock
git commit -m "Updated bundler"
🔗 使うプラットフォームでプリコンパイル済みのgemが利用可能かどうかをチェックする
これについては、使いたいgemをRubygemsサイトで見つけてバージョンページをチェックすることをおすすめします。
ここではnokogiriを例にバージョンを確かめてみましょう。
nokogiriは、本記事執筆時点では1.18.7
が最新バージョンです。
自分のコンピュータにあるnokogiriの生のバージョンが以下だとします。
1.18.7 March 31, 2025 (4.16 MB)
対応するプリコンパイル済みバージョンは、以下のように表示されています。
1.18.7 March 31, 2025 x86_64-linux-gnu (3.88 MB)
1.18.7 March 31, 2025 arm-linux-gnu (3.25 MB)
1.18.7 March 31, 2025 aarch64-linux-gnu (3.8 MB)
1.18.7 March 31, 2025 arm-linux-musl (3.44 MB)
1.18.7 March 31, 2025 x86_64-linux-musl (3.87 MB)
1.18.7 March 31, 2025 arm64-darwin (6.23 MB)
1.18.7 March 31, 2025 x86_64-darwin (6.4 MB)
1.18.7 March 31, 2025 aarch64-linux-musl (3.77 MB)
1.18.7 March 31, 2025 java (9.88 MB)
1.18.7 March 31, 2025 x64-mingw-ucrt (6.02 MB)
Gemfile.lock
をプラットフォーム固有の依存関係で更新する
私の経験則では、以下のコマンドを実行する形でプラットフォームを追加しています。
bundle lock --add-platform arm64-darwin
Writing lockfile to /Users/fidel/code/Gemfile.lock
bundle lock --add-platform x86_64-darwin
Writing lockfile to /Users/fidel/code/Gemfile.lock
bundle lock --add-platform x86_64-linux
Writing lockfile to /Users/fidel/code/Gemfile.lock
bundle install
git add Gemfile.lock
git commit -m "Use precompiled gems for all the platforms"
上の操作例では、Rails開発でよく使われるプラットフォームをほぼカバーしています。
- Intel/AMD Linux(サーバーはたいていこれ)
- Apple Silicon(M1/M2/M3/M4および対応するMac)
- Intel Mac (一部の開発者が古いハードウェアを使っているので)
もちろん、必要ならこの他のプラットフォームも追加できます。
これで、デプロイ時や次回のRubyアップグレードにびっくりさせられることもなくなります。
🔗 (オチ)はい、ここまでの話は全部忘れてください
実を言うと、現代のbundlerを実行すれば、プラットフォーム周りのことはbundlerが一通り代わりにやってくれます。
➜ cat Gemfile
source "http://rubygems.org"
gem "nokogiri"
➜ bundle
Fetching gem metadata from http://rubygems.org/.......
Resolving dependencies...
Fetching nokogiri 1.18.7 (arm64-darwin)
Installing nokogiri 1.18.7 (arm64-darwin)
Bundle complete! 1 Gemfile dependency, 3 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.
➜ cat Gemfile.lock
GEM
remote: http://rubygems.org/
specs:
nokogiri (1.18.7-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.7-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.7-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.7-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.7-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.7-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.7-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.7-x86_64-linux-musl)
racc (~> 1.4)
racc (1.8.1)
PLATFORMS
aarch64-linux-gnu
aarch64-linux-musl
arm-linux-gnu
arm-linux-musl
arm64-darwin
x86_64-darwin
x86_64-linux-gnu
x86_64-linux-musl
DEPENDENCIES
nokogiri
BUNDLED WITH
2.6.5
ご覧ください。プラットフォーム指定を手動でロックする必要すらなかったのです。
しかし、PLATFORMS
リストを見ると、nokogiri gemで利用可能なプラットフォームが全部追加されています(なぜかWindows用とJava用のプラットフォームは含まれていませんが、これは偶然なのか、はたまた意図的なのでしょうか?)。
しかし私は「少ないほどよい」と考えるたちなので、たとえば以下のようなコマンドを実行することで、Gemfile.lock
のリストをできるだけ最小限にとどめておくのが好みです。
bundle lock --remove-platform x86_64-linux-musl
Writing lockfile to /Users/fidel/code/Gemfile.lock
bundle lock --remove-platform aarch64-linux-gnu
Writing lockfile to /Users/fidel/code/Gemfile.lock
bundle lock --remove-platform aarch64-linux-musl
Writing lockfile to /Users/fidel/code/Gemfile.lock
bundle lock --remove-platform arm-linux-musl
Writing lockfile to /Users/fidel/code/Gemfile.lock
bundle lock --remove-platform arm-linux-gnu
Writing lockfile to /Users/fidel/code/Gemfile.lock
➜ cat Gemfile.lock
GEM
remote: http://rubygems.org/
specs:
nokogiri (1.18.7-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.7-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.7-x86_64-linux-gnu)
racc (~> 1.4)
racc (1.8.1)
PLATFORMS
arm64-darwin
x86_64-darwin
x86_64-linux-gnu
DEPENDENCIES
nokogiri
BUNDLED WITH
2.6.5
これで、使いもしない不要なプラットフォームやgemspecがPLATFORMS
のリストから削除されました。
「わかるけど、そこまでする?」とお思いの方への回答は以下の通りです。
- CI実行時やproduction環境へのデプロイ時に、古いgemがダウンロードされずに済む
- ネットの帯域幅を節約できる
- RubyGems.orgの中の人も、CDNから不要なダウンロードが発生しなくなって喜んでくれる
皆さんも、高速かつ予測の効く快適なビルドを楽しみましょう🖖
関連記事
- 訳注: これはAppleの Universal Binary形式を指します。 ↩
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。