PeXのRailsを2年ぶりに4.2から5.0にアップデートしました。キャッシュやセッションまわりでのエラーにご注意!

VOYAGE GROUPの駒崎です。PeXというポイント交換サービスの開発運用をやっています。

PeXは2016年3月にSymfonyからRuby on Railsにフルリニューアルを果たし、そこから2年ほどRailsのバージョンが4.2で止まっていました。 PeXというサービスを今後長く運用していくためにも、Railsに乗り続けるためにも、という考えで2018年7月頃に5.0へアップデートしました。(実は現時点ではRails5.2にアップデートされているのですが)

Railsのアップデートを行うまでの流れと、リリース後にキャッシュ、セッション周りでハマったことをここにまとめます。

Railsアップデートでやったこと

  • gemのバージョンを最新にする。
  • gemのバージョンを最新にアップデートし続ける仕組みを作る。
  • Railsのバージョンを4.2から5.0にする。

3行で言うとこの流れで進めました。 gemのバージョンを最新にし、アップデートし続ける仕組みを作るまでに2ヶ月くらい。Railsのバージョンを4.2から5.0にするのに1ヶ月程、かかりました。

Railsのバージョンアップを行うと依存しているgemのバージョンも上げることになるのですが、同時に行うと非常に大変なのでまずは周辺gemのアップデートを行いました。

次にRailsのバージョンアップ作業中および今後のバージョンアップを踏まえ、gemのバージョンを最新にし続けるような仕組みを導入しました。

最後にRailsのバージョンを上げました。これは Rails アップグレードガイド | Rails ガイド を見つつ進めました。多分普通にアップデートする分には問題ないはずなので、プロダクト固有のハマったところを紹介したいと思います。

規模感

参考に rake stats の結果です。テストが厚めに書かれています(素敵)。

+----------------------+--------+--------+---------+---------+-----+-------+
| Name                 |  Lines |    LOC | Classes | Methods | M/C | LOC/M |
+----------------------+--------+--------+---------+---------+-----+-------+
| Controllers          |  12864 |  10130 |     291 |    1307 |   4 |     5 |
| Helpers              |    278 |    222 |       0 |      49 |   0 |     2 |
| Jobs                 |    195 |    140 |       8 |      16 |   2 |     6 |
| Models               |  18907 |  10610 |     279 |     958 |   3 |     9 |
| Mailers              |    585 |    501 |      31 |      42 |   1 |     9 |
| Javascripts          |   3231 |   2628 |       0 |     404 |   0 |     4 |
| Libraries            |  40410 |  31991 |    1002 |    3728 |   3 |     6 |
| Tasks                |   1027 |    857 |       6 |      52 |   8 |    14 |
| Config specs         |     35 |     30 |       0 |       0 |   0 |     0 |
| Decorator specs      |   1323 |   1156 |       0 |       0 |   0 |     0 |
| Feature specs        |  35347 |  30387 |       3 |      33 |  11 |   918 |
| Helper specs         |     95 |     85 |       0 |       0 |   0 |     0 |
| Job specs            |    292 |    256 |       4 |       4 |   1 |    62 |
| Lib specs            |  39849 |  34079 |       6 |     153 |  25 |   220 |
| Mailer specs         |    112 |     94 |       0 |       0 |   0 |     0 |
| Model specs          |  30794 |  23455 |       0 |      18 |   0 |  1301 |
| Presenter specs      |    136 |    113 |       0 |       0 |   0 |     0 |
| Request specs        |   3521 |   3067 |       0 |       0 |   0 |     0 |
+----------------------+--------+--------+---------+---------+-----+-------+
| Total                | 189001 | 149801 |    1630 |    6764 |   4 |    20 |
+----------------------+--------+--------+---------+---------+-----+-------+
  Code LOC: 57079     Test LOC: 92722     Code to Test Ratio: 1:1.6

gemのバージョンを最新にする

周辺gemをアップデートし、最後にRailsを4.2系の最新にするのがゴールです。 これまでgemアップデートはセキュリティFixのみ行ってきたので、2年以上前のバージョンで止まっているgemがたくさんあります。これを全て最新にしていきます。

bundle updateでRails以外の各gemを最新にする

不要なバージョン固定を外し、 bundle update でgemを最新化します。 細かく書くと長くなるので割愛しますが、 unicorn , sidekiq などサービスへの影響が大きそうなgemは個別にアップデートし、development, test groupのgemはまとめてアップデートしていきました。 また、gemの一部の機能しか使っていなかったり、バージョンアップで大きな変更が行われ追随していくのが辛そうなgemを精査して削除も行いました。例えば cells というgemは3系から4系で大きな変更が行われ、gemを使うと楽になるというよりgemを使うために頑張るみたいな本末転倒になりそうだったのでView専用のコンポーネントを自前で実装し、削除をしました。

