Ruby の lazy loading の仕組みを利用して未使用の gem を探す

技術部開発基盤グループのシム(@shia)です。 最近は cookpad のメインレポジトリを開発しやすい環境に改善するために様々な試みをしています。 この記事ではその試みの一つとして不要な gem を検出し、削除した方法を紹介したいと思います。

背景

cookpad は10年以上にわたって運用されている巨大なウェブアプリケーションです。 巨大かつ古いアプリケーションには昔は使っていたが、現在は使われてない依存性などが技術負債として溜まっています。 事業的観点から技術的負債を完全返却するのはコストとのバランスが悪いことも多いです。 これは20万行を超えるプロジェクトを幾つも抱えている cookpad のメインレポジトリも例外ではなく、その規模から使ってないと思われる依存性を探しだすのも大変な状況でした。

どうするか

人が頑張るより機械に頑張らせたほうが楽ができるし、何より確実です。 ですので今回は未使用の gem を探すために Ruby の遅延ロード仕組みに乗りました。 遅延ロードのために用意された仕組みにパッチを当て、使用されている gem のリストを出します。 これを利用して依存してる gem のリストから未使用である gem のリストを逆引きします。

InstructionSequence(iseq) とは

InstructionSequence(iseq) とは Ruby のソースコードをコンパイルして得られる命令の集合を指します。 この命令は Ruby VM が理解できるもので、各 iseq はツリー構造で成り立ちます。例えば

class Cat
  def sleep
  end
end

このコードからはCat クラスを表現する iseq が一つ、 sleep メソッドを表現する iseq が一つ作られます*1が、 構造的には Catの iseq に sleep の iseq が含まれている状態です。 これより詳しい説明を見たい方は RubyVM::InstructionSequence の説明や「Rubyのしくみ」という本がおすすめです。 もしくは弊社で Ruby の内部が分かる Ruby Hack Challenge というイベントが不定期に開催されるので参加してみるのも良いも思います。 参考記事

InstructionSequence lazy loading

Ruby 2.3 では iseq を lazy loading するという仕組みが試験的に導入されました。 この機能は iseq を初めて実行する時まで中身の読み込みを遅延させることで、

  • アプリケーションのローディングが早くなる
  • メモリーの使用量を減らす

ということを狙っています。ですが、今回は「初めて実行する時まで中身の読み込みを遅延させる」ために用意された仕組みに興味があります。 iseq の定義パスや first line number は iseq から簡単に取り出せるので、これらを利用すれば実際に使用された gem のリストを作れます。

どういうパッチを当てるのかを見る前に少しだけ Ruby のコードを見てみましょう。

// https://github.com/ruby/ruby/blob/v2_4_3/vm_core.h#L415-L424
static inline const rb_iseq_t *
rb_iseq_check(const rb_iseq_t *iseq)
{
#if USE_LAZY_LOAD
    if (iseq->body == NULL) {
    rb_iseq_complete((rb_iseq_t *)iseq);
    }
#endif
    return iseq;
}

rb_iseq_check は iseq が実行される前に呼ばれる関数です。 ここで iseq の中身が空なら(まだ実行されたことがない)、中身をロードしてるのがわかります。 先程話したようにこれは実験的な機能であるため USE_LAZY_LOAD がマクロで宣言されてないと使われません。 ですので普段はなにもせず引数として渡された iseq を返すだけの関数です。 ここで iseq の初回実行のみ特定の関数を呼び、そこで必要なロギング作業すれば良さそうです。

パッチ

上記のコードからどういう感じのパッチを書けばよいのか理解できると思うので実際のパッチを見てみましょう。 以下のパッチは 2.4.3 をターゲットとして書かれてるので注意してください。

---
 iseq.c    | 16 ++++++++++++++++
 vm_core.h | 15 +++++++++++++++
 2 files changed, 31 insertions(+)

diff --git a/iseq.c b/iseq.c
index 07d8828e9b..322dfb07dd 100644
--- a/iseq.c
+++ b/iseq.c
@@ -2482,3 +2482,19 @@ Init_ISeq(void)
     rb_undef_method(CLASS_OF(rb_cISeq), "translate");
     rb_undef_method(CLASS_OF(rb_cISeq), "load_iseq");
 }
