blog

DeNAのエンジニアが考えていることや、担当しているサービスについて情報発信しています

2017.03.13 技術記事

「Sakasho」のRubyを2.4に、Railsを5にアップグレードしました

by kunpei.sakai

#ruby

はじめに

JPRゲーム事業本部開発基盤部の @namusyaka です。

業務ではDeNAのゲームプラットフォームである Sakasho のバックエンドやインフラ周りの開発・運用をしています。

そして最近アイコンを8~9年ぶりくらいに変えました。よろしくお願いいたします。

さて本題ですが、Sakashoでは今年の2月に管理アプリケーションのRuby・Railsのバージョンの大幅なアップグレードを実施しました。この記事ではそのアップグレード対応について、一つの事例として紹介させていただければと思います。

概略

冒頭でも触れましたが、アップグレードしたのはDeNAのモバイルゲームプラットフォームであるSakashoの機能を制御するための管理アプリケーションになります。

Sakashoとは

Sakashoは、DeNAが持つモバイルゲームのためのプラットフォームです。 モバイルゲームを開発するために必要な機能を一通り提供し、ゲームの開発の効率化を図るための共通基盤として開発・運用されています。

Sakasho全体のアーキテクチャは次のようなイメージです。

今回のアップグレードは、この図の右端に位置する管理アプリケーションが対象となります。 ところで、余談ではありますが、SakashoのAPIは原則としてRailsではなくSinatraをベースに実装されています。 機会があれば、そのアプリケーションアーキテクチャについても、今後紹介できればと思います。

管理アプリケーションの特性

主に次のような特性を持ちます。

  • ruby-2.1.4/rails-4.1.1で動作している
  • ゲームプラットフォームが持つ全機能に対する制御系統を持つ。
    • ゆえに、コードベースがかなり大きい。
  • 複数DBを前提とした作りになっている。
    • 一部シャーディングもしている。
  • テストはある程度揃っている

実際の規模感をお伝えするために、rake statsの実行結果を貼っておきます。

+----------------------+--------+--------+---------+---------+-----+-------+
| Name                 |  Lines |    LOC | Classes | Methods | M/C | LOC/M |
+----------------------+--------+--------+---------+---------+-----+-------+
| Controllers          |  19075 |  15518 |     247 |    2224 |   9 |     4 |
| Helpers              |   1879 |   1657 |       0 |     225 |   0 |     5 |
| Models               |  21974 |  16928 |     581 |    1442 |   2 |     9 |
| Mailers              |      0 |      0 |       0 |       0 |   0 |     0 |
| Javascripts          |   3000 |   2763 |       0 |     620 |   0 |     2 |
| Libraries            |  10018 |   7358 |      96 |     395 |   4 |    16 |
| Tasks                |   4416 |   3132 |       2 |      15 |   7 |   206 |
| Config specs         |     23 |     20 |       0 |       0 |   0 |     0 |
| Controller specs     |  45014 |  40239 |      28 |       4 |   0 | 10057 |
| Helper specs         |    850 |    727 |       0 |       1 |   0 |   725 |
| Lib specs            |   9852 |   8384 |       1 |      10 |  10 |   836 |
| Model specs          |  17982 |  15974 |       0 |       3 |   0 |  5322 |
| Support specs        |   1038 |    943 |       2 |      13 |   6 |    70 |
| Validator specs      |    396 |    328 |       2 |       7 |   3 |    44 |
+----------------------+--------+--------+---------+---------+-----+-------+
| Total                | 135517 | 113971 |     959 |    4959 |   5 |    20 |
+----------------------+--------+--------+---------+---------+-----+-------+
  Code LOC: 47356     Test LOC: 66615     Code to Test Ratio: 1:1.4

簡単に見方を説明すると、LOCというのはLines Of Codeの略で、コードの行数を指します。 その他にも、例えばモデルと分類されるクラスが何個あるか、といった情報を読み取ることができるため、参考にしていただければ幸いです。

改修期間

対応のために掛けた期間は着手から本番リリースまで凡そ一ヶ月弱です。 大きく「開発」・「テスト」の2つのフェーズに分けることができ、それぞれにかかった期間としては次のようになります。

  • ruby-2.4.0・rails-5.0.1にアップグレードし、全てのテストケースがpassすること (一週間)
  • QAによるテスト (二週間)

