Rails 5.2 から標準 gem として入っている bootsnap
私自身は **install しておくだけで何かをキャッシュしてくれて起動が高速化する・・**ぐらいの理解で使っていたので、改めてコードリーディングを通して bootsnap がどういう仕組で何を解決しているのかを理解してみました。
🙇🙇肝心のコードリーディング部分はWIPです 🙇🙇
bootsnap とは
bootsnap は Shopify 社によって開発された Rails/Ruby アプリケーションの起動時間を短縮するためのライブラリです。
Shopify 社によって開発される Shopify は、巨大なモノリシックの Rails アプリケーションであり、 アプリケーションの起動時間が長いことは開発体験の大きな毀損でした。
そこで開発されたのが bootsnap です。
Shopify 社曰く、 bootsnap を利用することで、Rails アプリケーションの起動時間を 50% ほど短縮することが見込めます。
[参考リンク] Bootsnap: Optimizing Ruby App Boot Time
bootsnap の仕組み
bootsnap は、大きく分けて2つの機能で構成されています。
- Path Pre-Scanning(パスの事前解決)
- Compilation caching(コンパイルキャッシュ)
コードリーディングの前に、それぞれの機能を詳しく見てみましょう。
Path Pre-Scanning(パスの事前解決)
ruby では、 require
に相対パスが指定された時、 $LOAD_PATH
(別名 $:
) に示されるパスを順番に探し、最初に見つかったファイルをロードします。
[参考リンク] module function Kernel.#require
ここで、 $LOAD_PATH
のについて見てみます。
例えば以下の Gemfile が置かれている環境で $LOAD_PATH
の中身を見てみましょう。
なお、 gem は vendor/bundle 配下にインストールしています。
# frozen_string_literal: true
source "https://rubygems.org"
git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
gem "pry"
$ bundle exec ruby -e 'puts $LOAD_PATH'
/Users/kekekenta/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/bundler-2.0.1/lib
/usr/local/Cellar/rbenv/1.1.1/rbenv.d/exec/gem-rehash
/Users/kekekenta/www/bootsnap-test/vendor/bundle/ruby/2.5.0/gems/pry-0.12.2/lib
/Users/kekekenta/www/bootsnap-test/vendor/bundle/ruby/2.5.0/gems/method_source-0.9.2/lib
/Users/kekekenta/www/bootsnap-test/vendor/bundle/ruby/2.5.0/gems/coderay-1.1.2/lib
/Users/kekekenta/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/bundler-2.0.1/lib/gems/bundler-2.0.1/lib
/Users/kekekenta/.rbenv/versions/2.5.1/lib/ruby/site_ruby/2.5.0
/Users/kekekenta/.rbenv/versions/2.5.1/lib/ruby/site_ruby/2.5.0/x86_64-darwin18
/Users/kekekenta/.rbenv/versions/2.5.1/lib/ruby/site_ruby
/Users/kekekenta/.rbenv/versions/2.5.1/lib/ruby/vendor_ruby/2.5.0
/Users/kekekenta/.rbenv/versions/2.5.1/lib/ruby/vendor_ruby/2.5.0/x86_64-darwin18
/Users/kekekenta/.rbenv/versions/2.5.1/lib/ruby/vendor_ruby
/Users/kekekenta/.rbenv/versions/2.5.1/lib/ruby/2.5.0
/Users/kekekenta/.rbenv/versions/2.5.1/lib/ruby/2.5.0/x86_64-darwin18
上記のように、 vendor/bundle 配下にインストールされた gem などが $LOAD_PATH
に設定されていることがわかります。
例えば require 'pry'
を実行した場合、 上記の $LOAD_PATH
のパスを上から順にチェックしていき、マッチするファイルがあったらそれを読み込み、なければ次のパスをチェックしていく形になります。
今回のケースだと、上から3番目の vendor/bundle/ruby/2.5.0/gems/pry-0.12.2/lib
でファイルが見つかるはずです。
見つかるまで順に実行するため、require
の度にフルスキャンが走るということになります。
$LOAD_PATH
を見るとわかるように、インストールした gem 毎に $LOAD_PATH
が設定されるため、gem が増えるとそれに応じて $LOAD_PATH
も増えていきます。
このことから、 Rails/Ruby アプリケーションが巨大になってきて、多数の gem に依存するようになると、 require
の際のパスの解決に時間がかかってしまうことが理解できると思います。
これを解決するのが、 Path Pre-Scanning(パスの事前解決) です。
Path Pre-Scannningでは、それぞれの require
に対して対象のパスを初回の解決時にキャッシュしておきます。
これにより、次回起動時に require
する際にキャッシュからパスを引っ張ることで、フルスキャンのコストを抑えることができ、高速に起動することができます。
bootsnap では、 既存の require
に対してモンキーパッチを当てていて、毎回 $LOAD_PATH
からフルスキャンするのではなくキャッシュを参照する仕組みになっています。
Compilation caching(コンパイルキャッシュ)
Compilation caching については、さらに以下の2種類に分類されます。
- Ruby Compilation Caching(Rubyコンパイル結果のキャッシュ)
- YAML Compilation Caching(YAMLコンパイル結果のキャッシュ)
それぞれについて見ていきましょう。
Ruby Compilation Caching(Rubyコンパイル結果のキャッシュ)
Ruby では、コードが実行される際に、まずは YARV 命令列 という 中間コード に変換されます。
そして、 YARV 命令列を Ruby VM が読み込み実行することで、みなさんが書いた Ruby のコードが実行されるという流れです。
YARV や Ruby VM については、下の記事などを参考にしてみて下さい。
RubyでRubyVMを実装してRubyVMの気持ちになってみる
さて、この 中間コードへの変換 つまり、 コンパイル は当然コストが掛かります。
Rails/Ruby アプリケーションを起動するたびに同じコードのコンパイルをしていては、無駄なわけです。
そこで、この コンパイル 結果をキャッシュしてしまおう、というのが Ruby Compilation Caching(Rubyコンパイル結果のキャッシュ)
です。
Ruby では中間コードへのコンパイル処理をフックすることができ、これを利用して実現しています。(RubyVM::InstructionSequence.load_iseq )
bootsnap は、コンパイル処理をフックし、すでにコンパイル済みのものがある場合にはキャッシュから利用することで、コンパイルの処理をスキップすることができるわけです。
[参考リンク] Ebisu.rb#18 で、RubyVM::ISeqの話をしました
YAML Compilation Caching(YAMLコンパイル結果のキャッシュ)
Rails のアプリケーションを開発していると、設定値等を YAML で管理する機会も多いと思います。
初回起動時に読み込む YAML ファイルが多くなると、 YAML の読み込みが無視できないくらいのコストになってしまいます。
そこで、 MessagePack
や Marshal
など、 YAML に比べて読み込みコストの低い形式に変換してキャッシュすることで、読み込み時間を抑える事ができます。
コードリーディング
🙇🙇コードリーディングのまとめが終わらなかったのでWIPです🙇🙇
まとめ
今回は bootsnap の仕組みをコードリーディングを通してお伝えしました。
次回は Docker x bootsnap に関する記事を書こうと思います。
bootsnap の各種キャッシュをビルド時に生成して Docker イメージに含めることで、immutable な Docker コンテナの起動高速化を図る方法について解説します。