Linkers Tech Blog

リンカーズ株式会社の開発者ブログです。

LS/LM がRuby 2.7 -> 3.2 にアップグレードするまでの軌跡

こんにちは、情報システム部の大河原です。

徐々に暑さが増してきて、夏本番が近づいているのを肌で感じます。先日、お気に入りの夏限定ドリンク1をコンビニで初めて見かけて、テンションが上がりました。

 

さて、遡って春先の話になりますが、Linkers Sourcing / Marketing(LS/LM)では3月にRubyバージョンのアップグレードを行いました

これまで2.7.2だったのを3.2.1に上げたため、多数の非互換の変更を含む大幅なジャンプアップとなりました。

 

Ruby 2.7は3月末にサポート終了済み

LS/LMは3月中にアップグレードを完了させたためなんとか間に合いましたが、Ruby 2.7系は今年の3月末にてEOLとなっているため、まだ運用しているシステムでは早急なアップグレードが必要です。

しかし、JetBrains社の調査によると、2022年5-7月時点では2.7を使っている人が最も多いとのこと2

こちらは2.7がEOLになる前のデータではありますが、3系への移行はそれなりにコードの改修が必要であることから、世の中にまだ2.7系で動いているシステムが多数あるものと思われます。

 

そこで、この記事では多くの方がRuby 3系にアップグレードすることを願って、作業の過程を詳しく書いていこうと思います。

この記事が、まだRuby 2.7系を使っている方にとってアップグレードの一助となることを願います。

 

 

基本戦略

LS/LMのインフラ構成上、Rubyのアップグレードにはサービスメンテナンスが必要となります。 しかし、サービス提供できない期間があるとその分だけ機会損失となるため、これを最小限に抑える必要があります。

すなわち、メンテナンス中にトラブルが発生するのを避けなくてはなりません。リスクをゼロにすることはできませんが、リリース時の差分を最小限に抑えることによって減らすことはできます。

 

また、Rubyのアップグレードと並行して別のメンバーが新機能開発や保守開発を行っているため、開発もできるだけ止めないように進める必要があります。

 

上記に基づいて、今回のRubyアップグレードは以下を心がけて行うことにしました。

  • リリース時にマージする差分は最小限にし、リリース時/リリース後のトラブルを抑えること
  • 他のメンバーの開発をできるだけ止めないこと
  • コード改修は小さい単位で行い、レビューの負担を小さくすること

 

これを踏まえて、作業手順を以下のように定めました。

  1. 非互換の変更について情報を集める
  2. キーワード引数分離への対処
  3. 新しいバージョンでCIが通るまで頑張る
  4. 必要な変更のうち、2.7でも動くものを先行して取り込む
  5. リリース

各手順について、詳しく説明していきます。

ちなみに、作業はほとんど大河原1人で行い、着手からリリースまでは2ヶ月ほど、実際に作業に費やしたのは1ヶ月ほどでした。

 

LS/LMのコードベース

具体的な過程を紹介する前に、LS/LMのコードベースについていくつか基本情報を記載しておきます。

情報はLS/LMでRuby 3.2への対応がリリースされる直前の、2023年3月後半のものです。

Rubyバージョン 2.7.2
Railsバージョン 6.1.7(当時の6.1系最新)
コード行数 約24万5000行
テストを除いたコード行数 約5万行
モデル数 約170個
テストカバレッジ 97%
最初のコミット 2011年

 

1. 非互換の変更について情報を集める

まずは、Rubyのリリースノートや、コミッターの方が公開されているブログ記事を参考に、非互換の変更について情報を集めます。

例えば、以下のような記事を参考にさせていただきました。

(他、コミッターの在籍されている企業様のブログ記事や、個人で書かれた記事なども参考にさせていただいております)

 

情報収集の結果、対応すべき非互換の変更は以下と結論付けました。

  • 位置引数とキーワード引数の分離
  • サードパーティライブラリのソースコード同梱廃止
  • Psychのメジャーバージョンアップ
  • 非推奨となっていた各種メソッドの廃止

 

