Pythonのメモリリークを診断して修正する
2017年3月7日
0 分で読めます編集者注
このブログ記事は、もともとfugue.coに掲載されていました。Fugueは2022年にSnykの一員となり、Snyk IaCの重要な構成要素となっています。
Fugueでは、使いやすさ、Pythonのセキュリティ、豊富なパッケージライブラリ、強力な言語ツールを理由に、クラウドセキュリティSaaS製品全体とサポートツールでPythonを幅広く使用しています。クラウド向けの複雑なソフトウェアを開発する中で学んだのは、言語の優劣はデバッグやプロファイリングのツールに左右されるということです。ロジックエラー、CPUの急増、メモリリークは避けられません。しかし、優れたデバッガー、CPUプロファイラー、メモリプロファイラーがあれば、こうした問題を大幅に簡単かつ迅速に見つけられ、開発者はFugueの動的なクラウドオーケストレーションおよび強制適用システムの構築に戻ることができます。具体例を見てみましょう。
秋、Fugueの「reflector」と呼ばれるPythonコンポーネントで、数日間稼働した後にランダムな再起動や不安定な動作が発生していると、メトリクスが示しました。メモリ使用量を調べると、reflectorのメモリフットプリントが単調かつ継続的に増加しており、メモリリークが疑われました。Python標準ライブラリの強力なメモリ追跡ツールtracemallocを使うことで、リークをすばやく診断して修正できました。原因は、人気のあるサードパーティ製Python HTTPライブラリrequestsの使用にあることがわかりました。このコンポーネントをPython標準ライブラリのurllibを使うように書き換えたところ、メモリリークは解消しました。このブログでは、その詳細を紹介します。

