Rails アップグレードを安全に進めるための実践ガイド

こんにちは、ザックです。フリーランスの Rails 開発者として、過去 2.5 年間アンドパッドで働いています。

アンドパッドでは、主にモノリシックアプリケーションの Rails アップグレードを担当しています。

Rails のアップグレードは、破壊的変更の早期検知、保守性の維持、将来的なコスト削減のために欠かせません。 古い挙動や monkey-patch を抱えたまま放置すると、アップグレードの難易度とリスクは急速に上がります。 そのためアンドパッドでは、変更を小さく保ち、安全に進めるためのアップグレードプロセスを運用しています。 このポストでは、その手順と考え方を紹介します。

Rails アップグレードプロセス

アップグレードワークフローは、変更を小さく保ち、リスクを早期に評価し、CI・ログ・回帰テストを通じて安全性を確認することを目的としています。

Rails のデフォルトに極力寄せ、monkey-patch を削除していくことで、保守コストを最小化し、将来のアップグレードを円滑にします。

アップグレード時の diff を可能な限り最小に保つことを目標とし、不要な変更はアップグレード後に先送りし、必要なコード変更はリリース前に積極的に進めます。

1. 🎯 対象バージョンの選定

Rails は常にマイナーバージョンを順番にアップグレードします。 バージョンをスキップすると、deprecation や削除を見逃すリスクがあります。

方針として、同一マイナー内の最新パッチバージョンにアップグレードします。 例えば Rails 7.1 からであれば、7.2.0 ではなく 7.2.3 のような最新の 7.2.x をターゲットにします。 8.0 に飛ばすことはしません。

これにより、最新のバグ修正とパッチの恩恵を受けられます。

2. 🟢 Green Build

最初のビルドが通ることは期待していません。まず現状把握が目的です。

  • Git ブランチを切り、Gemfile の Rails バージョンを上げる
  • rails 以外の gem は必要がない限り更新しない
  • アプリの起動やテストを妨げる問題を修正

load_defaults について

Rails をアップグレードした直後は config.load_defaults を更新しません

load_defaults は複数の挙動をまとめて変更するため、変更範囲が大きくなり、diff が膨らむためです。

アップグレード diff を最小化するため、この段階では変更しません。

また、load_defaults は deprecation になるまで旧デフォルトをサポートするため、即時更新する必要はありません。

deprecations について

新しい Rails バーションでは多くの deprecation が追加されるため、最初のビルドは失敗することがよくあります。

deprecation は、削除予定の挙動を警告しつつ、変更のための猶予期間を与えてくれます。

通常は次のマイナーで削除されますが、依存関係が多いなどの理由で延長される場合もあります。

アンドパッドでは CI で deprecation を raise させ、新しい警告を見逃さないようにしています。

# config/environments/test.rb
config.active_support.deprecation = DeprecationHandler

以下が Rails の deprecation behavior API に準拠したクラスです。

class DeprecationHandler
  def call
    return if ignored_warning?(@message)

    if allowed_warning?(@message)
      $stderr.puts(@message)
      return
    end

    e = ::ActiveSupport::DeprecationException.new(@message)
    e.set_backtrace(@callstack)
    raise e
  end

  private

  def allowed_warning?(message)
    Regexp.union(
      'This is an allowed deprecation warning.',
    ).match?(message)
  end

  def ignored_warning?(message)
    Regexp.union(
      'This is an ignored deprecation warning.',
    ).match?(message)
  end
end

本番(production)では例外は発生させたくないため、ログに記録します。

def call
  return if allowed_warning?(@message)
  # Log the warning
end

アップグレード前には、新しい deprecation を allowed_warning? に追加し、CI を継続できるようにします。

例:Rails 7.2 アップグレード時に追加した内容。

diff --git a/lib/ci/deprecation_handler.rb b/lib/ci/deprecation_handler.rb
index sha..sha 100644
--- a/lib/ci/deprecation_handler.rb
+++ b/lib/ci/deprecation_handler.rb
@@ -24,6 +24,8 @@ def call
     def allowed_warning?(message)
       Regexp.union(
         'This is an allowed deprecation warning.',
+        'Defining enums with keyword arguments is deprecated and will be removed',
+        'Passing nil to the :model argument is deprecated and will raise in Rails 8.0',
       ).match?(message)
     end

新しい deprecation をこの段階で修正することは しません。 CI が通ればよいので、警告を許可して進めます。

目的は、旧挙動を維持しつつ CI を Green にすることです。

エラー対応

前回のバージョンで allowed_warning? に入れていた警告が、新バージョンで削除されて例外になることがあります。

これがアップグレード後の失敗で非常によくあるパターンです。

削除済みの deprecation が残っていないか確認し、未対応であればアップグレード前に修正します。

Config flags について