gemのバージョンを最新にアップデートし続ける仕組みを作る

一度gemを最新化して終わりだと、次回以降のバージョンアップ作業がまた辛い作業になってしまい手付かずになってしまいます。 そこで、毎週bundle updateして Gemfile.lock を更新したPullRequestが作られるようにしました。 こんな感じでupdateされる各gemとchangesのリンクがついたPRを勝手に作るようになっています。

f:id:dkkoma:20181210180200p:plain

月曜にPullRequestを自動作成し、誰かがレビューして翌日くらいにはリリースをするようにしました。 最初は自分で何度かやってみてからフローをチームに共有し、あとはやりたい人がやる形で今は回っています。 毎週やれているとそこまでボリュームがないので、gemのCHANGELOGを眺められたり、こんなgemに依存してたんだって発見があるので良いです。

Rails4.2から5.0にアップデートしていた間も、gemの自動アップデートは別途やっていました。

Railsのバージョンを4.2から5.0にする

Rails自体のアップデートは Rails アップグレードガイド | Rails ガイド が充実していますし、Web上にも知見が転がっておりテストが書いてあれば不安は少ないです。何よりRails本体で大きな変更をするときは、DEPRECATION WARNINGを経て変更を行ってくれている点が多く、まずはアップデートしてからDEPRECATION WARNINGを消していくということがやりやすくなっています。

おおまかには以下の流れで進めました。

  • Rails5.0でいらなくなる大変お世話になったgemを削除 🙏
    • activerecord-mysql-awesome
    • quiet_assets
    • 等々
  • 雑に bundle update -> bin/rails app:update でテストを流し、落ちているテストを直していきます。
  • Rails アップグレードガイド | Rails ガイド を参考に進めましたが差分を小さくするため、いくつかはこのタイミングではやりませんでした。
    • ApplicationRecord の導入は後回しにしました。
    • ActiveSupport.halt_callback_chains_on_return_false = false をいれ、beforeコールバックの修正を避けました。
子PullRequestを作ってspec/以下のディレクトリ毎にPullRequestをわけた

まずはテストを直していくのですが量が多いので、 spec/model だけ通すなどいくつかにPullRequestをわけて進めました。 スコープが明確なのとFile changedが小さく抑えられると読みやすくなり、レビューアに優しいです。

PullRequestで変更点を解説する

バージョンアップ作業をやってる人にとっては小さいことを自分で積み重ねているので自明ですがメモしきれないことが多いしノッているのでそもそもメモったりしないし、 変更量が多くなって見る人には辛いのでレビュー前にPullRequest上で適宜コメントをいれていました。

PullRequestの概要に全体の内容をザクッと説明してリンク等もつけたり f:id:dkkoma:20181210181030p:plain

パッと見なんでこの変更入ったんだろう?って思われそうなとこにコメントいれたり f:id:dkkoma:20181210181216p:plain

その上でチーム全員にバージョンアップで変わる点を認識してもらえるように、全員にざっと目を通してもらったりもしました。

ハマったところ

キャッシュまわりでハマったのが印象的だったのですが、Web上で情報をあまり見かけなかったのでここに残しておきます。

キャッシュにActiveRecord_Relationがキャッシュされているとエラーになる

PeXでは redis-rails というgemを利用して、キャッシュストアにRedisを使っています。

検証環境にRailsアップデート後のアプリケーションをデプロイして確認したところ、

NoMethodError: undefined method `binds' for #<Array:0x00007f7de27c4ec8>

というエラーが起きてました。

該当のコードはcontrollerで Headline というmodelのスコープを利用してキャッシュに読み込む箇所でした。

      @headlines = Pex::Cache.fetch("headline", expires_in: 5.minutes) do
        Headline.limit(5).order("updated_at DESC")
      end

そもそもこれだと ActiveRecord_Relation がキャッシュされていて、キャッシュの恩恵がないのですがそれは置いておいて、 Rails4.2のコードでキャッシュされた ActiveRecord_Relation を5.0でロードするとどうなるかという話です。 Rails5.0から ActiveRecord_Relation の実装が変わっていて、 https://github.com/rails/rails/blob/v5.0.7/activerecord/lib/active_record/relation/query_methods.rb#L119where_clause にあたるものが、4.2時代は Array だったらしくそのキャッシュをロードすると Array として復元されます。 Array に対して bind というメソッドをcallしようとしますが、 Array#bind はないのでエラーになります。

対応としては ActiveRecord_Relation をキャッシュするのをやめ、viewでfragmentキャッシュするように修正した結果、Rails4.2でキャッシュを生成させてからRails5.0にアップデートしてキャッシュを読み込んでも動作するようになりました。

また、切り戻しによってRailsをバージョンダウンしたときにも同じ問題が起きそうなので確認してみます。 Rails5.0のアプリケーションで生成したキャッシュをRails4.2のアプリケーションで読み込むテストをしてみたところ、以下のように marshal_load メソッドでエラーが出ました。

TypeError: instance of ActiveRecord::LazyAttributeHash needs to have method `marshal_load'

