Ruby/Railsアップデートを乗り越える戦略と攻めのリファクタリング

初期構築から3年近くメジャーアップデート対応をしていなかったRuby on Railsのプロダクトについて、事前のリファクタリングなどの整備を経てアップデート作業を実施しました。本稿ではその事前整備に焦点を当て、大規模なアップデートの経験やそれに対する準備がされていないプロダクトに対する事前整備として、どういう背景から何を考えどのようなことを行ったのかを紹介します。

Ruby/Railsアップデートを乗り越える戦略と攻めのリファクタリング

株式会社LITALICOは「障害のない社会をつくる」というビジョンを掲げ、主に児童福祉・障害福祉や教育分野で、幅広く事業を展開しています。その数ある事業群の中に、従事者向けメディア・転職支援サービスを提供するLITALICOキャリアというプロダクトがあります。

LITALICOキャリアではRuby on Rails (以下Rails) をバックエンドにしたWebサイトが運用されていますが、約3年前の構築時からRailsメジャーアップデートされておらず、また同様にRuby自体も古くなっていました。これに対し、まずシステム全体に対して大規模なアップデートを円滑に行えるような事前整備を行い、そして本命のRubyやRailsのアップデート作業を実施するという対応を行いました。

本稿では、大規模なアップデートの経験やそれに対する準備がされていないプロダクトに対する事前整備を考えた際に、どういう背景から何を考えどのようなことを行ったのかを紹介します。またアップデート実施を通じて、この整備で得られた成果について確認します。

紹介する内容は、アップデート作業との直接的な依存関係はなく整備だけで独立して実施可能であり、また特別なツール導入などというよりはコードリファクタリングの延長線上にあるものの集合となっています。そのため、現時点でアップデートを行う予定が無い場合であったり、まとまった大きな時間が取れない場合であっても活用しやすい内容になっていると思います。

前提環境とプロジェクトの規模感

本文に入る前に、実例のアップデート対象となるコードベースやそのチーム、また実施した筆者のステータスを記載しておきます。本文中で触れる意思決定は暗黙的にこれらの前提に依存していることがあります。

  • サービスローンチが2019年2月で、取り組み開始時点(2021年11月)で3年経過してない程度。コードベースは、整備の主対象となるRails側(.rbファイル)に限れば5.5万行ほど、フロントエンドなど含め全体では16万行ほど
  • チームは高速に開発して小さくリリースしていくスタイル(1日に2~5リリースくらいのペース)を採っており、大きな変更を小さく低リスクなものに分割してリリースしたり、通常の機能開発と平行して整備を進めやすい
  • 筆者(本件の実施者)は2020年10月に入社と同時にプロダクトチームにジョイン。これまでに一年ほど開発に携わっており、全体の技術構成やどこにどんな機能やコードがあるかなどをほぼ把握していた

また、整備のあとに実施した本アップデート作業も含め、下記のような作業日数の取り組みとなっています(おおまかですが実績値です)。本アップデートについては記事の最後に軽く触れますが、整備後に3段階の更新を行っています。

  • 整備全体: 約14日 (週1日作業、4ヶ月)
  • Ruby 2.7 / Rails 6.0: 約5日 (週1日作業、1ヶ月少々)
  • Ruby 3.0 / Rails 6.1, Ruby 3.1 / Rails 7.0: 約25日 (6, 7割の作業配分、2ヶ月少々)

Rails 6.0については、過去にアップデートを試みたが一部gemの互換性の問題で諦めたという経緯があり、その際に事前作業の一部が完了していたため作業日数が少なくなっています。

全体で約44日の作業に対し、1/3を占める整備部分が本記事の主内容となります。

ことのはじまり

今回のRuby/Railsアップデート実施を検討することになったきっかけは「Railsの7.0のalpha版が公開」というニュースを受けたことでした。この当時のRailsのメンテナンス(セキュリティリリースの提供)ポリシーに則ると、このニュースは事実上「(今使っている)Rails 5.2系のサポート切れが近い」を意味していました。