deprecation とともに旧挙動を維持する設定フラグを導入されることがあります。

新バージョンで挙動が削除されると、このフラグ自体も deprecation になるため、アップグレード前にはフラグを削除できる状態にしておきます。

本番で警告の発生がなければ、フラグを削除して新挙動へ移行できます。

diff --git a/config/application.rb b/config/application.rb
index sha..sha 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -79,9 +79,6 @@ class Application < Rails::Application
 
-    # FIXME
-    config.active_record.allow_deprecated_singular_associations_name = true
-

Rails には過去のフラグ(5.0 まで遡るもの)も残っている場合があります。

コード変更

場合によってはコード変更が必要です。

config.active_record.commit_transaction_on_non_local_return のように、挙動が変わるケースもあります。

アンドパッドではこの挙動の変更は RuboCop の Rails/TransactionExitStatement により事前に把握していました。

# Before
Model.transaction do
  model.save
  return
  other_model.save
end
# model is saved, transaction commits

# After
Model.transaction do
  model.save
  return
  other_model.save # not executed
end
# model && other_model are NOT saved, transaction rollback

以下のように修正もできます。

# Before
ApplicationRecord.transaction do
  return render_json(422, msg) unless record.save
end

# After
begin
  ApplicationRecord.transaction { record.save! }
  render_json 200, ok: true
rescue ActiveRecord::RecordInvalid => e
  render_json 422, msg: e.record.errors.full_messages
end

これらの問題は、アップグレード前に対応が必要です。


Green Build になったら安全に見えるかもしれませんが、まだ油断できません。

Rails には deprecation を経ないまま変更される部分もあります。 内部実装や private API の更新、バグ修正で挙動が変わるケースもあります。

つまり、deprecation の有無だけでは安全性を判断できません。 リリースノートや差分を確認し、各変更のリスクを評価したうえでプロダクトチームと回帰テストを行い、安全性を確認します。

3. 👓 アップグレードガイドを読む

まず公式の Rails upgrade guide を確認します。

近年の Rails は deprecation サイクルに基づいて破壊的変更を導入するため、ガイドは薄いことがありますが、最初の導入として有用です。

このプロセスの大部分はこのガイドに基づいています。

4. ✅ リリースノートと ChangeLog の確認

Rails の各ライブラリ(Active Record, Action Pack など)のリリースノートを確認します。

目的は、無視できる変更をふるい落とすことです。

  • ガイドと実リリース内容に矛盾がないか確認
  • 使用していない領域を除外(例:ActionText)
  • 高リスク領域の把握(例:ActiveRecord のクエリ変更)

Rails 7.2 の ActiveRecord ChangeLog の例。

# ActiveRecord 7.2 Changelog

We will use blame view to find associated commits for each change.
https://github.com/rails/rails/blame/7-2-stable/activerecord/CHANGELOG.md

## Rails 7.2.3 (Unreleased) ##

* [x] Fix checking whether an unpersisted record is `include?`d in a strictly
    loaded `has_and_belongs_to_many` association

  * PR: https://github.com/rails/rails/pull/55199

ここでは grep なども使用し、影響が薄いと判断できるものを除外します。

5. 🔍 ChangeLog の詳細レビュー

次に、ChangeLog を 1 行ずつ精査します。

リリースノートより詳細で、小さな内部変更も含まれます。

このステップでは。

  • 無視できる項目を除外
  • 影響がありそうなものを Blockers ドキュメントへコピー
  • 判断がつかないものを「要調査」とする

必要に応じて PR・コミット履歴を調べます。

6. 🧱 Blockers リストの作成

CI を通すことができたら、Blockers ドキュメントを更新します。

含めるもの。

  • ChangeLog/リリースノートに存在
  • アプリに影響しうる
  • テストで検出できない
  • 深い調査が必要

通常 30〜100 件ほどになります。

以下は Rails 7.2 アップグレード時の Blockers ドキュメント例です。

# Rails 7.2 Blockers

This document is designed to track the following:

* Changes in 7.2 that require review or investigation
* Changes to builderpad required to update Rails 7.2


## Changes

These changes may need additional review or investigation.

### ActionPack

* [ ] Fix `url_for` to handle `:path_params` gracefully when it's not a `Hash`.
  * PR: https://github.com/rails/rails/pull/51496

* [ ] Request Forgery takes relative paths into account.
  * PR: https://github.com/rails/rails/pull/32770

...

7. 🙆 Blockers の調査

各 Blocker を安全と判断できるまで調査します。

以下のいずれかの方法で対応します。

  • 本番で利用されているか不明な場合はログを追加して観測
  • 内部 API に依存している場合は monkey-patch
  • 特定機能のみ影響する場合は該当チームがテスト

ログ

影響範囲が広い場合や不明確な場合に使用します。

