Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails 3アプリを段階的にRails 4にアップグレードする(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

Rails 3アプリを段階的にRails 4にアップグレードする(翻訳)

私が最近リーダーを務めた大きなRailsプロジェクトのひとつに、アプリをRails 3.2から4.2へのアップグレード作業がありました。そのときに学んだ、問題を最小限に抑えて大規模なRailsアプリをアップグレードする方法を皆さまと共有したいと思います。本記事で扱っている運用上の問題には、Rails 3.2から4.2へのアップグレード固有の問題もありますが、ほとんどの問題は一般的なRailsの段階的アップグレードに関連するはずです。

Railsアプリのアップグレード方法には次の3種類が考えられます。

  1. 「世界を止める」: 機能開発を中断し、開発者全員がRailsアップグレードに専念する方法
  2. 長期のアップグレード用ブランチ: 開発者がmasterブランチの追加開発に追従し続ける方法
  3. 段階的アップグレード: アプリを複数バージョンのRailsで動くようにし、新旧バージョンで振る舞いが同じになるようにコードを条件化することで互換性の問題を段階的に修正する方法

私たちの場合、アップグレードで取りこぼしが起きないようにしたかったのと、これまでの経験からアップグレード用ブランチ方式にはつらみがあることがわかっていました。その結果、「段階的アップグレード」と「段階的ロールアウト」をやってみることに決めました。この方法は作業量が増える代わりに、以下のような多くのメリットがあります。

  • 開発時間を効果的に使える: ほとんどの開発者が新機能のリリースを担当し、一部の開発者のみRailsのアップグレードを担当します。個別の互換性の問題は細かなタスクに分割できます(そう願いたいものです!)。開発者たちはアップグレード中の問題の修正が必要なときに手伝うこともありますが、基本的には通常のアプリ機能開発を継続します。
  • アップグレード用ブランチが長期化しない: 1つのブランチで(Railsの)メジャーバージョンの違いを超えて互換性を保つのは、ほぼ不可能です。開発者がコミットし続けるコードは新しいバージョンで動かなくなるためです。masterブランチで2種類のバージョンを動かすと、新バージョンで動かないコードを書いたときに詰んでしまいます。
  • リスクを軽減できる: アプリがデュアルブート可能になっていれば、うまくいかない場合にすぐ切り替えられます。新バージョンにコミットしてロールアウトする方法では、productionで大きな問題が見つかったときに元に戻すのが困難になります。

これらを満たすために、アプリをRails 3.2とRails 4.2の両方で動くようにする計画を立てました。テストが両バージョンでパスしたら、新バージョンを少量のAPIトラフィックで検証し、それからサーバーを段階的に新バージョンに切り替えます。最後に、Rails 4で100%完全に動作するようになったら、Rails 3をサポートするのに必要だった条件ロジックを削除します。

Railsの段階的ロールアウトに必要なもの

段階的アップグレードを始める前に、機能上の準備を完了する必要があります。

テストとCIを通しで実行

もっとも重要なのはおそらくここでしょう。テストもCIもなければ始めることすらできません。アプリを両バージョンのRailsとバンドルできれば、アップグレードのためのビルドを分けて作成できるようになります。広範囲に渡ってテストすることが重要です(私たちの場合、単体テスト/結合テスト/Capybaraでのシステムテストがありました)が、production環境との違いがあるため、テストスイートでアップグレード中の問題を残らず検出するというわけにはいきませんでした。

アップグレード中はRails 4のCIビルドをオプションにしておき、すべてのテストを修正したらCIを必須にして、テストが失敗したときに間違ってもmasterにマージされることのないようにします。

「カナリア」デプロイメント

最高のテストが揃っていても十分とは言えません。test環境とproduction環境の振る舞いを同期する裏技をどれほど駆使したところで、違いはどうしても生じます。特にクラスの読み込み/キャッシュ/アセットなどのエラーは、production環境でないと発生しないことがあります。

テスト実行の際は、内部環境を一貫させておきます。さもないと、段階的ロールアウトの途中でproductionの内部環境が一貫しなくなってしまいます(これはあらゆるロールアウトに該当します)。複数バージョンを同時に実行するときにシステムがどのようにやり取りするのかを知るための、何らかの手段が必要になります。

テストでカバーできない未知の問題については、productionのトラフィックを使ったテストで明らかにします。productionのデータベースにしかないレガシーデータがあると、リクエストやデータ量が増大したときにパフォーマンスの問題が発生したり、単にテストケースが漏れてしまったりします。

監視

複数バージョンのRailsでアプリを動かすときには、監視によってバージョン比較の洞察を得る必要があります。

私たちの場合、これを行うためにActiveSupport instrumentationを用いてフックをかけました。以下のようなコードを用いてActiveSupportイベントをかき集めてStatsDへの記録を繰り返しました。記録した情報のイベント名にはRailsのバージョンが含まれています。

def sanitize_metric_name(metric_name)
  metric_name.to_s.strip.gsub(/\W/, '-')
end

ActiveSupport::Notifications.subscribe(/process_action.action_controller/) do |*args|
  event = ActiveSupport::Notifications::Event.new(*args)
  controller = event.payload[:controller]
  action = event.payload[:action]

  prefix = "app"
  suffix = "controller.#{sanitize_metric_name(controller)}.action.#{sanitize_metric_name(action)}"

  scopes = [
    "global",
    "rails-#{Rails::VERSION::MAJOR}-#{Rails::VERSION::MINOR}",
    "rails-#{sanitize_metric_name(Rails.version)}"
  ]

  scopes.each do |scope|
    StatsD.measure("#{prefix}.#{scope}.#{suffix}", event.duration)
  end
end

このアプローチによって、リクエスト数/パフォーマンス特性/エラー率をバージョン間で比較するダッシュボードを作成できました。

当初は上のコードのように、Railsのフルバージョン表記を測定結果に記録していました。これはアップグレードにパッチを当てるときに便利だと思ってのことでしたが、ストレージ容量が増えすぎてしまったので削除せざるを得ませんでした。他のツールならこうした制約のないものもあります(たとえばDatadogなら代わりにタグを使えます)。

ダウンタイムゼロでRailsをアップグレードする手順

必要条件を満たすことができたら、おおまかには以下の手順に沿って進めます。

  1. 調査
  2. 初期探索
  3. Gemfileのパラメータ化
  4. 依存関係を臨機応変にアップグレード/削除
  5. テストの修正(必要に応じて条件ロジックを追加)
  6. 運用上の問題の修正
  7. 新バージョンへの段階的ロールアウト
  8. 古いコードの削除

1. 調査

アップグレードを開始する前に事前調査を行います。Railsアップグレードガイドを熟読しておきます(この後繰り返し読み返すことになるでしょう)。対象バージョンへのアップグレード記事や、対象バージョンで主要なgemの互換性の問題が発生していないかどうかもチェックしておきましょう。Ready for Railsが便利です。

最後に、段階的ロールアウトについて十分調べておきます。Upgrading GitHub to Rails 3 with Zero Downtime(Shay Frendt)には「ゼロダウンタイム」アップグレードへのアプローチが示されており、私の仕事に天啓を与えてくれました。

2. 初期調査

調査がある程度進んだら、次にRailsを更新後のバージョンでとりあえず起動してみることをおすすめします。rails gemをアップデートしてからbundle installを実行し、必要に応じて依存関係を更新したりコメントアウトしたりしてとにかく動かします。これで、アップグレードがどのぐらいつらい作業になりそうかを雑に見積もれます。

私がやったときは、ほとんど殴り合いに近い無造作な調査でした。アップグレードでgemがいくつ影響を受けるかを知りたかったのです。テストをローカルで動くようにし、開発環境でアプリのいくつかのパーツを使えるようになったことで、作業のためのデータがある程度揃いました。

3. Gemfileのパラメータ化

バージョンの異なる複数のRailsをGemfile.lockに共存させられないので、バージョンごとにGemfile.lockを用意する必要があります。幸い、GemfileはRubyプログラムなので(Bundler 2でGemfileがgems.rbにリネームされた理由はこれです)、Gemfileを環境変数でパラメータ化して複数のGemfile.lockを生成できます。

最終的なGemfileは次のような感じになります。

def rails3?
  ENV['RAILS_VERSION'] == '3'
end

if rails3?
  gem 'rails', '3.2.0.1'
  # ...以下Rails 3向けgemのみ
else
  gem 'rails', '4.2.1.1'
  # ...以下Rails 4向けgemのみ
end

# ...その他のgem

以下の2つに注意しましょう。

  • 依存gemをvendorディレクトリに配置する場合、異なるバージョンのgemが誤ってprune(=古いgemの削除)されないようにしてください。これは--no-pruneオプションでできます。
  • bundleを実行するたびに、すべてのロックファイルを更新しなければなりません。--gemfile引数でGemfileを選択できます。たとえばGemfile-rails4というファイルからはGemfile-rails4.lockというロックファイルが作成されます。

私たちの場合、bundlerを常に正しい引数で実行し、両バージョンのRailsを実行できるようにするスクリプトを作成しました。

私たちのセットアップでは、1つのGemfileからGemfile-rails3Gemfile-rails4にシンボリックリンクを張りました。続いてbundleのため、script/bundleラッパーでRAILS_VERSION=3 bundle install --gemfile Gemfile-rails3 --no-pruneを、次にRAILS_VERSION=4 bundle install --gemfile Gemfile-rails4 --no-pruneを実行します。

アプリ起動時のGemfileの指定にはBUNDLE_GEMFILE環境変数を使います: BUNDLE_GEMFILE=Gemfile-rails4 bundle exec rails server

最後のbundlerには1つ注意点があります: --no-pruneオプションは.bundle/configに保持されます。アップグレードが完了したらこの設定を削除して、不要なgemをpruneできるようにしておきましょう。

4. 依存関係を臨機応変にアップグレード/削除

Railsアップグレード作業中、私と同僚は機会があるたびに不要な依存関係や置き換えやすい依存関係を削除しました。これにより、不要なコードのクリーンアップ、不要なgemやモンキーパッチの削除が必然的に発生しました。gemやモンキーパッチの中にはRails 4と互換性がないものもありましたが、その他の依存関係はアップグレード量を減らすために削除されました。

Railsの新しい機能のバックポートの中には、アプリに含めることでバージョンに依存するコードを減らせるものもあります。たとえばstrong_parameters gemを使うと、Rails 3でパラメータをホワイトリスト化できます。この他にもactiverecord-deprecated_findersなど、非推奨になったRails機能の多くがgemに切り出されています。アプリが非推奨APIを使っている場合は、当面はgemでしのぎ、Railsアップグレードが完了してから非推奨部分を修正することを検討しましょう。

5. テストの修正(必要に応じた条件ロジックの追加)

作業量が多くなるのは、新しいRailsでテストがパスするまで修正を繰り返す部分です。「アップグレードブランチの長期化」の呪いを回避するために、あらゆる変更を個別のプルリクに小分けし、masterブランチに頻繁にmergeするべきです。

訳注: アップグレードブランチの長期化については以下の記事もどうぞ。

「巨大プルリク1件vs細かいプルリク100件」問題を考える(翻訳)

API変更のカプセル化

私たちは、条件ロジックを切り離しておくためにRailsVersionSupportモジュールに条件ロジックをカプセル化しました。カプセル化の大まかなルールは、RailsVersionSupportモジュールそのものの内部でRailsVersionSupport.rails3?メソッドとRailsVersionSupport.rails4?メソッドだけを使うことです。コードベースのあらゆる場所で、こうした「意図を明らかにするメソッド」が用いられました。

たとえば、APIコントローラに含めるモジュールをRailsのバージョンに応じて切り替える必要が生じたときは、以下のメソッド呼び出しに切り出しました。

class Api::SomeController < ActionController::Metal
   RailsVersionSupport.include_api_controller_modules!(self)

   def index
     # ...
   end
end

別の例として、SimpleForm gemを条件付きでアップグレードする必要が生じたときに、APIのテキストラベルがわずかに異なっていたことがありました。両方のAPIをサポートするために、以下のようなsimple_form_text_labelメソッドを追加しました。

def self.simple_form_text_label
  if rails3?
    lambda { |label, required| label }
  else
    lambda { |label, required, explicit_label| label }
  end
end

これで、以下のようにlabel_textを設定した場所では現在のRailsのバージョンに合うAPIが使われるようになります。

config.label_text = RailsVersionSupport.simple_form_text_label

プロジェクトが進むに連れて、RailsVersionSupportモジュールも肥大化してきます。最終的にRails 3のサポートが削除されたときに、Rails 3でサポートされるコードはすべてRailsVersionSupportから削除されました。しかし私たちはこのクラスを捨てずに取っておきました。結局その後Rails 5がリリースされましたので...

6. 運用上の問題の修正

テストがすべてパスしたら、アップグレードされたアプリに本番トラフィックを少しかけてテストします。ここで運用上の問題を明らかにして修正する必要があります。

こうした問題のほぼすべては、アプリのインスタンス同士でステートを共有したことによって発生しました。これは、production環境で複数バージョンを同時に実行したときにしか起きない問題です。

図のとおり、バージョンの異なる2つのアプリが同じデータベース/キャッシュ/cookieに対して読み書きを行います。そうしたデータの前方/後方互換性が失われると問題が発生します。

発生する問題は、Railsやライブラリをどのバージョンからどのバージョンにアップグレードしたかによって異なるので、すべての問題をカバーするのは到底無理ですが、典型的な問題はいくつかあると思います。アプリのバージョンに関わらず、ステートを共有すれば常にエラー発生の可能性があることを頭の隅に置いておきましょう。

キャッシュシリアライズの非互換性

Rails 4をカナリアリリースで動かしたときに最初に気づいた問題の1つに、Rails 3サーバーでの例外発生がありました。この原因は、ActiveSupport::Cache::Entryインスタンスが書き込むキャッシュがRails 3と4の間で互換性がなかったためでした。Rails 3が書き込んだキャッシュはRails 4から読み出せましたが、逆はだめでした。私たちはActiveSupport::Cache::Entryへのモンキーパッチ適用を試みましたが、問題の再発が繰り返された結果、キャッシュで使われるキャッシュ用名前空間を分けることに決めました。環境とバージョンの組み合わせに応じて、それぞれに独自のキャッシュを与えたのです。

Railsフラッシュのシリアライズにおける非互換性

Railsのflashにも同様の問題がありました。Rails 3はRails 4のflashをシリアライズできません。これはRails 3とRails 4のアプリサーバーを混在してロールアウトするときに問題になります。

Unicodeエスケープの修正

今回発生した中で最も微妙な問題は、JSONとUnicodeに関連していました。アップグレード版のカナリアを実行中に、一部の入力に絵文字が含まれてしまうことがありました。Rails 3ではUnicode文字のエスケープActiveSupport::JSON.encodeが使われていました。しかしUnicode文字が基本多言語面ほとんどの絵文字はここに属しますが、すべてはありません)から外れている場合にうまく動かなくなってしまい、これらの文字が脱落してしまいます。Rails 4.0ではこれらの文字をエスケープしないようにすることで修正されました(JSON文字列はUTF-8なのでエスケープは不要です)。

