クラウドワークス エンジニアブログ

日本最大級のクラウドソーシング「クラウドワークス」の開発の裏側をお届けするエンジニアブログ

安全にRailsを更新するためにモンキーパッチをたくさん作った話

クラウドワークスの弓山 (@akiray03)です。

前回のブログに引き続き、Rails3からRails4にアップグレードした際に行った工夫をご紹介したいと思います。前回の記事を未読の方は、合わせてどうぞ。

engineer.crowdworks.jp

今回は、「モンキーパッチ」に焦点をあてて、取り組みを紹介します。安全な並行稼動を実現するために、いくつかのモンキーパッチを作成しました。作成したモンキーパッチを振り返ってみると、以下のように分類することができます。

  • Rails3/4並行稼動時に、双方を分離するためのモンキーパッチ
  • Rails3/4並行稼動時に、双方の振る舞いを揃えるためのモンキーパッチ
  • Rails3の振る舞いをRails4と揃えるためのモンキーパッチ (Rails3側にパッチを当てた)
  • Rails4の振る舞いをRails3と揃えるためのモンキーパッチ (Rails4側にパッチを当てた)

今回の記事では、それぞれどのようなモンキーパッチを当てながらリリースを進めていったかをご紹介します。

Rails3/4並行稼動時に、双方を分離するためのモンキーパッチ 🐒

クラウドワークスでは、非同期処理の基盤として Delayed::Job を利用しています(最近は Shoryuken も利用し始めていますが、 Delayed::Job の方がまだ多いです)。Delayed::Jobは以下のように delay メソッドを途中に挟んでチェインさせるだけで簡単に非同期処理が実現できて便利なのですが、コンテキストを保持するためにActiveRecordオブジェクト自身をシリアライズしています。Rails3とRails4では、オブジェクトの構造が変わり、シリアライズしたデータ形式も変化するために、Rails3/4を1つのキューで混在させて処理することはできません。

Mailer.delay.notification_created(notification)

この問題への対応としては、Rails3でキューに積んだジョブはRails3のワーカーに処理させ、Rails4でキューに積んだジョブはRails4のワーカーに処理させる、という対応を行いました。

# ジョブを登録するときに、Rails4の場合は label カラムに値をセットする
Delayed::Worker.lifecycle.before(:enqueue) do |job|     
  if Rails::VERSION::MAJOR >= 4        
    job.label = "rails4"     
  end     
end

# Rails3のワーカーは、Rails3で投入されたジョブだけを処理する。Rails4も同様。
module Delayed::Backend::ActiveRecord
  class Job
    def self.reserve(worker, max_run_time = Worker.max_run_time)
      # ...
      # ラベルを見てRails3/4のジョブが混ざらないように処理対象を区別する
      if Rails::VERSION::MAJOR >= 4
        ready_scope = ready_scope.where("label = 'rails4'")
      else
        ready_scope = ready_scope.where("label IS NULL")
      end
      # ...
    end
  end
end

Delayed::Jobでは、 queue_name というカラムでキューを区別することが可能です。クラウドワークスでは処理に応じて数個のキューを利用しており、当初は、以下のようなLIKE文でバージョンの区別を試みていました。

if Rails::VERSION::MAJOR >= 4
  ready_scope = ready_scope.where("queue_name LIKE 'rails4_%'")
else
  ready_scope = ready_scope.where("queue_name NOT LIKE 'rails_4%' OR queue_name IS NULL")
end

開発環境では問題無く動作するのですが、本番のデータ量の多さと、クエリ実行頻度の多さ(ワーカーは数秒間隔でこのクエリを実行して次のジョブを探します)から、データベースサーバのパフォーマンス問題に繋がったため、独立したカラムを追加することでRails3/4の区別を行うことにしました。

Rails3/4並行稼動時に、双方の振る舞いを揃えるためのモンキーパッチ 🐒

今回の並行稼動環境は、以下のような特徴を持っています。

  • URL単位でRails3からRails4に切り替えていく
  • Rails4でリリースしたURLについても10%のリクエストはRails3で処理する

そのため、Rails4でレンダリングされたページから、Rails3に対してリクエストを投げるパターンが発生します。Rails4では更新系HTTPメソッドの推奨がPATCHになりましたが、Rails3はPATCHを処理することができないため、並行稼動の障害となってしまいます。この問題に対処するために、モンキーパッチを作成しました。

f:id:akiray03:20160708103228p:plain

以下のようなモンキーパッチを作成し、Rails3、Rails4、それぞれに適用しました。

if Rails::VERSION::MAJOR < 4
  # Rails3 で PATCH をサポートする
  ActionDispatch::Routing::Mapper::HttpHelpers.module_eval do
    def patch(*args, &block)
      map_method(:patch, *args, &block)
    end

    # route.rb に put と書いてあるときに、該当リクエストを put でも patch でも受け取れるようにする
    def put(*args, &block)
      # map_method を使用すると args の中の Hash が書きかわるため、 deep_dup しておく
      map_method(:put, *args.map {|arg| arg.is_a?(Hash) ? arg.deep_dup : arg }, &block)
      map_method(:patch, *args.map {|arg| arg.is_a?(Hash) ? arg.deep_dup : arg }, &block)
    end
  end