例として、rails/rails@1eab83a では、 Hash ではないオブジェクトを nested attributes に渡した場合、例外が発生することありました。 この変更には deprecation がなく、事前に警告されないものでした。

そこで、新しい Rails バージョンで例外が起きる引数を渡していないかを確認するため、 ログを使って発生状況を検知しました。

module ActiveRecordPatch
  module NestedAttributes
    private

    def assign_nested_attributes_for_one_to_one_association(association_name, attributes)
      unless attributes.is_a?(Hash) || attributes.is_a?(ActionController::Parameters)
        # Log
      end

      super
    end
  end
end

ActiveSupport.on_load(:active_record) do
  ActiveRecord::NestedAttributes.prepend(ActiveRecordPatch::NestedAttributes)
end

数週間の観測後、問題がなければ削除します。

この挙動がアプリケーションの変更によって再び発生する可能性がある場合は、 Rails のアップグレード後までログを残して監視を続けることができます。

Monkey-patching

変更が大きすぎてすぐに採用できない場合や、影響が重大な場合は、 Rails のアップデート自体は進めつつ、アプリケーションが依存している旧挙動を monkey-patch によって復元できます。

Rails 7.1 ではこれに該当する例があり、問題は回帰テスト中に検出されましたが、 この変更を原因にアップグレードを止めることなく進めることができました。

さらに厄介だったのは、この変更がアプリケーションにどのように影響しているかを 正確に検知できなかった点です。 このケースでは検知が 100% の保証になりませんでした。 そのため、少し複雑な手順で対応する必要がありました。

  1. 破壊的変更の条件に該当するデータを受け取った場合にログを記録する
  2. 偽陽性を含む可能性があるため、「貪欲な」検知方法を使って広めに拾う
  3. 一定期間ログを収集し、このコードを呼び出している場所ごとにフィルタする
  4. 呼び出し元が特定できたら、そのコードパスのオーナーを確認する
  5. コードオーナーと連携し、挙動変更に問題がないことを検証する

検証には、monkey-patch を一度外し、環境にデプロイして回帰テストを行うことも含まれます。

この手順をコードオーナーと数回繰り返し、本番でログに出ていた呼び出し箇所も減らしていくことで、 最終的に monkey-patch を安全に削除できました。

このプロセスには時間がかかりましたが、安全性を確保するために必要なものでした。


Blockers がすべて解消できたら、次は回帰テストです。

8. 🤝 回帰テスト

アップグレードブランチをデプロイした環境で、各プロダクトチームが回帰テストを行います。

以下の手順を踏みます。

  • ステージング上で機能フローを確認
  • 挙動・性能に regression がないか確認
  • 問題を報告

CRE チームにも顧客影響の観点から確認してもらいます。

このステップが重要なのは、自動テストだけではすべての挙動を網羅できないためです。 その機能を開発したチームが、何が「正常」で何が「壊れているか」を最もよく理解しています。

注意事項

場合によっては、Blockers フェーズで十分に調査・検証できなかった変更が残ることあります。

アプリケーションとの互換性へ影響する可能性がある変更は、 このフェーズを開始する際に共有する「Operational Check」ドキュメントにまとめています。

各チームには、どの領域を重点的に確認すべきか、どの程度のテストを行うべきかを明確に伝える必要があります。 CRE チームには、影響が考えられる場合に、どの顧客フローを重点的に確認すべきかを共有します。

協力依頼 (Request For Cooperation)

特定の機能やチームに影響する変更が分かっている場合は、 その内容を文書化して該当チームに共有する必要があります。

この文書は影響を受けるチームに直接共有し、 回帰テストの際に重点的に確認してもらうよう依頼します。

Rails 7.1 のアップデート時には、複数のチームにまたがって 回帰テスト中に確認してもらう必要のある変更がいくつかありました。

たとえば、あるチームには回帰テストに入る前、 次の deprecation を解決してもらうよう依頼していました。

過去 2 か月間にわたり本番環境の deprecation 警告を調査したところ、 これら 2 つの deprecation が新たに判明しました。

DEPRECATION WARNING: Integer#to_s(:delimited) is deprecated. Please use
Integer#to_fs(:delimited) instead. (called from block (3 levels) in show at
/path/to/controller.rb:111)
DEPRECATION WARNING: Float#to_s(:delimited) is deprecated. Please use
Float#to_fs(:delimited) instead. (called from block (3 levels) in show at
/path/to/controller.rb:222)

すべての Blocker が解消され、各チームが問題なく回帰テストを完了できたことを確認できれば、 次のステップへ進むことができます。

9. 🙏 リリース

すべての検証が完了したら本番デプロイを行います。 git diff は Gemfile の変更だけになるべきです。 これによりロールバックが容易になります。