2. キーワード引数分離への対処

今回のアップグレードで対処が最も大変なのがこちらの変更です(多くの方がそうだと思います)。

大変なポイントは、

  • 影響範囲が非常に広いため、特定が難しい
  • アップグレード作業中に古い書き方が新たにコミットされる可能性がある

の2点です。

後回しにすると改修対象が増えてしまう可能性があるため、こちらの作業を初めに実施しました。

影響範囲の特定が難しい

幸い、こちらの変更にはRuby 2.7の時点で以下のような警告が表示されるようになっています3

/path/to/code/some_class.rb:99: warning: Using the last argument as keyword parameters is deprecated; maybe ** should be added to the call

LS/LMのテストカバレッジは非常に高いため、CIの実行ログに出ているこの警告をgrepして、しらみ潰しに対処していけば良いはずです。

 

LS/LMで利用しているGitlab CIでは、テストの実行ログが画面からDLできます。しかし、ログファイルはテスト実行の並列数と同じ20個出力されているため、これら全部を画面からDLするのは非常に手間がかかります。

そこで、Gitlab APIを利用して全件を一気にDLしました。

for i in `seq [Job ID 始点] [Job ID 終点]`; do curl --header "private-token: [Gitlab API Token]" "https://gitlab.example.com/api/v4/projects/8/jobs/${i}/trace" >./${i}.log; done

DLしたログファイルに対して以下のコマンドを実行することで、deprecated と書かれた警告の抽出が可能です。

grep -h "deprecated" ./*.log | sed -E 's/^[*.]*//g' | sed -E 's/\.\.+//g' | sed -E 's/ +$//g' | sort | uniq

上記のように手早く検証できるようにしておくことで、警告が減っていることを実感できて作業のモチベーションを保ちやすくなります。

 

アップグレード作業中に古い書き方が新たにコミットされる可能性がある

こちらを防ぐため、警告に対処するMerge Request(MR)4 は1つにまとめた上で、Warningモジュールに以下のようなパッチを当てます。

# Ruby 3.2 にアップグレードするまでの間にキーワード引数分離に非対応のコードが書かれないようにする
# TODO: アップグレード後にこのパッチを削除
module Warning
  KWARGS_WARNING_MESSAGES = [
    'Using the last argument as keyword parameters is deprecated',
    'Passing the keyword argument as the last hash parameter is deprecated',
    'Splitting the last argument into positional and keyword parameters is deprecated'
  ]

  def self.warn(message)
    # キーワード引数のdeprecation warning 以外の警告はそのまま出力
    if KWARGS_WARNING_MESSAGES.none? { |warning| message.include?(warning) }
      super(message)
      return
    end

    raise 'Ruby3以降キーワード引数が分離されます。キーワード引数の代わりにハッシュを渡す際は **{ hoge: piyo } としてください'
  end
end

キーワード引数への警告が出たら、例外が上がるようにしています。

これを警告への対処と同時にマージすることによって、新しく追加されたコードに古い書き方が含まれるのを防ぐことができます。

 

3. 新しいバージョンでCIが通るまで頑張る

キーワード引数分離への対処が終わったら、いよいよ新しいRubyバージョンでCIを実行します。最初は当然テストの実行もままなりませんが、問題を一つずつ解消してCIが通るまで頑張ります。

 

CI実行に使っているDockerイメージのRubyを3.2に更新した上で、ソースコード同梱が廃止されたライブラリ(LS/LMの場合はlibyaml, libcurl)のインストールを行うようDockerfileを修正し、ビルドします。

ビルドしたDockerイメージを使ってCIを回すためのブランチを切り、そこからCIが通るようになるまでコミットを積んでいきます。

 

この工程では、以下の対処を行いました。

  • 3.2で動くようにgemバージョンを調整
  • 削除されたメソッドFile.exists? 等を置き換える
  • Psychが4系になったことでYAMLに複数の非互換が発生したため修正
  • その他動かなくなった場所の修正

 

3.2で動くようにgemバージョンを調整