開発期間中のアップグレード作業は私一人で進め、変更点のレビューなどはチームメンバー全体に依頼する形で進めました。 今思うと大分駆け足だった感はありますが、現時点で大きな障害もなく稼働しています。

なお、一応補足しますが、この記事は「テスト」ではなく「開発」のフェーズについての解説となります。

アップグレード戦略

前提となりますが、この管理アプリケーションはruby-2.1.4/rails-4.1.1で動作していました。

それらを目的のruby-2.4.0/rails-5.0.1にアップグレードするために参考にしたのが、Railsが公式に提供している A Guide for Upgrading Ruby on Rails です。

このアップグレードガイドによると、rails-5にアップグレードするには、対象のアプリケーションはrails-4.2でなければならないようです。 したがって、目標のアップグレードを実施するには段階を踏んでアップグレードする必要があり、まずはrails-4.1.1をrails-4.2.7.1にアップグレードしなければなりません。

実際のアップグレードの流れは次のとおりです。

  • rails-4.1.1をrails-4.2.7.1までアップグレード
  • ruby-2.1.4をruby-2.4.0までアップグレード
    • ruby-2.4にバージョンをあげたらrails-4.2.7.1では動作しなかったので、railsの4-2-stableにこの時点でスイッチしました。
  • rails-5.0.1までアップグレード

一つのフェーズごとにテストが全てpassすることをゴールと設定しています。

そして、各バージョンにアップグレードする前には、可能な限りdeprecation warningsを潰すようにしました。 これは、将来的に削除される機能などを事前に回避しておくことでトラブルを防ぐ意味合いがあります。

また、この戦略を進めるにあたり、リリースブランチとは別にアップグレード専用のブランチを用意するほか、ruby-2.4でテストするための専用のjenkins jobを用意して、通常のリリースフローとは独立する形で作業を進めました。

変更点

この手の作業はbundle updateを通す作業から始まります。 幸いにして5.0.1は去年の12月リリースだったこともあって、ある程度依存しているgemは対応済みで、gemに対してパッチを当てたりforkしたり、といったことは特にする必要はありませんでした。 新しいバージョンが出たばかりのHotな時期にアップグレードするのもいいですが、数年間アップグレードされてこなかった規模の大きいアプリケーションを対象とする場合は、数ヶ月程度の時間を置いてから進めた方がハマりどころが少なくて助かるかもしれません。

次に実際の変更点について紹介します。 しかしながらあまりに数が多すぎるため、特に影響範囲が大きかった変更と、deprecation warningsなどの中から、アップグレードガイドに記載されていないものを中心にいくつか取り上げようと思います。

rails-4.2.7.1対応

元がrails-4.1.1なので、まずは Upgrading from Rails 4.1 to Rails 4.2 に従って粛々と対応しました。 Upgrade guideに書いてあることは割愛するとして、主要な変更点を挙げます。

deprecations & minor changes

Primary keyでないidcolumnを持つテーブルにおいて、取得したレコードをinspectした結果、idがnilと表記される

idというカラムは存在するが、primary keyではないケースで発生する不具合です。 不具合といってもinspectの結果がおかしい程度ではあるのですが、次のようなコードで正常にidが出力されない問題がありました。

Book.find_by(id: 1).inspect #=> "#<Book id: nil, ...>"

これについてはRails側を修正することで対応しました。たまたま見つけた不具合という感じです。

Pull Request: Fix inspection behavior when the :id column is not primary key by namusyaka · Pull Request #27935 · rails/rails · GitHub

t.timestampsのデフォルトがNULLを許可からNOT NULLに変更

次のように指定することで解決しました。

create_table :books do |t|
  t.timestamps null: false
end

従来どおりの挙動にしたければnull: trueとします。 動的にtableを生成するケースなどがもしあれば、要注意といえるでしょう。

JSON.loadnilを渡さないように修正

4.2.7.1では例外が出るようになっていました。 nullが返されることを期待しているわけでもなかったので渡さないように変更しました。

関連: Fixed JSON coder when loading NULL from DB by chancancode · Pull Request #16162 · rails/rails · GitHub

MySQL-5.6以上では時刻のミリ秒を保存するようになった

MySQLにミリ秒を持たせるのは別の意味でしんどくなりそうだったので、モンキーパッチで回避しました。

参考