しかし困ったことに、私たちの分析データベースで使っていたMySQLはutf8エンコーディングで動かない設定になっていました。Rails 4のJSONエンコーディングを修正したことで、今度はカナリア版のイベントでマルチバイト文字が出力されるようになってしまいました。このイベントは私たちの分析パイプラインで処理されていましたが、その後の分析結果の行を設定の不適切なMySQLテーブルに挿入できなくなりました。この処理が本質的に非同期だったため、分析ジョブが失敗する理由の原因を突き止めるのにかなり時間を取られてしまいました。

こぼれ話: この問題のせいで、自分の娘をデイケアから引き取りに行くのをころっと忘れてしまいました。同僚のデスクにかじりついて血眼でデバッグしていたので時計を見る暇もなかったのです。あぁ~~。

訳注: MySQLとUTF-8エンコーディングの問題については以下の関連記事もどうぞ。

MySQLのencodingをutf8からutfmb4に変更して寿司ビール問題に対応する

アセットパイプラインの変更

次なるRailsの機能は、みんな大好きアセットパイプラインのお話です。Rails 3とRails 4でアセットパイプラインが変更されたせいで死ぬほど頭の痛い日々が続きました。アセットのマニフェストがローカルの開発環境で使われないのでテスト中はこの問題が発生せず、しかもテストを部分的なロールアウトで簡単に行う方法が見当たらなかったのです。