アップデート対応を考えるには具体的なスケジュールが欲しかったのですが、当時は「そう遠くないうちに5.2のEoLが来るはず」くらいの情報量しかなく、それが具体的にいつなのかの確定情報はありませんでした。

時期の目安としては、7.0 alphaのリリース記事に「今年(2021年)中に本リリースできるようご協力を(意訳)」と年内リリースの計画を匂わす記載があったものの、過去傾向などから実際にはもっと先になる(余裕がある)だろうと推測していました。また過去リリースの流れより、より本リリースに近づいたタイミングでbeta版やRC版が出ると考えられ、それで軌道修正もできるだろうという見立てもありました。

この状況から、具体的にどう対応を進めていくかを考えていきました。

戦略としての事前整備

スケジュールが不確実で計画がしづらいが時間的猶予はありそうだ、また直近でのアップデートの実施有無の判断はしづらそうだということから、この時点で事業的にキッチリと時間を取って進めるのではなく、一旦は筆者個人が負債返済の日(チーム全体で、そういう日が週一で存在した)を使って事前整備から進めておこうということにしました。

実際のアップデートに紐づかない整備であれば、直後にアップデートを行う想定がなくても単独で実施でき、またアップデートまで含めた作業よりは小規模なタスクの集合になることで、時間の調整がしやすいというポイントがあります。

時間の猶予があるうちは腰を据えた重めの整備を進めつつ、beta版やRC版が出たタイミングなどで小粒なタスクに舵を切ったりアップデート本体作業に移るなど進捗を考慮しつつ調整する想定としました。整備は途中で止めて腐るものではないので、状況によって(アップデート本体も含め)計画を中断することも可能です。

※実際にRails 5.2, 6.0のメンテナンス期間が伸びたという事態はあり、中断も十分ありうる

なお、アップデート作業に直接紐づかない整備ということは、実際にはアップデートの時間短縮にならない作業が入る可能性があります。しかし今回はスケジュールには余裕がある想定であり、アップデート完了まで最短期間で駆け抜ける必要性は無く、特に問題ではないと判断しました。

具体的な作業トピックについては、アップデート時に実施する必要がありそうだが事前にできそうなこと・アップデート関連作業を阻害しそうな要素を取り除くこと、を考えて選びました。周辺ライブラリを事前に最新化し、コードベースを小さくすることでアップデートの作業対象を減らし、アップデート後の検証をスムーズにする整備をする、といった内容です。

以降、実際の取組みのいくつかについて、トピックごとに必要性や考えたことと共に紹介していきます。

※記事の都合上、実施内容は時系列には並んでいないことがあります

トピック1: 周辺ライブラリの最新化

元々RubyやRailsのアップデートがなされていなかったという前提があるコードベースですが、Rails以外のgem(Rubyにおけるライブラリのこと)も同様にあまりアップデートがされていない状態でした。このままの状態でRubyやRailsのアップデートを進めた場合、他のgemの依存関係やgem自体が新しいRubyやRailsのコードに未対応などによって、本命のアップデートが阻害される恐れがあります。

また最終的に周辺gemをすべてアップデートするのであっても、個別gemのアップデートであれば少しずつ一度のリリース影響範囲を小さく保ちながら実施できます。更に、先に広範囲なアップデートを試しておくことで、システム全体のうち実は変更や検証が難しい部分をあぶり出す(先んじて踏み抜く)ことができます。これらの理由から、事前準備としてRailsを除いた全てのgemの最新化を目指します。

更に、Rubyに限った話ではありませんが、ライブラリ類のアップデートは貯めれば貯めるだけハードルが上がります。gemごとの新旧バージョン乖離による変更量の大きさもありますが、要対応なgemの数が増えることにより心理的に「大変そう...」と更に後回しにしてしまい、悪循環が発生します。そのため、最新化したgemのバージョンを最新に保つフローの整備もここで行います。

