💎

Ruby製バックエンドのgemを一括アップデートした話

に公開

こんにちは!アルダグラムでエンジニアをしている秋田です。

弊社では以前、 フロントエンドのライブラリ134個を一気にアップデートしてリリース しました。
それと同様に Ruby製のバックエンドのアップデートも実施したので、今回はその内容について紹介します。

まず言いたいこと

ライブラリを一括アップデートするのは全くもってお勧めしません。

フロントエンドのときに分かっていたのにどうして……というのはもっともなのですが、全体的にアップデートされていなかったので仕方ないです。

それでもアップデートされていないならアップデートしましょう!

後回しにするほど大変になるので。

主なバージョン

railsを含む88個のgemと、Rubyの更新を実施しました。
以下は主なもののバージョンの変化です。

before after
ruby 3.0.2 3.4.3
bundler 2.2.24 2.5.22
rails 6.1.4 7.2.2.1
graphql 2.0.12 2.5.0
sidekiq 6.2.1 7.3.8

Railsはアップデート作業時点でv8系がリリースされていました。しかしリリースされて間もなかったことと、v8に対応しきれていないgemがあったために見送りました。
Sidekiqについては、この記事の執筆時点では8系がリリースされていますが、アップデート作業開始時にはリリースされていなかったと記憶しています。

作業の進め方

対象gemの確認

最新でないgemは、Bundlerであれば bundle outdated コマンドで確認できます。
ただ、これだとGemfileに明記されていない依存gemも表示されるので、 awk で以下のように整形しました。Groupsが空のものはGemfileにない依存gemなので、$5 を条件にフィルタリングしています。

$ bundle outdated | awk -F '  +' '$5!="" {print $1,$2,$3,$5}'
Gem Current Latest Groups
graphql 2.0.12 2.5.0 default
rails 6.1.4 8.0.0 default

これをNotionにまとめ、アップデート進捗を管理しながら進めました。

リリース方針の策定

上げられるバージョンはすべて上げる方針です。とはいえ、フロントエンドのときは大変なことになったので、バックエンドでは分けられるものは分けてリリースすることにしました。
検証工数の考慮もあるので2段階のリリースとし、

  1. 開発でしか使わないもの(development, testグループ)
  2. 本番で使用するもの(defaultグループ)

のうち、1番のものは早期にメインブランチへのマージすることで、本番影響があるリリースのコード差分を減らしました。

アップデート実施

アップデートの流れ自体はフロントエンドのときとほぼ変わりません。

  1. アップデート対象のgemをピックアップ
  2. https://rubygems.org/ や 各gemのGitHubリポジトリを参照
  3. 使用バージョンからアップデートターゲットバージョンまでの変更履歴を確認(特に Breaking Changes)
  4. Gemfileのバージョンを更新し、 bundle install 実行
  5. 影響箇所を修正
  6. Pull Requestを出す、CIでエラーがある場合は再確認

これを対象のgem全てに対して実施しました。

検証

実はフロントエンドのときほどの不具合は、QAでは検出されませんでした。
これには以下の要因があります。

  • バージョンアップしたライブラリ数がフロントエンドより少ない
  • 修正コード量もフロントエンドより少ない
  • バックエンドのほうがテストコードが充実している

特に今回はRSpec実行時点で検出した不具合が多く、結果としてQAでの不具合検出は13件、そのうちアップデート起因は3件でした。
やはりテストを書くのは大事だな、と改めて感じます。
テストが多すぎてRSpecの実行時間が長いという別の問題はありますが……。

リリース

1段階目のリリース
リリースフェーズ1

2段階目のリリース
リリースフェーズ2

これぐらいなら……という差分量でしょうか。多くの差分が出ていますが、実際にはRuboCopをアップデートしたことによる整形差分も含まれているので、挙動に影響する変更は見た目ほど多くないです。
大きな不具合もなく無事にリリースできました。

アップデートの運用

フロントエンド同様、バックエンドにも Renovate を導入しました。バックエンドの中でもKotlin側は以前からRenovate運用されていたのですが、Rails側はまだだったので揃えた形です。
これで定期的にアップデートを実施する運用としています。

今回の主な出来事

今回のライブラリアップデートを通じて印象に残った出来事を記載します。

従来は本番でも全gemインストールされていた

リリース方針策定の段階で気付きましたが、従来はdevelopmentやtestのグループも本番コンテナに含まれていました。
Bundlerは特に指定がないと全グループのgemをインストールするため、意識しないとそうなります。
コンテナイメージ作成時に bundle config without development test を設定しておくことで回避しました。

Rubyバージョンを上げると一部config取得結果が変わる

configsettings.yml があり、その中身が

demo:
  first:
    value: xxx
  second:
    value: yyy

だとして、これを取得しようとすると……

Settings.demo.first.value   # NG(Arrayが返る)
Settings.demo.second.value  # OK("yyy"が返る)

secondは意図通りですが、firstは意図しない結果になります。
不可解すぎるので調査したところ、 default gem である ostruct のバージョンアップにより Config::Options の挙動が変わり、 firstConfig::Options のメソッドとして扱われるためにこのような結果になるようです。
回避するためには first を別のキーに変更するか、以下のように一旦Hashにする必要があります。

Settings.demo.to_h[:first][:value]  # OK("xxx"が返る)

Railsバージョンを上げるとSQLクエリのサニタイズが変わる

Rails 6系から7系に上げると発生する問題で、以下のissueの内容になります。

https://github.com/rails/rails/issues/44312

実際のものとは異なりますが、以下のようなコードがあるとします。

