📙

メモリリーク調査に沼らないための実践ガイド

に公開

メモリリークの調査は、ソフトウェア開発における不具合対応の中でも特に難易度が高い領域です。

もし「直近のリリース以降に発生した」と明確に分かっているのであれば、原因となり得る変更範囲は限定され、比較的短時間で原因へ辿り着ける可能性があります。
しかし、どのタイミングからリークが始まったのか不明な場合、過去の変更履歴の中から原因を変更を特定する必要があり、調査は困難を極めます。

さらに問題を複雑にしているのは、メモリリークが頻繁に発生する問題ではないという点です。
多くの開発者にとって、メモリリークの調査を体系的に理解する機会はそれほど多くありません。

この記事では以下のような状況に対してメモリリーク調査の基本的な考え方を整理して解説します。

  • メモリリークが発生したが、どのように調べればよいかわからない
  • OOM(Out Of Memory)の根本原因がどこにあるのか見当がつかない
  • 再現性が低く、何を手がかりにすればよいか迷っている

⚠️ なお、具体的な調査方法について言語ごとに異なるためここでは解説しません。
また、アプリケーションは k8sで運用されていることを前提に解説しますが、基本的な考え方は同一です

大前提としてまずは可視化する

メモリリークの兆候を観測した際、最初に取り組むべきことはコードの読み込みや推測ではなく、アプリケーションの状態を正確に把握することです。
必要なメトリクスを揃え、可視化したうえで継続的に観測できる状態を作ることが最初のステップになります。

メモリリーク調査で特に重要となる指標は以下のとおりです。

カテゴリ 必須 あると便利
GC 発生頻度 / 世代別GC回数 / Paused Time / GC後メモリ量 –
Heap Young / Old / Metaspace / Native の使用量 –
CPU User / System / GCスパイク判定 –
スレッド/ルーチン – スレッド数 / ゴルーチン数
FD – open FD 数
コンテナ – Working Set / OOM Event

これらの指標は、メモリリーク調査を進めるうえで土台となる非常に重要な観測ポイントです。
これらのメトリクスが可視化できていることを確認し、不足している指標がある場合は、調査に着手する前に導入してください.

本当にメモリリークは発生しているのか??

メモリリークの兆候を観測したときに、最初に確認すべきポイントは本当にメモリリークが発生しているのかどうかです。
メトリクスでメモリ使用量が右肩上がりになっていたり Podが OOMKilledされていたりすると「メモリリークだ」と判断してしまいがちです。

しかし、こうした状況でも 必ずしもメモリリークが原因とは限りません。
最初に疑うべきは、Pod のリソース設定、そしてアプリケーション言語ごとのメモリ設定です。

確認すべき設定項目

ヒープ領域の設定値とコンテナのメモリ制限を確認し、実際の使用状況と照らし合わせてバランスが取れているかを判断します。

■ ヒープ領域のオプション例:

  • Node.js:--max-old-space-size
  • Java:-Xmx

■ 調査用のテーブル

項目 値
limit.Memory
Heap Size
pod used Memory
used heap size

上記の様な表にまとめた時に 実際の使用量に対して設定が適切かを評価してください。
ありがちなケースについて幾つか紹介しておきますので当てはまる場合、原因はメモリリークではなく設定起因のため設定値を適切に見直してください。
※ k8sで観測する際は キャッシュの影響を受けるcontainer_memory_usage_bytesではなく container_memory_working_set_bytesを見て下さい

なお、適切なヒープサイズやメモリ設定は言語やランタイムによって大きく異なります。
値を検討するときは言語ごとのGCの仕様を確認して設定してください。

Heap Size がコンテナメモリの 80%以上を占めている
スタック・ネイティブ領域・ライブラリロードなどにもメモリが必要です。
ヒープにほぼ全量を割り当ててしまうと、非ヒープ領域に使える余力がなく、old領域の蓄積だけで OOMが発生することがあります。

