本番環境のメモリリーク特定:RSS 増加からソースコード行番号までの階層的な原因分析
メモリリークとは、プログラムがメモリを割り当て、参照を保持したまま、永久に解放しない状態を指します。時間の経過とともに、プロセスの常駐メモリ(RSS)は増加する一方で減少しません。本番環境でのリーク特定には、開発環境よりはるかに厳しい制約が課されます。サービスを再起動できない、GDB のアタッチや valgrind の実行ができない、コードを変更して再デプロイできない、という制約です。
本記事では、これらの制約下でも実行可能な特定手法を紹介します。まず、本物のリークと「偽リーク」を区別します。安定した大きなメモリフットプリントや、アロケータが OS に返却しない空きプールは、リークではありません。次に、ガベージコレクション言語における無制限に増えるキャッシュから、C/C++ コードにおける所有権のエラーまで、4 つの実在するリークパターンに照らして問題を分類します。最後に、トップダウンの階層的な原因分析によって、増加し続ける RSS 曲線を具体的なオブジェクト名とソースコード行番号まで追跡します。金融系の Perl サービスでメモリを数 GB まで押し上げ、修正後は 60MB で安定したリークキャッシュの事例が、まさにその実例です。
症状:増加する一方のメモリ曲線
メモリリークは、監視ダッシュボード上で典型的な 3 つの特徴を示します。
- RSS 曲線が増加し続け、トラフィックが安定していても増える――プロセスの消費メモリはリクエスト量と無関係に増え続けます。
- 再起動が唯一の「痛み止め」になる――再起動後にメモリは急落し、その後再び上昇します。監視グラフは周期的なノコギリ状の曲線を描きます。メモリが 100% 近くまで達すると再起動を余儀なくされ、その後また上昇が始まります。
topやpmapでは総量しか見えない――「このプロセスが 1GB 使っている」ことは分かっても、具体的なオブジェクト・モジュール・コード行まで原因を特定することはできません。
メモリリーク特定の鍵は top の数値ではなく、オブジェクトレベルの参照パス分析にあります。誰がメモリを保持しているのか、どの経路で保持しているのか、なぜ解放されないのかを突き止めることです。
メモリリークか、単なる高メモリ使用か
「メモリが多い」状態がすべてリークとは限りません。区別の決め手は、安定した大きなメモリフットプリントか、無制限な増加かという点です。
ある PHP プロセスのメモリの大半が約 40MB の HTML 文字列だったとしても、それは必ずしもリークではありません。ProdController.php の 40 行目で file_get_contents によって Web ページ全体を一度にメモリに読み込んでいただけで、大きなメモリフットプリントが形成されたにすぎない可能性があります。この種の問題の解決策は、メモリ使用パターンの最適化(たとえばストリーミングレスポンスへの変更)であって、リーク修正ではありません。
プロセスのメモリ使用量が多くても、それ以上増えていないなら、それはメモリフットプリントの問題であってリークではありません。以下の 4 つのチュートリアルでは、コード変更も再起動もせずに、実行中のプロセス内で最大のメモリオブジェクトを特定する方法をそれぞれ紹介しています。
- PHP:実行中の PHP プロセスで最大のメモリオブジェクトを特定する――コントローラ内の 40MB の HTML 文字列まで追跡
- Python:実行中の Python プロセスで最大のメモリオブジェクトを特定する――約 570MB のモジュールレベルの辞書を発見
- Django:Django アプリケーションのオブジェクトレベルのメモリ分布を分析する――インタープリタ自身の
.modulesだけで 38.27MB を占有 - Perl:実行中の Perl プロセスで最大のメモリオブジェクトを特定する――180MB の Perl プロセス内で、シンボルテーブル内のハッシュテーブルを特定
一方、メモリが増加し続けて止まらないなら、それはリークです。このまま読み進めてください。
4 つのリークパターンと実際の根本原因チェーン
メモリリークのメカニズムは大きく 4 つに分類できます。各パターンについて、一般的なメカニズムと、オブジェクト参照パスからソースコードレベルの着地点まで追跡した実際の根本原因チェーンを 1 つ以上示します。
1. ガベージコレクション言語における無制限に増えるキャッシュ
メカニズム:ガベージコレクタ(GC)は、参照されていないオブジェクトだけを回収します。キャッシュ構造がオブジェクトへの参照を保持し続け、一切破棄しない場合、GC から見てこれらのオブジェクトは永遠に「生きている」ことになり、メモリは決して回収されません。
実際の根本原因チェーン:
ある Python
gunicornプロセスで、OpenResty XRay の GC オブジェクトフレームグラフが約 570MB のメモリをorder_service.service.order.prev_processorモジュール内のorder_name_cache辞書まで追跡しました。handle()関数が注文ごとに新しいレコードを書き込む一方、古いレコードを削除するコードがどこにも存在しない、典型的な無制限増加のパターンでした。
金融業界のある Perl サービスでは、メモリが数日で数 GB まで膨張しました。Perl GC オブジェクトのメモリ分布フレームグラフが問題を即座に暴き出しました。まったく予想外の場所で、キャッシュデータ構造がメモリを蓄積し続けていたのです。修正後、メモリは起動時の約 100MB から約 60MB に低下し、長期稼働でも 60MB 台で安定、削減率は 95% を超えました。
YUNDUN 社の本番環境では、OpenResty worker プロセスのメモリが想定を大きく超えていました。OpenResty XRay の LuaJIT GC オブジェクト参照関係フレームグラフにより、
ngx.ctx.game_conf.tcpが 66 個の table を参照して 1MB 超のメモリを占有し、プロセス内に数百万個の table オブジェクトが存在することが判明しました。分析ツールのデプロイから本番環境での修正検証まで、全工程はわずか半日でした。修正後、総メモリは 60% 以上削減され、うち Stream LuaJIT 部分は 80% 以上削減されました。
2. 高レベルオブジェクトによる低レベルネイティブメモリの固定
メカニズム:言語レベルの小さなオブジェクトが、低レベルの C/C++ 構造体への参照を保持することがあります。これらの C 構造体のメモリは Glibc アロケータの勘定に計上される一方、言語レベルの GC から見えるのは数十バイトの参照だけです。この差は極端なものになり得ます。
実際の根本原因チェーン:
ある OpenResty アプリケーションで、worker プロセスのメモリが線形に増加し続けていました。OpenResty XRay のメモリ分析レポートでは、Glibc アロケータが約 93% のメモリを占有し、LuaJIT アロケータはわずか約 2.4% でした。一見リークは C レベルにあるように見えますが、真の根本原因は Lua レベルにありました。
_LOADED.dynamic_cert.cert_cacheという名前のlua-resty-lrucacheオブジェクトが、ssl.parse_pem_certとssl.parse_pem_priv_keyで解析した SSL 証明書をキャッシュしており、LRU キャッシュの容量設定が大きすぎたためにエントリがほぼ破棄されていなかったのです。新しいドメインの証明書を解析するたびに、その背後の OpenSSL 構造体が Glibc arena に恒久的に残り続けていました。
Lua レベルのメモリリークについては、OpenResty XRay の
lj-gco-refアナライザが GC オブジェクトの完全な参照パスを直接表示できます。GC roots => registry => ._LOADED => <モジュール名> => <table 名>という形で、GC ルートからリークしている具体的な table まで、コード変更も再起動もなしに追跡できます。
3. C/C++ コードにおける所有権のエラー
メカニズム:割り当て側と解放側で「このメモリは誰が管理するのか」の認識が食い違うパターンです。割り当て側は下流が解放すると考え、下流は上流や別のモジュールが解放すると考えた結果、誰も解放しない状態になります。
実際の根本原因チェーン:
ある Nginx C++ モジュールのメモリリーク事例では、OpenResty XRay のメモリリークフレームグラフが
ngx_dubbo_hessian2_encode_payload_map関数を直接特定しました。ソースコードを掘り下げると、ngx_dubbo_util.cppの 96 行目でnewによってstd::basic_stringオブジェクトが割り当てられていました。このオブジェクトはラップされて下流モジュールに渡されますが、その特殊な生成方法のために「本モジュール管理外」と誤ってマークされていました。下流の自動回収機構は「このメモリは外部が責任を持つ」というシグナルを受け取り、静かにスキップしていたのです。しかし実際には、他の誰も解放を担っていませんでした。新しいリクエストのたびにこのメモリブロックが割り当てられ、決して解放されることはありませんでした。
4. サーバーのメモリプールのリーク
メカニズム:Nginx などの高性能サーバーは、メモリプール(memory pool)アーキテクチャでメモリ割り当てを管理します。プール型アロケータでは、個々の割り当てが外部ツールから見えなくなります。valgrind に見えるのはプール単位の一括割り当てだけで、プール内部のライフサイクルはまったく理解できません。プール自体のライフサイクル管理に誤りがあると、メモリがリークします。
実際の根本原因チェーン:
Nginx ベースの、同時接続数の多い API ゲートウェイで、単一の worker プロセスのメモリが数百 MB から 1GB 超まで増加し続けました。OpenResty XRay はまずシステムレベルで、メモリの大半を Glibc アロケータが保持していることを確認し、次に Nginx メモリプール層に踏み込みました。その結果、メモリ消費が Tengine
dynamic upstreamモジュールが作成したメモリプールに集中していることが判明しました。さらに Glibc のメモリブロックサイズ分布を分析すると、256k~512k の範囲に 2240 個のメモリブロックが見つかりました。この数は通常稼働時の特徴から著しく逸脱しており、これらのブロックは長期にわたって保持され解放されていませんでした。最終的に、C レベルのメモリリークフレームグラフによって割り当て挙動を完全な C 関数呼び出しチェーンにマッピングし、メモリ割り当て点からライフサイクルの終端までを検証可能な根本原因チェーンとして確立しました。
増えているように見えて、実はリークではないケース
メモリ増加のすべてがリークとは限りません。以下の 2 つのよくある「偽リーク」パターンは、一般的なメモリデバッグガイドではほとんど言及されません。
LuaJIT アロケータの free pool 機構:LuaJIT のメモリアロケータには空きメモリプール(free pool)があり、連続した空き領域が存在するときにしかメモリを OS に返却しません。これは設計どおりの動作であり、リークではありません。YUNDUN 社の事例では、トラフィックを切り離した後に in-use メモリは確かに減少しましたが、RSS は目立って下がりませんでした。トレンドグラフには、トラフィックを切り離すと in-use が徐々に減って free が徐々に増え、トラフィックを戻すと free が再利用される様子が明確に示されていました。解放済みのページを実際に OS へ返却する必要がある場合(たとえば Kubernetes のメモリ制限下)は、アロケータレベルで LuaJIT の RSS 膨張を解決する記事を参照してください。
インタープリタ自身の基礎的なオーバーヘッド:業務ロジックが軽量でも、インタープリタが読み込むモジュール自体が大量のメモリを占有することがあります。約 85MB の Django プロセスでは、.modules(読み込み済み Python モジュールのレジストリ)だけで 38.27MB を占めていました。1521 個のモジュールの合計です。うち openpyxl.utils.cell という単一モジュールが 2.6MB、標準ライブラリの linecache が 648KB でした。これはリークではなく、起動時の固定フットプリントです。
判断基準のまとめ:本物のリークと偽リークを区別する鍵は、増加が in-use メモリと free/cached メモリのどちらから来ているかを見ることです。in-use が増加し続け、負荷が下がっても減らないなら、リークです。in-use が安定していて free が返却されないだけなら、それはアロケータの挙動であり、リークではありません。
リーク特定の手法:トップダウンの階層的な原因分析
メモリリークの特定は当てずっぽうの作業ではありません。有効な方法論は、トップダウンで段階的に絞り込むことです。システムレベルの総量からアロケータ層へ、さらに具体的なオブジェクトやコード行へと絞り込んでいきます。
アロケータごとのメモリ内訳の確認
最初のステップは、オブジェクト探しに飛びつくことではなく、メモリが誰の勘定に計上されているかを明確にすることです。システムアロケータ(Glibc)か、言語アロケータ(LuaJIT/Python/Perl の GC)か、それともアプリケーションレベルのメモリプール(Nginx pool)か。
このステップでの判断力が決定的に重要です。前述の LRU キャッシュリーク事例で、Glibc の占有率だけを根拠に「リークは C レベルにある」と断定して C コードのレビューに突き進んでいたら、完全に見当違いになっていたでしょう。正しいアプローチは、言語レベルのオブジェクトが C 拡張を通じて間接的にこれらのメモリを保持していないかを引き続き確認することです。同様に、Nginx メモリプールの事例では、メモリブロックサイズ分布の異常が、調査範囲を「Glibc の総量が多い」から「特定サイズのブロックが異常に蓄積している」へと一気に絞り込みました。
GC オブジェクトの参照パスからソースコード行番号へ
ガベージコレクション言語では、GC オブジェクトフレームグラフが参照パスを表示します。GC ルートから各アクティブオブジェクトまでの完全なパスで、幅がメモリ占有量を表します。最も幅の広いパスをたどっていけば、最も多くのメモリを保持しているオブジェクトが見つかります。
オブジェクト名が分かったら、次はソースコード内での位置を特定します。PHP の事例では、参照パスが productPage プロパティを指しており、ソースディレクトリでこの名前を grep すると、ProdController.php の 40 行目に直接たどり着きました。Python の事例では、参照パスが order_name_cache を指しており、モジュール名をコピーし、その中のドットを grep のワイルドカードとして活用してソースファイルを検索することで、同様に具体的なソースファイルとコード行まで素早く到達できました。
Java の場合も同様に、ヒープダンプ(heap dump)もサービス再起動もせずに、本番環境の Java メモリリークを診断できます。実行中の JVM の GC オブジェクト参照チェーンを分析し、GC Root に保持されたままの、本来解放されるべきオブジェクトを見つけ出します。
従来ツールが本番環境で通用しない理由
従来ツールにはそれぞれ、本番環境における決定的な制約があります。「使いにくい」のではなく「使えない」のです。
- valgrind:valgrind 配下でプロセスを再起動する必要があり、本番環境では実行不可能です。さらに重要なのは、Nginx メモリプールのライフサイクルを理解できない点です。プール型アロケータはメモリをまとめて割り当てるため、valgrind に見えるのはプールレベルの割り当て/解放だけで、プール内部のリークにはまったく気づけません。
- GDB:本番環境で GDB をアタッチすることにはセキュリティリスクがあります。対象プロセスを一時停止させるため、同時接続数の多いサービスでは、すべてのリクエストが瞬時にタイムアウトします。
- ヒープダンプ(heap dump):数 GB のヒープスナップショットのエクスポート自体が Stop-the-World イベントであり、稼働中のサービスには受け入れられません。
@profileデコレータ / Memray:コードを変更してデコレータを追加するか、起動方法を変える必要があります。つまりコード変更・再デプロイ・プロセス再起動が必要で、いずれも本番環境では高リスクな操作です。top/pmap:Glibc アロケータの総量しか見えず、具体的なメモリオブジェクトまで原因を特定できません。
OpenResty XRay の Guided Analysis と Insights 機能は、これらとは異なるアプローチを提供します。実行中の未変更のプロセスを直接分析し、再起動もコード変更もデバッガのアタッチも必要ありません。動的トレーシング技術によって、システムレベル(Glibc/メモリプール)と言語レベル(Perl/Python/PHP/Lua/Java/C/C++)のメモリ分析を同時に生成し、最も顕著な参照パスと根本原因の提案を自動的に導き出します。
よくある質問
サービスを再起動せずに本番環境のメモリリークを特定するには?
OpenResty XRay の Guided Analysis 機能で「High memory usage」問題タイプを選択し、実行中のプロセスを直接分析します。システムが GC オブジェクトのメモリ分布フレームグラフとメモリ割り当ての原因分析レポートを自動生成し、最大のメモリオブジェクトとその完全な参照パスを表示します。全工程を通じて、サービスの再起動もコードの変更もデバッガのアタッチも必要ありません。OpenResty XRay は非侵入型の方式で、実行中のプロセスを動的トレーシングによって分析します。
メモリリークか高メモリ使用かを判断するには?
決め手は、メモリが無制限に増加しているかどうかです。プロセスのメモリ使用量が多くても安定して変化しないなら――たとえば PHP プロセスが Web ページ全体を一度に読み込んで 40MB を占有しているケース――それは大きなメモリフットプリントであってリークではありません。メモリが増加し続けて止まらず、負荷とも無関係なら、それはリークです。どこかのコードパスがメモリを割り当て続け、決して解放していません。
ガベージコレクション言語でもメモリリークは起きますか?
起きます。ガベージコレクタは、参照が到達不能になったオブジェクトだけを回収します。キャッシュ構造がオブジェクトへの参照を保持し続け、一切破棄しない限り、GC はそれらを生きているとみなし、メモリは決して回収されません。実際の事例では、Python のモジュールレベル辞書 order_name_cache が注文ごとに新しいレコードを書き込みながら古いレコードを削除しなかったケースや、Lua の cert_cache LRU キャッシュの容量が大きすぎて解析済み SSL 証明書が破棄されなかったケースがあり、いずれもガベージコレクション言語における典型的なリークパターンです。
プロセスのメモリが増え続けているのに、実はリークではないのはなぜですか?
よくある原因は 2 つあります。1 つはアロケータの空きプール機構です。たとえば LuaJIT のメモリアロケータは、オブジェクト解放後すぐにメモリを OS に返却するとは限りません。RSS は増えているように見えても、in-use メモリはすでに減少しており、free pool 内のメモリは新しいリクエストが来たときに再利用されます。もう 1 つはインタープリタ自身の基礎的なオーバーヘッドです。たとえばある Django プロセスは、1521 個の Python モジュールを読み込むだけで 38.27MB を占有していました。これはリークではなく、起動時の固定フットプリントです。区別するには、in-use メモリと free/cached メモリのどちらが増えているかを確認します。
OpenResty XRay について
OpenResty XRay は動的トレーシング製品であり、実行中のアプリケーションを自動的に分析して、パフォーマンスの問題、動作の問題、セキュリティの脆弱性を解決し、実行可能な提案を提供します。基盤となる実装において、OpenResty XRay は弊社の Y 言語によって駆動され、Stap+、eBPF+、GDB、ODB など、様々な環境下で複数の異なるランタイムをサポートしています。
著者について
章亦春(Zhang Yichun)は、オープンソースの OpenResty® プロジェクトの創始者であり、OpenResty Inc. の CEO および創業者です。
章亦春(GitHub ID: agentzh)は中国江蘇省生まれで、現在は米国ベイエリアに在住しています。中国における初期のオープンソース技術と文化の提唱者およびリーダーの一人であり、Cloudflare、Yahoo!、Alibaba など、国際的に有名なハイテク企業に勤務した経験を持ちます。「エッジコンピューティング」、「動的トレーシング」、「マシンコーディング」の先駆者であり、22 年以上のプログラミング経験と 16 年以上のオープンソース経験を持っています。世界で 4000 万以上のドメインに採用されているオープンソースプロジェクトのリーダーとして、OpenResty® オープンソースプロジェクトをベースに、米国シリコンバレーの中心部にハイテク企業 OpenResty Inc. を設立しました。同社の主力製品である OpenResty XRay(動的トレーシング技術を利用した非侵入型の障害分析・診断ツール)と OpenResty Edge(マイクロサービスおよび分散トラフィックに最適化された多機能ゲートウェイソフトウェア)は、世界中の多くの上場企業および大企業から高い評価を得ています。OpenResty 以外にも、章亦春は Linux カーネル、Nginx、LuaJIT、GDB、SystemTap、LLVM、Perl など、複数のオープンソースプロジェクトに累計 100 万行以上のコードを寄与し、60 以上のオープンソースソフトウェアライブラリを執筆しています。
翻訳
英語版の原文と日本語訳版(本文)をご用意しております。読者の皆様による他の言語への翻訳版も歓迎いたします。全文翻訳で省略がなければ、採用を検討させていただきます。心より感謝申し上げます!


















