57
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Railsアプリのメモリが「無駄に」減る話 〜リーク・断片化〜

57
Posted at

お疲れ様です!
前回はメモリ管理の基本を書きましたが、今回はその続きで メモリリーク断片化(フラグメンテーション) についてまとめてみました :pencil:
前回ふれた「OOM(メモリ枯渇でプロセスが落ちる)」の 原因編 にあたる話です。
興味ある方は読んでいただけると嬉しいです :thumbsup:

おさらい:メモリは限りある「作業スペース」

前回(メモリ管理の基本と、Railsで省メモリに書くための実装Tips)では、メモリは「机の上」に例えられる、という話をしました。

  • 速いけど容量は小さい
  • 載せすぎると作業効率が落ち、最悪あふれて止まる(OOM)

今回はこの机が「使ってないのに片付かない(リーク)」「隙間だらけで広く使えない(断片化)」という、2つの"無駄に減る"パターンの話です。

メモリリークとは?

ひとことで言うと 「使い終わったのに手放せていないメモリ」 です。
不要なのに解放されず、じわじわ溜まり続け、最終的にメモリを食いつぶして OOM に至ります。

ありがちなリーク例:常駐プロセスでクラス/定数に溜める

ありがちなのが、Sidekiqワーカーやバッチのような「常駐プロセス」 で、クラス変数・定数にデータを貯めるパターンです。

# ❌ 常駐するSidekiqワーカーが、ジョブをまたいでデータを溜め続ける
class ReportWorker
  include Sidekiq::Worker
  PROCESSED_IDS = []   # 定数の配列。ローカルスコープではなくクラスにぶら下がるので、プロセスが生きている間ずっと残る

  def perform(user_id)
    return if PROCESSED_IDS.include?(user_id)   # 二重実行を防ぎたかった…
    PROCESSED_IDS << user_id
    # ジョブが終わっても消えず、プロセスが生きてる間ずっと増え続ける💥
  end
end

perform はジョブごとに新しいインスタンスが作られますが、PROCESSED_IDSクラス(定数)にぶら下がっている ので、全ジョブで同じ配列を共有します。
Web のリクエストと違い、Sidekiq のプロセスは何時間も生き続けるため、ジョブを処理するほどメモリが増え、最終的に OOM の危険があります。

このコードは、実はメモリ以外にも地雷を抱えています。

  • スレッドセーフでない … Sidekiqは1プロセスで複数スレッドが並行実行。メモリはスレッド間で共有されているので、PROCESSED_IDSに同時にアクセスすると競合して壊れる可能性があります!
  • 重複排除としても機能しない … 非同期で並列実行されているためこのプロセスだけでPROCESSED_IDS保持しても、別プロセスで同じジョブが走る二重実行を防げないので、Redisなどの外部ストレージで管理するのが正解です!

リークしやすいのは 「寿命の長い場所」に参照を貯めるパターン です。
具体的には クラス変数・定数・グローバル変数・上限のないキャッシュ・解除し忘れたコールバックなど
「リクエストやジョブが終わっても消えない場所に、データを足し続けていないか?」を疑いましょう。

どう防ぐ?

  • 溜める必要がないなら そもそも溜めない(処理したら手放す)
  • キャッシュには 上限(件数・有効期限) を設ける
  • 状態は できるだけ短いスコープ(ローカル変数)に閉じ込める

断片化(フラグメンテーション)とは?

ひとことで言うと 「空きの合計はあるのに、隙間に散らばっていて"まとまった広さ"が取れない」 状態です。

本棚で例えると、大小の本を 抜いたり差したり していくと… :books:

[本][空][本][空][空][本][空]   ← 空きはたくさんある

合計すれば結構な空きがあるのに、連続した大きな空きがないので「分厚い本」が入らない。
メモリでも同じで、確保と解放を繰り返すうちに空きが細切れになり、**「合計は足りてるのに、大きな領域を確保しようとすると失敗する」**ことが起きます。

厳密には断片化には2種類あります。区画と区画の 「間」が無駄になる外部断片化(上の本棚の例)と、確保した区画の 「中」に余りが出る内部断片化 です。

ありがちな例:N+1を避けようとアプリ側に大量に持つ

親子関係のあるデータで、子を1件ずつ引くと N+1(クエリが多発)になります。
これを避けるため、includes(IN句)で親子を 一括で先読み すると、今度は 大量のオブジェクトを一度にアプリ側のメモリに載せる ことになります。

# 子(line_items)も IN句でまとめて先読み → N+1は防げるが、一度に大量のオブジェクトを確保
Order.includes(:line_items).where(id: ids)

この「大量に確保 → 処理が終わって解放」を繰り返すと、ヒープに空きが散らばり、スカスカなのに解放できない=断片化が起きやすくなります。
N+1(クエリ多発)を取るか、メモリ(大量オブジェクト)を取るかのトレードオフ、という側面もあるわけですね。

Ruby のオブジェクトは「ページ」と呼ばれる区画に並べて管理され、1つでも使用中だとページごと解放できないため断片化が起きえます。