実際の used heap に対して Heap Size が大きすぎる
実際は300MBしか使っていないのに、Xmx=2GBのように過剰に割り当てているケースです。
ヒープに余裕があるため GCが積極的に走らず、限界に近づくまでメモリが増加し続けるためメモリリークのように見える事象が発生します。

メモリリークを再現する

設定に問題がないと判断できた場合、次に取り組むべきはメモリリークの再現です。
調査を進めるうえで、再現性の確保は最も重要なポイントです。

本番環境で heap dump を取得したり、プロファイルを走らせたりすれば性能劣化を招くため現実的ではありません。
また、修正と検証を何度も繰り返す必要があるため、本番を使った調査はユーザー影響等を考えると現実的に不可能です。

そのため、理想はローカル環境、次点で開発環境(staging)で再現させることが推奨されます。
しかし、メモリリークはすべてのリクエストで一律に発生するわけではなく、
特定のパス・特定のユーザー条件・特定のエラーハンドリングなど、部分的に発生することが多いため再現が難しい場合があります。

以下では、どのような観点で再現条件を整理し、効率的に再現性を確保するかをまとめます。

エントリポイントを洗い出す

まず初めにローカル環境に立てたアプリケーションに負荷をかけてみて下さい。
本番と同じようにメモリが上がっていくのであればこれより後ろは基本的に読まなくて問題ありません。
そうでない場合は...残念ながら長い戦いになりますが以降の内容を確認しながら進めていきましょう.

まず、アプリケーション内のすべての公開パス(HTTPエンドポイント)を一覧化することが重要です。
どのパスが原因で発生しているのかを見つけ出すために以下の手順でパスを洗い出します。
(ここで洗い出したパスは 後で検証の際にどのパスでリークしたか確認するために使用します.)

  1. ルーティング設定ファイルをすべて確認
  2. フレームワークの自動バインディング(decorator や annotation)も含めて確認
  3. もし、内部で Cron処理を回しているならそれも確認する

状態依存の条件を洗い出す

パスだけでなく 内部ロジックの条件分岐を洗い出す必要があります。
同じパスでも特定条件によって発生するケースがあるため、コードを解析しながら それらを可能な限り洗い出します。
(ここでも同じように因子と条件をマトリクス化して整理しておくと後の検証が楽になります)

原因となりえる因子の例としては以下のようなものです。

  • ユーザー属性(課金ユーザー、一般ユーザーなど)
  • 特定の Cookie 付与時
  • 特定の HTTPヘッダ(ABテストのフラグ等)
  • 特定の機能フラグ(feature flag)

また、依存しているライブラリにも注意を払って下さい。
特に以下のようなライブラリは Nativeメモリや FDをリークしやすい代表例です

  • 外部 API クライアント
  • 一部のキャッシュライブラリ
  • 画像変換・圧縮ライブラリ
  • DBドライバ

イレギュラーケースの洗い出し

最後にイレギュラー系のパスも見落とされがちですが忘れずに確認します。
ただし、これらのイレギュラーケースは発生条件がわかっても再現できないケースが多いです。
(外部APIのエラー等を擬似的に再現することは難しい)

実際の検証ではわざとコードを書き換えて強制的に発生させたりするため、あまり発生条件を調査する必要はありません。
こういう例外ケースがあるということだけ認識できていればOKです。

  • エラーハンドラー
  • 例外時のスタックトレース生成
  • リダイレクト処理
  • タイムアウト時の後始末
  • 成功時とは異なる終了処理
  • 非同期処理のキャンセル時(Abort)

原因となる処理を見つける

前段で洗い出した 各検証項目(パス・状態依存・イレギュラーケース) を総当たりで検証していきます。
経験的に再現率が高い順に パス > イレギュラーケース > 状態依存 となります。
状態依存は調査コストの割に期待値が低いため、まずはパスとイレギュラーケースで手がかりを見つけ、
それでもヒットしなかった場合に状態依存を深掘りする方針が効率的です。