Rails以外の全gemを更新する

まずは上げられるものをすべて更新しますが、影響範囲や依存関係が近いある程度の塊ごとに、少しずつアップデートしていきました。たとえば、本番では動かないdevelopment/testグループのものや、画像アップロード系(carrierwave, fog-aws, rmagick)、検索系(elasticsearch, searchkick)、といった塊です。

こうすることで、1つずつアップデートする場合に比べて、同じ機能を何回も検証するのを避けつつ1リリースあたりの影響範囲を狭めて対応することができます。当然すべてがこのようなグルーピングができるわけではなく、対象によっては1gem1リリースにしたり、関係は薄いが影響が軽微なものをまとめてリリースしたりもしました。

それぞれのアップデート作業自体は、地道に対象gemのリリースノートや差分を見ながら修正対応をし、検証し、リリースするという流れです。Railsのような大型のものでないものは、リリースノートやマイグレーションの整備が不十分なものも多々あるため、具体的な対応は臨機応変に行います。

対象のgemをすべてアップデートで対処するのではなく、削除することでアップデート自体を不要にするという対応もいくつか行いました。これらについてはトピック2: 保守対象の断捨離セクションで紹介します。

また、多くのgemは単にアップデートして簡単に検証するのみで完了しましたが、検証やアップデート自体が難しいものもいくつかありました。その対応の一部については後述の トピック3: 検証環境の整備セクションで紹介します。

自前forkを解除する

なんらかのやむを得ない事情により、rubygemsで公式に配布されているgemではなく、自前でforkしたものであったり特定のbranchを直接指定して使っている場合や、またvendorなどリポジトリに直接取り込んで使っていることがあると思います。

LITALICOキャリアの例でいえば、dangerというPull Requestの差分を見て警告を出すgemの内部で使っているruby-gitというgemにて、日本語などの名前のファイルがあるとエラーになるという問題がありました。これを修正するPull Requestは存在していましたが、発見当時で既に長らく放置されておりmergeされる見込みが見えなかったため、Gemfileに該当ブランチを直接指定して使っていました。

このように本家大本ではないgemを指定していた場合、forkを自分たちで積極メンテするのでもない限り、本家のアップデートに追従できない(更新していることに気付けない)ことになります。これらは定期的に棚卸しできるのが理想だとは思いますが、実際問題としてはできていなかったため、このアップデートの機会に棚卸しと更新を行うことにしました。

LITALICOキャリアでこの手の対応をしていたものは前述のruby-gitを含めて2件ありましたが、幸いにもどちらも対応パッチが既に本家に取り込まれていました。そのため、Gemfileに本家を使うように指定しつつ最新版にアップデートすることができました。

gem最新化のフローをつくる

最新化ができた後はその最新状態を維持します。実現方法については、チームの努力目標にすることを避ける(形骸化する可能性を減らす)ことと、仕組みをあまり自作せずマネージドに寄せる方向で検討しました。具体的には、GtiHubのdependabotによる自動化を設定しました。作業としては以下の内容で .github/dependabot.yml ファイルを追加したのみです。

version: 2
updates:
  - package-ecosystem: “bundler”
    directory: “/”
    schedule:
      interval: “daily”
    labels:
      - “ruby”
      - “dependencies”
    open-pull-requests-limit: 2

「bundlerのパッケージの更新PullRequestを、日次で最大2個まで作成する」という内容です。特にツールやライブラリなどの導入は必要なく、このファイルを追加するだけなので、設定としては非常に簡単にできました。

もともと我々のチームでは、PullRequestに対して自動でレビュアーが割り当てられる仕組みとレビューが放置されない流れが既にできており、更にdependabotのセキュリティパッチがその運用で概ね回っていました。そのため、この自動アップデートもそのフローにそのまま載せるかたちとしました。

