Nic Lin's Blog

喜歡在地上滾的工程師

快就是帥,加速你的 Rails 專案啟動時間

速度快就像雷神降臨一樣帥

當 Rails 專案逐漸成長之後,最令人頭痛的就是 Rails 的 boot time 實在是很久。

有些沒辦法 reload on change 的部分(例如修改 config、安裝 gem)都需要將 server 重啟,然後發現跑一次竟然要十幾秒甚至二十秒,發現 Rails developer 人生大半的時間其實都浪費在啟動時間…

除了加強硬體規格以外,比較能做的事情就是用 preloader 預先加載 Rails 進行加速,一般常用的有 spring / zeus,加上近期的 bootsnap 又更好的提昇效率了,依照多數使用者回報的數據,幾乎都能減少一半以上的時間,這個 Gem 預先把 code compile 出來的 bytecode cache 到 tmp/cache 之下再加上把 Ruby 不太有效率的 require 也 cache 所以就更快了。

不過如果用了這些工具還是想要更快的話,那就需要一些 tricks 了,這邊分享一下我的優化過程,主要會是著重於 Test + Development 模式, Production 能加速的著實有限。

如何測試啟動時間

完全測冷啟動,不吃 preloader

$ time bundle exec rake environment
# bundle exec rake environment  22.78s user 11.76s system 75% cpu 45.601 total

會吃 preloader 的啟動

$ time (bin/rails runner nil)

# Running via Spring preloader in process 60378
# ( bin/rails runner nil; )  0.22s user 0.10s system 31% cpu 1.017 total

測試每個 gem 的加載速度,這裡我用 bumbler

測之前記得讓 bootsnap 關閉,否則會測不出來

$ bumbler

    296.76  carrierwave
    378.10  ethereum
    393.60  devise
    410.36  authy
    532.06  compass-rails
    546.09  newrelic_rpm
    558.73  config
    575.63  fog
    604.26  country_select
    608.17  coffee-rails
    621.22  pry
    711.51  sass-rails
    761.82  google-authenticator-rails
    887.80  stellar-sdk
    948.57  twilio-ruby
   1482.54  grape
   2627.09  rails

測試初始化程序加載的狀況

$ bumbler --initializers

Slow requires:
    279.70  newrelic_rpm.start_plugin
    383.96  compass.initialize_rails
    432.23  :set_clear_dependencies_hook
    589.01  active_record.initialize_database
    611.57  ./config/initializers/translation.rb
    753.06  :load_environment_config
    800.46  :load_config_initializers
   3288.36  :finisher_hook
   9697.74  :set_routes_reloader_hook

現在我們大概能掌握在慢在哪裡,我們可以開始著手處理了。

移除上古遺 Gem

專案大起來的時候,有些 Gem 可能是當初為了某個功能先加的,說不定後來不再使用或是有別的 Gem 取代了。

這時候就要去考古,找一下有沒有哪些 Gem 是「完全沒在用」的。

沒在用的一定要優先拔掉,這是比較好做卻也比較花時間的選項。

檢查有沒有用到較舊且有 memory leaky issue 的 Gem

可以參考 A list of gems that have memory leaks

對環境進行 Gem 分組

進行分組的用意是要明確的執行,在每個環境下只載入需要用的 Gem,避免多餘的 require 來拖慢速度。

前端相關非啟動就需要執行的 Gem, 在 Test 環境下不該被加載,應該給予 group [:production, :development]

# Styles
group :production, :development do
  gem "coffee-rails", "~> 4.2"
  gem "sass-rails", "~> 5.0"
  gem "bootstrap-will_paginate"
  gem "bootstrap-sass"
  gem "bootstrap-switch-rails"
  gem "bootstrap3-datetimepicker-rails", "~> 4.14.30"
  gem "font-awesome-rails"
  gem "jquery-ui-rails"
  gem "active_link_to"
  gem "rqrcode"
end

異步執行等相關任務,沒有要進行測試也不要在 Test 環境下加載

# Backgroud Jobs
group :production, :development do
  gem "sidekiq"
  gem "sidekiq-statistic"
  gem "sidekiq-cron"
  gem "sidekiq-unique-jobs"
  gem "sidekiq-status"
  gem "whenever"
end

只有在 Production 有用的監測服務也不要在 Development﹑Test 等其他環境下加載

