物件基盤システムをRails 7.1にアップデートしました & ハマりポイントを共有します!

こんにちは!
イタンジ株式会社でバックエンドエンジニアをしている藤崎 (https://x.com/aki19035vc) です。
イタンジの各種サービスの要である物件基盤システムを開発をしています。

Rails 7.2が今にも出そうな気配がしていたので、置いていかれないように私がメインで見ているRailsアプリケーションは7.1に上げました。

その際、いくつか気をつけるべきポイントとハマりポイントがあったのでその共有も兼ねて記事を書こうと思います。

前提

今回アップデートしたRailsアプリケーションの特性は下記の通りです。

  • Ruby 3.3.3
  • Rails 7.0.6
  • APIモードで動作しており、レスポンスはJSONしか返さない
  • テストのラインカバレッジは(ほぼ)100%
  • 型の記載率は(ほぼ)100%

記載の通りテストのラインカバレッジを100%に維持しています。

また、型に関しても「appディレクトリ以下」は100%書かれています。 ハッシュの中身などは untyped していますが、メソッドの引数や戻り値そのものを untypedにしている箇所はありません。 メタプロしている場合はgeneratorを作ったり自前で頑張って書いたりしています。

そのため、既存のテストや型検査が通ればアプリケーションに重大な破綻が起きていない事に自信を持つ事ができます。(フラグ)

Rails 7.1にアップデート!

特に難しいことはありません。Railsアップグレードガイドにしたがって作業をします。

https://railsguides.jp/upgrading_ruby_on_rails.html

Railsをアップデート後、アップデートタスクを実行します。

$ bundle udpate rails

$ bin/rails app:update

生成される設定ファイルはコンフリクトしますが、1度全て受け入れてコミットしてしまいます。その後、既存の設定との差分を見比べながら手作業で直していきました。

また、このタイミングでnew_framework_defaults_7_1.rb に記載されている設定のコメントアウトを、1つずつ確認しながら外していきます。

config.active_support.cache_format_version

今回影響がありそうなデフォルト設定の変更は、キャッシュフォーマットバージョン (config.active_support.cache_format_version) の部分だけでした。

Railsガイドには下記のように書かれているのですが、ここの「前方互換性」という表現には少し注釈が必要です。

https://railsguides.jp/v7.1/configuring.html#config-active-support-cache-format-version

どの形式も前方互換性と後方互換性があります。 つまり、ある形式で書かれたキャッシュエントリは別の形式で読み取り可能です。 この振る舞いによって、キャッシュ全体を無効化せずに異なる形式間の移行を楽に行えるようになります。

Rails 7.0で読み取ることができるのは、Rails 7.1で cache_format_version = 7.0 の状態で生成されたキャッシュのみだからです。

Rails 7.1でcache_format_version = 7.1の状態で生成されるキャッシュは、Rails 7.0で読み取ることはできません。

※ 読み取れる場合もあるようですが、危険なのでやめましょう。

Rails アップグレードガイドの 6.1 -> 7.0 には記述があるので、こちらが参考になります。

https://railsguides.jp/upgrading_ruby_on_rails.html#activesupport-cache%E3%81%AE%E6%96%B0%E3%81%97%E3%81%84%E3%82%B7%E3%83%AA%E3%82%A2%E3%83%A9%E3%82%A4%E3%82%BA%E3%83%95%E3%82%A9%E3%83%BC%E3%83%9E%E3%83%83%E3%83%88

new_framework_defaults_7_1.rbの中のコメントには書かれているのですが、Rails アップグレードガイドの 7.0 -> 7.1 には書かれてないので、ここで取り上げておきました。

ハマりポイント

アップデートで必要な対応が終わったためステージング環境でしばらく様子を見ていたのですが、いくか問題が発生してしまい、変更をリバートすることがありました。

それぞれについて原因・対応・対策を記載していきます。

エラーがSentryに送信されない

弊社では、Railsアプリケーション上で発生したエラーはSentryに送るようになっているのですが、ここが上手く動いていませんでした。
ログを見てみると、

got #<NoMethodError: undefined method `show_exceptions?' for an instance of ActionDispatch::Request>

どうやら、Rails7.1からActionDispatch::Request#show_exceptions?がなくなったのが原因のようでした。

対応

この問題の対応方法は簡単で、sentry-railsも最新バージョン(※ 記事執筆時点では5.17.3)にアップデートするだけです。

bundle update sentry-rails

対策

この問題をテストで検知できなかったのはSentryの初期化処理をローカル環境ではスキップしていたことが原因でした。

ラインカバレッジが100%なのはappディレクトリ以下のファイルであり、config/initializers以下のファイルは計測対象に含まれていなかったのも要因の1つです。

そのため、どの環境でもSentryの初期化処理が走るようにしつつ、手元の環境ではSentryに送信されないように変更し、テストで今回の問題を再現できるようにしました。

# config/initializers/sentry.rb
Sentry.init do |config|
  config.environment = ENV.fetch('SENTRY_ENV', 'local')
  config.enabled_environments = %w[production staging]
  # -- 省略 --
end

ちなみに

sentry-railsの実装が気になったので見てみましたが、内部では下記のようにRailsのバージョンが7.1かどうかで処理を分岐しているようでした。

https://github.com/getsentry/sentry-ruby/blob/389395eae5bf003edbf2864c258ed585443ac150/sentry-rails/lib/sentry/rails/capture_exceptions.rb#L51-L59

module Sentry
  module Rails
    class CaptureExceptions < Sentry::Rack::CaptureExceptions
      # -- 省略 --

      def show_exceptions?(exception, env)
        request = ActionDispatch::Request.new(env)
        if RAILS_7_1
          ActionDispatch::ExceptionWrapper.new(nil, exception).show?(request)
        else
          request.show_exceptions?
        end
      end
    end
  end
end

Rails.cache.redisがRedisインスタンスを返さなくなった

本アプリケーションではキャッシュストアとしてRedisを使用しています。

キャッシュを扱う際は基本的にActiveSupport::CacheStoreのインターフェースを使うのですが、一部処理ではRedis特有の機能を使用したく、Rails.cache.redisで取得できるRedisインスタンスを使用している箇所がありました。

しかし、Rails 7.1からはRails.cache.redisで返ってくる値がRedisではなくConnectionPoolのインスタンスになってしまい、Redisインスタンス前提の処理が動作しなくなってしまいました。

下記がRailsコンソールで実際に試してみた結果です。

# === Rails 7.0 ===
$ rails c
Loading development environment (Rails 7.0.6)
irb(main):001> Rails.cache.redis
=> #<Redis client v4.6.0 for redis://redis:6379/1>

# === Rails 7.1 ===
$ rails c
Loading development environment (Rails 7.1.3.4)
irb(main):001> Rails.cache.redis
=> #<ConnectionPool:0x0000ffff750cbaf0>

Railsアップグレードガイドには

https://railsguides.jp/upgrading_ruby_on_rails.html#memcachestore%E3%81%A8rediscachestore%E3%81%8C%E3%83%87%E3%83%95%E3%82%A9%E3%83%AB%E3%83%88%E3%81%A7%E3%82%B3%E3%83%8D%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3%E3%83%97%E3%83%BC%E3%83%AB%E3%82%92%E4%BD%BF%E3%81%86%E3%82%88%E3%81%86%E3%81%AB%E3%81%AA%E3%81%A3%E3%81%9F

2.5 MemCacheStoreとRedisCacheStoreがデフォルトでコネクションプールを使うようになった

という記載はあるのですが、今回の件に関係しそうな記述は見当たりませんでした。

なお、このConnectionPoolというのは下記の connection_pool gem で提供されているものになります。

https://github.com/mperham/connection_pool

対応

connection_pool gem のREADMEに記載されているように、#then#withメソッドを使うことで、この問題を解消することができます。

Rails.cache.redis.with do |redis|
  redis.set(key, value)
end

今回の修正では#withメソッドを使用する形にしました。

#thenメソッドがRubyのあらゆるオブジェクトで使用できるメソッドであるため、#withメソッドを使用することで「少し特殊」という事を読み手に伝えられると考えたためです。

対策

この問題に気がつけなかった原因は、テストではRedisを使っておらず、Rails.cacheで返ってくる値をモックしてしまっていたことです。

そのため、テストでもキャッシュストアとしてRedisを使うように変更しました。

ただし、parallel_tests gem を利用してテストを並列で実行しているため、下記のように使用するRedisのDBを被らないようにする必要はありました。

# config/environments/test.rb
Rails.application.configure do
  # Avoid db number used in development
  redis_db_num = Integer(ENV['TEST_ENV_NUMBER'].presence || '1', 10) + 10
  config.cache_store = :redis_cache_store, { url: "#{ENV.fetch('REDIS_URL')}/#{redis_db_num}" }
end

また、下記はRSpecの例ですが、各テストケースごとにキャッシュがクリアされるようにshared_contextを定義して使用する形にしました。

# spec/support/using_cache_store.rb
shared_context 'when using cache store' do
  around do |example|
    Rails.cache.clear
    example.run
    Rails.cache.clear
  end
end

ちなみに

RedisCacheStoreの生成処理は以下の通りで、「コネクションプールを使わない」という設定を明示している時はRedisインスタンスを生成する形になっていました。

Rails.cacheの型がuntypedではなくなったとしても今回の件は型では気がつけないですね、、、

https://github.com/rails/rails/blob/19eebf6d33dd15a0172e3ed2481bec57a89a2404/activesupport/lib/active_support/cache/redis_cache_store.rb#L149-L162

module ActiveSupport
  module Cache
    class RedisCacheStore < Store

      # -- 省略 --

      def initialize(error_handler: DEFAULT_ERROR_HANDLER, **redis_options)
        universal_options = redis_options.extract!(*UNIVERSAL_OPTIONS)

        if pool_options = self.class.send(:retrieve_pool_options, redis_options)
          @redis = ::ConnectionPool.new(pool_options) { self.class.build_redis(**redis_options) }
        else
          @redis = self.class.build_redis(**redis_options)
        end

        @max_key_bytesize = MAX_KEY_BYTESIZE
        @error_handler = error_handler

        super(universal_options)
      end

      # -- 省略 --
    end
  end
end

まとめ

本記事では、Railsアプリケーションを7.1にアップデートする際の注意点と、実際に発生したハマりポイントをご紹介いたしました。

テストや型があってもハマる時はあるので、何か問題が起きた時にすぐリバートできるようにしておくのと、今後同じ事を起こさないように対策を入れるようにしましょう。

7.1へアップデートする際の参考に少しでもなれば嬉しい限りです。

最後に

Rails 8.0 が楽しみですね。デフォルトのジョブキューバックエンドになると思われる Solid Queue に特に注目しています。

https://github.com/rails/solid_queue

弊社ではSidekiqを使用することが多いですが、乗り換え先となりえるのかどうか、、、
実際に触ってみたら、またブログで共有しようと思います。