当然、セキュリティパッチとは違い、通常のアップデートは慎重を要する変更もあり運用負荷は少し上がります。しかし全最新化が終わった状態からであり、日々のアップデートの一度の変更幅は小さいと見込まれるため、日常業務としてそのまま組み込むことにしました。

この対応は、直後に行うRubyやRailsのアップデートに特段効いてくるわけではありませんが、日々のセキュリティパッチや将来の大型アップデート時に大きな効果があると考えています。

トピック2: 保守対象の断捨離

アップデート対応に限った話ではありませんが、原則として保守対象のコードの総量は少なければ少ないほど保守は楽になります。ライブラリアップデートにしても、コード総量が少ないほどdeprecated対応を行う対象の数は少なくなるはずですし、バグが混入しうる対象は減ります。

そのため、本命のアップデートの手間を減らしかつ安全に実施できるようにする観点で、事前に断捨離を実施することには大きな価値があると考えています。また、頻繁なリリースのハードルが低い環境であれば、リスクの一部を断捨離パートのリリースに移譲することでリスク分散も可能です。

なお、自分たちがリポジトリで管理しているわけではない外部ライブラリのコードであっても、自分たちが提供する本番システムや開発フロー(開発環境やCIなど)の中で動作するものであれば、それらは全て「保守対象のコード」に含まれます。ライブラリには自分たちが使っていない機能やコード、また汎用化のためのコードが含まれることが多く、プロダクトから見ればデッドコードに相当する割合が高くなります。そのため、ライブラリ依存を減らすことで「コードを減らして保守を楽にする」につながることがあります。

※当然、中身の質やメンバーのスキルなどによってはライブラリとして外部で保守されたものを使うほうが良いという判断は大いにあります

断捨離対象の選定には、以前より不要と認識していたが対応していなかったもの、また対応を進めていたが最後まで完了してなかったもの、前述のgem更新時に消せそうだと気付いたものなどがあります。また対処方法にも単純に消す、依存gemから必要コードのみ抽出して残りを消す、実装方法を置き換えるといったいくつかの方法があります。

ここでは、実際に断捨離されたものの実例や意思決定の一部を紹介します。

フロントエンド関連の依存を消す

LITALICOキャリアのシステムは、Railsではスケルトンなhtml (body内にはReactのマウントポイント用のdivがあるのみ) と表示データのjsonの生成のみを行い、フロントエンドの機能はフロントエンド(Railsから独立したReact.jsのコードベース)に切り離す構造になっています。

しかし、プロダクトの初期に作成して利用が終わったランディングページ(以後、LP)が消されずに残っており、かつそれがRailsのフロントエンド機能 (アセットパイプライン) に依存していました。そしてそのためだけにアセットパイプライン系gemが入っており、またRailsコンテナにNode.jsを同居させている状態になっていました。

特にRailsコンテナにNode.jsが入っていると、ビルドプロセスがそのインストールスクリプトに依存してしまったり、Railsアップデート時にバージョン互換性を考慮する要素となってしまうことから、gemとあわせてきれいに消してしまうことにしました。

削除対応の事前確認として、該当LPについて

  • アクセスログを過去1年ほど検索し、該当LPがほぼアクセスされていないこと
  • 利用状況についてビジネスメンバーにヒアリングし、現在は使っていないこと(そもそも大半のメンバーがそのLPの存在を認識すらしていなかった)

の確認ができたため、実際の削除対応に踏み切りました。

該当のLPの削除をリリースし問題無いことを確認したあと、GemfileにあったRailsフロントエンド関連のgem(jquery-rails, turbolinks, uglifier)とRailsコンテナに入っていたNode.jsのインストールスクリプトを削除しました。

redisのラッパーgemを一部取り込む

アプリケーション内でちょっとした値のキャッシュをするためにredisをラップするようなgemが使われていたのですが、ほとんどのユースケースではRails.cacheで置き換えが可能なこと・ライブラリ自体が保守されていないことから、以前より少しずつ置き換えを進めていました。本件のRuby/Railsアップデート前の時点の利用箇所は残り数ヶ所となっており、一つを残して全て順調に置き換えが完了していました。

