Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Bundler: ネイティブgemのクラッシュやコンテナでの不要な再コンパイルを解決する(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Bundler: ネイティブgemのクラッシュやコンテナでの不要な再コンパイルを解決する(翻訳)

Rubyアプリでbundle installを実行中に、ネイティブ拡張gemが原因でクラッシュすることが結構あります。特に、nokogiriffiなどの、壊れやすいことで有名なC拡張gemを使っていると起きやすくなります。

この問題は、複数のOSにまたがって作業する場合や、Rubyバージョンをアップグレードしている場合に深刻になります。こうした問題を一気に解決しましょう。

sparklemotion/nokogiri - GitHub
ffi/ffi - GitHub

🔗 私がネイティブ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.lockPLATFORMSセクションがまだない場合(古いRailsアプリを更新中の場合など)は、この後で説明する手順に沿って進めてください。

Gemfile.lockPLATFORMSセクションがあっても、アプリを実行するプラットフォーム固有の記述が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がuniversal1である場合」または「ローカル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の適切なセットで動作しなければなりません。たとえば、バイナリはarmv5armv6hfarmv6larmv7などのプラットフォームで動作する必要があります。
arm-linuxプラットフォームを使う場合は、リリース前にさまざまなARMハードウェアでgemをテストして、正常に動作することを確認してください。

プラットフォームの例:

  • x86-freebsd # x86 CPU上の任意のFreeBSDバージョンで動作する
  • universal-darwin-8 # Darwin 8でのみ動作するgem(CPUは任意)
  • x86-mswin32-80 # VC8でコンパイルされたWindows向けgem
  • armv7-linux # Linuxを実行するARMv7 CPU向けにコンパイルされたgem
  • arm-linux # Linuxを実行する任意のARM CPU向けにコンパイルされたgem

プラットフォームgemをビルドする場合は、gemspecファイルのplatformGem::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から不要なダウンロードが発生しなくなって喜んでくれる

皆さんも、高速かつ予測の効く快適なビルドを楽しみましょう🖖

関連記事

Ruby: Gemfileに.ruby-versionを読み込む便利技(翻訳)


  1. 訳注: これはAppleの Universal Binary形式を指します。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。