ActiveRecord/ActiveModelが範囲外の値に対して例外を吐くようになった

例えば、次のようなコードについて考えます。

Book.where(id: -1).first

この処理はrails-4.1と4.2で挙動が異なり、4.1ではnilが返り、4.2以降はActiveModel::RangeError: -1 is out of range for ActiveModel::Type::UnsignedInteger with limit Xといった例外が発生するようになっています。

ActiveModelにtype castingの機構が実装されたお陰だと思います。 ロジックに問題のあるコードがこれで顕在化されることになるため、これを機に既存の実装を見直しました。

ところで、この機構は明示的にlimitが指定されていない場合に、signed int(4bytes)を自動的にリミットとして定めてしまうという問題があります。

参考: Avoid RangeError without explicit limit by kamipo · Pull Request #26302 · rails/rails · GitHub

Adequate Recordの影響で動的にテーブル名をセットしているところが壊れた件

Adequate Recordはactive_record/core.rbに定義されている.find.find_by.find_by_xxx系のメソッドに対して、ActiveRecord::StatementCacheを用いてキャッシュを有効化する機能を指します。 Arelを経由したSQLへの変換を行わなくて良くなるので二倍ほど高速化が見込めるという話のようです。

この機能自体は非常に素晴らしいのですが、これを実現するために内部的に使用されているキャッシュキーはprimary keyをベースにしたものとなっており、テーブル名については一切考慮されていませんでした。 したがって、テーブル名が動的に変わった場合もprimary keyが一致すればキャッシュが効いてしまうので、誤ったSQLを発行してしまうリスクが存在しています。

無論そんな使い方をするなという話もあるかもしれませんが、動きそうなところが動かないのは困るということで、Railsを修正しました。 直し方としてはtable_name=が実行されたらキャッシュをリセットするように修正する、というものです。

Pull Request: Make table_name= reset current statement cache by namusyaka · Pull Request #27953 · rails/rails · GitHub

ちなみにこれを回避するには、Adequate Recordが効かないQueryMethodsを使ってやるだけで良いので、次のように書き換えるのが簡単です。

Book.find_by(id: 1) # before

Book.where(id: 1).first # after

ruby-2.4.0対応

rails-4.2.7.1の対応を終えた後に、ruby-2.1.4からruby-2.4.0にアップグレードしたところ全く動きませんでした。 どうやら4.2.7.1はruby-2.4.0に対応していないようで、ruby-2.4にアップグレードする前に一旦4-2-stableを使うように変更しました。

なお、私が対応していた時点では4.2系のバージョンの中では4.2.7.1が最新版でしたが、つい先日 4.2.8がリリースされ、公式にruby-2.4がサポートされています 。今後アップグレードする際には4.2.8を使用することをオススメします。

さて、この項ではruby-2.1.4からruby-2.4.0にアップグレードしたことで発生した主要な問題点を挙げてみます。といっても、Railsほど苦労はなかった印象です。

FixnumIntegerに統合

軽く書いてはいますが、native extension系のgemは要注意です。 以下は具体例です。

アップグレードする対象のアプリケーションが依存するgemのruby-2.4 対応状況については事前に洗い出しておいた方が良さそうです。 必要であればパッチを送り、新バージョンのリリースをねだりましょう。

サブクラスの抽出に自前のObjectSpace#each_objectではなくActiveSupportのコア拡張であるClass#subclassesを使うように変更

ruby-2.3.0からObjectSpace#each_objectがシングルトンクラスを含むようになりました。 SakashoではClass#subclassesの自前実装をObjectSpace#each_objectをベースに持っていて、そちらを修正しようとも考えたのですが、ActiveSupportのコア拡張でほぼ同じことをやっていたので、そちらを使うようにして修正しました。

参考

openssl系の標準添付ライブラリにて、keyやivの文字数がvalidでないと例外が発生するようになった

もともとの実装では、文字数がオーバーしている場合は丸められていたようです。 したがって、事前に文字列を適切にsliceするように変更して修正しました。

参考: openssl: make Cipher#key= and #iv= reject too long values · ruby/ruby@ce63526 · GitHub

rails-5.0.1対応

rails-5.0.1にアップグレードする際にも、まずは Upgrade guide に沿って進めました。

