先日、携わっているサービスで一番大きいRailsアプリをRuby 3.2にアップデートし、YJITを有効化できました。 方針を検討した結果、今回はRails 6.1およびPsych 3系のままRuby 3.2にアップデートする戦略をとったため、その手順をまとめます。

前提

今回のRailsアプリはサービスの機能がほぼすべて詰まっているモノリスで、歴史も8年と比較的長いです。 アップデート前のバージョンはRuby 3.0、Rails 6.1で、Psychは3系。

正攻法では、おおむね以下の手順でアップデートを進めていくことになります。

  1. Rails 6.1の最新版にアップデート
  2. 各種gemのアップデート
  3. Psych 4対応
  4. Ruby 3.1にアップデート
  5. Rails 7.0にアップデート
  6. Ruby 3.2にアップデート

Ruby 3.1と3.2のそれぞれで非互換があるため、その対応を考慮するとだいたいこうなるはずです。

Ruby 3.1での非互換

  • net-smtpnet-popなどがbundled gemsとなっており、依存関係を明示的に追加する必要がある
    • Railsが依存するgemではmail(Action Mailerの依存)などに影響が出る
    • これはアップデートすれば対応できるため、まずはRailsと関連gemを最新版にアップデートする
  • Ruby 3.1に同梱されるPsych 4に非互換がある
    • YAMLを扱うgemのアップデートやアプリケーションの改修が必要となる
    • Psych 4系への対応完了後、Ruby 3.1にアップデートする

Ruby 3.2での非互換

  • Rails 6.1はRuby 3.2に対応していない
    • Rails 6.1はすでにセキュリティ修正のみ行うフェーズになっており、Ruby 3.2に対応したRails 6.1の新バージョンがリリースされることはない
    • したがって、先にRails 7.0にしてからRuby 3.2にアップデートしなければならない

ということで、事前に小さなRailsアプリのアップデートに取り組んだときは上記の手順で行いました。

今回の方針

それに対して、今回アップデートしたモノリスでは以下の流れにしました。詳細については後述します。

  1. Rails 6.1の最新版にアップデート
  2. 各種gemのアップデート
  3. Psychを3系に固定する
  4. Ruby 3.1にアップデート
  5. RailsをGitHubの6-1-stableブランチを使うように変更
  6. Ruby 3.2にアップデート

これはYJITにより現実のRailsアプリが高速化できることはわかっていて、かつ言語処理系のアップデートだけで大きな効果を得られるタイミングはめったにないので、できるだけ早くリリースしてビジネス的な価値を出したいと判断したためです。 とくに今回は7月末にサービスの大規模キャンペーンを控えており、正攻法だとリリースは厳しい状況でしたが、Rails 6.1のままRuby 3.2にできれば間に合う可能性が高かったのも理由のひとつ。

この戦略をとったことで、無事キャンペーン前にRuby 3.2アップデートを完了できました。

手順

では具体的な手順に入ります。

Rails 6.1の最新版にアップデート

これはアップデートするだけです。執筆時点での最新版は6.1.7.4

各種gemのアップデート

changelogを読みながらひたすらアップデートします。 bundle update --group development testで開発環境のgemだけアップデートしたり、bundle update --minorでマイナーバージョンまでアップデートするなどのオプションを使っていくと便利。

メジャーバージョンアップは慎重に検証し、ひとつずつアップデートします。 すぐに直せない非互換があれば時間をかけすぎず、ToDoコメントを残していったんバージョンを固定して次に進みます。 それがRuby/Railsアップデートのブロッカーになったらまた向き合いましょう。 このアプリではgraphqlcommitteeが古いバージョンで止まっており、運良くRuby 3.2のブロッカーにはなりませんでしたが今後やらないといけない…。

Psychを3系に固定する

Ruby 3.1に同梱されるPsych 4では破壊的変更があり、セキュリティのためデフォルトではYAMLのエイリアスが使えなくなり、DateやTimeクラスのデコードもできなくなりました。

• Psych 4.0 では Psych.load が safe_load を利用するように変更されました。この挙動が影響ある場合は、従来の挙動である unsafe_load を利用する Psych 3.3.2 を移行パスとして利用できます。

よって、YAMLを扱うgemのアップデートやアプリケーションの改修が必要となります。

アプリケーションでは適切にpermitted_classes: [Date]aliases: trueオプションをつけていけばOK。 YAMLを扱うgemは対応バージョンにアップデートします。

