Rubyのbundlerを劇的に高速化するShopifyの取り組み(翻訳)
Shopifyでは開発環境の高速化が求められています。特にShopifyほど大規模なアプリケーションになると、依存関係のインストールにも時間がかかるものです。TypeScriptのbunやPythonのuvは、依存関係のインストール時間を劇的に改善しましたが、同じことがBundlerとRubyコミュニティでも実現可能だとしたら嬉しいですよね?
ShopifyのチームはRubyのBundlerとRubyGemsのさまざまな改善に取り組み続けています。Bundlerのgemダウンロード速度は200%高速化し、モノリス内でgemをgit cloneするときの速度も3倍高速化しました。
さらに私たちは、gemをcibuildgemでプリコンパイルする方式に変えたことで、Shopifyのアプリケーション内でbundle installの実行時間全体を3.5倍も短縮しました。皆さんも、新しいプリコンパイル用ツールチェインであるcibuildgemの威力をぜひお試しください。
ここ数か月の間に行われたいくつかの強化点の概要を以下で解説します。
🔗 1: gemダウンロードの高速化
改善の著しかった変更のひとつは、一見他愛もないものでした。
BundlerのHTTPフェッチャーに設定されていたコネクションプールは、何とこれまでたった1個だったのです。つまり、gemを複数インストールしているときにあらゆるスレッドがたった1個のHTTPコネクションを奪い合っていたのでした。
図1: たった1個のコネクションの空きを待ちわびているスレッドが表示されているプロファイル
ピンクのスパイクは、たった1個のコネクションの空きを待っているスレッドです。
HTTPコネクションのプールを増やしたことで、 Bundlerが同時にダウンロードできるgemの個数も増加しました。スピードの差は、RubyGems.orgの負荷が大きいときや、CDNから地理的に遠い場合など、単一コネクションを待つコストがレイテンシによって増大する状況で特に顕著になります。
この変更のベンチマークを行なうために、ダウンロード時間と展開時間だけを測定するためにネイティブ拡張機能のコンパイルを除外し、レイテンシを自由に変更できるローカルのgemサーバーを構築しました。
以下は新規生成したRailsアプリケーションで測定した結果です。あらゆるgemがレイテンシ100msで配信完了しています。
Scenario: rails (164 gems)
Cold +/- Warm +/-
------------------------------------------------------------------------------
5 HTTP connections 5.86s 0.10s baseline 4.17s 0.02s baseline
1 HTTP connection 19.80s 0.02s 237.6% slower 4.16s 0.02s 0.3% faster
🔗 2: ホットスポットの特定と最適化
私たちはさまざまなGemfileを用いてBundlerを繰り返しプロファイリングし、最適化すべきホットスポットを特定してきました。個別の最適化の効果は小さいのですが、多くの最適化が積み上がると効果は著しくなります。
そうした最適化の1つが、gemのインストール周りにありました。
拡張子が.gemのファイルは圧縮されたtarballであり、gzipには整合性チェック機能が既に組み込まれているので、ファイルの解凍に成功すればコンテンツが破損していないことは保証されます。
にもかかわらず、これまでのRubyGemsはファイル破損をチェックするために、わざわざtarball内の全エントリをくまなくチェックしてすべて読み込み終えてからインストールに進んでいたのでした。解凍に成功すれば同じ保証が得られるので、この余分な検証ステップは丸ごと廃止されました。
図2: tarballの内容の検証に費やされた時間を示すプロファイル
gemインストールに要していた時間の9〜17%は、tarballの内容の検証に使われていました。
インストールにまつわる別のホットスポットもあります。
RubyGemsは、gemにRubyGemsプラグインが含まれているかどうかのチェックと、含まれている場合に再生成が必要かどうかのチェックも行っています。RubyGemsプラグインが含まれているgemは少ないにもかかわらず、そういう少数派のgemのために、すべてのgemがDir.globでディレクトリをくまなくチェックするというコストを負担していたのです。
プラグインが存在するかどうかの事前チェックをやめて、プラグインを無条件に再生成する方が速いことが判明しました。
図3: Bundlerがgemプラグインの再生成が必要かどうかのチェックに費やしている時間を示すプロファイル
Bundlerは、gemのプラグイン再生成が必要かどうかをいちいちチェックしていました。
🔗 3: git cloneのパラレル化
Railsアプリケーションの多くは、Gitリポジトリから直接取得したgemに依存しています。これは、特にリリース前のgemがアップストリームで変更されている場合に便利です。
従来のBundlerは、gitリポジトリから1個ずつシーケンシャルにフェッチしていました。同時にフェッチ可能な件数に技術的な上限がないにもかかわらず、です。
ShopifyのコアRailsモノリスでは、gitリポジトリからフェッチするgemが33個使われていますが、git cloneをパラレル化する変更を導入したところ、gitリポジトリからのgemのフェッチのパフォーマンスが3倍に向上しました。
| Bundler 2.7.2 | Bundler 4.0.7 | パフォーマンス向上 | |
|---|---|---|---|
| gitリポジトリからgemを33個フェッチ | 121.57s | 38.75s | 68%高速化 |
🔗 4: ネイティブ拡張
bundle install実行時の最大のボトルネックといえば、ネイティブ拡張のコンパイルです。Rubyエコシステムには、インストール時に開発用コンピュータ上でコンパイルの必要なCコードを含むgemがたくさんあります。json、date、bigdecimalが代表的なgemです。
ネイティブ拡張ありのgemに直接依存していなくても、間接的にGemfile.lockで推移的な依存関係が生じる可能性は十分ありえます。
インストーラスレッドの時間の92%がgemのコンパイル時間で占められています。
ネイティブ拡張のコンパイルの遅さを説明するために、新規作成直後のRailsアプリでbundle installを実行したときの例を示します。
| インストールされるgemの総数 | 126個 |
| ネイティブ拡張ありのgem | 18個 |
bundle installの所要時間1 |
〜13秒 |
bundle installの所要時間(コンパイルを除く) |
〜2秒(15%) |
bundle installの所要時間(コンパイル部分のみ) |
〜11秒(85%) |
ネイティブ拡張ありのgem 18個をインストールする所要時間は、bundle installに要する時間の85%を占めています。
🔗 5: プリコンパイル済みgem
Nokogiri gemのインストールにものすごい時間がかかっていた時代を覚えていますか?メンテナーのMike Dalessioによる目覚ましい功績のおかげで、そんな時代は過去のものとなりました。Mikeはgemのパブリッシングパイプラインを更新して、プラットフォーム固有のバイナリをプリコンパイルし、サポート対象のプラットフォーム(macOS、Windows、Linux)ごとに別のgemをリリースする方法に変更しました。こうして、Nokogiriのインストール速度は素のgem並に高速化されました。
この方法論をRubyエコシステムの他のgemにも拡大したらどうなるかを想像してみてください。主要なネイティブ拡張gemのほとんどについてプリコンパイル済みバイナリをリリースするようRubyコミュニティが一致協力したとしたら、誰もが爆速のbundle installという恩恵を受けられることになります。
バイナリgemをビルドする方法としては、Rake-compiler-dockツールチェインを使う方法が有名です。Rake-compiler-dockは、Dockerコンテナ内で実行可能なクロスコンパイル環境を提供します。
しかしクロスコンパイル環境は壊れやすく、デバッグしにくい問題を踏む可能性もあります。コンパイルは、やはりターゲットプラットフォーム上で行なう方がずっと信頼性が高いと言えます。
現代ではCIプロバイダの多くがクラウドマシンへの無料アクセスを提供しています。有名どころではGitHub Actionsもそうですし、ruby/setup-rubyのようなCIですぐ利用できるアクションもRubyコミュニティによって構築されています。
それと同じ手法を適用して、無料のクラウドマシン上でバイナリgemをネイティブコンパイルできるとしたらどうでしょう?
🔗 6: cibuildgemの導入
Shopifyではかねがね、開発者がネイティブコンパイルをGitHub Workflowsで実行してプリコンパイル済みバイナリ付きのgemを手軽にリリースできるツールがあればよいと考えていました。
cibuildgemを使うと、GitHub Actionsの標準的なワークフローを生成できます。このワークフローがトリガーされると以下のようなジョブが実行されます。
- バイナリへのコンパイルを実行してgemにパッケージ化する
- テストスイートのマトリックスを実行する
- できあがった
.gemファイルが破損しておらず、インストール可能であることを検証する - gemをRubyGems.orgでリリースする
図5: cibuildgemがトリガーされたときのGitHubワークフローのスクリーンショット
cibuildgemを用いてバイナリgemをリリースしています。
私たちのねらいは、cibuildgemを手軽かつ短時間でセットアップできるようにすることです。ネイティブ拡張ありのgemは大抵の場合、開発時のコンパイル用にRakeコンパイラがセットアップされているものなので、それに乗っかることに決めました。これにより、ほとんどのgemで設定を追加する必要がなくなります。
cibuildgemが生成するワークフローは、努めて意識的に標準化してあります。
- Linux AArch64でgemをコンパイルしたい場合は、マトリックスに追加する
- gitの新しいタグをプッシュしたときにワークフローを自動的に開始したければ、好みに応じて調整可能
また、cibuildgemがビルドしたバイナリがmacOS開発環境とLinux本番環境の両方で動作することを確認したいと考えました。
あくまで実験としてですが、cibuildgemを用いてオープンソースのgemを数十個コンパイルして、RubyGems.orgのとある「名前空間」にひっそりと公開しました(例: sassc -> precompiled-sassc)。
目的は、プリコンパイル済みバイナリにするとパフォーマンスがどの程度向上するかを確かめることでした。これをテストするためにBundlerのプラグインを作成しました。これはBundlerのリゾルバをハイジャックして、リリースされているプリコンパイル済みバイナリ付きのgemをダウンロードするように仕向けます。たとえば依存関係ツリーのどこかでjsonが要求されるとprecompiled-jsonが強制インストールされます。
私たちは、235個のgemを含んだ社内アプリケーションでこれをテストし、デプロイしました。その結果、17個のgemがプリコンパイル済みになっていればパフォーマンスが3.5倍向上することがわかりました。
| プリコンパイル済みバイナリなし | プリコンパイル済みバイナリあり(一部のgem) | |
|---|---|---|
bundle install |
24.2s | 7.0s (3.5x faster)2 |
この実験は、プリコンパイル済みgemを使えばbundle installがどのぐらい高速化可能かを示しています。また、cibuildgemがmacOSとLinuxの両方について互換性のあるバイナリをビルドできるという確信も得られました。
実際、Shopifyのgemのいくつかは、cibuildgemのおかげで既にプリコンパイル済みバイナリとしてリリースされています↓。
ネイティブ拡張gemをメンテナンスしている皆さんにも、ぜひcibuildgemをお試しいただき、どうか私たちにフィードバックをお寄せください❤️!

概要
CC BY-NC-SA 4.0 International Deedに基づいて翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。