今度は ActiveRecord_Relation ではなく ActiveRecord が依存したクラスのロードを行うところでエラーが起きているようです。 結局 ActiveRecord のオブジェクトをキャッシュに入れる限りは、切り戻しは出来ないということがわかりました。何かあった時に切り戻しが出来ないのは困ります。

そこで、以下のようにバージョンアップ後のキャッシュキーに rails5-0 というprefixをつけ、Railsバージョンを変えた時にキャッシュ全体が切り替わるようにしました。

- config.cache_store = :redis_store, ENV['CACHE_STORE']
+ config.cache_store = :redis_store, ENV['CACHE_STORE'] + '/rails5-0'

この方法を取るとリリース時にキャッシュが全て吹き飛ぶのと同じことになるので、アクセスが少なめの時間(PeXでは昼過ぎ頃)にリリースすることにしました。 キャッシュがなくてもサービスが落ちない程度に適切にインデックスは張ってあるため、この方法で問題なさそうだと判断しました。

sessionにActiveRecordのオブジェクトが入ってて、バージョンの前後でsessionのロードができなくなった

上記の問題が解決し、ようやくリリースした後しばらく production.log を眺めていると、頻度は少ないのですが以下のようなエラーが起きていました。

ActionView::Template::Error (uninitialized constant ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter::Column)

backtraceがログに出ておらず再現条件がわからなかったのですが、前述のキャッシュでハマっていたことからキャッシュが怪しそうと見て redis monitor コマンドなどを使ってエラーが起きたタイミングのredisのクエリを調べていたところ、とあるアクションを行ったユーザのセッションに ActiveRecord のオブジェクトが格納されていて、セッションのロード時にエラーになっていることがわかりました。

このケースに該当する方はサービスが全く利用できなくなってしまっているため、切り戻しを行いRails4.2に戻しました。 が、今度はリリースから切り戻しまでの2時間ほどの間に、とあるアクション(先程と同じもの)を行ったユーザがRails5.0の ActiveRecord のオブジェクトがセッションに格納され、Rails4.2のコードではロードできなくなってしまっていました。 バージョンを進めても戻してもエラーが出るユーザがでてしまい、詰んでいます...。

悩んだ結果、ロードできない(壊れた)セッションは破棄して再ログインしてもらうしかないため、以下のようなコードを ApplicationController に定義して、セッション削除(強制ログアウト)&トップページへリダイレクトすることにしました。

  before_action :migrate_session

  def migrate_session
    session[:user_id]
  rescue StandardError => e
    # 壊れたsessionのloadが走るとエラーになるのでredisからセッションを消して強制的にログアウト状態にする
    sid = request.cookies[ENV['SESSION_KEY']]
    redis = Redis.new(url: ENV['SESSION_STORE'])
    redis.del "cache:#{sid}"
    redirect_to :root
  end

session.delete でなく redis.del にしている理由は、 session にアクセスするとセッションからのロードが走ってしまいエラーを避けられないためです。 その後、セッションに ActiveRecord を格納しないようにする根本対応は別途行いました。

まとめ

gemのバージョンアップを日頃からやっておく
  • フレームワークバージョンアップ時にまとめて頑張るのではなく、週次など日頃からgemのバージョンアップをしておく。
  • これをやっておけばRails本体のアップデートはそこまで大変ではない。(Rails3時代は結構大変だった記憶ですが楽になりましたね)
キャッシュに注意
  • ActiveRecord などライブラリのオブジェクトを突っ込んでいるとバージョンアップ後にエラーが起きることがある。
    • バージョンアップでキャッシュキーは変える。
    • キャッシュが全部吹き飛んでもサービス継続出来る程度にインデックスが適切に作成されていると安心、スロークエリがでていないか日頃から確認しておく。
  • セッションに ActiveRecord などライブラリのオブジェクトを突っ込んでいるとバージョンアップ後にエラーが起きる。強制ログアウトは最後の手段なのでセッションにはプリミティブなオブジェクトをいれるようにする。