最後の一つとしてメール配信バッチの送信制御ロジックが残されましたが、残り一箇所を置き換えれば脱却可能という事実に加え、そのgemがredis-rb gemのバージョンを古いものにロックしており他のgemアップデートを阻害し始めていたことから、この機会に完全に脱却することにしました。

とはいえ、簡単に脱却できないからこそ最後まで残されていたという事情もあり、単純置換では対応できませんでした。具体的にはredisのSorted setsを使うコードになっており、単純なキャッシュ利用をしていた他のコードと違いRails.cacheで簡易に置き換えるようなことはできませんでした。

そこで最初にいくつか対応パターンを検討して試しましたがうまくいかなかったため、最終的に以下のような方法と手順でリファクタリングをするかたちを採りました。

  1. 該当gemを直接利用しているクラス単体テストを手厚く書き直す
  2. 上記クラスから参照しているgemのコードをそのままクラス内に取り込む
  3. テストが通る状態を維持しながら、クラスをリファクタリングする
  4. 参照されなくなったgemを消す

2. の「gemのコードをそのままクラス内に取り込む」は具体的には下記の差分になりました。継承で取り込んでいたコードをベタ書きに移植するかたちです(実際に取り込んだコードはこちら)。

-class SentOfferIdSet < Cacchern::SortedSet
+class SentOfferIdSet
+  # ----- ここからcacchernのCacchern::SortedSetのコード -----
+  attr_reader :key
+
+  class << self
+    def contain_class(klass)
+      @value_class = klass
+    end
+
+    def value_class
+      @value_class ||= SortableMember
+    end
+  end
+
+  def initialize(key)
+    @key = "#{self.class.name.underscore}:#{key}"
+  end
+
+  ...中略...
+
+  def remove_all
+    Redis.current.del @key
+  end
+
+  # ----- ここまでcacchernのコード -----
+
  contain_class SentOfferIdMember

  def add_with_expire(sent_offer_ids, expire_at)
    sent_offer_ids.each { |sent_offer_id| add SentOfferIdMember.new(sent_offer_id, expire_at) }
  end

  ...中略...
end

単純に貼り付けただけでは動作しない場合もありますが、その場合は最低限動くように調整します。最初にテストを手厚く書き直しているので、調整自体は難しくないはずです。

ここまでできれば後は通常のコードリファクタリングと同じで、未使用コードを削除したり分離している関数をひとまとめにしたり、このクラスでは不要になる分岐を削除したりしていきます。一般ライブラリであれば必要になるユースケースでも特定の具象クラスに限れば不要なものは多く、結果的に多くのコードが削除されていきます。そうしてgemの削除まで完了すれば、既存の機能は残したまま多くの保守対象コードを減らすことができます。

なお、この方法を採る以外にも、既存手法を踏襲せずに全く別の方法で(redisではなくRDBを使うなども視野に入れて)要件を再実装する選択肢も考えました。しかし、あくまでここでの目的は該当gemへの依存を消すことであり既存実装に他の問題があったわけでもなかったため、確実に低リスクな方法を採りました。

searchkick gemへの依存を置き換える

LITALICOキャリアでは、求人データを始めとした一部のデータ検索にElasticsearchを活用しており、そのラッパーライブラリとしてsearchkickを使っています。前述の全gemアップデートによりこれもアップデート対象であり、かついくつかのdeprecation warningの対応が必要でした。

CIや本番環境のログを見ながら修正対象のコードを見ていくのですが、よくよく見るとそれらのコードの一部は使われていない可能性があったり、Elasticsearchである必要がなさそうなモデルでした。

