
こんにちは。crowdworks.jpで不正利用対策チームのエンジニアをしている得能(yoshi_10_11)です。 今回は先日取り組んだmini_racerのgemを剪定した話をします。
mini_racerってなんだっけ
mini_racerはRubyからJavaScriptを実行するのに利用されるgemです。JavaScriptのV8 エンジンを使って実行できる環境を提供します。 Ruby on Railsにおいてはアセットパイプライン環境においてJavaScriptコードを処理するために利用されます。
https://github.com/rubyjs/mini_racer
Ruby on Rails 5くらいの時代にはrails newで生成したGemfileにmini_racerが含まれているなど利用機会が多いgemではありました。
しかし、Webpackerが採用され始めたあたりからJavaScriptの実行環境にNode.jsが選択されることとなり、Gemfileからもmini_racerの記述が消えました。
Ruby on Railsで利用するJavaScriptの実行環境を選択する役目を持つExecJSというgemが今もmini_racerに対応しているので、最新のRuby on Rails環境でもmini_racerは利用可能です。
mini_racerの問題点(crowdworks.jpの場合)
これまでのRuby on Railsを支えてきてくれたmini_racerはcrowdworks.jpでも利用されていました。そのまま残していても問題なく動作はするのですが、いくつかの問題点を抱えていました。
JavaScript実行環境が複数存在している
crowdworks.jpではmini_racerとは別にNode.jsも開発環境やCI環境などで利用していました。 主に、Vue.jsで実装した箇所のビルドや開発時のホットリロードを実行する(Webpackで動作)ために利用しています。
mini_racerはRuby on Railsのアセットプリコンパイル処理にのみ利用している状況でした。この状況にものすごく大きな問題を抱えていたというわけではありませんが、どちらかに1本化する方が保守・管理の面では優れています。
Vue.js/Webpackでのビルドはmini_racerでは行うことができないため、1つにまとめるのであればNode.jsにまとめるのがよさそうです。
bundle installの長期化
こちらが一番の問題点でした。 mini_racerをインストールすると依存gemであるlibv8-node(JavaScriptのV8 ランタイムを提供するgem)も一緒にインストールされますが、インストール時間がとてつもなく長いのです。
実行環境により変わる場合はありますが、crowdworks.jpの開発環境の場合ではゼロからbundle installをすると完了までに1時間ほどかかる場合がありました。
ゼロからbundle installする機会はそれほど多くありませんが、厳密な動作チェックのためキャッシュやDockerボリュームなどに残したデータを使い回さず都度ゼロからinstallする機会もあります。
剪定の流れ
mini_racerを剪定するにあたって、おおまかに以下の流れで調査を行いました。
- Node.jsで実行した場合の変更点を調査
- 剪定前後でのアセットプリコンパイルの出力差分の確認
- リリースのテスト
Node.jsで実行した場合の変更点を調査
mini_racerもNode.jsもJavaScriptエンジンは同じV8を利用していますが、それぞれ想定している用途が違うことから、実行結果に差異がある場合がありました。
mini_racerとNode.jsの両方が入っている環境で、EXECJS_RUNTIMEを指定してrails consoleを起動すると指定したランタイムでJavaScriptを実行することができます。
すると、Node.jsの方ではconsole.logがエラーで実行できませんでした。アセットプリコンパイルの対象となるコードでconsole.logは利用されていないため問題はありませんが、他にも動かないコードがないか心配になります。
mini_racerの場合
# Node.jsよりmini_racerの方が優先度が高いためランタイムの指定は省略も可 $ EXECJS_RUNTIME=MiniRacer rails console # ExecJS経由で実行する [1] pry(main)> ExecJS::eval('console.log("foobar")') => nil
Node.jsの場合
$ EXECJS_RUNTIME=Node rails console # ExecJS経由で実行する [1] pry(main)> ExecJS::eval('console.log("foobar")') ExecJS::ProgramError: TypeError: Cannot read properties of undefined (reading 'log') from eval (eval at <anonymous> ((execjs):1:204), <anonymous>:1:10)
原因を調査した結果、ExecJS側でJavaScriptを実行する際に一部の関数を無効化していることがわかりました。Node.jsの場合はnode_runner.jsで定義されており、console以外にも無効化している関数のリストを確認することができました。 https://github.com/rails/execjs/blob/master/lib/execjs/support/node_runner.js
無効化している関数が利用されているかどうかを実装コード内でgrepしたところ利用している箇所はありませんでした。
剪定前後でのアセットプリコンパイルの出力差分の確認
mini_racerのgemを含むブランチと含まないブランチとでそれぞれrails assets:precompileした生成物を比較しました。
問題がある場合は、そもそもrails assets:precompileで失敗する、生成物のコードに差分が発生する可能性があります。
確認してみたところ完全に一致しました。 先ほど確認したExecJS経由でNode.jsを利用する場合に無効化される関数は利用していないことを確認していたため、それほど心配せずに確認することができました。
リリースのテスト
アセットプリコンパイルはリリースの中でも行われるため、正常にリリースが行われるのかどうかをステージング環境でテストしてみました。
すると、リリースフローの一部のJobで ExecJS::RuntimeUnavailable: Could not find a JavaScript runtime. とJavaScriptのランタイムが見つからないことでのエラーが発生しました。
調べてみると、実装コードの中でSprockets関連の処理を行うinitializersが定義されていました。また、エラーが発生したJobでの実行環境にはNode.jsがインストールされていませんでした。
このJobはJavaScriptに全く関係のない処理を行うものでしたが、bundle exec経由で処理を実行していたため、initializersが呼ばれた際にエラーが起きる…というものでした。
JavaScriptを利用していないのにこのエラーを回避するためだけにNode.jsをインストールする…のはやりたくなかったので、エラーの原因となったinitializersをNode.jsがある場合にのみ読み込みさせられるように実行条件を追記することで解決しました。
結果
細かい調査フェーズは省きましたが、これらの調査を経て、無事にmini_racerを剪定することができました。
今回の剪定により、bundle installの実行時間が
約1時間 → 約2分(約97%の削減!!!!!)
にまで短縮することができました!
crowdworks.jpでは多くのgemを利用しているためmini_racerを削除してもある程度インストール時間がかかると思っていたので、予想以上の成果に腰を抜かしてしまいました…笑
最後に
crowdworks.jp以外にも今もmini_racerを利用している環境があるのではないかと思い、少しでも参考になればと思いご紹介させていただきました!
また、実はこの取り組みは施策としてではなく、私個人が自主的に行ったものです。私の所属するチームでは毎週金曜日限定で、施策外のプロダクトの問題解決に自由に取り組んでもよいルールを導入していて、そのルールの中でコツコツと取り組みました!
入社後、誰でもその動きができる保証はありませんが、個々人の頑張りや意向を尊重してもらえる職場であると思っています。もし、弊社に興味があれば求人を確認してみてください!