解決方法

暫定対応:定期的に再起動してリセットする

根本対応ではありませんが、プロセスを再起動すれば、OSがメモリを丸ごと回収するので、リークも断片化も一旦ゼロに戻せます

原因調査・修正には時間がかかるので、「まず落ちないようにしのぐ」現実的な対症療法として下記のような方法があります。

  • ??_killer系のgemを入れる(例:puma_worker_killerunicorn-worker-killer
  • Sidekiqや常駐バッチを 定期的に再起動する(cron等)
  • Kubernetes / systemd の メモリ上限に当てて自動再起動させる

あくまで時間稼ぎです。再起動でRSSは戻りますが、リークが直ったわけではないのでまた増えていきます
「再起動でしのいでいる間に、原因を調べて根本対応する」のが正しい使い方です。

恒久対応:原因を切り分けて直す

恒久対応は、まず「リークなのか断片化なのか」を切り分けるところから始めます。

STEP1. 原因調査:2つの値を測る

自分は下記で 「生きているRubyオブジェクトの数」と「OSから見た物理メモリの使用量」 を測ることが多いです。

  • GC.stat[:heap_live_slots] … 今"生きている"Rubyオブジェクトの数
  • RSS(Resident Set Size) … OSから見た、そのプロセスが実際に使っている物理メモリ

怪しい処理の前後でこの2つを測り、変化を見ます。

def rss_mb
  # Linux: /proc から VmRSS(kB) を読む(macなら `ps -o rss= -p #{$$}`.to_i など方法はなんでもOK)
  File.read("/proc/#{Process.pid}/status")[/VmRSS:\s+(\d+)/, 1].to_i / 1024.0
end

def snapshot(label)
  GC.start
  puts "[#{label}] live_slots=#{GC.stat[:heap_live_slots]} RSS=#{rss_mb.round(1)}MB"
end

snapshot("before")
1000.times { suspicious_process }
snapshot("after")

結果の下記でs対応が2手に分かれます

heap_live_slots(生存オブジェクト) RSS 疑うべき原因 対応
増え続ける 増える Rubyオブジェクトのリーク(参照が残っている) → A
ほぼ一定 増える 断片化、または C拡張/ネイティブのメモリリーク → B
一定 一定 シロ(問題なし)

STEP2-A. リーク(生存オブジェクトが増え続ける)→ アプリ側で直す

経験上アプリケーションの修正で対応できることが多いです。原因は「参照を残していること」なので、

  • 溜め続けている クラス変数・定数・グローバル を見直す(そもそも溜めない/処理したら手放す)
  • キャッシュに 上限(件数・有効期限) をつける
  • subscribe などの 登録を解除する

といった形で、参照を断てばGCが回収できるようになります。

どの型が増えているか」は ObjectSpace.count_objects の前後差分で特定できます。

require "objspace"

before = ObjectSpace.count_objects
1000.times { suspicious_process }
GC.start
after = ObjectSpace.count_objects

after.each { |type, n| puts "#{type}: +#{n - before[type].to_i}" if n > before[type].to_i }
# => 例) T_STRING: +50000  のように、増えている型がわかる

STEP2-B. RSSの高止まり(断片化)→ アロケータを見直す

生存オブジェクトは一定なのに RSS が下がらない、というケースは メモリアロケータ(mallocの実装)依存であることが多いです。

Linux の デフォルトの glibc malloc は、マルチスレッド環境(Puma / Sidekiq)でメモリが断片化し、RSSが高止まりしやすいという特性があります。
もしデフォルトのアロケータを使っているなら、jemalloc が個人的におすすめです :raised_hands:

  • glibc … スレッドごとに別々の領域(arena)を持ち 空きを共有できないため 断片化しやすい
  • jemalloc … サイズ(8, 16, 32, 64...)ごとに整理して確保するため 断片化しにくい

完全に主観ですがjemalloc、「Rubyは推していないが、Railsは推している」ような感じがしています。

  • Ruby本体は jemalloc を 標準採用していません(「デフォルトにするか」という提案 Feature #14718 は見送られ、--with-jemalloc というビルドオプションを用意するに留まっています)
  • 一方 Rails は 7.1 以降、rails new が生成するデフォルトの Dockerfile に jemalloc を同梱し、LD_PRELOAD で有効化しています

なお jemalloc は 2025年に一度「開発停止」が話題になりましたが、2026年に Meta が開発を再開 したため、現在は再びメンテされています:tada:

最後に

メモリリークは「使い終わったのに手放せていない(参照が残っている)」状態、断片化は「空きはあるのに隙間に散らばって使えない」状態で、どちらも前回ふれた OOM/スワップ の原因になります。
こうした問題は、ある日いきなりドカンと現れるのではなく、日々の運用の中でじわじわ増えていくことが多いです。だからこそ、実装するときも、リリースした後も、メモリの動きを毎日見る習慣を持っておくことが何より大事だと思います!

57
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
57
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?