🐶

Ruby / Railsバージョンアップで七転八倒した話

2023/07/11に公開

こんにちは。
株式会社ココナラ DevOps開発グループ 業務システムチームのY.S.です。
ココナラでは専ら経理会計システムの開発を行なっています。
以前同チームでリポジトリ分割を行なった話を紹介させていただきました。
今回は悲願のRubyバージョンアップについてのお話です。現在下記のスペックで元気に稼働中です。

version
Ruby 3.1.2
Rails 7.0.4

背景

巨大なモノリスから分離したことで会計システムを独立して運用できるようになりました。
ですが、理想的な会計システムにしていくためにはまだまだやりたいことが山ほどあり、果てしない旅路を踏破する必要があります。
その手始めとしてまずはRubyバージョンアップを行いたい……そもそもEOLがヤバい

事前の作戦

着手当時、安全策に倒すか最新をターゲットに頑張るか悩みました。下記がバージョンアップ以前の状況です。だいぶ古いですね。

version
Ruby 2.5.5
Rails 5.2.3

うーん、取り敢えず1バージョンを上げてみますか!
軽い気持ちでRubyを2.7系に上げてみたらRailsが対応していない模様。こちらも一緒に対応する必要がありそうです。
Railsを一旦6系に上げることで比較的安全に作業出来そうでしたが、下記の観点から今回Rails 7をターゲットとしました。

  • FactoryBot化のため、どちらのバージョンをターゲットにしてもどのみちファイルの差分は大量に出る
  • Rails 6とRails 7でどれだけエラーの出方が違うのか。そんなに変わらないなら Rails 6 -> Rails 7のバージョンアップのためにまたプロジェクトを立ち上げる方が手間である
    • Rails 6系spec落ち件数:約200件
    • Rails 7系spec落ち件数:約300件
    • うん、そんなに変わらないな!(※)

※ 実際のところ、数字上結構な差分があるように見えますが、内訳を見たところどうも同様の問題でspec落ちしてそうなので、エラーの量として体感はそんなに変わりませんでした。
そのため、別プロジェクト化して2段階でリスクテイクする動機の方が薄かったです。
またカバレッジも97.5%と高く、自動テストでエラーを検知出来る手応えがあったため、一気に上げる決断に踏み切りました。

ハマりどころ

立ち上がらない

Dockerfileの問題

Ruby 3系にすることで bundler のバージョンも上がるので、Dockerfileの書き方を変える必要があります。個別の設定があるかと思いますが、要諦は下記です。

RUN gem install bundler:2.3.7 && \
    gem update --system

CircleCIを使っている場合、同様に変更する必要があります。

Gemの問題

  • switch_pointがサポート外になるので外す
  • mimemagicに対応……しようとしましたが、Rails 7では依存しなくなっているのでOK。参考
  • Webrick対応。Rubyの標準ライブラリから削除されたため、入れてあげる(下記のエラーからお好みで。アプリケーションサーバーがないのが問題)。参考
Couldn't find handler for: puma, thin, falcon, webrick
  • libv8やmini_racerと戦っていたのですが、そもそもexecjsがあればどちらも不要でした。APIサーバーとして使っているのでjs周り自体不要な気もします。参考
  • Sprocketsをあげると問題が発生したので、バージョンをgem "sprockets", "~> 3.7.2"で固定しました。
<class:Railtie>': Expected to find a manifest file in `app/assets/config/manifest.js` (Sprockets::Railtie::ManifestNeededError)
web_1    | But did not, please create this file and use it to link any assets that need
web_1    | to be rendered by your app:
web_1    |
web_1    | Example:
web_1    |   //= link_tree ../images
web_1    |   //= link_directory ../javascripts .js
web_1    |   //= link_directory ../stylesheets .css
web_1    | and restart your server
web_1    |

Zeitwerkなるものでオートローダーの挙動が変わる

所謂親の顔より見たuninitialized constantエラー。Zeitwerkの壊し方を参考に一つずつファイル名とクラス名、モジュール名を照応させていきます。

今までのオートロードが定数名からファイル名を推測してロードしていたのに対し、ZeitwerkはまずActiveSupport::Dependencies.autoload_pathsに存在するファイル名(含む相対パス)から定数名を推定していきます

公式も参照。弊社会計システムはAPIを構えていて、APIという名前空間があるのですが、ZeitwerkはApiという名前を期待するため名前解決が出来ません。
そのため、下記のように設定ファイルを追加してZeitwerkに教えてあげます。

# config/initializers/inflections.rb
ActiveSupport::Inflector.inflections(:en) do |inflect|
  inflect.acronym "API"
end

initializer等にログを仕込む

Rails.autoloaders.log!

すべてOKになるまで下記コマンドを実行

bin/rails zeitwerk:check

謎のspec落ち

float問題

Rails 7のActiveRecordでfloatの挙動が変わったようです。参照

# With the following schema:
create_table "measurements" do |t|
  t.float "temperature"
end

# Before:
Measurement.average(:temperature).class
# => BigDecimal

# After:
Measurement.average(:temperature).class
# => Float

このことにより、例えばある取引の金額を合算し、その金額を売上と消費税に按分する……といった計算ではことごとく微妙な差異が出ていました。

sum_amount = order_details.sum(:amount) # 税込価格で取得しているものとする。従来はここでdecimalで返って来ていた
sales = (sum_amount / 1.1).to_d.floor(3) # 割り算のかっこ内で誤差が生じている

日付問題

この方と同様の事象が発生しました。具体的に時間のRange周りでエラー。
arel_tableではなくwhereに書き換えて回避します(どうもRails 5で動いているのが不思議な挙動らしい……)

Hoge.arel_table[:created_at].in((365.days.ago)..(Time.now)) # => can't iterate from ActiveSupport::TimeWithZone
Hoge.where(created_at: (365.days.ago)..(Time.now)) # => こちらはOK

当然Ruby 3記法問題もあるよ

new(user_id:, related_id:)みたいなメソッドを仮定すると、下記のように振る舞いが変わります。
わたしはspecの依存関係で泣かされましたが、どちらかと言えばあるgemが動かない理由が引数エラーだった場合、ここら辺の記法に引っ掛かっている、つまりRuby 3系に対応していない……といった状況の方がクリティカルかも知れません。

let(:params) { { user_id: user.id, related_id: related_record.id } }
subject { described_class.new(params) } # => 引数エラーで落ちちゃう
subject { described_class.new(user_id: user.id,  related_id: related_record.id) } # => ちゃんと展開してあげる
subject { described_class.new(**params) } # => もしくは'**'をつける

その他

  • redisのメソッド名がちょびっとずつ変わっています……exist?からexists?などなど
  • default_value_forで引数エラー。どうもnewする時に何か渡している模様。Rails 6以降ActiveRecordに同じ機能が梱包されいるのでそちらを利用します
attribute :amount, default: -> { 0 }
  • ruby君、ちょっと厳密になった? enum値とDB値で型がズレることがあり微妙に手こずりました
'1' == 1 # false。当たり前と言えば当たり前なのだが……

おわりに

過ぎてみれば踏まなくていい地雷を踏んで回った感じがしなくもないですが、何かひとつでも似たようなハマりを回避出来れば僥倖です。
実はこの後もプロジェクト的にはだいぶん大変だったのですが、それはまた別の話として今回は割愛します。

ココナラではエンジニアを募集しています。
よろしければぜひ以下のページもご覧ください。

https://coconala.co.jp/recruit/engineer/

Discussion