Pythonのメモリ割り当て
ほとんどの場合、Pythonのメモリ管理については、インタープリターがメモリを管理してくれると知っていれば十分です。しかし、安定性が強く求められる大規模で複雑なPythonプログラムを書く場合は、内部の仕組みを理解し、Pythonのメモリ管理アルゴリズムと適切に連携するコードを書く方法を知っておくと役立ちます。Pythonは参照カウントとガベージコレクションを使ってメモリブロックを解放し、特定の内部要件が満たされた場合にのみシステムへメモリを返します。純粋なPythonスクリプトから、インタープリターのメモリ割り当てを直接制御することはできません。メモリ割り当てを直接制御したい場合は、拡張機能を作成または使用することで、インタープリターのメモリ割り当てを回避できます。たとえば、numpyは独自のメモリアロケーターを使って、大規模なデータ配列のメモリを管理します。
基本的に、Pythonは参照カウントを用いるガベージコレクション言語です。インタープリターはオブジェクトの作成時にメモリを自動的に割り当て、そのオブジェクトに関連付けられたデータ構造で参照数を追跡します。オブジェクトの参照カウントがゼロになると、そのメモリは解放されます。また、ガベージコレクションは循環参照を検出し、循環参照のみで参照されているオブジェクトを削除します。この2つの仕組みにより、Pythonインタープリター内で割り当てられたすべてのメモリは解放可能です。ただし、拡張機能で割り当てられたメモリについては保証できません。
Pythonは、システムヒープとは別に独自のヒープを管理します。Pythonインタープリターは、作成するオブジェクトの型に応じて異なる方法でメモリを割り当てます。整数や浮動小数点数などのスカラー型と、リスト、タプル、辞書などの複合型では、メモリの割り当て方法が異なります。一般に、メモリは型に応じた固定サイズのブロック単位でPythonヒープに割り当てられます。ブロックはプールにまとめられ、プールはさらにアリーナにまとめられます。アリーナ、プール、ブロックを使ってメモリが事前に割り当てられ、プログラムの実行中に必要に応じてデータの保存に使われます。これらのブロック、プール、アリーナはPython独自のヒープに保持されているため、メモリブロックを解放しても、インタープリター内で再利用可能になるだけです。Pythonでメモリを解放しても、システムレベルで直ちに解放されるわけではありません。アリーナ全体が空きとマークされたとき、Pythonインタープリターがそのメモリを解放してシステムに返します。ただし、メモリの断片化により、これはめったに起きない場合があります。
こうした抽象化のため、Pythonのメモリ使用量はしばしば高水位標のような動きを示します。つまり、ピーク時のメモリ使用量が、そのメモリが実際に使われているかどうかにかかわらず、その後の実行中のメモリ使用量を決めます。さらに、コード上で「解放」されたメモリと、システムに返されるメモリとの関係は曖昧で、予測が困難です。そのため、複雑なPythonプログラムのメモリ使用量を完全に理解するのは非常に難しいことで知られています。
tracemallocを使ったメモリプロファイリング
tracemallocはPython標準ライブラリに含まれるパッケージ(バージョン3.4以降)です。メモリ割り当てをブロック単位で詳細に追跡し、割り当てが発生した行までの完全なトレースバックや、プログラム全体のメモリ動作に関する統計情報を提供します。機能の概要はこちらのドキュメントをご覧ください。導入のきっかけとなったPython Enhancement Proposal(PEP)の原文にも、設計に関する考察が記されています。
tracemallocを使うと、メモリ使用量の多いコード領域を次の2つの方法で特定できます。
メモリ使用量の累積統計を確認し、最も多くのメモリを使用しているオブジェクトの割り当てを特定する
実行フレームを追跡し、コード内でそれらのオブジェクトが割り当てられている場所を特定する
モジュールレベルのメモリ使用量
まずプログラム全体のメモリ使用量を追跡し、どのオブジェクトが最も多くのメモリを使用しているかを大まかに把握します。これにより、さらに詳しく調べる場所や方法を見つけるための手がかりが得られるはずです。次のラッパーは追跡を開始し、Ctrl-Cが押されると統計情報を出力します。
tracemalloc.start(10)はメモリ追跡を開始し、各エントリーについて10フレーム分のトレースバックを保存します。デフォルトは1ですが、後で説明するように、トレースバックを使ってメモリリークの発生箇所を特定する場合は、より多くのフレームを保存すると便利です。tracemalloc.take_snapshot()は、Pythonヒープで現在割り当てられているメモリのスナップショットを取得します。割り当て済みのブロック数とサイズ、そしてどのコード行がどのメモリブロックを割り当てたかを特定するためのトレースバックを保存します。スナップショットを作成すると、メモリ使用量の統計を計算したり、スナップショットを比較したり、後で分析するために保存したりできます。top_nは、tracemallocの出力を見やすく整形するために私が作成したヘルパー関数です。ここでは、スナップショット内のメモリ割り当て上位25件をファイル名ごとにまとめて表示します。数分間実行すると、次のような出力になります。
これは、コンポーネントの実行開始からの累積メモリ割り当て量をファイル名ごとに示しています。この粒度では、結果を解釈するのは困難です。たとえば、最初の行からcollectionsオブジェクトが17 MB割り当てられていることはわかりますが、どのオブジェクトがどこで使われているかまでは把握できません。問題を絞り込むには、別の方法が必要です。
tracemallocの出力を理解する
tracemallocが示すのは、スナップショット取得時点でのメモリ使用量の正味の値です。2つのスナップショットを比較すると、その間のメモリ使用量の正味の変化が示されます。スナップショット間に割り当てられて解放されたメモリは、出力には表示されません。そのため、ループ内の同じ箇所でスナップショットを作成した場合、差分に表示されるメモリ割り当ては、実行中の一時的な割り当てではなく、長期的な総メモリ使用量の増加に関係しています。
ガベージコレクションが必要な参照循環の場合、未回収の循環は出力に記録されますが、回収済みの循環は記録されません。スナップショットの対象期間中にガベージコレクターが解放したブロックは、解放済みメモリとして記録されます。そのため、スナップショットの取得前にgc.collect()でガベージコレクションを強制実行すると、出力のノイズを減らせます。
反復ごとのメモリ使用量
メモリリークを探しているため、プログラムのメモリ使用量が時間とともにどう変化するかを把握することが重要です。コンポーネントのメインループから次のメソッドを呼び出すと、各反復で割り当てられたメモリ量を確認できます。
このコードはメモリのスナップショットを取得して保存し、snapshot.compare_to(other_snapshot, group_by='filename')を使って最新のスナップショットと前回のスナップショットを比較します。結果はファイル名ごとにまとめられます。メモリが安定するまで数回反復すると、次のような出力になります。
linecache(1)とtracemalloc(2)の割り当ては計測処理に伴うものですが、さらに調査すべきrequests HTTPパッケージによるメモリ割り当て(3)も確認できます。tracemallocはメモリ使用量の正味の変化を追跡するため、これらのメモリ割り当ては反復ごとに蓄積しています。個々の割り当ては小さく、問題として目立つほどではありませんが、メモリリークが明らかになるまで数日かかるため、小さな損失が積み重なっている可能性があります。
スナップショットのフィルタリング
調査すべき箇所がわかったので、tracemallocのフィルタリング機能を使い、requestsパッケージに関連するメモリ割り当てだけを表示できます。
snapshot.filter_traces()は、スナップショットに適用するFiltersのリストを受け取ります。ここではinclusiveモードでFilterを作成し、filename_patternに一致するトレースだけを含めます。inclusiveがFalseの場合、フィルターはfilename_patternに一致するトレースを除外します。filename_patternではUNIX形式のワイルドカードを使って、トレースバック内のファイル名に一致させます。この例では、「requests」内のワイルドカードが、"/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/sessions.py"のように、パスの途中にある「requests」に一致します。
次にcompare_to()を使って、結果を前回のスナップショットと比較します。フィルタリング後の出力は次のとおりです。
Filterを適用すると、requestsがメモリをどのように使用しているかがはっきりわかります。4行目を見ると、メインループの反復ごとにrequestsで約50 KiBのメモリが失われています。(5)のような負のメモリ割り当ても、この出力に表示されます。これらは、前回までのループ反復で割り当てられたメモリを解放していることを示しています。
メモリ割り当ての発生箇所を特定する
requestsのどの使い方でメモリリークが発生しているのか特定するには、Filterで出力を絞り込みながら、compare_to()の引数にfilenameではなくtracebackを指定し、問題のあるメモリ割り当てがどこで発生しているか詳しく調べます。
出力の各エントリーについて、トレースバックを10フレーム分(tracemalloc.start(10)で追跡を開始したため)表示します。出力を一部省略した例を以下に示します。
完全なトレースバックがあれば、メモリ割り当てから、割り当てを発生させているプロジェクトコードの行までさかのぼれます。このコンポーネントでは、requestsの使用箇所はHTTP APIを利用する内部ストレージライブラリにありました。そのライブラリをurllibを直接使うように書き換えたところ、メモリリークは解消しました。