Rails のシリアライズ形式には後方互換性がない場合、ロールバックが危険になることもあります。 本番環境で破損したデータを扱うことになれば、ユーザーのデータ損失につながりかねないため、最も避けたい状況です。

🎉 アップグレード後のステップ

リリース後、先送りにしたタスクを片付け、次のアップグレード準備をします。

10. bin/rails app:update

新しい Rails の設定ファイルと initializer を取り込みます。

これは load_defaults 更新への導線になりますが、必ずしも即時適用する必要はありません。

最新の Rails デフォルトに近づけていくことで、将来のメンテナンスが楽になります。

11. 新しい load_defaults の適用

次に config.load_defaults を新バージョンへ更新します。

同時に、変更されたデフォルト挙動を手動で旧挙動へ上書きします。

Rails 7.1 では、まず new defaults を確認し、 各設定値について「以前の値・新しい値・現在アプリケーションが依存している値」を比較するドキュメントを作成しました。 その上で、アプリケーションへのリスクの高さで並べ替えることで、 どの変更を重点的に調査すべきか、どの変更は安心して opt-in できるかを判断できるようにしています。


load_defaults(7.1)

In order to update builderpad.jp from load_defaults(7.0) to 7.1.

Critical

These config values will require a rollout plan.

config 7.0 7.1 builderpad.jp
active_support.cache_format_version 7.0 7.1 7.0

Rails の initialization 中で設定でき、副作用が生じる可能性を避けるため、 これらのオプションは config/application.rb に設定します。

bin/rails app:update を実行すると、app/initializers/new_framework_defaults_MAJOR_MINOR.rb が生成されます。 これらの設定のすべてが initializer に安全に記述できるとは限りません。 生成されたファイルに注意書きが入っている場合もありますが、注意が必要です。

このステップでは、新しい設定項目をアプリケーションに取り込みますが、挙動自体はまだ新しいものには切り替えません。

これらの上書きは長期的に Rails のデフォルトへ近づけることを目標に、段階的に削除していきます。

12. オーバーライドの段階的削除

新挙動が安全と確認できたものから 1 つずつオーバーライドを削除します。

これらの削除は一度にまとめてではなく、1 つずつ段階的に進め、 新しい挙動がアプリケーションで安全に動作することを確認してから適用する必要があります。

Rails 7.1 へのアップデート後、ここではテスト用の依存関係に関する変更へ opt-in しました。

diff --git a/config/application.rb b/config/application.rb
index sha..sha 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -164,9 +164,6 @@ class Application < Rails::Application
 
-    # FIXME
-    config.dom_testing_default_html_version = :html4
-

CI のみ影響し、本番に影響がない変更はすぐに採用できます。

最終的な目標は、オーバーライドを一切必要とせず、アプリケーションを Rails 本来のデフォルト設定で動作させることです。

ただし、いくつかのデフォルトについては、正当な理由があり旧挙動を維持し続ける場合もあります。 そのため、これらのオプションは慎重にレビューし、旧挙動を保持する必要があると判断した場合は、 FIXME コメントを削除し、その理由を明記してください。 その上で、設定フラグをこのファイル内の適切な位置(アルファベット順)へ移動します。

13. Monkey-patch の削除

旧挙動へ依存しなくなったことが確認できた monkey-patch は削除します。

これは、互換レイヤーをすべて取り除くという長期的な目標の一環であり、次の Rails アップグレードへ進む前に可能な限り解決するよう努めています。


まとめ

Rails のアップグレードのポイントは変更点を小さく保ち、リスクを抑えつつ進めることです。

破壊的変更はリリース前に解決し、互換レイヤーは追跡して最終的に削除します。

補足

このプロセスは、アンドパッドでの業務とRailsへのコントリビューションを結び付けます。 フレームワークに関する知見を最大限に活かし、アップグレードを安全に行うことを目的としています。

フレームワークの変更を確認する際には、プロダクトとしての ANDPAD へ影響が及ばさないように判断します。 Rails がリリースされる前に、アプリケーションに対する破壊的変更を特定し、事前に対処することを目指しています。

Rails の標準に従い、メンテナンスされている最新バージョンへ継続的に追従することで、保守コストを最小限に抑えられます。 これは将来的に、大規模でリスクの高いアップグレードを避けるうえでも有効です。

Rails の最新動向を追わない場合、破壊的変更に気付くのが遅れることもあります。 その場合、バグフィックスリリースでは対処できない状況に陥る可能性あります。 さらに monkey-patch を維持するといった回避策が必要となり、本来 Rails が提供するコストメリットを失ってしまいます。

破壊的変更を早期に検知することで、こうした隠れたコストを避けられます。

できる限り最新の状態を保ち、フレームワークへの貢献を続けることは重要です。 これはアプリケーションの長期的な健全性と事業の継続的な成長に寄与します。