Railsの起動時間を7分の1にした話
こんにちは。2021年12月にCAMPFIREに入社した小川です。
CAMPFIREではRailsを使って開発しています。わたしの入社後、いくつかRailsのパフォーマンスチューニングをする機会があったのですが、今回はそのうち、開発環境でのRailsの起動時間を約7分の1に短縮することができた事例についてご紹介したいと思います。
Railsの起動が遅い
CAMPFIREで開発をしはじめてひとつ気づいたのが、開発環境でのRailsの起動にやけに時間がかかることでした。計測してみると1分以上かかっています・・・。
$ time bundle exec rake environment
# ...
real 1m10.845s
user 0m8.075s
sys 0m2.086s
これは遅い。アプリケーションの規模はたしかに大きいのですが、それを加味しても遅すぎる印象です。
チームメンバーに尋ねてみると、しばらく前から遅くなっていたとのことでした。
起動が遅いとテストを実行したりといったローカルでの作業が億劫になり、開発生産性が下がります。調査して改善をしてみることにしました。
何をしたか
速度改善には大きく2つのステップで取り組みました。
Bumbler による大まかなボトルネックの把握
StackProf 、 rblineprof での詳細なプロファイリング
以降ではそれぞれの詳細を見ていきたいと思います。
Bumbler による大まかなボトルネックの把握
Bumbler は、Railsの起動で各Gemやinitializerの読み込みにどれくらい時間がかかっているかを計測してよしなにレポートしてくれるGemです。
使い方は非常にかんたんで bumbler コマンドを実行するだけです。すると、以下のように計測結果をレポートしてくれます。
$ bumbler
# ...
Slow requires:
112.92 ddtrace
116.69 enumerize
128.62 shoulda-matchers
138.91 activeadmin_addons
142.85 rails
# ...
計測結果を見たところ、極端に遅いGemはないようです。
もうひとつ、Bumlber はinitializerでどれくらい時間がかかっているかも計測することができます。今度はそちらを実行してみましょう。 --initializers オプションを付けて実行するだけです。
$ bumbler --initializers
# ...
Slow requires:
134.17 :load_environment_config
179.83 :set_clear_dependencies_hook
445.80 :finisher_hook
3106.27 :set_routes_reloader_hook
60070.21 ./config/initializers/rollbar.rb
60169.64 :load_config_initializers
どうやらRollbarのinitializerの読み込みが時間を食っているようです。約60秒・・・。起動の遅さはほぼこれが原因とみて間違いないでしょう。これで大まかなボトルネックの箇所を特定することができました。
StackProfで具体的に遅い箇所を特定する
前節までで、起動時間のうち、Rollbarのinitializerの箇所で大きく時間がかかっていることがわかりました。続いてinitializerのうち、具体的にどこが遅いかを特定していきます。
今回はStackProfを使ってみましょう。定番のGemなので使ったことがあるかたも多いかと思います。
以下のようにしてプロファイルしたい箇所をStackProfのブロックで囲います。
StackProf.run(mode: :wall, out: 'tmp/stackprof-cpu-myapp.dump', ignore_gc: true) do
Rollbar.configure do |config|
# ...
end
end
かんたんにオプションを説明すると以下となります。
mode : CPU処理時間など含めた全体での実行時間
out : プロファイル結果の出力先
ignore_gc : GCの実行を計測結果に含めるか否か
今回はCPU処理時間以外でボトルネックが発生している可能性も考慮して、wallを設定しています。また、GCは計測にあたってノイズになってしまうと考えられたのでignore_gcをtrueに設定しました。
では、Railsを起動して計測を開始します。
$ bundle exec rails c
このとき、注意しておくのがSpringの無効化です。Springを無効化しておかないと、正しく計測がなされません。以下のようにしてあらかじめSpringが動かないようにしておきましょう。
$ export DISABLE_SPRING=1
参考: https://github.com/rails/spring#temporarily-disabling-spring
続いて、計測結果を出力します。先程の計測結果を stackprof コマンドで確認します。
$ bundle exec stackprof tmp/stackprof-cpu-myapp.dump
# ...
==================================
Mode: wall(1000)
Samples: 59181 (0.07% miss rate)
GC: 0 (0.00%)
==================================
TOTAL (pct) SAMPLES (pct) FRAME
59102 (99.9%) 59101 (99.9%) Net::HTTP#connect
63 (0.1%) 40 (0.1%) Bootsnap::LoadPathCache::Cache#push_paths_locked
14 (0.0%) 14 (0.0%) Bootsnap::LoadPathCache::Path#latest_mtime
9 (0.0%) 9 (0.0%) #<Module:0x000000014ca8e808>.storage_to_output
15 (0.0%) 4 (0.0%) Kernel#require_with_bootsnap_lfi
3 (0.0%) 3 (0.0%) Bootsnap::LoadPathCache::Path#non_directory?
3 (0.0%) 3 (0.0%) Bootsnap::LoadPathCache::Path#expanded_path
2 (0.0%) 2 (0.0%) #<Module:0x000000014c83c910>.paths
59102 (99.9%) 1 (0.0%) #<Module:0x0000000158dd6ef0>.timeout
1 (0.0%) 1 (0.0%) <module:Backtrace>
1 (0.0%) 1 (0.0%) #<Module:0x00000001036e33a0>.notifier
10 (0.0%) 1 (0.0%) Bootsnap::CompileCache::ISeq::InstructionSequenceMixin#load_iseq
4 (0.0%) 1 (0.0%) Bootsnap::LoadPathCache::Path#stability
59181 (100.0%) 0 (0.0%) ActiveSupport::Dependencies::Loadable#load_dependency
59181 (100.0%) 0 (0.0%) Rails::Engine#load_config_initializer
59181 (100.0%) 0 (0.0%) #<Module:0x000000010d017eb8>.instrument
59181 (100.0%) 0 (0.0%) block (2 levels) in <class:Engine>
59181 (100.0%) 0 (0.0%) block in <class:Engine>
59181 (100.0%) 0 (0.0%) Rails::Initializable::Initializer#run
59181 (100.0%) 0 (0.0%) Rails::Initializable#run_initializers
59181 (100.0%) 0 (0.0%) #<Module:0x000000014cae6aa8>.tsort_each
59181 (100.0%) 0 (0.0%) #<Module:0x000000014cae6aa8>.each_strongly_connected_component
59181 (100.0%) 0 (0.0%) #<Module:0x000000014cae6aa8>.each_strongly_connected_component_from
59181 (100.0%) 0 (0.0%) Rails::Initializable::Collection#tsort_each_child
59181 (100.0%) 0 (0.0%) TSort#tsort_each
59181 (100.0%) 0 (0.0%) Rails::Application#initialize!
59181 (100.0%) 0 (0.0%) <main>
59181 (100.0%) 0 (0.0%) Kernel#require_with_bootsnap_lfi
59181 (100.0%) 0 (0.0%) Bootsnap::LoadPathCache::LoadedFeaturesIndex#register
59181 (100.0%) 0 (0.0%) Kernel#require
Net::HTTP#connect に時間がかかっていることがわかりました。 99.9% とあるので、ここの箇所が完全にボトルネックになっていますね。
59102 (99.9%) 59101 (99.9%) Net::HTTP#connect
余談ですが、StackProfは出力結果を graphviz や flamegraph で出力することもできます。
以下では graphviz で出力した結果です。
$ bundle exec stackprof --graphviz tmp/stackprof-cpu-myapp.dump > tmp/stackprof.dot
$ dot -T pdf -o tmp/stackprof.pdf tmp/stackprof.dot
ボトルネックになっている箇所がどのように呼び出されているかを把握するには非常に便利です。
rblineprof で具体的に遅い箇所を特定する
前節で使った stackprof でも遅い箇所がどこなのかは特定できるのですが、少し切り口を変えてラインプロファイラーである rblineprof でもプロファイルしてみましょう(ちなみに StackProf も rblineprof も同じ tmm1さんの作です)。
rblineprof はそのままだとちょっと扱いづらいので、出力をいい感じにしてくれる rblineprof-report も使います。
StackProf の時と同じく Rollbar のinitializerの箇所をrblineprofのブロックで囲みます。
lineprofの引数に、具体的に行ごとの時間を計測したいファイル名(ないしはフィルター)を指定します。
profile = lineprof(/config\\/initializers\\/rollbar.rb/) do
Rollbar.configure do |config|
# ...
end
end
Railsを起動すると、指定箇所のプロファイル結果が出力されます。
$ bundle exec rails c
# ...
[LineProf] ===============================================================
config/initializers/rollbar.rb
| 1 # frozen_string_literal: true
| 2 profile = lineprof(/config\\/initializers\\/rollbar.rb/) do
60338.6ms 1 | 3 Rollbar.configure do |config|
# ...
| 12
60006.5ms 3 | 13 config.host = Net::HTTP.get(URI.parse('...')) rescue nil
| 14
原因箇所が特定できました!Rollbarのhostを設定している箇所で Net::HTTP.get をしているのですが、ここで非常に時間がかかっていることがわかりますね。起動速度の改善にはここを直せばよさそうです。
原因と解決
端的にいえば、ローカル環境からはアクセスできないURLが設定されていたのが原因でした。
当該箇所はRollbarに結果を送信するときにIPアドレスから判別できるように、AWSのインスタンスメタデータにアクセスして、インスタンスのプライベートIPアドレスを取得していました。
ですが、AWSのインスタンスメタデータはAWSの環境内でないと当然ながらアクセスができません。そのため、開発環境での起動時は毎回 Net::HTTP がタイムアウトするまで待ちが発生していたわけです。Net::HTTP のタイムアウトはデフォルトで60秒なので、Bumblerで計測したときにかかっていた時間とも一致します。
というわけで、原因が判明したので修正しましょう。該当部分はRollbarが必要になる場合のみ有効であればよいはずです。以下のように本番とステージング環境でのみ設定するように修正しました。
if Rails.env.production? || Rails.env.staging?
config.host = Net::HTTP.get(URI.parse('...')) rescue nil
end
修正ができたら次は改善効果の確認です。timeとbumblerで測定してみましょう。
$ time bundle exec rake environment
# ...
real 0m10.634s
user 0m8.067s
sys 0m1.989s
$ bumbler --initializer
# ...
0 of 0 gems required
Slow requires:
109.23 :load_environment_config
125.73 :load_config_initializers
213.26 :set_clear_dependencies_hook
456.61 :finisher_hook
3159.21 :set_routes_reloader_hook
起動時間が70秒 → 10秒と7分の1に改善しました!!
ローカルでのspec実行などがだいぶ快適になりますね。
補足
bumblerのユースケース
今回はbumblerでinitializerのボトルネックを見つけるかたちとなりましたが、開発環境で不要な(かつ時間のかかっている)Gemを見つけるのが一般的なユースケースになるかなと思います。
たとえばテスト関連のGemはジェネレーターの関係でdevelopmentにも入っていることが多いのですが、ジェネレーターをあまり使っていないのであれば、削除を検討したくなります。このとき、bumblerでどれくらい時間がかかっているかが分かることで削除の判断材料にすることができます。
StackProf と rblineprofの使い分け
どちらもボトルネックの特定には非常に有用なGemなのですが、特定のファイルのどこに時間がかかっているかを見るのであればrblineprofが使いやすいです。
一方、StackProfは使っているGem起因だったり、コードを掘り下げて調べるときには非常に有用です。今回のケースではinitializerのコードに原因があることが概ね分かっていたのでそこまで真価を発揮できた感じではなかったのですが、Gem内部のボトルネックなどを調査するときはとても役立ってくれるでしょう。
まとめ
以上、Railsでの起動をプロファイルツールを使って改善した事例をご紹介しました。まとめると以下のようになるかと思います。
速度改善においては、まず大まかなプロファイリングでボトルネック箇所を特定し、そこからより具体的に箇所を特定していくとやりやすい
BumblerはRailsの起動周りで大まかなボトルネック箇所のプロファイリングに便利
rblineprofはボトルネックとなるファイルが分かっている場合、使いやすい。一方、StackProfはもう少しボトルネックの発生箇所が込み入っているときに便利。
今回のような大幅な改善につながるケースは実際はあまりないかもしれませんが、ボトルネックを特定し、改善するプロセスについては参考にしていただける部分も多いのではないかなと思います。みなさまの速度改善の参考になれば幸いです。
参考資料
この記事が気に入ったらサポートをしてみませんか?