未使用と思われるものはコードを見て呼び出し元を辿り、未使用であることを確認して削除しました。Elasticsearchである必要がなさそうなモデルというのは法人データだったのですが、法人名に対する単純なLIKE検索のようなユースケースしかないこと・将来的にもレコード数が爆発的に増える見込みは無いことから、下記のようにElasticsearch連携を消してRDBで直接検索するように書き換えて対応しました。

-def self.search_with_params(params)
-  ids = search(params[:words]).map(&:id)
-
-  where(id: ids)
-end
+def self.search_with(name:)
+  return self if name.blank?
+
+  where('name like ?', "%#{name}%")
+end

RDB版では検索対象カラムがtokenizeされないため厳密には挙動が変わっていますが、対象は法人名なのでむしろ直感に沿う挙動になりました。ついでに、パラメータはnameのみ受け付けることを引数で明示するようにしています。

そもそもLITALICOキャリアでのElasticsearchの使い方としては、RDBにオリジナルデータがあるモデルに対してElasticsearchに射影を作り、そこに検索をかけてRDBのレコードのPrimary Keyを取り出し、そのKeyを使ってRDBからオリジナルデータを取り出す、となっています(searchkickの標準ユースケースです)。これはデータの二重管理が発生し、同期タイミングやテストコードの前提作りで問題が発生することがあります。そのため、Elasticsearchで検索するメリットが無いモデルでは利用しない方がよいと判断できます。

※Elasticsearchに載せてメリットがあるモデルには、長いテキスト群に対しいわゆる全文検索をしたい場合、様々な属性から凝ったスコアリングやソートや集計をしたい場合、またRDBのテーブルの構造が複雑などの問題でSQLではパフォーマンスが出ない場合、などがあります。

もちろん上記に当てはまらずwarning対応が必要なコードもありましたが、この掃除によって対応範囲を減らすことができました。RDBへの置き換えに関していえば、warning対応をする代わりにコード置き換えをやっているので、手間としては大差ないか少し増えているともいえます。しかしよりシンプルな実装になったことで、テストやgemアップデートを含めた将来のメンテナンスを踏まえるとトータルで低コストになったと考えています。

トピック3: 検証環境の整備

RubyやRails、その他全gemをアップデートしていく場合、最終的にはシステムのほぼ全機能を事前検証することになります。それを為すためには、当然ながら全機能が可能な限り検証環境で動作できる必要があります。

しかしながら現実的には、外部に本番リソースしか存在しないなどの理由で検証が難しいものも存在します。LITALICOキャリアの場合、SMS送信や外部連携サービスのうちの一つが該当します。それ以外にも、処理時間や料金などの兼ね合いで検証環境での実行を止めている場合や、単に検証を想定してコードや設定を書いていないといったパターンもあるかと思います。

とはいえ、検証せずに変更をリリースするリスクは思考停止で受け入れられるものではないので、個別の状況を鑑みつつも、可能な限り本番に近い検証ができるように調整していきます。

実際にどの機能が検証できなくて整える必要があるのかは、実際に何かを検証しようとしたときに初めて気付くことが多く、本章の例のうち2件は実際にgem整備中に気付いたものです。どれも普段の開発のついでには少々やりづらい規模の開発ですが、この機会にきちんと整備しました。

SMSをテスト送信できるようにする

LITALICOキャリアでは利用者にSMS (Short Message Service) を送る機能があり、それはAmazon SNS (Simple Notification Service) のSMS機能で実現されています。

当初、非本番環境でのSMS送信機能の挙動は、ログ出力のみを行うdry-runとなっており、実際には何も送信していませんでした。というのも、SMS送信のダミー送信先を簡易に用意する手段(メールでいうmailtrapのようなもの)が見つけられなかったこと、SMS送信は都度料金が発生することなどからです。

気軽に送信されるのは避けたいという観点からの対応としては良いのですが、これではAmazon SNSのライブラリのアップデート時に検証環境での事前の疎通検証ができません。そこで、それらに対応できるようSMS送信機能の実装に改修を行いました。