ただし今回はそのgemがSettingslogicであり、アップデートが止まっています。 チームで検討した結果、SettingslogicをやめてRails標準のconfig_forを用いたカスタム設定へ移行する方針とし、移行完了するまではPsych 4未満に固定することにしました。 Gemfileでバージョン指定すればRuby 3.1以降でもPsych 3系を利用できます。

# Gemfile
gem 'psych', '< 4.0.0'

モンキーパッチを当てたりgemをforkして一時対応し、まずPsych 4以降に上げてしまう手もあります。 今回は急いでPsych 4に上げるメリットもそこまで大きくないと判断しました。

Ruby 3.1にアップデート

Ruby 3.1ではnet-smtp、net-ftp、matrixなどがbundled gemsになりました

以下のライブラリが新たに bundled gems になりました。Bundler から利用する場合は Gemfile に明示的に指定してください。

  • net-ftp 0.1.3
  • (以下略)

外部のgemがこれらのライブラリに依存している場合、そのgemが対応済みであればアップデートします。もしくは明示的にGemfileに追加します。

アプリケーション内で該当のライブラリを使っていれば、こちらも明示的にGemfileに追加します。 今回のアプリではnet-ftpを使っていたので対応しました。

# Gemfile
gem 'net-ftp'

RailsをGitHubの6-1-stableブランチを使うように変更

Rails 6.1はすでにセキュリティ修正のみ行うフェーズになっており、Ruby 3.2に対応したRails 6.1の新バージョンがリリースされることはありません。

ただし、GitHubの6-1-stableブランチにはバックポートが取り込まれておりこのブランチを使うことで対応できます

# Gemfile
gem 'rails', git: 'https://github.com/rails/rails.git', branch: '6-1-stable'

実際に6-1-stableブランチを利用したところ、Ruby 3.2で落ちていたテストがすべて通るようになりました。 このやり方は非公式な対応のため、もちろん速やかにRails 7.0にアップデートするべきです。

Ruby 3.2にアップデート

ここまでの手順ができていれば、あまり苦労はなくRuby 3.2にアップデートできました。 Ruby 3.2ではDir.exists?やFile.exists?が削除されたのが比較的大きい非互換です。

アプリケーションはgrepしてDir/File.exist?に直し、gemはアップデートしていけば問題ありません。

ビルド時の注意点

コードの修正が伴う変更ではありませんが、libyamlなどのライブラリのソースコード同梱が廃止された影響で、ビルドする際は事前にライブラリをインストールしておく必要があります。 また、YJITを有効にしてビルドするにはRustも必要です。

  • libyamllibffi のような 3rd パーティのライブラリのソースコードの同梱を廃止しました
  • YJIT をビルドするためには Rust 1.58.0 以降が必要となります

RubyのDocker official imageYJITを有効にしてビルドしてあるため、対応不要でYJITが使えます。

YJITを有効化する

いよいよ最終目標のYJITを有効化します。主に2つの方法が利用できます

  • コマンドラインオプションで--yjitを指定する
  • 環境変数でRUBY_YJIT_ENABLE=1をセットする

Herokuや各種コンテナプラットフォームなど、多くの環境では環境変数を利用するのが手っ取り早いと思います。

YJITが有効化されていれば、ruby -vの出力に+YJITが含まれます。 irbなどからRubyVM::YJIT.enabled?を実行しても確認できます。

$ ruby -v --yjit
ruby 3.2.2 (2023-03-30 revision e51014f9c0) +YJIT [arm64-darwin22]

$ RUBY_YJIT_ENABLE=1 ruby -e 'p RubyVM::YJIT.enabled?'
true

YJITでどれだけ高速化できたか

とくに大きい効果を得られたページでは、p95レスポンスタイムが650msから500〜550msになり、100〜150msほど短縮しました。 少なく見積もって100msとしても、約18%パフォーマンス向上できたことになります。 リクエスト全体でもグラフを目で見てわかるくらい改善できていました。

JITコンパイラなのだから当たり前ですが、DBや外部IOがボトルネックになっている箇所では効果が得られず、Rubyがヘビーに使われている処理では大きく改善できる傾向です。 具体的なグラフなどは会社のテックブログで出せたらなと思っています(力尽きなければ)。

まとめ

以上のように、Ruby 3.2+YJITの恩恵をいち早く受けるため、Psych 4対応やRails 7.0アップデートを後回しにする方針をとりました。 そして実際に大規模キャンペーンの前にリリースできたことで、多くのお客様に高速化したサービスをご利用いただけました。

RubyKaigi 2023でのYJITの発表に感銘を受けてアップデートに取り組み、そこから2か月ほどでリリースできてよかったです。 今後は引き続きRails 7.0に向けてがんばっていきます。