Rails 4ではアセットをダイジェスト化するアルゴリズムが変更されました。このため、生成されるファイル名のダイジェストが変わってしまい、ダイジェスト化されないファイルが生成アセットに含まれなくなってしまいました。また、マニフェストファイルの形式も変更されました。私たちはアセットのビルドを2回行い(Rails 3で1回、Rails 4で1回)、ダイジェスト化されたアセットとマニフェストファイルの2セットをデプロイすることで、どうにかこの変更を回避しました。

アセットヘルパーの振る舞いも変更されていました。アセットが見つからないときに生成されるフォールバック先のパスがRails 3とRails 4で違っていたのです。作業中に、アセットマニフェストのないアセットがバックグラウンドのワーカーで使われている(HTMLメールの画像URLなど)ことを発見しました。つまりこのワーカーはフォールバックの振る舞いに依存していたのです。Rails 3のコンパイル済みアセットにはダイジェスト化されていないファイルが含まれていたためにこの問題が起きたのですが、Rails 4のコンパイル済みアセットにはダイジェスト化されないファイルはもう含まれていません。このアセットマニフェストファイルもサーバーにアップロードしてサーバーが動くようにすることで、この問題を修正しました。

Sprocketsは理解するのもデバッグするのも困難です。仮に私ひとりでSprocketsに対応しなければならなかったとしたら、両バージョンのアセットを修正して動かすのはやめて、とっととWebpackに乗り換える調査を始めていたでしょう。

