note の Rails アップグレード戦略(from 5.2 to 6.1)

この記事は note株式会社 Advent Calendar 2022 の 13 日目の記事です。

note の Architecture チームでパフォーマンス改善やミドルウェア・ライブラリのバージョン更新対応をしている tic40 です。今回、同チームの toda と Ruby on Rails (以下 Rails)のアップグレードを実施しました。

note は 2022 年 4 月時点ではバージョン 5.2 系を利用しており、そこから複数回のアップグレード作業を行う事で 2022 年 11 月に 6.1 系までアップグレードが完了しました。

幸い大きな問題は発生していませんが、アップグレード作業は決して簡単ではありませんでした。そこで本記事では note でどのように Rails アップグレード作業を進めているかを紹介します。

リリースまでの流れ

Rails アップグレードの大まかな作業は以下の手順で行いました。

  • Rails のバージョン更新による変更点の確認

  • アップグレードをするための Pull Request(以下 PR)を作成

  • テスト(rspec)の修正

  • Monkey patch の確認・修正

  • Deprecation warning 対応

  • 先にリリース可能な差分は Rails バージョン更新 PR とは切り離して先にリリース

  • 社内告知・本番リリース

  • 監視

Rails のバージョン更新をする際には Railsアップグレードガイド で変更点をある程度把握したら、アップグレードをするための PR を早めに作るようにしています。 理由としてアップグレードガイドや Changelog に Breaking change として載っていない内容でもテストが落ちたり挙動が変化したりする場合があるからです。

PR を作成するとどこかしらのテストが Failed になるので、まずはそれらを修正してアプリケーション側で必要な変更箇所を把握して行きます。テストを修正して CI が通るようになったら、Deprecation warning や note 独自で当てている Monkey patch も確認して必要に応じてコードの変更をします。

アップグレード前に先行してリリース可能な差分は先にリリースします。先にリリース可能な内容の例としては関連 Gem のバージョン更新、Deprecation warning 対応などが挙げられます。

Rails のアップグレード PR とは切り離して先に出すことで、アップグレード PR の差分を少なくでき反映時には影響範囲が見積もりやすくなるというメリットがあります。

ここまでの作業が一通り終わったら note で影響を受けた箇所をドキュメントにまとめ、社内共有をしつつリリース日の確定をして行きます。

次の項目からは実際にアップグレードをする際に躓いた点や、工夫した点などを解説します。

Deprecation warning との向き合い方

Deprecation warning は漏れなく対応しましょう。といってもコード全てを自動テストや静的解析で網羅することや、手作業で見ていくことは現実的ではありません。そこで本番環境に出ている Deprecation warning ログを全て拾い、可能な限り漏れをなくすという方針で進めました。

note では Sentry を導入していたのでそれを活用し Deprecation warning ログを Sentry へ集約するようにしました。

# ActiveSupport::Notifications を使用して deprecation.rails を通知するようになる
config.active_support.deprecation = :notify
# Deprecation warningをフックしてSentryへ送信する
ActiveSupport::Notifications.subscribe('deprecation.rails') do |*args|
  event = ActiveSupport::Notifications::Event.new(*args)
  message = event.payload[:message]
  Sentry.capture_message(message, level: :warning)
end

Sentry ダッシュボード画面。詳細ページではトレース情報も確認できて便利です。

Sentryダッシュボード

Deprecation warning の対応

実際にどのような Deprecation warning が出ていて、どう対応したかをピックアップします。

Uniqueness validator will no longer enforce case sensitive comparison

note では MySQL の collation に case-insensitive(大文字小文字の区別をしない)を設定しています。ですので Rails サイドも case-insensitive 挙動に合わせる方針で対応しました。これは該当箇所へ case_sensitive: false オプションを付与することで対応しています。

Dangerous query method (method whose arguments are used as raw SQL) ...

Arel.sql()で該当 SQL をラップすることで警告は解消できます。本質は SQL インジェクションなどの危険のある SQL を検知することにあるので、実際に SQL の内容が安全かどうかを確認しながら対応していきましょう。

NOT conditions will no longer behave as NOR in Rails 6.1.

少し変な例ですがこのようなコードがあるとします。

User.where.not(id: 1, name: '太郎')

6.0 ではこれを NOR(否定論理和) として評価していました。6.1 ではこれが NAND(否定論理積)として評価されます。実際に発行される SQL は以下のように変わります。

# User.where.not(id: 1, name: '太郎') で発行されるSQL

# 6.0
"SELECT `users`.* FROM `users` WHERE `users`.`id` != 1 AND `users`.`name` != '太郎'"

# 6.1
"SELECT `users`.* FROM `users` WHERE NOT (`users`.`id` = 1 AND `users`.`name` = '太郎')"

6.0 の挙動を維持する場合は where.not を個別に指定することで対応できます。

Class level methods will no longer inherit scoping

運良く該当箇所は 0 だったのですが興味深い変更点なので把握しておくと良いでしょう。kamipo さんのブログで詳細な解説があるため一読をおすすめします。

未対応な Deprecation warning

Using return, break or throw to exit a transaction block is deprecated without replacement.

これは 6.1 で出てくる Deprecation warning です。
6.1 以前では transaction 内で return, break, throw で transaction を抜けた場合、コミットする挙動です。これが 7 系ではロールバックする挙動になります。
これは結構大きな変更だと思います。決済処理などの部分で該当コードがあったため、7 系にアップグレードする際の課題として先送りしています。

