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を勝手に作るようになっています。
月曜に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の概要に全体の内容をザクッと説明してリンク等もつけたり
パッと見なんでこの変更入ったんだろう?って思われそうなとこにコメントいれたり
その上でチーム全員にバージョンアップで変わる点を認識してもらえるように、全員にざっと目を通してもらったりもしました。
ハマったところ
キャッシュまわりでハマったのが印象的だったのですが、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#L119
の where_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
などライブラリのオブジェクトを突っ込んでいるとバージョンアップ後にエラーが起きる。強制ログアウトは最後の手段なのでセッションにはプリミティブなオブジェクトをいれるようにする。