7. 新バージョンの段階的ロールアウト

私がこのプロジェクトを担当していた頃は、1つのモノリシックアプリを以下の複数の環境で動かしていました。

  • web: 静的なWebサイトとユーザー向けダッシュボード
  • api: REST API
  • admin: 内部向け管理インターフェイス
  • work: バックグラウンドワーカー

これらは別々のRails環境ですが、同じproduction設定を継承しています。

2つのバージョンのRailsでアプリが実行するコードがまったく同じになったおかげで、サーバーのバージョンを切り替えるのに必要な設定は環境変数の変更だけで済みました。

ロールアウトは以下のような手順で進められました。

  1. 問題をすべて特定/修正するまで、カナリア版でのテストを繰り返す
  2. Railsアップグレード版をadmin環境にデプロイする
  3. Railsアップグレード版をweb環境にデプロイする
  4. workサーバーを1つずつ切り替える
  5. apiサーバーを1つずつ切り替える

各ロールアウトが完了するたびに、測定結果をチェックして問題がなさそうなことを確認してから先に進みました。前述したように、完全なロールアウトには一週間を要しました。

すべて問題なく実行できていれば、以下のようなグラフを眺めながら満足感に浸れるでしょう。

実に爽快な気持ちです。

8. 古いコードの削除

🎉お疲れさまでした!新バージョンのRailsがproduction環境である程度の期間運用できれば、後は旧バージョンを動かすためのお粗末なコードや設定を取り除く作業を残すだけとなります。先ほどのRailsVersionSupportGemfile設定コードは捨てずに取っておくことをおすすめします。おそらく次のアップグレード作業でまた必要になるでしょうから。