再現できる条件を特定できたら、次はその条件でリクエストを投げ続けながら heap dump を取得して解析します。
heap dump の解析方法は言語・ランタイムごとに大きく異なるためここでは触れませんが、
Java / Node / Go いずれも多くのツールや記事があるため調査が難しいことはありません。

一般的には以下のような観点で差分を見ると原因が特定しやすいです。

  • GC後にも残り続けるオブジェクト
  • インスタンス数が異常に増えている型
  • ノードやツリー構造が意図せず保持されている箇所
  • キャッシュやコレクションのサイズ増加
  • ライブラリの内部構造が膨れ上がっている

再現条件と heap dump の組み合わせが見つかれば、原因の特定はかなり容易になります。

原因が見つからない場合

ここまでの手順で再現条件が一つも当たらなかった場合は残念ながら本番環境から heap dump を取得する必要が出てきます。  
ただし、これは以下のようなサービス影響や情報漏えいリスクが極めて高い最終手段であり、慎重な判断が必要です。

  • パフォーマンス劣化の可能性がある
  • heap dump は機密性が高いため、保管方法・転送方法を厳重に管理する必要がある
  • Pod が一時停止する可能性がある
  • スレッドが止まるため、タイムアウト・エラーが発生し得る
  • 容量が数百MB〜数GBになる場合もある
  • 監査対応が必要な場合もある

リスクを最小限に抑える方法として 1 Podだけ dump 用の環境を作る方法があるため、そちらを紹介しておきます。

まず 本番deploymentを複製します

kubectl get deploy <app> -o yaml > dump-deploy.yaml

続いて複製した deploymentを以下の手順で編集します

  1. replicas: 1 に変更
  2. metadata.name を <app>-dumpに変更 (名前は任意)
  3. args や envで実行引数に dumpを取得するための設定を追加する (言語ごとに違うため要確認)

本番に反映する
(!) リクエストは他のpodと同様に一受け付けるため実ユーザーに影響があります。

kubectl apply -f dump-deploy.yaml

heap dumpを解析する

heap dump の取得方法や UI 操作は言語ごとに異なるため詳しく書くことはできませんが、
何を基準に怪しいと判断するかはどの言語でも共通です。
特に以下の観点を基準にチェックすると、原因に辿り着きやすくなります。

  • インスタンス数が異常に増えている型
  • インスタンス合計サイズが異常に大きい型
  • GC後にも生き残り続けるオブジェクト
  • 参照チェーンをたどったとき参照が切れていないオブジェクト
  • コレクション(List / Map / Array)が肥大化している箇所

この観点で 増え続ける、残り続ける、参照が切れないものがあれば、そこがリークの原因になっている可能性が高いです。

まとめ

メモリリーク調査は、コードを読む前に 状態の可視化・設定の見直し・再現条件の特定 といった下準備が最も重要です。
メトリクスや設定が不足しているまま原因究明を始めても、的外れな仮説を追い続けて調査が長期化してしまいます。

今回の記事で紹介した以下のポイントを踏まえて当たりをつけていくことが重要です。

① 本当にメモリリークが発生しているか確認する

  • 設定値(Heap Size / Pod の memory limit)が適切か
  • GC 後のメモリ推移や Working Set を基準に判断する

② ローカルまたは開発環境で再現性を確保する

  • パス → イレギュラーケース → 状態依存の順に検証
  • 条件分岐や外部ライブラリが動く箇所も網羅して確認する

③ 再現条件が見つかったら heap dump を取得して解析する

  • インスタンス数・サイズ・参照チェーンから怪しいオブジェクトを特定
  • 言語ごとに操作方法は違うが、見るべき観点は共通

④ どうしても再現できない場合は最後の手段として本番で dump を検討する

  • Kubernetes なら dump 専用の Pod を作る運用も可能
  • ただしサービス影響が大きいため慎重に判断する

この記事がメモリリークの調査の手助けになれば幸いです

Discussion