Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby: エコシステムに潜むメモリリークを発見する(翻訳)

概要

CC BY-NC-SA 4.0 Deedに基づいて翻訳・公開いたします。

CC BY-NC-SA 4.0 Deed | 表示 - 非営利 - 継承 4.0 国際 | Creative Commons

参考: Finding Memory Leaks in the Ruby Ecosystem - RubyKaigi 2024

Ruby: エコシステムに潜むメモリリークを発見する(翻訳)

本記事は、RubyKaigi 2024でAdam Hessと私が行った発表を元にしています。

Rubyやネイティブgem内でのネイティブレベルのメモリリークを検出するメカニズムは、最近までRubyにありませんでした。その理由は、Rubyの実行が終了したタイミングでは、まだ生きているオブジェクトやRuby VM(仮想マシン)で使われているメモリが解放されないためです。使われているメモリは、いずれにしろシステムによってすべて再利用されるので、Rubyの終了を遅らせる余分な作業が発生するだけのことです。しかし言い換えれば、これはメモリの一部がリークしたのか、それとも単にクリーンアップされていないだけなのかを判断しようがないということでもあります。

要するに、システムのメモリが底をついてアプリケーションが終了するまでRubyアプリケーションのメモリ消費量が増大し続けているとき、そうした問題を引き起こすメモリリークが発生しているRubyアプリケーションを分析するのは、かつては困難であったということです。メモリリークはシステムリソースの無駄遣いであり、費用とパフォーマンス低下を引き起こし、Webサーバーのダウンタイムにつながる可能性があるため、望ましくありません。

私は2021年に、ネイティブgemのメモリリークをテストスイートで検出するruby_memcheckというツールを開発しました。

Shopify/ruby_memcheck - GitHub

このruby_memcheckは、nokogiriやliquid-c、protobuf、gRPCなどの広く使われているネイティブgemでメモリリークの検出に成功しました。ただしruby_memcheckはヒューリスティック(発見的方法)に基づいていたため、メモリリークの発生を見逃す偽陰性が起きる可能性もありました。これについては、次のセクションと別の過去記事で詳しく説明しています。

本記事では、私とAdam Hessが2023年から取り組んでいるRUBY_FREE_AT_EXITという機能を見ていくことにします。このRUBY_FREE_AT_EXITを使えば、ruby_memcheckのヒューリスティックの制約を受けずにメモリリークチェッカーを動かせるようになります。

🔗 ruby_memcheck

ruby_memcheckは、Valgrind memcheckというツールをラップする形でネイティブgemのメモリリークを検出します。Valgrind memcheckはネイティブアプリケーションのメモリリーク検出に使われるツールですが、Rubyはアプリケーションの終了時にメモリを解放しないため、Valgrind memcheckを直接使うと数千件もの偽陽性メモリリークが発生してしまいます。Rubyがアプリケーションの終了時にメモリを解放しない理由は、終了時にシステムがプログラムのメモリをすべて再利用するためです。すなわち、メモリを明示的に解放してもRubyアプリケーションの終了が遅くなるだけです。

Rubyには、終了時に「メモリリーク的な」ことが発生する場所が数十〜数百か所もあるので、終了時にすべてのメモリを解放する機能を作成しようとすると開発期間がものすごいことになってしまうでしょう。そこで、私が作ったruby_memcheckではヒューリスティックを用いてメモリリークを検出することにしました。このヒューリスティックは、ネイティブgemのメモリリークが本物か、それとも偽陽性かどうかを決定するのに使われます。もちろんヒューリスティックは完全というわけにはいかないので、偽陰性が発生する(つまりメモリリークを除外してしまう)可能性は残ります。

ruby_memcheckで使われているヒューリスティックはハックに満ちていますが、nokogiriやliquid-c、protobuf、gRPCなどの著名なネイティブgemでメモリリークの検出に成功しています。

ruby_memcheckのもう1つの制約は、ValgrindがLinux上でしか実行できないため、Linuxシステムでの利用に限定されることです。つまり、Googleのサニタイザのような高速なメモリチェッカーを使ったり、macOS用のリーク検出ツールのように別OSをサポートするといったことができません。

ruby_memcheckが動くしくみについて詳しくは、私の記事をご覧ください。

参考: How ruby_memcheck Finds Memory Leaks in Native Gems - Peter Zhu

🔗 RUBY_FREE_AT_EXITを実装する