依存関係解決のためインストールされるgemの中には、3.2未対応の古いバージョンのものがいくつかあったため、Gemfileにバージョン固定を明記することでこれを回避しました。

 

最初からGemfileに記載しているgemで3.2に未対応だったものは1つだけだったので、安心した……かと思いきや、そうでもありませんでした。

なぜかといえば、そのgemがよりにもよってRailsだったからです。

Rails 7は3.2に対応したバージョンがリリースされていますが、6.1はまだだったのです(2023/06/21現在も未リリース)。

rails/rails を見に行くと6-1-stable ブランチには変更が取り込まれていたため5Gemfileに当該ブランチを指定することで無事に動くようになりました。

 

削除されたメソッドFile.exists? 等を置き換える

削除されたメソッドの中でDir.exists?, File.exists?, FileTest.exists? の使用箇所をそれぞれexist? に置き換えました。

バージョン固定をしており削除予定であるgemに一箇所だけDir.exists? があったため、こちらはDir.exist? に委譲するようパッチを当てました。

ちなみに、Ruby 3.2のリリースノートにはDir.exists?, File.exists? しか記載されていませんが、FileTest.exists? も削除対象なので注意しましょう。

 

Psychが4系になったことでYAMLに複数の非互換が発生したため修正

Ruby 3.1から、同梱されているPsychが4系に上がりました。

これによる変更のうち、影響があったのが以下の2点です。

  • YAML.loadsafe_loadを使用するよう変更された
  • YAML.loadではデフォルトでエイリアスが無効になった

それぞれ、

  • YAML.loadunsafe_load に書き換える
  • YAML.loadalias: true のオプションを付与

することで解決しました。

変更の詳しい背景などは省略しますが、いずれもセキュリティ上の理由でデフォルト動作が変更されています。

書き換えの際は使用箇所をあらためて確認し、不要ならばより安全なメソッドを使用しましょう。

 

その他動かなくなった場所の修正

アップグレードしたことで動かなくなった箇所には、2.7の時点でるりまに記載のない動作に依存している箇所がありました。

この手の非互換は動かなくなった原因を調べるのに手間がかかるため、2.7/3.2 のいずれでも動くようにリファクタリングすることで対応しました。

 

developから何度かforkをやり直しながら頑張った末、ようやくCIが通りました 👍

 

4. 必要な変更のうち、2.7でも動くものを先行して取り込む

CIが通ったところで、Ruby 2.7で動くものをcherry-pick してMRにしていきます 🍒

 

これらがすべて取り込まれたら、Ruby 3.2対応リリース用のMRを作成します。

差分を少しずつ取り込んだおかげで、このリリース用MRは当初に決めた方針通り、

  • .ruby-version の変更
  • CI用コンテナイメージの変更
  • 開発用docker-compose.yml の変更
  • gemの更新
  • Deprecation Warningで例外を挙げる対応の削除

という最低限の差分になりました。

 

5. リリース

リリース用MRのレビューが通ったら、リリース試験を行った上でリリース日を決めます。

 

リリース時に起こる可能性があると想定して、事前に周知した内容はインフラ構成に依存するため、ここでは記載しません。

ただ、可能な限りリスクを避ける努力をした上で、それでも残るリスクを説明して皆で合意する、というのが今後もアップグレードを続けていくための信頼につながるものと思います。

 

そんなわけで3月某日、LS/LMは無事Ruby 3.2.1へのアップグレードを達成しました 🎉

 

リリース後のトラブル: Kernel#open の廃止を見逃していた

リリース後、1件だけトラブルが発生しました。

Kernel#open がRuby 3.0にて削除されたのを見逃していたため、それに依存したバッチの実行が失敗していたのです。

 

これに気付くことができなかったのは、

  • URLを開く処理のためテストではモックしていてwarningが出ていなかったこと
  • open-uriがdefault gem となったため、公式のリリースノートの記載対象から漏れていたこと

が原因でした。

Ruby 2.7の時点でもこのメソッドを使うと警告が表示されていたため、本番環境でのバッチの実行ログを一度でも見ていれば避けられたトラブルでした。