平常時には本番以外では送りたくないという要件は保持しつつ、本番以外でも仕込みをすれば一時的に送信テストができるという機能にしたかったため、環境ごとにSMS送信可能な宛先番号をホワイトリスト指定できるようにしました。全番号許可も指定できるように実装し、本番は常にその状態にしています。

本番以外の環境では開発者が意図して入力しない限りは有効な電話番号が保存されることはなく、意図せぬ宛先にSMS送信が実行される事故は発生しないようになっていますが、安全のため検証用の環境でも送信可能な番号は制限できるようにしました。

この整備により、Amazon SNSのgemアップデートや何かしら実際のSMS送信機能がきちんと事前検証をした上でリリースできるようになりました。

メール配信バッチの検証環境を整備する

LITALICOキャリアでは利用者にメールを送る機能がいくつかありますが、その中の一つに「毎朝たくさんの求職者に求人案内メールを配信する」というバッチ処理があります(以後、メルマガと呼ぶ)。これは他のメール送信よりも送信件数が非常に多いという特徴があります。

このメルマガのロジックに手を入れる工程(前セクションで触れたもの)があり、いくつかリファクタリングのアイデアを考え実装・検証を行っていました。何回か開発環境(ローカルではなくAWS上の環境)にて動作検証を行っていると、途中から所定のメールボックスにメールが届かなくなってしまいました。調べてみると、LITALICOキャリアの開発環境ではメールの宛先としてmailtrapというサービスを使っているのですが、メルマガの一回あたりの送信件数が多すぎてmailtrapの月当たりの受信可能件数を使い切っていることがわかりました。

このままでは、これに限らずメルマガの事前検証が十分にできないため、まずは検証できるように手を加えることにしました。具体的には、アプリケーション側のメール送信クライアントのコードにて、「開発環境かつバッチ送信をする場合、件数が多ければ(100件以上なら)先頭1件のみを対象とする」という処理を差し込みました。

-request_bodies = build_request_bodies(envelopes)
+# mailtrapの利用枠を埋め尽くしてしまう問題への対処
+if sandbox? && envelopes.size > 100
+  Rails.logger.warn("サンドボックスへのメール送信件数が多い(#{envelopes.size})ため、先頭一件のみ送信します")
+  envelopes = [envelopes.first]
+end
+
+request_bodies = build_request_bodies(envelopes)

