こんにちは hsbt です。やっとアサシンクリード・シャドウズのプラチナトロフィーを取りました。年末年始は鳴潮とゼンレスゾーンゼロのアップデートをプレイしながら過ごそうと思います。
さて、Ruby 4.0.0 がリリースされ、毎年恒例の プロと読み解くRuby 4.0 NEWS - STORES Product Blog が公開されましたが、RubyGems や Bundler の解説が何もないことに気がついたのでリリースノートの解説を自分で書こうと思います。
- 4.0.0 Released - RubyGems Blog
- 4.0.1 Released - RubyGems Blog
- 4.0.2 Released - RubyGems Blog
- 4.0.3 Released - RubyGems Blog
前回は 4.0 にアップグレードするにあたっての非互換を中心に紹介したので、このエントリでは新機能に着目したいと思います。
C 拡張の gem ビルドの並列化
C拡張を持つ Gem(例: mysql2 や pg)をインストールする際、make に対して並列実行を指示する MAKEFLAGS=-j が自動的に付与されるようになりました。従来、ユーザーは手動で環境変数を設定する必要がありましたが、現代の CPU が持つマルチコアをデフォルトで活用することで、ビルド時間を大幅に短縮できるようになりました。
何も指定しない場合は Etc.nprocessors + 1 の値が使われるため、CPU をフルに使用してコンパイルを実行します。もし使う数を明示したい場合は環境変数 MAKEFLAGS=-j2などとすると、この値が優先されます。
手元で使うローカルの環境ではほとんど問題なく、便利な機能ですが CircleCI などのコンテナ環境では Etc.nprocessors の値がホストマシンの CPU の数を返すため、実際に使える CPU の数が 2 つにも関わらず -j32 など指定されて、ジョブが死んでしまうという問題があることが実装後に分かったので次のような緩和策を入れてます。
並列ジョブの実行オプションの統一
最初に紹介した仮想環境で CPU を使い切ってしまう対策として RubyGems の gem install に新しい -j フラグが導入され、さらに環境変数 BUNDLE_JOBS の値が自動的に RubyGems のコンパイル時にも引き継がれるようになりました。
この変更により BUNDLE_JOBS=4 などとすると、4並列に bundle install を実行しながら C 拡張のインストールの番になると -j4 でビルドするようになります。CI などリソースが少ない環境では 1 または 2 を指定することで溢れることは予防できるようになります。
しかし、この変更を入れても BUNDLE_JOBS に 4 を指定した時の最悪のケースでは CPU を 16 使ってしまい、スタックする可能性がやや高い印象があります。そのため、make jobserver と呼ばれる実際に使える CPU の数をサーバーで制御し、実行ジョブはそのサーバーに問い合わせて利用できる CPU の数だけ実行する仕組みの検討を進めています。
具体的にどう割り振るかまでは、まだ開発中ですが最悪のケースは回避できそうです。また、Go では GOMAXPROCS がコンテナの CPU 情報を cgroups を使って取得することで CircleCI のようなケースでも対応できるという話を ruby-jp の slack で教えてもらい、早速 moznion さんが proposal を投げてくれました。
この機能を Ruby 本体に入れつつ、make jobserver で適当な数を扱うようにすると CPU を十分に使い切って最速ビルドが達成できそうです。乞うご期待。
接続プールの増加とネットワーク通信の効率化
Bundler や RubyGems が外部と通信する際には connection pool を用意して使い回しているのですが、この並列接続数を 5 に増やしています。接続プールを増やすことで、bundle install 時の速度が最大 70% 向上したそうです。
また、API リクエストの回数を減らすための制限値 API_REQUEST_LIMIT を50から100に増やしています。この変更により、サーバーから返される gem の依存情報の数が倍になり、400の依存関係を持つ Gemfile の場合に 8 回ネットワークアクセスが必要だったものが 4 回で済むようになってます。
なお、ここまで紹介した bundle install の際の高速化を行なったのは Edouard-chin (Edouard CHIN) · GitHub で、Shopify のエンジニアです。おそらく Shopify の中の Rails アプリケーションの何かを高速化している気配があり、その作業の結果高速化された、というものなのでみなさんの環境でももれなく高速化されていると思います。もし、RubyGems/Bundler 2.x から 4.x にすることでこれだけ高速化した、というベンチマークを得られた場合は教えてもらえると嬉しいです。きっと Edouard も喜ぶと思います。
Gem::NameTuple や Gem::Platform のパターンマッチングサポート
Gem の名称やバージョン、実行環境のプラットフォームを判定するスクリプトを記述する際、以下のようにパターンマッチングでRuby のモダンな書き方を用いて、より簡潔かつ安全にロジックを組むことが可能になりました。
GitHub に記載の例のように、RubyGems の中の Gem::Platform のインスタンスに対して以下のように cpu や os の値による分岐処理が綺麗に書くことができます。
case platform in cpu: "x86_64", os: "linux" install_linux_x64 in cpu: "arm64", os: "darwin" install_macos_arm else ... end
一方で Gem::Version などへのパターンマッチングサポートについては検討中、または却下という見込みです。
提案では major, minor, build と3つにバージョン文字列を分解してパターンマッチを行うことを提案していますが、実際のバージョン番号には 4.0.0.beta3 というような文字列もあり、さらには 4.0.0.beta.3 という文字列もあります。これらが 4.0.0 と等しくなるのは明らかにおかしいですし、beta3 と beta.3 が異なるものとなるのもやはりおかしいです。また個人的には build の位置の文字列は tiny や patch などと呼ばれる場合もあり、一意ではないものを導入するにはややネガティブです。
bundle list のJSON 出力機能
bundle list に --format=json オプションが導入されました。プロジェクトで利用している Gem の一覧をプログラム(CI ツールやセキュリティスキャナなど)で解析する際、テキスト出力をパース(解析)する手間がなくなり、外部ツールとの連携が容易になります。Gemfile や lockfile は parser は Bundler の中にあるものの、単なる Ruby のコードだったり独自フォーマットのテキストなので jq などで処理ができると嬉しいのかもしれません。
$ bundle list --format=json | jq -r '.gems[] | select(.name == "json") | .version' 2.18.0
lockfile を生成しないで bundle install ができるようになった
bundle install --no-lock とすることで、lockfile を生成せずにインストールだけ実行することができるようになりました。
自分も多数の gem や rails アプリを開発し、それらを RubyGems や Bundler の動作チェックなどで使うときに bundle exec を介せずテストを実行させ、自分の都合に合わせて依存関係のバージョンをコントロールしたいとか、RubyGems や Bundler の内部で gem が使われており、bundle exec などで先にアクティベートされると困る、というときにとりあえずインストールだけ実行することがあります。そのようなときにインストールする、lockfile の変更を戻すという手間が省けて便利かもしれません。
生成する lockfile 名を指定できるようになった
上の --no-lock オプションの派生です。bundle install --gemfile=foo --lockfile=bar などとして、lockfile の名称を変更できるようになりました。指定しないときは従来通り .lock 拡張子が付与された値となります。Gemfile.next とか Gemfile.rails81 のような運用をしていたりするときに便利なのかもしれません。
須藤さんが、out-of-source の時に使うというようなことを書いていたので、ソースコードがありつつ、外のディレクトリから試行錯誤する時には確かに使えそうな気がします。
go 拡張の gem が作成できるようになった
Bundler が作成する雛形と RubyGems が提供する builder にはこれまで C と Rust が対応していたのですが、そこに Go でも作れるようになる機能が加わりました。bundle gem --ext=go foo などとすると Go で拡張を書くためのテンプレートが作成されます。
こちらは pull request を出してくれた sue445 さんのブログをご覧ください。
Bundler の typo check に did_you_mean を使うようになった
今まで知らなかったのですが bundler のコマンド実行などで typo を検出して正しいものを提案する機能は独自に実装されたものを使っていました。これを他の ruby 本体のツールと同様に did_you_mean を使って処理するようにしました。
まとめ
以上、RubyGems/Bundler 4.0 で追加された機能について紹介しました。実は RubyGems も Bundler も runtime と呼ばれる dependency resolver を実行する箇所と CLI/UI と呼ばれる runtime とのやりとりをしながらユーザーとのやりとりを行う箇所の2つによって大まかに構成されるのですが、今回は非互換の変更も含めて主に CLI/UI への変更が多かったように感じます。
実は RubyGems も Bundler も runtime と呼ばれる箇所が似たような処理をやりつつ、異なるコードベースが2つあるというなかなか歴史ある構成のため、来年の 4.1 に向けた開発では runtime の統合やマイグレートを少しずつ進めていけたらなあと計画しています。それでは皆さん良いお年をお過ごしください。