株式会社SchooでWebエンジニアをしております福島です。
私が所属しているチームでは「Schoo ビジネス管理ツール」という主に法人向けに Schoo を利用いただく為のSaaSを開発しています。
今回、そのアプリケーションで使用されているRuby on Rails(以下Rails)のバージョンが古いため、最新の機能が使用できない、セキュリティアップデートに追従できない、Rubyが古く使いたいgemを入れられない…等の課題に対応すべく、Railsを4.2.0から7.1.3へ、Rubyを2.1から3.3へと対応時点の最新まで一気にアップグレードしました。
Rails 4.2.0のリリースは2014年、Rails 7.1.3のリリースは2024年なので約10年分、
Ruby 2.1のリリースは2013年、Ruby 3.3のリリースは2023年なので、こちらも約10年分のバージョンを一気に上げたことになります。
ちなみにアプリケーションのファーストコミットは2015年です。歴史を感じますね。
本記事では実際に運用中のプロダクトにおけるバージョンアップの実例として、手順や課題、各ポイントをお伝えします。
扱うアプリケーション
すべてバージョンアップの対応に取り掛かる前の状態です。
- rails stats
- Code LOC: 21604
- Test LOC: 28748
- Code to Test Ratio: 1:1.3
- エンドポイント数 ( = URI × HTTPメソッド)
- テストカバレッジ
rails stats の詳細はこちらから
+----------------------+-------+-------+---------+---------+-----+-------+
| Name | Lines | LOC | Classes | Methods | M/C | LOC/M |
+----------------------+-------+-------+---------+---------+-----+-------+
| Controllers | 9252 | 6878 | 108 | 540 | 5 | 10 |
| Helpers | 1056 | 814 | 0 | 90 | 0 | 7 |
| Jobs | 108 | 96 | 9 | 9 | 1 | 8 |
| Models | 5297 | 4172 | 156 | 405 | 2 | 8 |
| Mailers | 223 | 179 | 10 | 19 | 1 | 7 |
| Javascripts | 4854 | 3752 | 0 | 614 | 0 | 4 |
| Libraries | 137 | 103 | 3 | 7 | 2 | 12 |
| Uploaders | 150 | 119 | 2 | 23 | 11 | 3 |
| Forms | 314 | 215 | 2 | 22 | 11 | 7 |
| Decorators | 1066 | 747 | 24 | 90 | 3 | 6 |
| Lib | 1275 | 1078 | 16 | 109 | 6 | 7 |
| Templates | 184 | 161 | 1 | 7 | 7 | 21 |
| Validators | 72 | 40 | 4 | 5 | 1 | 6 |
| Services | 4289 | 3250 | 55 | 280 | 5 | 9 |
| Form specs | 581 | 519 | 0 | 0 | 0 | 0 |
| Mailer specs | 471 | 385 | 0 | 0 | 0 | 0 |
| Decorator specs | 600 | 525 | 0 | 0 | 0 | 0 |
| Model specs | 5994 | 5237 | 1 | 3 | 3 | 1743 |
| Request specs | 6545 | 5512 | 0 | 0 | 0 | 0 |
| Lib specs | 15687 | 13264 | 0 | 6 | 0 | 2208 |
| Template specs | 10553 | 0 | 0 | 0 | 0 | 0 |
| Job specs | 316 | 270 | 0 | 0 | 0 | 0 |
| Controller specs | 241 | 200 | 0 | 1 | 0 | 198 |
| Helper specs | 85 | 74 | 0 | 0 | 0 | 0 |
| Service specs | 3194 | 2762 | 0 | 0 | 0 | 0 |
+----------------------+-------+-------+---------+---------+-----+-------+
| Total | 72544 | 50352 | 391 | 2230 | 7 | 17 |
+----------------------+-------+-------+---------+---------+-----+-------+
Code LOC: 21604 Test LOC: 28748 Code to Test Ratio: 1:1.3
rails statsやエンドポイントの数を見て分かる通り、小〜中規模レベルのアプリケーションです。
特徴としてはモデルと比較してコントローラーのコード量が多いこと、Code to Test Ratio
の割にはテストカバレッジが低い、というところでしょうか。
上述の状態だったため、バージョンアップ前の準備として、それぞれ以下を行いました。
テストカバレッジの向上
既存のプロダクトはテストコードが充実していないという課題感があり、実際にテストカバレッジが約40%と低い状態でした。
また、ビジネスロジックがモデルに実装されておらず、コントローラーにトランザクションスクリプトとしてビジネスロジックがベタ書きされているような状況でした。そのような状況からなるべく少ないコストで効率的にテストカバレッジを上げる手段として、コントローラーを基準にRequest Specを充実させる選択をしました。
対応としては テストカバレッジが低い × コード量が多い
コントローラーを優先的にテストを書くようにしました。
細かな振る舞いの検証はできなくても、限られた時間の中で結合テスト観点のRequest Specで広くカバレッジを上げることで、バージョンアップでの破壊的変更によるエラーやDEPRECATION WARNINGに気が付けるようにすることが目的です。
ここでは最終的に 約80% にテストカバレッジを上げることができました。
またポイントとして、よく言われる事ですがテストカバレッジ100%を目指さないことも重要です。
デッドコードの削除
他にも、未使用な機能(コード)が残り続けているという課題感もあり、今も使われているかわからない機能が多数ありました。そのため、今回のバージョンアップの対応をきっかけに削除することとしました。
ただし、削除するといっても必要な機能を消してしまってはいけないので、以下の手順を行いました。
- ログから過去半年間のリクエスト件数をエンドポイントごとに一覧化する
-
bin/rails routes
のエンドポイントを一覧化する - 1と2を突き合わせて、半年間1度もリクエストされていないエンドポイントを洗い出す
- プロダクトオーナーと相談して洗い出した機能が必要であるかを確認する
- 必要ないと判断した機能を削除する
上記をおこなった結果、全430のエンドポイントのうち、約90が過去半年間に1度もリクエストされていないことが判明しました。今回リクエストがないだけで必要な機能もあったため、これらすべてを削除できませんでしたが、この作業によって後続のバージョンアップの作業量を減らし、無駄が出ないようにしました。
今回はわりと原始的な方法でデッドコードを洗い出しましたが、他にも Coverband や OkuribitoRails といったgemがあるので、こちらも参考にしてみてください。
これまでのテストカバレッジの向上とデッドコード削除で以下のようにstatsが改善されました。
RubyとRailsを同時にリリースできる環境
RubyとRailsは依存関係にあるため、それぞれ同時にリリースできる環境を作るのがベストです。
これまでの「Schoo ビジネス管理ツール」のリポジトリで管理されているアプリケーションは大きく分けて、
- RailsのWebアプリケーション
- 非同期Job
- 定期実行されるrakeタスク1
- 定期実行されるrakeタスク2 (1とは別環境)
の4つ存在していましたが、歴史的経緯により、1点目のRailsのwebアプリケーションはAWSのEKSでコンテナ化されていましたが、それ以外はデプロイツールの運用もなく素のEC2サーバー上で直接Rubyがセットアップされているような歪な状況でした。そのため、一方の環境では最新状態のアプリケーションがデプロイされているが、別の環境では古い状態のアプリケーションのまま、というような状態が発生し得る、とても危険な構成でした。
このような課題があったため、今回はそれぞれ同一のDockerfile
でビルドしたイメージを使うようにして全環境をコンテナ化、デプロイプロセスも1つに統一して全環境同時にデプロイできるようにしました。
コンテナ化することで、RubyとRails、その他の依存関係をまとめて扱うことが可能になりました。
バージョンアップ時のリリースもそうですが、何か問題があった場合、即座にかつ全環境同時に切り戻しができる状態にする、ということが重要になります。
エラー通知の仕組み
バージョンアップ関係なく、アプリケーションを本番運用する上でエラーを検知し、通知する仕組みは必ず必要です。エラーが発生しても検知できずに対応が遅くなればなるほど、ユーザーに影響を与えてしまうからです。
Schooの開発ではこのようなエラー通知のため BugSnag を導入しておりました。このBugSnagのようなエラートラッキングツールは他にも Sentry や Rollbar など複数あるので、未導入の際は必ず導入しておきましょう。
どれだけテストを実施していても開発を行う以上、バグをゼロにすることはできません。テスト/検証でリスクを最小にしつつも万が一バグによるエラーが発生してもそれを即時に検知することが大事です。
実際に今回のバージョンアップのリリース後にエラーを検知し、通知されてからxx分後に修正しリリース、といったケースもありました。
具体的な作業は Rails アップグレードガイド に従います。また、Ruby-jp の Rails アップグレードガイド も参考にしました。
基本的な作業の流れとしてはgemを更新後、アップデートタスクの app:update
を実行して、diffを確認しながら修正していきます。この時、app:update
で表示されるdiffだけでは分かりにくいので、以下の RailsDiff
で対象のバージョンを指定しながら差分を反映しました。
実際の作業自体は上述のアップグレードガイド等に詳細があるので、以降観点を絞って取り上げます。
段階的なバージョンアップ
長らくアップデートされていないRailsは、一気に最新に上げるのではなく、細かく段階的にバージョンアップしていくことが重要です。
そして、以下のようにRailsに対応しているRubyのバージョンがあるため、例えばRails 5に上げる場合はまずRubyを2.2.2以降にバージョンアップするなど、目的のRailsのバージョンに対してRubyのバージョンを決め、交互にバージョンアップする計画を立てます。
- Rails 7.2: Ruby 3.1.0以降が必須
- Rails 7.0と7.1: Ruby 2.7.0以降が必須
- Rails 6: Ruby 2.5.0以降が必須
- Rails 5: Ruby 2.2.2以降が必須
そのため、以下のように段階的なバージョンアップを行いました。
- Rails 4.2から4.2系の最終パッチ
1-1. Ruby 2.1から2.5 - Rails 4.2から5.0
- Rails 5.0から5.1
- Rails 5.1から5.2
4-1. Ruby 2.5から2.7 - Rails 5.2から6.0
(…以下目的のバージョンまで繰り返し)
実際のPull Requestを抜粋すると、このような感じになりました。
この他、細かなconfig系の修正やgemの見直し、ローカル環境の整備、デプロイ周りの整備などもこのタイミングで一緒に行っていたため関連するPull Requestの件数は合計で40件弱になりました。
バージョンアップ中に発生した課題など
複数データベース対応
バージョンアップで大変だったのが複数データベースの対応です。
従来より octopus で複数データベースの接続を行っていましたが、Rails 6.0へ上げた際に以下のエラーで動かなくなってしまいました。
NameError: undefined method `any?' for class `ActiveRecord::Associations::CollectionAssociation'
/app/vendor/bundle/ruby/2.7.0/gems/ar-octopus-0.10.2/lib/octopus/shard_tracking.rb:23:in `alias_method'
octopusのリリースが2019年から止まっていたことやRails 6.0からは標準で複数データベース接続がサポートされるようになったことから、このタイミングでRails標準の方へ切り替えることにしました。
replicaとwriterへの接続切り替えもRailsデフォルトの仕組み (簡単に言うとリクエストがPOST、PUT、DELETE、PATCHだとwriterに自動接続、それ以外はreplicaに接続する) をそのまま使用したのですが、標準のRESTful
なルーティングが守られていなく、何故かGETリクエストなのにレコード作成/更新を行っているようなアクションが結構存在していましたので、一つずつ手動で接続先を切り替えるように修正しました。
フレームワークの標準に従うことはこのような場面でも重要だと感じました。(GETなのに登録/更新されるような処理はフレームワークの標準以前の問題だとは思いますが)
belongs_to の optional
Rails 5.0からbelongs_to
のデフォルト挙動が変更になりました。5.0以降はbelongs_to
の関連先が存在しない場合はバリデーションエラーになります。これを回避するにはoptional: true
(または required: false
) を明示的に指定する必要があります。
テーブルのNOT NULL
制約がある場合はそのまま指定せずにOK、ない場合はoptional: true
を一律指定していけばよかったのですが、残念ながら実際には論理的にNULL
が入り得ないカラムに対してNOT NULL
制約が付いているとは限らない状況でしたので、belongs_to
の設定があるすべてのモデルに対して以下を行いました。
- 該当カラムに
NOT NULL
制約がある場合-
optional: true
を指定しない
-
- 該当カラムに
NOT NULL
制約がない場合- 本番レコードを確認して
NULL
の存在有無を確認-
NULL
が1件も存在しない場合- nonnullとみなして
optional: true
を指定しない(コード上からも入り得ないことを確認)
- nonnullとみなして
-
NULL
が1件以上存在する場合- nullableとみなして
optional: true
を指定する
- nullableとみなして
-
- 本番レコードを確認して
MySQLのdatetime型カラムに 0000-00-00 00:00:00
を入れると落ちる
Rails 7.0へバージョンアップした際にMySQLのdatetime型カラムに 0000-00-00 00:00:00
を入れようとすると落ちる、という問題がありました。
MySQL独自仕様でNOT NULL
制約なカラムでも0000-00-00 00:00:00
を入れると、NULL
等価として扱える、という仕様です。
これをActiveRecord
で扱うと以前は問題なかったのですが、Rails 7.0からはNULL
と等価の挙動になり、保存時にActiveRecord::NotNullViolation
が発生するようになりました。
本来はNULL
を入れるべきですが、NOT NULL
制約を外す必要がある & 他アプリケーションで参照しているテーブルのため影響が大きい、という理由から以下のようなクエリを直接書いてエラーを回避しました。
(根本原因としてはMySQLのsql_mode
が空で設定されており0000-00-00 00:00:00
を入れることが許容されていたため)
sql = "UPDATE foo SET bar_at="0000-00-00 00:00:00" WHERE id = #{id}"
ActiveRecord::Base.connection.execute(sql)
Ruby / Rails のバージョンを一気に上げたこと、特に10年分のバージョンアップという影響は大きく、今までのバージョンが低いことを原因とした諸々の問題が一気に解消されました。
開発体験の向上
大前提としてRubyやRailsの最新の機能が使えるようになったことは大きなメリットとしてありますが、特に以下のような普段の開発体験が大幅に向上しました。
- 言語サーバの Ruby LSP によるローカル環境の開発体験の向上
- gemを含む定義元へのコードジャンプ
- フォーマッター & リンターの警告リアルタイム表示や保存時の自動修正
- コントローラーのアクションメソッドから
routes
定義やviewファイルへのコードジャンプ - ホバー時のドキュメント表示
- 大幅に強化された debug gem や IRB による、デバッグや
rails console
の体験向上
パフォーマンスの向上
こちらもバージョンアップによる影響で、リリース以降でパフォーマンスが大きく改善されました。
アプリケーション全体のレスポンス時間
アプリケーション全体としてリリース以降、パフォーマンスが改善されました。P95, P99が赤と緑のグラフに該当しますが、そのような重たい部分が改善されていることが確認できます。
機能1のレスポンス時間
夜間はほとんど使われていないのでグラフが途中で切れていますが、こちらもかなり改善されています
機能2のレスポンス時間
この機能もかなり改善されています。緑と赤の部分がP99とP95を示していますが、特にバージョンアップ前に遅かった部分が劇的に改善されています。
特にこの機能2はユーザによって扱うレコード数が大きく変わる機能で、少ない場合は数十件ですが、多い場合は数千件を取得し、繰り返し処理する機能です。
取得するレコードが多い場合は比例してRubyの処理が多くなるため、そのような機能では顕著にパフォーマンスの改善が見られました。
開発文化の改善
バージョンアップ前の準備としてテストを拡充したことにより、テストコードを書く文化が定着するようになりました。
対応前のテストカバレッジは約40%でしたが、これが80%以上になったことで、割れ窓的な状況が改善されたことが大きいように感じます。
- バージョンアップに耐えられる環境を整備することが重要 (テストカバレッジやデプロイ環境、エラー通知の仕組み等)
- バージョンアップに対する課題を一気に解決できる方法はないので、現状を認識して愚直に1つずつ修正を繰り返す以外に方法はない
- バージョンアップを貯めること自体が負債そのものなので、日頃から計画的にバージョンアップを行い一気にバージョンアップすること自体を避ける
- テストを書く、不要な機能は削除する、フレームワークのデフォルトに従う、RESTに従う、テーブルの制約を正しく付ける、なるべく特殊なことを行わない等々がバージョンアップを容易にする
Views: 0