+
+#if USE_EXECUTED_CHECK
+void
+rb_iseq_executed_check_dump(rb_iseq_t *iseq)
+{
+    iseq->flags |= ISEQ_FL_EXECUTED;
+    char *output_path = getenv("IE_OUTPUT_PATH");
+    if (output_path == NULL) { return; }
+
+    char *iseq_path = RSTRING_PTR(rb_iseq_path(iseq));
+    FILE *fp = fopen(output_path, "a");
+    fprintf(fp, "%s:%d\n", iseq_path, FIX2INT(rb_iseq_first_lineno(iseq)));
+    fclose(fp);
+}
+#endif
diff --git a/vm_core.h b/vm_core.h
index 8e2b93d8e9..96f14445f9 100644
--- a/vm_core.h
+++ b/vm_core.h
@@ -412,6 +412,16 @@ struct rb_iseq_struct {
 const rb_iseq_t *rb_iseq_complete(const rb_iseq_t *iseq);
 #endif

+#ifndef USE_EXECUTED_CHECK
+#define USE_EXECUTED_CHECK 1
+#endif
+
+#define ISEQ_FL_EXECUTED IMEMO_FL_USER0
+
+#if USE_EXECUTED_CHECK
+void rb_iseq_executed_check_dump(rb_iseq_t *iseq);
+#endif
+
 static inline const rb_iseq_t *
 rb_iseq_check(const rb_iseq_t *iseq)
 {
@@ -419,6 +429,11 @@ rb_iseq_check(const rb_iseq_t *iseq)
     if (iseq->body == NULL) {
        rb_iseq_complete((rb_iseq_t *)iseq);
     }
+#endif
+#if USE_EXECUTED_CHECK
+    if ((iseq->flags & ISEQ_FL_EXECUTED) == 0) {
+       rb_iseq_executed_check_dump((rb_iseq_t *)iseq);
+    }
 #endif
     return iseq;
 }
--
  • iseq が持っている未使用のフラグ一つを iseq が実行されたことがあるかを判断するためのフラグ(ISEQ_FL_EXECUTED)として使えるようにする
  • ISEQ_FL_EXECUTED フラグが立ってない場合 rb_iseq_checkrb_iseq_executed_check_dump という関数を呼ふ
  • rb_iseq_executed_check_dump ではその iseq の path, first_lineno を(環境変数 IE_OUTPUT_PATH で指定した)ファイルに書き込む

このように rb_iseq_check にフックポイントを作ることで TracePoint とは比べるまでもないほどの低コストで実行された iseq を探せます。 もちろんロギングのコストは発生するので注意する必要はありますが、仕組み自体のコストは実質ゼロに近いことがわかっています。

このパッチを当てた Ruby を利用することで実行された iseq のリストを得ることができます。 今回は手作業で確認したい対象を減らすためのものなので、パッチを当てた ruby でテストを完走させ、そのログを利用することにしました。以下のような大量のログが吐かれるのでこれらを処理して実際使われてる gem のリストを作成できます。

.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:46
.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:58
.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:71
.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:63
.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:67
.../2.4.3/lib/ruby/gems/2.4.0/gems/capybara-2.13.0/lib/capybara/node/matchers.rb:245
.../2.4.3/lib/ruby/gems/2.4.0/gems/capybara-2.13.0/lib/capybara/node/matchers.rb:3

依存している gem のリストは Bundler::LockfileParser を利用すると簡単に得られます。

# プロジェクト root
require "bundler"

lockfile_parser = Bundler::LockfileParser.new(File.read("Gemfile.lock"))
lockfile_parser.specs.map(&:name)

この使用された gem のリストと依存している gem のリストから、後者から前者を引き算することで、 依存しているが使用されてない gem のリストを作れます。

成果

現在、cookpad のメインレポジトリには1つの mountable engine を共有する 5つのプロジェクトがあります。 この5つのプロジェクトを対象に上記のパッチを利用して作り出した未使用 gem のリストを作成し、必要のないものをなくす作業を進めました。

結果としてすべてのプロジェクトから未使用の gem が 41個見つかりました。 これらを削除することで、依存している gem の数を大幅に減らすことができました。 さらに require するファイルの数が大量に減ったため、アプリケーションの読み込み時間が最大1秒程度速くなりました。

まとめ

Ruby の lazy loading という仕組みを利用して未使用の gem を探す方法を紹介しました。 この方法は使用されてないコードを探すのに以下のような利点を持っています。

  • プロジェクト別にコードを書く必要がないのでどのプロジェクトからも簡単に利用することができる
  • 動的に生成されるメソッドもある程度追跡ができる
  • 低コストにコードの使用状況が分かる

特に三番目が重要だと思っていて、本番のサービスから使われてない依存 gem やプロジェクトコードを簡単に追跡できるんじゃないかと期待しているので、次回にご期待ください。

*1:正確には3つが作られますが、ここでは説明のため省略しています