group :production do
  gem "newrelic_rpm"
  gem "scout_apm"
end

只有開發使用的必須要自己一組

group :development do
  gem "letter_opener"
  gem "rubocop", require: false

  # profiling
  gem "bumbler", require: false
  gem "rack-mini-profiler", require: false

  # Dev helpers
  gem "annotate"
end

分組後可以避免加載不需要的部分,讓啟動更有效率。

require: false

在 Gem file 裡面寫 require false 是指說,bundle 的情況下一定會安裝這個 Gem, 但不在 rails 啟動時直接 require 加載進來。

只在有需要的部分 require 會更有效率。

# Web Server 不需要直接 require,因為我們是單獨啟動,並不是執行在 Rails application 內
gem "puma", "~> 3.9", require: false

# sitemap 生成器,只有執行時會用到,不需要 preload
gem "sitemap_generator", require: false

# 顯示進度條的小工具,通常用在 task 執行,也只要在 task 的檔案 require 就好 
gem "ruby-progressbar", require: false

# 只有語法檢測的時候會執行,其他時候 Rails app 並不會使用到
gem "rubocop", require: false

可以看到以上的範例有一個共通點,就是這些 Gem 都是單獨執行任務的,並不是隨時隨地在 Rails application layer 內需要用到的,所以可以直接 require: false

這樣一來,可以確保執行這個專案的時候一定會安裝相依套件,但卻不會在 Rails 啟動時直接加載。

別讓 Airbrake 在 development 被 require

這個一直是我認為滿煩的問題,常常會需要用到 Airbrake 來接 error,但畢竟這服務是賣 quota 的,恨不得你在 Development 模式下也用。

Development 的情況我是一定不用的,原因如下:

  1. 開發模式可以自己噴 Error,真的不需要你接走,謝謝喔
  2. Quota 要錢的

不過雖然 Airbrake 可以設定 ignore_environments = %w(development test),但最煩的是,他是在每個環境下都會加載的,不信你關個 server 或是退出 console 就知道了。

$ rails console

# Running via Spring preloader in process 61400
# Loading development environment (Rails 5.0.7)
# Development [1] rocket(main)> exit
# **Airbrake: closed

看到 **Airbrake: closed 了嗎?表示他是無所不在的。

如果你直接把他從 development 和其他環境下拔除

# Gemfile

group :staging, :production do
  gem "airbrake", "~> 6.1"
end

那麼就會遇到

  1. 啟動時直接噴錯,因為 initialize/airbrake.rb 初始化加載會找不到 gem
  2. 有使用 Airbrake.notify(error) 的地方會爆炸

最佳解法,在不該出現的環境做一個空殼

# config/initializers/airbrake.rb

if Rails.env.staging? || Rails.env.production?
  Airbrake.configure do |c|
    ...
    ...
  end

  Airbrake.add_filter do |notice|
    ...
    ...
  end
else
  module Airbrake
    extend self
    def notify(exception, opts = {})
    end
  end
end

就可以完美解決這個問題了。

Console 要用的套件不該 preload

我相信應該滿多人裝 awesome_rails_console 之類的,進 console 除了格式漂亮以外真的很慢,而且這種算是開發人員工具的也不該直接就在各個環境下加載。

我認為最好的做法是,在啟動 console 時才加載,不需要讓整個 Rails app 提早 preload

實際作法

先用 require: false

group :staging, :development, :test do
  # Dev helpers
  gem "awesome_rails_console", require: false
end

再設定 development 環境下開啟 console 要使用 Pry

# config/environments/development.rb

config.console = Pry

建立一個 .pryrc 的檔案在 project 目錄下

# .pryrc

require "awesome_rails_console"
AwesomePrint.pry!

這樣在 Console 啟動時,會直接使用 Pry 啟動,然而 Pry 啟動前又會去讀取 .pryrc 的設定檔,如此一來,就只有在 console 啟動時會 require 了。

讓 routes 加速更快

我很遺憾,這是唯一一個目前想不到解法的部分,當專案的 routes 被越養越大,大型的路由文件在啟動上花費的時間真的很多,我目前手上最大的專案大概就有 4 秒在 reload routes,在 bumbler 的效能分析上,他是 :set_routes_reloader_hook initializer,如果把這個問題也解掉,也許能在快上 30% ?

參考資源

comments powered by Disqus