2023年にGitHubのAdam Hessと私が協力する形で、Rubyの終了時に全メモリを解放する機能を開発しました。私たちが実装したRUBY_FREE_AT_EXIT機能(#19993)を環境変数に設定すると、Rubyの終了時に全メモリを解放するようRubyに指示します。これをフラグとして実装することで、この機能が不要な場合は高速に終了できるようになり、この機能を有効にしたときだけ終了時にメモリを解放するようになります。

実装はかなりシンプルです。RUBY_FREE_AT_EXITが有効になっていると、Ruby終了時にRuby VMをクリーンアップするタイミングでVMのあらゆる部分を解放します。実装のスニペットは以下のような感じになります。

int
ruby_vm_destruct(rb_vm_t *vm)
{
    if (rb_free_on_exit) {
        rb_free_default_rand_key();
        rb_free_encoded_insn_data();
        rb_free_global_enc_table();
        rb_free_loaded_builtin_table();

        rb_free_shared_fiber_pool();
        rb_free_static_symid_str();
        rb_free_transcoder_table();
        rb_free_vm_opt_tables();
        rb_free_warning();

...

🔗 依存関係の循環

しかし依存関係が循環することで複雑になるので、ここまでシンプルには書けません。たとえば、Rubyオブジェクトを解放するためにVMが動いている必要があるため(ファイナライザの実行など)、Rubyオブジェクトを解放してからVMを解放するようにしていますが、スレッドやメインのRactorなどはすべてRubyオブジェクトなので、VMの大半が解放されるまではそれらのオブジェクトを解放できません。

この問題は、より長期間生かしておくべきオブジェクト(スレッド、ミューテックス、ファイバー、メインのRactorなど)を決定して、それ以外のオブジェクトを先にすべて解放する形で解決しました。

switch (BUILTIN_TYPE(obj)) {
  case T_DATA:
    if (rb_obj_is_thread(obj)) break;
    if (rb_obj_is_mutex(obj)) break;
    if (rb_obj_is_fiber(obj)) break;
    if (rb_obj_is_main_ractor(obj)) break;

    obj_free(objspace, obj);
    break;

続いてVMを解放し、最後に、先ほどスキップした残りのオブジェクトを解放します。

🔗 RUBY_FREE_AT_EXITの影響

RUBY_FREE_AT_EXITの実装を終えてから、ValgrindとmacOSのリーク検出ツールによってメモリリークを検出するためにRubyのテストとspecを実行しました。この機能によって、Ruby内部に由来する30件以上ものメモリリークを検出できました。

図1: RUBY_FREE_AT_EXITで発見されたメモリリーク修正プルリクのリスト

それでは、RUBY_FREE_AT_EXITで修正されたメモリリークの1つを詳しく見てみましょう。

🔗 事例: 正規表現タイムアウト時のメモリリーク

RUBY_FREE_AT_EXITで発見したメモリリークの1つは、正規表現のマッチがタイムアウトしたときに発生します(#20228)。以下のコードで考えてみましょう。

## タイムアウトをうんと短く設定する(ここでは1ms)
Regexp.timeout = 0.001
## 作成する正規表現と文字列は、文字列に正規表現をマッチさせるとタイムアウトするように作る
regex = /^(a*)*$/
str = "a" * 1000000 + "x"

## メモリ使用量を10回表示して増大がわかるようにする
10.times do
  # マッチを100回実行してメモリリークを増やす
  100.times do
    # 文字列への正規表現マッチがタイムアウトするので
    # ここをrescueブロックで囲む必要がある
    begin
      regex =~ str
    rescue
    end
  end

  # 現在のRubyプロセスのメモリ使用状況を出力する
  puts `ps -o rss= -p #{$$}`
end

この修正が行われる前は、Rubyプロセスが利用するメモリ(kB単位)が、マッチを繰り返すたびに300MBずつ線形に増加し、最終的に3GBにも達することがわかります。

328800
632416
934368
1230448
1531088
1831248
2125072
2414384
2703440
2995664

これをグラフ化してみると、メモリ使用量が線形に増加する様子が視覚的にわかります。

図2: メモリリーク修正前のメモリ使用量のグラフ

このメモリリーク修正後は、メモリは最初少しは増加するものの、すぐに56MBあたりで落ち着くことがわかります。

39280
47888
49024
56240
56496
56512
56592
56592
56720
56720

こちらもグラフで視覚的に確認してみましょう。

図3: メモリリーク修正後のメモリ使用量のグラフ

🔗 正規表現タイムアウト時のメモリリークを修正する

このメモリリークはGitHubの#9765で修正されました。実際の差分はかなり大きいのですが、主要な変更点を以下に要約します。

  • 1. タイムアウトをチェックする関数を、正規表現のマッチがタイムアウトしたときにエラーを発生する関数から、マッチがタイムアウトしたかどうかのブーリアン値を返す関数に変更しました。raiseはこの関数から脱出してrescueとともにRubyフレームに入るので、マッチに割り当てられたメモリのクリーンアップ処理がバイパスされてメモリがリークしていました。マッチがタイムアウトしたかどうかのブーリアン値を返すことで、Regexp::TimeoutErrorraiseされる前にクリーンアップが可能になります。
// この関数は正規表現のマッチ中に定期的に呼び出される
-void
-rb_reg_check_timeout(regex_t *reg, void *end_time_)
+bool
+rb_reg_timeout_p(regex_t *reg, void *end_time_)
 {
     rb_hrtime_t *end_time = (rb_hrtime_t *)end_time_;

@@ -4631,10 +4664,18 @@ rb_reg_check_timeout(regex_t *reg, void *end_time_)
     }
     else {
         if (*end_time < rb_hrtime_now()) {
-            // timeout is exceeded
-            rb_raise(rb_eRegexpTimeoutError, "regexp match timeout");
+            // Timeout has exceeded
+            return true;
         }
     }
+
+    return false;
+}
  • 2. 次に、正規表現のマッチ中にさまざまな割り込み(タイムアウトやスレッド割り込みなど)をチェックするマクロを変更しました。修正前は、rb_reg_check_timeoutを定期的に呼び出してタイムアウトが発生したときにraiseするだけでした。修正後は、タイムアウトしたかどうかをチェックして、タイムアウトの場合はtimeoutというラベルにジャンプします。
 #ifdef RUBY

 # define CHECK_INTERRUPT_IN_MATCH_AT do { \
   msa->counter++; \
   if (msa->counter >= 128) { \
     msa->counter = 0; \
-    rb_reg_check_timeout(reg, &msa->end_time);  \
+    if (rb_reg_timeout_p(reg, &msa->end_time)) { \
+      goto timeout; \
+    } \
     rb_thread_check_ints(); \
   } \
 } while(0)
  • 3. 次に、マッチがタイムアウトしたときに実行するtimeoutというラベルを追加しました。このラベルは、HANDLE_REG_TIMEOUT_IN_MATCH_ATを呼び出す前にメモリを解放し、Regexp::TimeoutErrorraiseします。
   STACK_SAVE;
   xfree(xmalloc_base);
   return ONIGERR_UNEXPECTED_BYTECODE;
+
+ timeout:
+  xfree(xmalloc_base);
+  xfree(stk_base);
+  HANDLE_REG_TIMEOUT_IN_MATCH_AT;
 }

🔗 RUBY_FREE_AT_EXITの使い方

これまで見てきたように、この機能はRuby内部のさまざまなメモリリークを効果的に検出してきました。しかしRubyユーザーがこの機能を使いたい場合はどうすればよいでしょうか?

コミュニティでRUBY_FREE_AT_EXITを利用する例は以下の2通りが考えられます。

  1. ネイティブgemのメモリリークをネイティブレベルで検出する
  2. Rubyアプリのメモリリークをネイティブレベルで検出する

🔗 1: ネイティブgemのメモリリークをネイティブレベルで検出する

メモリを手動で管理するのが困難であることは言うまでもありません。だからこそ、RUBY_FREE_AT_EXITでRubyのメモリリークが多数検出され、ruby_memcheckでネイティブgemのメモリリークも多数検出されたのです(nokogiriやliquid-cやprotobufやgRPCなどの著名なgemも含まれます)。メモリ管理は、特に例外発生時の処理に注意が必要です。例外が発生するとネイティブのスタックフレームからジャンプし、コード実行が予期せず中断したりコードがスキップされたりする可能性があるためです。

ネイティブgemのメンテナーであれば、gemのテストスイートでRUBY_FREE_AT_EXITとメモリリークチェッカー(Valgrind、macOSのリークチェッカー、ASAN)を用いて、ぜひメモリリークの検出を試してみてください。

現在のruby_memcheck gemは、RUBY_FREE_AT_EXITでヒューリスティックを無効にできるRubyバージョンに対応しました(ただし古いRubyではRUBY_FREE_AT_EXITではなく引き続きヒューリスティックが使われます)。ruby_memcheckは、既存のminitestやRSpecのテストスイートでValgrind memcheckを手軽に使える手段を提供します。

🔗 2: Rubyアプリのメモリリークをネイティブレベルで検出する

ネイティブレベルのメモリリークがRubyアプリに影響していることが疑われる場合(おそらくネイティブgemかRuby自身のどちらかに由来します)、 RUBY_FREE_AT_EXITと何らかのメモリリーク検出ツールを利用できます。ただし、このツールはRuby内部のメモリリークの検出には使えない点に注意しておくことが重要です。大量に生成されたRubyオブジェクトがメモリ上に保持される形でメモリが肥大化している場合、このツールでは原因の特定に役立ちません。

🔗 Rubyコミュニティへのお願い

私のruby_memcheckブログ記事に書いた結論をここでも再録します。
ネイティブ拡張を使ったgemのメンテナーは、ぜひruby_memcheckでgemをテストしてメモリリークが発生しないようにしてください。みんなの力を合わせて、Rubyを今よりも高効率で安定した、誰もが使えるプラットフォームにしましょう!

関連記事

Rails: Autotuner gemでRailsアプリを高速化する(翻訳)

Ruby: mallocでマルチスレッドプログラムのメモリが倍増する理由(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。