メモリプロファイリングは技術か、それとも芸術か?
tracemallocは、Pythonプログラムのメモリ使用量を把握するための強力なツールです。このツールを使って、モジュール単位のメモリ使用量を把握し、最も多く割り当てられているオブジェクトを特定できました。また、イテレーションごとのreflectorのメモリ使用量の変化も確認できました。便利なフィルタリング機能を備え、メモリ割り当ての完全なトレースバックを確認できます。これほど多くの機能があっても、Pythonのメモリリークの特定は、科学というより芸術のように感じられることがあります。メモリプロファイラーを使えばメモリの使用状況を把握できますが、問題の原因となっているメモリ割り当てを正確に突き止めるのは、多くの場合困難です。ツールから得た情報を総合してプログラムのメモリ動作について結論を導き、そこから取るべき対策を判断するのは、私たち自身です。
Fugueのシステムの信頼性、パフォーマンス、保守性を高めるため、テストフレームワークやcProfileなど、利用可能なほぼすべてのPythonツールを活用しています。brokerとreflectorはどちらもPythonのイントロスペクションを活用して、AWS APIへの動的な呼び出しについて判断します。これにより、あらゆるケースを網羅するコードを書くのではなく、ロジックに集中できます。Fugueは、システムの中で適切な場面にPythonの強みを活用し、最終的にエンドユーザーにとっての製品の安定性と拡張性を高めています。
開発者のために設計されたIaCセキュリティ
Snykは、統合されたポリシー・アズ・コードエンジンにより、SDLCからクラウドでの実行時までInfrastructure as Codeを保護します。すべてのチームが安全に開発、デプロイ、運用できるよう支援します。