テストを書くうえで、モックしなければならない処理はどうしても発生します6。今回の件で、いくらカバレッジが高くても盲信せず、実際に本番で出力されているログを見ることの重要性を再確認しました。

 

YJITの効果

Ruby 3.2へのアップグレードに伴って、YJITを有効にしました。

リリース前後の1週間の平均応答時間をそれぞれ比較したところ、平均応答時間が8%ほど改善しました 🙌

Ruby 2.7(平均リクエスト数: 0.46)

Ruby 3.2 + YJIT(平均リクエスト数: 0.41)

平均リクエスト数が異なるので単純比較はできませんが、性能は確かに改善しているようです。

RubyKaigi 2023でYJIT開発の歴史に関するトークを聞きましたが、改めてこれを見ると感慨深いですね。開発に尽力された皆様に感謝です。

 

アップグレード作業を振り返って

アップグレードはこまめにすべきだった

まず、お約束ではありますがこれです。

システム停止によって顧客に不便を強いることから躊躇いがちではありますが、年に1回のアップグレードくらいはなんとか実施すべきでしょう。

今回は2.7 -> 3.0、3.1 -> 3.2 という非互換の多いアップグレードを2つも含んでしまったことで作業が長期化し、ツケを払う形になってしまいました。

 

いきなりCIを通すアプローチで問題なかった

最初に非互換変更の情報を集めてから作業に取り組んだのですが、振り返ってみると、いきなり着手しても良かったように思いました。

キーワード引数分離を除けば、テストの落ちた箇所を修正していけば自然に改善していきましたし、ドキュメントに書かれていない非互換変更も検出できました。

 

本番稼働しているコードからしか得られない情報がある

「CIの落ちた箇所を修正していく」ようなアプローチができたのは、これまでコツコツとテストコードの実装を積み重ねてきていたおかげです。

しかし、「リリース後のトラブル」の項に書いたように、それが却って本番ログ調査の怠りを招き、不具合の原因となってしまいました。

どこまで行ってもテストはテストでしかなく、実際に本番稼働しているコードからしか得られない情報が確かにある、ということは大きな勉強になりました。

 

今後のために

Rubyアップグレードの間隔がここまで空いてしまった原因の一つに「アップグレードマニュアルがないため、毎回手探りでやらざるを得ない」ことがあります。

そのため、今回は対応初期から「いつ、どの順番で、何をやったか」を詳細に記録し、作業完了後には反省会を行ってアップグレードマニュアルを作成しました。

Ruby 3.3が出た暁にはスッと上げられるような内容になっているはずですので、今からリリースが楽しみです。

 

ちなみに、この記事もマニュアル作成のために作った記録から起こしたものです。記録ってやっぱり大事ですね。

まとめ

LS/LMにおけるRuby アップグレードについて説明いたしました。そんなわけで、実は、RubyKaigi 2023 には最新のRubyを本番運用している状態で参加していたのでした。

ちなみに、このアップグレードの直後にRuby 3.2.2 が出てしまい、ぼやく羽目になってしまいました 😓

 

改めまして、この記事がRuby 2.7で止まっている方々がアップグレードを進めるための一助となればと思います。

それでは、良いRubyライフを!


  1. 梅でソルティなアレです
  2. Ruby Programming - The State of Developer Ecosystem in 2022 Infographic | JetBrains: Developer Tools for Professionals and Teams
  3. なお、Ruby 2.7系でも2.7.2 以降ではデフォルトでは表示されません。コマンドラインオプションに-w -W:deprecatedをつけたり、Railsの場合はboot.rbWarning[:deprecated] = true を追記したりするなどすれば表示されます。参考: Ruby 2.7.2 リリース
  4. GitHubでいうPullRequest
  5. Backports ruby 3.2 compatibility fixes for rails 6.1 by javiyu · Pull Request #46895 · rails/rails
  6. 今回のように、外部アクセスを伴う処理は不安定なテストになりやすいためです。ここら辺のトレードオフについては最近読んだ「Googleのソフトウェアエンジニアリング」に詳しく書かれていました