else
  # Rails4 で PUT / PATCH のどちらが書かれていても、 update系メソッドで処理できるようにする
  # routes.rb に put と直接書いてあるケースや、rails3 から来るパターンに備えるため
  ActionDispatch::Routing::Mapper::HttpHelpers.module_eval do
    def patch(*args, &block)
      map_put_and_patch(args, &block)
    end

    def put(*args, &block)
      map_put_and_patch(args, &block)
    end

    private

    def map_put_and_patch(args, &block)
      # map_method を使用すると args の中の Hash が書きかわるため、 deep_dup しておく
      map_method(:patch, args.map {|arg| arg.is_a?(Hash) ? arg.deep_dup : arg }, &block)
      map_method(:put, args.map {|arg| arg.is_a?(Hash) ? rename_route_for_put(arg) : arg }, &block)
    end

    def rename_route_for_put(arg)
      # ルーティングでは as でつけた名前がユニークになっている必要があるため、
      # as を使っている定義に対して二つのルーティングを設定しようとするとエラーが発生する
      # それを回避するため、 as の中見を変更しておく
      # PUT を意図的に指定したいことは原則としてないと思われるので、既存の名前で指定したときは PATCH にルーティングされるようにする
      # put_ をプレフィクスにつけることで、意図的に PUT を指定したい時は put_ がついた名前を用いる手段を残しておく
      arg[:as] &&= "put_#{arg[:as]}"
      arg
    end
  end
end

Rails3の振る舞いをRails4と揃えるためのモンキーパッチ 🐒 (Rails3側にパッチを当てた)

Rails3とRails4のフラッシュを相互に利用できるようにするために、Rails3側にパッチをあてました。このパッチは https://github.com/envato/rails_4_session_flash_backport に対して、幾つかの変更を加えたもので、 https://github.com/crowdworks/rails_4_session_flash_backport にて公開しています。

Rails3とRails4では、フラッシュの保存形式が以下のように異なります。

# Rails3.x
#<ActionDispatch::Flash::FlashHash:0x0055b297ab94c0 @flashes={:sample=>"This is sample message."}, @now=nil, @used=#<Set: {}>, @closed=false>

# Rails4.2
{"discard"=>[], "flashes"=>{"sample"=>"This is sample message."}}

このGemはRails3.x側に入れて利用し、Rails 3.xでRails 4.xのフラッシュを扱えるようにするためのものです。 また、Rails 3.xでRails 4.xのフラッシュが読めるようになるだけでなく、Rails 3.xでフラッシュをハッシュ形式で出力するようにもなっていました。

ただし、このGemがRails 3.2で出力するハッシュ形式のフラッシュは不完全で、Rails 4.2は解釈できませんでした。 具体的にはハッシュのキーが文字列ではなく、シンボルになっていました。

{"discard"=>[], "flashes"=>{:sample=>"This is sample message."}}

この問題の対処として、Rails 4.2ではRails 3.2の形式も解釈できることを利用し、Rails3.2本来の形式である ActionDispatch::Flash::FlashHash でセッションに格納するように修正を行いました。

Rails4の振る舞いをRails3と揃えるためのモンキーパッチ 🐒 (Rails4側にパッチを当てた)

Rails4.2 + MySQL5.6 を組み合わせて利用しているときに、 datetime型の精度が変わり、意図しない動作に変わってしまう問題がありました。現在はデータベース上でdatetimeの値は秒精度でしか保存していないため、SQL文の生成結果がマイクロ秒精度になると結果が変わってしまうためです。

# users.created_at = '2016-07-07 07:07:07' が存在するとき...

User.where('created_at < ?', Time.current)

# 秒精度の時:
# created_at < '2016-07-07 07:07:07' → ヒットしない

# マイクロ秒精度の時:
# created_at < '2016-07-07 07:07:07.777777' -- ヒットする

この問題に対処するために、以下のようなモンキーパッチを適用しています。

unless Rails::VERSION::MAJOR == 4
  raise "#{__FILE__} is a patch script for case Rails::VERSION::MAJOR == 4"
end

ActiveRecord::ConnectionAdapters::Mysql2Adapter.class_eval do
  # revert: https://github.com/rails/rails/commit/df5a38fc6aeb8dfaa816fcbe0efb3fe4de169833#diff-e7dead35794529e0cc5d0b2d788f8235
  # このメソッドはRails4には存在するがRails5では以下に移動しているので注意
  # activerecord/lib/active_record/connection_adapters/abstract/quoting.rb
  def quoted_date(value)
    super
  end
end

このモンキーパッチは、Rails3/4の並行稼動期間が終わった現在も利用していますが、Rails5に更新する際には、このままでは動かなくなるために、何らかの対処を行うことを考えています。

まとめ

クラウドワークスではRails3からRails4に安全に移行するために、いろいろなモンキーパッチ 🐒 を作りました。移行時にだけ必要なものと、Rails4移行後も必要になるものを分離しておくことで、必要なくなったモンキーパッチを安全に削除できるようにするなど工夫しながら対応を進めてきました。

ある程度の規模のサービスになると、一気にバージョンアップが難しいことが多くなると思います。モンキーパッチは、移行期・並行稼動期の相互運用を実現するための重要な手段です。本記事では、クラウドワークスがRails3からRails4に更新する際に、たくさんのモンキーパッチとどのように付き合ってきたかをご紹介しました。

明日以降も、Rails4アップグレードブログの更新は続きますのでご期待ください。

We're Hiring !

クラウドソーシングのクラウドワークスでは、Rails5にバージョンアップしたいエンジニアや、がんばらないで成果を出すエンジニアを募集中です。

www.wantedly.com

© 2016 CrowdWorks, Inc., All rights reserved.