アップグレードの意義について

Railsを新しいバージョンにできてまことによい気持ちでしたが、企業にとってアップグレード作業は決して最優先事項ではありません。大規模なアップグレード作業を試みることの意義については一口では語れませんが、結果としてアプリは問題なく動くようになりました。

しかし、いずれRailsのバージョンは時間とともに古び、船底で増殖するフジツボのように作業の足手まといになります。ドキュメントも陳腐化し、そのままでは新しい機能も導入できなくなります。ライブラリの修正も、いつの日か自分たちのアプリでサポートされるバージョンと互換性がなくなってきます。「ツールを最新に保つことの優先順位を上げるわけにはいかない」というメッセージが開発者に伝わるたびに、開発のモラルも下がり、古いバージョンを扱える開発者を採用するのも難しくなります。しまいにはセキュリティアップデートもリリースされなくなり、ビジネスがリスクにさらされることになります。

しかし、本記事で取り上げた「段階的アップグレード」の手法はこのリスクやアップグレードコストの低減に役立ち、アプリを最新に保ちやすくしてくれます。安全なアップグレード手法に関する体系的な知識を蓄えておけば、アップグレード作業はできれば先延ばしにしておきたい面倒な作業ではなく、普段のワークフローの一部になります。

本記事のドラフトをレビューしてくれたTom CopelandBrian Stevensonに深く感謝いたします。すべての文責は私にあります。

関連記事

Rails 3.2を4.0にアップグレードする(翻訳)

新しいRailsフロントエンド開発(1)Asset PipelineからWebpackへ(翻訳)

[翻訳] Ruby on Rails 4.2 リリースノート + Rails アップグレードガイド


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。