Rails のバージョン更新で Activerecord-Import の挙動が変化した

Rails v5.2 系から当時の v6.0 系の最新バージョンである v6.0.5.1 まで一気に上げてみたところ一部のテストが失敗しました。

原因を調査したところ Activerecord-Import 側に作成されている下記の issue のもので、Rails v6.0.4 以降にすると STI 周りの挙動が変化するというものでした。

上記の STI 問題は Activerecord-Import v1.2 で入ったパッチを取り込むことで解決します。
しかし note では当時 v0.27 を利用しており、v1.2 まで上げると v1.0 で入った import メソッド利用時に発行されるクエリが変化するという Breaking change も取り込む必要が出てきました。

この修正は本来あるべきクエリが発行される様になるという修正のパッチなので、この修正を取り込むことによる影響は基本的には無いと考えていました。しかしリスクは最小限にしたかったため、note では下記のような手順を踏み Rails と Activerecord-Import のバージョンを個別で上げていく方針を取りました。

  • Rails v6.0.3.7 に更新する

  • Activerecord-Import を v1.2 以降に更新する

  • Rails v6.0.5.1 に更新する

少し慎重になりすぎていると思う部分はありましたが、実際のバージョン更新作業時の考慮するべき点が減るので、より安心して作業を進めることが出来ました。

SwitchPoint から Multiple Databases 機能への移行

note では r/w splitting の実現にSwitchPointを採用していました。しかし ActiveRecord v6.1 以降ではサポートされなくなることが明記されています。そこで代替手段として Rails6 から組み込まれた Multiple Databases 機能へ移行することにしました。

移行時にハマったポイント1

SwitchPoint を広範囲に利用していたので一気に置換してリリースすることはリスクがありました。そのため SwitchPoint と Multiple Databases 機能の併用期間を設け順次移行していく方針で進めました。
その際、SwitchPoint と Multiple Databases による DB 切り替えが入れ子になっているとコネクションエラーが起きるということがありました。

# このようになると ActiveRecord::ConnectionNotEstablished エラーが起きる
ActiveRecord::Base.connected_to(role: :reading) do
  User.with_readonly { User.first }
end

これは一時的なパッチを当てて相互に r/w を切り替えないようにすることで対処しました。

移行時にハマったポイント2

以下のようなコードがあり、これを素直に置換してリリースしました。

# 移行前
users = User.with_readonly { User.where(...) }
users.where(...)
# 移行後
users = ActiveRecord::Base.connected_to(role: :reading) do { User.where(...) }
users.where(...)

すると予期しない大量レコードを取得するクエリが発行されてしまうという問題が発生しました。

これはなぜかというと、まず SwitchPoint の方では、1 行目の with_readonly ではクエリは発行されず、2 行目でクエリが遅延評価されます。この場合 with_readonly のブロック外でクエリが実行されるため reader にクエリは向いておらず、使い方としては誤りなのですが気づきにくいためこのようなコードも存在していました。

次に移行後の Multiple Databases の方では、1 行目の connected_to ブロック内でクエリが評価されます。そして、2 行目でもクエリは実行されます。この挙動の違いを理解せずに置換すると上記のケースでは挙動が変わり、予期せぬクエリが実行される、ということが起きてしまいます。実際に起きた問題では、大規模テーブルに対しての SELECT で、1行目の where ではあまり対象を絞り込まず、2行目で大きく対象を絞り込むというコードだったため、大量のレコードを取得してしまうということが起こってしまいました。
これを受けて、移行時に遅延評価になっているのかどうかという点に注意しながら進めました。

放流試験

note では Rails のバージョン更新の様な大きめの変更をする際には、いきなり全サーバーにデプロイするのではなく放流試験というのを実施しています。この放流試験では本番の 1 台のサーバーにだけ変更差分を放流(デプロイ)して、各種メトリクスを見てパフォーマンスの劣化やエラーが発生していないかを監視する様にしています。
一般的に言うカナリアリリースと同等の物になります。

放流試験を実施している間は旧バージョンと新バージョンが共に本番環境で稼働している状態となります。
そのためキャッシュのキーの変更などの差分が含まれていないか、データベースのマイグレーションなどが含まれていないかも確認してから実施するようにしています。

開発環境で動作確認は入念にしたとしても、いざ本番に出してみたらエラーや警告が出るケースはよくあります。
そのため1台だけでも放流してから本リリースを実施出来るのはメンタル的な面でもとても安心が出来ます。

また過去には開発環境では検知出来なかった Deprecation warning をこの放流試験で検知出来たケースもあるので、これからも必要に応じて活用していきたいです。

最後に

Gem アップグレード、自動テスト、エラー対応、デッドコード削除...など地味ですがメンテナンス作業を日頃どれだけ取り組んでいるかで難易度が変わります。今回作業をして改めて実感しました。
これを機に Dependabot を導入して Gem 定期アップグレードを実施するなど、今後のアップグレードを見据えた継続した取り組みも開始しています。

明日の執筆担当はフロントエンドやプロダクト開発チームで活躍している @mogamin3 です。引き続きよろしくお願いします。


この記事が気に入ったらサポートをしてみませんか?