5へのアップグレードには設定ファイルの追加やinitializerの追加などを含むため、そのあたりを中心に対応を自動化するための機構として、app:updateというRakeタスクが提供されています。 対話的に大きな差分を自プロダクトに反映していくことになりますが、ある程度既存のコードにも影響のある部分なので、しっかり差分を注視しながら進めた方が良さそうです。

このフェーズについても、Upgrade guideの内容は割愛し、主要な変更点を挙げていこうと思います。

deprecations & minor changes

ActionController::ParametersがHashを継承しなくなった

多くの場所で取り上げられている変更点ですが、例に漏れず対応が困難だったもののうちの一つです。 Hashが持つメソッドや振る舞いに依存しているコードの多くが正常に動作しなくなるため、アップグレードする際には事前にその辺を洗い出しておくと楽かもしれません。

参考: Make AC::Parameters not inherited from Hash by sikachu · Pull Request #20868 · rails/rails · GitHub

datetime_selectに入力された値をDateTimeとして取得する際に、受け取った値をそのままhidden_fieldtext_fieldに渡すとRubyのHashをinspectした値がvalueに埋め込まれてしまう件

例えば次のような要素があるとします。

= f.datetime_select :opened_at, use_month_numbers: true, start_year: default_start_year

このヘルパーは、日付にかかる情報を扱うために複数のselect要素をレンダリングします。 そして、opened_at(1i)opened_at(2i)…といった複数のキーからなるopened_atにかかる情報をサーバに送信し、それらのパラメータをActiveRecord::Baseインスタンスに渡してやることで、よしなにopened_atに時刻情報が代入されるという作りになっています。

これはもとからRailsが持つ機能ではありますが、Sakashoでは、更にこのsubmitされた値を確認画面などでhidden_fieldに埋め込むケースが非常に多いです。

サンプルとしては、次のようなコードになります。

= f.hidden_field :opened_at

form_forなどに代表されるこれらのform methodは、第一引数で受け取ったカラム名を用いて、対象となるインスタンスに対して実行し、その結果をvalue属性に代入します。 今回はこの代入される値に問題がありました。次のようになります。

<input type="hidden" value="{1=&gt;2017, 2=&gt;2, 3=&gt;27, 4=&gt;21, 5=&gt;0, 6=&gt;0}" name="archive[opened_at]" id="archive_opened_at" />

これは、内部的に実行されるxxx_before_type_castという、その値をキャストする前の値を返す機構に起因します。 今回のケースでは、opened_atにはもともとdatetime_selectによって生成された複数のselect要素群から作られたHashが代入されています。 当然、validationを通過した結果の確認画面ではそのHashがopened_atの値として埋め込まれており、確認画面を経て、いざ作成しようとした際にエラーが発生して保存できない、といった問題が発生することになります。

この問題についてはPull Requestを投げてはいるものの、まだレビュー待ちという状態です。

反省点

今回は短期間で一気にアップグレードを実施しましたが、本来であれば計画的に実施すべきだと思います。 Railsを使ってアプリケーションを作って終わりではなく、定期的に依存するgemのアップグレードに追従しようとする姿勢こそがRailsと付き合っていくコツといえるでしょう。 そこでSakashoでは、bundle updateを自動で行い、アップグレードされたgemの差分を表示しつつ、そのGemfile.lockの変更差分をコミットするPull Requestを自動で作成する機構を導入しました。

これにより、普段の業務でも自分たちが採用しているossのリリースを考慮して取り組めるようになると考えています。

おわりに

大規模なRailsアプリケーションで、かつ長らくアップグレードされてこなかったプロダクトを最新バージョンにアップグレードするのは非常に骨の折れる作業でしたが、短期間で集中的に取り組めたことは良い経験となりました。

また、管理アプリケーションとしての特性上複数DBを前提としているなど、一般的なRailsアプリケーションのそれとは異なる構成であり、それをきっかけとして発見したRailsのいくつかの不具合を修正することができました。

このような環境下で運用されるRailsアプリケーションはそう多くはない認識ですし、Sakashoのような環境でしか発生しない不具合はまだ存在するはずです。Railsを利用する立場として、そういった不具合に積極的に対処してコミュニティに還元していく姿勢が今後重要であると考えています。

最後になりますが、Railsを採用される際には、用法用量を守って正しくお使いください。

最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。

recruit

DeNAでは、失敗を恐れず常に挑戦し続けるエンジニアを募集しています。