limit = 10
sanitize_sql_array(["SELECT id FROM user LIMIT :limit", {limit: limit}])

これを実行すると、出力されるクエリはこうなります。

SELECT id FROM user LIMIT '10'

MySQLのLIMIT句には整数値を指定する必要があるため、構文エラーとなります。
今回はLIMIT句などのサニタイズは諦めて、文字列として設定することで回避しました。

limit = 10
sanitize_sql_array(["SELECT id FROM user LIMIT #{limit}"])

Sidekiqのredis-namespaceサポートが廃止された

これまではRedisのnamespaceを使用して環境を分離していましたが、Sidekiqでは使用できなくなりました。
これにより以下2つの課題が発生しました。

  1. 複数ある開発環境で共有Redisサーバーを使用するために設定していた
  2. 本番でも単一のnamespaceを使用して運用していた

1つ目についてはnamespaceをやめ、RedisのDB番号を各環境に割り当てることで回避しました。

問題は2つ目で、そのままデプロイするとキューの名前が変わってしまいます。
本番に無停止でリリースするためには、新旧ワーカーを同時に動かす必要があり、リリースの調整が必要でした。
並行実行については以下の記事で言及されています。

https://www.mikeperham.com/2017/04/10/migrating-from-redis-namespace/

上記の例では環境変数により分岐していますが、並行動作用のコードを埋め込むのも嫌だったので、以下のようにしました。

APIリリース前
ECSのワーカーサービスを複製しておき、複製側はワークフローよるデプロイがされないように設定。
古いコンテナイメージで動き続ける状態。

ワーカーの並行動作1

APIリリース後
デプロイ。APIと通常ワーカーは新しいコンテナイメージで動く。

ワーカーの並行動作2

キューにジョブがなくなった段階で古いワーカー削除
複製された古いワーカーサービスを削除。

ワーカーの並行動作3

なおスケジュールされたジョブは残るので、古いscheduleキューからジョブを取り出し、新しいキューへ入れ直すRakeタスクを作成して実行しました。ジョブとして保存されているRedisのソート済みセットの値には互換性があるので、取得したものをそのまま保存すれば問題なく動きます。
Rakeタスク作成は Cline に任せましたが、これぐらいのものであればいい感じに作ってくれますね。

Railsのキャッシュフォーマットの変更

これはRailsのアップグレードガイドに記載されていますが、キャッシュのシリアライズフォーマットが変更されています。

https://railsguides.jp/upgrading_ruby_on_rails.html#activesupport-cacheの新しいシリアライズフォーマット

Railsの6から7におけるcache_format_versionは 6.17.07.1 があります。
段階的にバージョンアップするのであれば、ガイドにある通り前バージョンのフォーマットが読めるので、

  1. Rails 7.0 or 7.1, cache_format_version 6.1
  2. Rails 7.0 or 7.1, cache_format_version 7.0
  3. Rails 7.1 or 7.2, cache_format_version 7.0
  4. Rails 7.1 or 7.2, cache_format_version 7.1

という順番で上げていけばいいです。
が、今回はいきなりRailsを 6.1.4 から 7.2.2.1 に上げたために、これができません。さらに Rails 7.2 では cache_format_version 6.1 のサポートが削除されている ため、一旦 cache_format_version 6.1 でリリースするということも不可能で詰みました。
仕方がないので今回は Rails 7.2.2.1 + cache_format_version 7.0 でリリースし、以下の状態としました。

  • 元のキャッシュフォーマットは6.1なので、デプロイしても前バージョンは読める
  • cache_format_version 7.0 指定なので、新規書き込みは7.0
  • もしリリースを切り戻す場合はキャッシュ削除

結果的には無事リリースできたので切り戻さずに済みましたが、致命的な不具合が発覚して切り戻しとなった場合は厄介だったかなと思います。

パフォーマンス改善

YJITを有効化したことによるものと思われますが、APIサーバーのパフォーマンスが改善しました。

APIレスポンスタイムの改善

上記はAPIサーバーのレスポンスタイムで、矢印がアップデートのリリースタイミングです。
大体250ms→190msで25%程度レスポンスタイムが短くなりました。YJITによるパフォーマンス改善は元々期待していましたが、しっかり結果として出ていてよかったです。

なお、YJITはRailsのデフォルトコンフィグを上げると自動で有効化されます。

# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.2

最後に

ライブラリは段階的にアップデートしましょう。特にフレームワークや言語のバージョンなどは大きな変更になりやすく、他のライブラリと同時に上げると作業の負荷が高いです。

今回の記事では主な出来事としてRailsとSidekiqを中心に取り上げていますが、他にも

  • 使用していたgemの名前が変わっていた
  • 古いgemは非推奨となり、新しいgemになっていた。ついでに使用方法も大きく変更されていた
  • これまで明示的にGemfileでgemを指定しなくても動いていたが、指定する必要が出てきた
  • 特にドキュメントで言及されていないがデフォルト挙動が変更され、アップデートしたら謎のエラー発生

というようなことがあり、初期調査からリリースまで半年ほどかかっています。

長期の作業ではどうしても他機能開発による新規コードのマージは避けられず、それに対してアップデート影響の再確認なども増えることになります。なるべく作業期間を短く、かつ影響範囲を狭くするためにも、段階的・部分的に継続して作業するのが望ましいです。
細かいものはなるべく自動化(今回の例だとRenovate)し、大きいものは計画を立てて最小限に実施していくのが良いと思います。

アルダグラム Tech Blog

Discussion