request_bodies.each do |request_body|
  response = SendGrid::API.new( ...

実装としては、ごく簡易的に送信対象をフィルタする分岐を追加するだけとなりました。

この他にも、メルマガ自体のロジックで開発環境の場合に送信対象を1件に絞る方法や、実際には送信せずにdry-run的な実行を行う方法も採れたのですが

  • 対象送信問題はメルマガに限らないので、それ以外でも事前防止ができたほうがよい
  • メルマガロジックの「全対象者用の送信データ生成が完了すること」を検証できる余地を残したい(最初に送信対象を絞るとそれができない)
  • dry-runの場合でも、受信メールの内容検証のために1件は(mailtrapなどに)実送信する必要があり、ロジックが少々煩雑になりそう

などの理由から前述のような対処を選択しました。

この整備によりメルマガロジックを安全に事前検証できるようになり、Ruby/Railsアップデートの検証が確実に実施できたのみならず、その後のメルマガの改善施策の開発にも役立つようになりました。

全ての定期バッチを検証環境でも動かす

システムには所定の日時に毎度実行するといった定期バッチ処理がいくらか存在することが多いと思います。LITALICOキャリアにおいても、DBデータのバックアップやメルマガ配信やユーザへのリマインドメールなどがあります。

これまでこれら定期バッチは本番で実行されるのみで、検証環境では実行されていませんでした。個々のバッチはrakeタスクとして実装されており、個別タスクを手動で実行するインフラは存在しているため、これまでは必要に応じて必要なタスクだけを手動実行して検証するという運用でカバーしていました。

しかし、たとえばメール送信の基底コードの変更のように多数のタスクを全て検証する場合に手間なことや、定期実行の設定そのものを事前検証する手段が存在していなかったことから、検証環境にも定期バッチの設定を全て移植することにしました。

やったこととしては単に本番の設定を検証環境用に移植しただけですが、Ruby/Railsアップデート時の検証や今後のgemアップデートの検証など、単に検証環境にデプロイして1日放置するだけでほとんどのバッチ検証が完了できるようになり、効率化と安心化が達成できました。

※LITALICOキャリアの定期バッチは日次がほとんどで、1日おくだけで残りが週次と月次の数件のみになる

アップデート実施から見る整備の効果

紹介したことを主とした事前整備を終えた後、そのままRuby/Railsをそれぞれ1段階 (to Ruby 2.7 / Rails 6.0) アップデートし、更に少し日を空けてから更に2段階ずつ (to Ruby 3.1 / Rails 7.0) アップデートを行いました。

本稿はあくまで事前整備が主体のためアップデートの詳細な作業内容については触れませんが、整備の効果の参考として、大まかな作業内容への影響と全体の所感を紹介します。

まず、アップデート作業のおおまかな内容は以下のようになりました。

  1. 各種ログを見てdeprecation warningを確認し、修正対応
  2. (Railsの場合)アップデートによるデフォルト設定の変更について意味や影響範囲を調べ、必要なら修正対応
  3. アップデートを実施して軽く動作確認し、エラーになる箇所を修正
  4. 検証環境で全体検証を行い、問題なければリリース

1, 2 は整備の有無で特に差はありませんが、3については整備により「互換性問題で周辺ライブラリが壊れる、もしくはアップデート自体ができない」の懸念が取り除かれており、Ruby/Railsの対応に集中できました。

また4の検証についても、整備によって検証自体が楽になった・詳細な検証までできるようになったという分かりやすい効果もありますが、楽になることで「検証が難しい・コストがかかるため検証しなかった項目でバグを見逃す」リスクを減らす意味もあったと思っています。

また具体的な作業期間は冒頭に触れたとおりですが、ざっくりと整備に14日、アップデート3段階で30日となっています。Ruby 2.7 / Rails 6.0 の日数が少ないことも鑑みつつ平均すると、Ruby/Railsの1段階のメジャーアップデートに10~13日程度の作業で完了したことになります。整備も含めた全体を平均しても15~18日程度でしょうか。感覚値ではありますが、この手のアップデートとしてはかなりハイペースな印象です。

事前整備の効果としてこれらのようなアップデート作業自体の円滑化もありますが、戦略のセクションでも触れたように計画・スケジュールを柔軟に組みやすいという利点もあります。また、コードベースをきれいにしてから挑むアップデートは「きれいに均されているから、さほど厄介な問題はないはずだ」という自信が持てます。とても精神論ですが、案外重要な要素ではないでしょうか。

まとめ

Ruby/Railsアップデートのために実施した事前整備について、そこに至る経緯や戦略、作業内容を紹介し、その効果への所感をまとめました。

あくまで自分たち固有の状況に対して考えた方法ではありますが、先に整備するという選択はある程度汎用的に適用できると思いますし、また個別トピックの考え方や打ち手はアップデートを前提とせずとも少しずつ実施することもできます。

自分たちのように大型アップデートを検討している方でもそうでない方でも、何か参考になる部分があれば幸いです。

井元 滉(Inomoto Hikaru) twitter: @cumet04 / Zenn: cumet04

inomoto_icon
中学時代より電子工作やコンピュータプログラミングに興味を持ち、そのまま高専に進学。その後、大学の情報系学科に編入。2017年よりAWSインフラの構築や保守、Railsアプリケーションの開発などに従事し、2020年10月より株式会社LITALICOに勤務。LITALICOキャリアのプロダクト開発チームに所属した後、現在はプロダクト横断で技術面での基盤整備を検討・推進している。