Skip to main content

Pythonのメモリリークを診断して修正する

blog hero python code purple

2017年3月7日

0 分で読めます

 

Fugueでは、使いやすさ、Pythonのセキュリティ、豊富なパッケージライブラリ、強力な言語ツールを理由に、クラウドセキュリティSaaS製品全体とサポートツールでPythonを幅広く使用しています。クラウド向けの複雑なソフトウェアを開発する中で学んだのは、言語の優劣はデバッグやプロファイリングのツールに左右されるということです。ロジックエラー、CPUの急増、メモリリークは避けられません。しかし、優れたデバッガー、CPUプロファイラー、メモリプロファイラーがあれば、こうした問題を大幅に簡単かつ迅速に見つけられ、開発者はFugueの動的なクラウドオーケストレーションおよび強制適用システムの構築に戻ることができます。具体例を見てみましょう。

秋、Fugueの「reflector」と呼ばれるPythonコンポーネントで、数日間稼働した後にランダムな再起動や不安定な動作が発生していると、メトリクスが示しました。メモリ使用量を調べると、reflectorのメモリフットプリントが単調かつ継続的に増加しており、メモリリークが疑われました。Python標準ライブラリの強力なメモリ追跡ツールtracemallocを使うことで、リークをすばやく診断して修正できました。原因は、人気のあるサードパーティ製Python HTTPライブラリrequestsの使用にあることがわかりました。このコンポーネントをPython標準ライブラリのurllibを使うように書き換えたところ、メモリリークは解消しました。このブログでは、その詳細を紹介します。

リフレクターのメモリ使用率が1日目の約8%から4日目までに20%へ上昇する様子を示す折れ線グラフ。
指標が問題を示しています:requestsライブラリを使用した、リフレクターが使用するシステムメモリ全体に対する割合。

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が押されると統計情報を出力します。

import tracemalloctracemalloc.start(10)
try:    
	run_reflector()
except:    
	snapshot = tracemalloc.take_snapshot()    
	top_n(25, snapshot, trace_type='filename')

tracemalloc.start(10)はメモリ追跡を開始し、各エントリーについて10フレーム分のトレースバックを保存します。デフォルトは1ですが、後で説明するように、トレースバックを使ってメモリリークの発生箇所を特定する場合は、より多くのフレームを保存すると便利です。tracemalloc.take_snapshot()は、Pythonヒープで現在割り当てられているメモリのスナップショットを取得します。割り当て済みのブロック数とサイズ、そしてどのコード行がどのメモリブロックを割り当てたかを特定するためのトレースバックを保存します。スナップショットを作成すると、メモリ使用量の統計を計算したり、スナップショットを比較したり、後で分析するために保存したりできます。top_nは、tracemallocの出力を見やすく整形するために私が作成したヘルパー関数です。ここでは、スナップショット内のメモリ割り当て上位25件をファイル名ごとにまとめて表示します。数分間実行すると、次のような出力になります。

[ Top 25 with filename tracebacks ]
197618 blocks 17.02311134338379 MB/Users/mike/.pyenv/versions/3.4.2/lib/python3.4/collections/__init__.py:0: size=17.0 MiB,
 count=197618,
 average=90 B105364 blocks 11.34091567993164 MB frozen importlib._bootstrap:0: 
size=11.3 MiB, 
count=105364, 
average=113 B60339 blocks 9.233230590820312 MB/Users/mike/.pyenv/versions/3.4.2/lib/python3.4/json/decoder.py:0:
size=9455 KiB, 
count=60339, 
average=160 B...

これは、コンポーネントの実行開始からの累積メモリ割り当て量をファイル名ごとに示しています。この粒度では、結果を解釈するのは困難です。たとえば、最初の行からcollectionsオブジェクトが17 MB割り当てられていることはわかりますが、どのオブジェクトがどこで使われているかまでは把握できません。問題を絞り込むには、別の方法が必要です。

tracemallocの出力を理解する

tracemallocが示すのは、スナップショット取得時点でのメモリ使用量の正味の値です。2つのスナップショットを比較すると、その間のメモリ使用量の正味の変化が示されます。スナップショット間に割り当てられて解放されたメモリは、出力には表示されません。そのため、ループ内の同じ箇所でスナップショットを作成した場合、差分に表示されるメモリ割り当ては、実行中の一時的な割り当てではなく、長期的な総メモリ使用量の増加に関係しています。

ガベージコレクションが必要な参照循環の場合、未回収の循環は出力に記録されますが、回収済みの循環は記録されません。スナップショットの対象期間中にガベージコレクターが解放したブロックは、解放済みメモリとして記録されます。そのため、スナップショットの取得前にgc.collect()でガベージコレクションを強制実行すると、出力のノイズを減らせます。

反復ごとのメモリ使用量

メモリリークを探しているため、プログラムのメモリ使用量が時間とともにどう変化するかを把握することが重要です。コンポーネントのメインループから次のメソッドを呼び出すと、各反復で割り当てられたメモリ量を確認できます。

def collect_stats(self):        
self.snapshots.append(tracemalloc.take_snapshot())        
if len(self.snapshots)  1: 

stats = self.snapshots[-1].filter_traces(filters).compare_to(self.snapshots[-2], 'filename')    

for stat in stats[:10]:                
print("{} new KiB {} total KiB {} new {} total memory blocks: ".format(stat.size_diff/1024, stat.size / 1024, stat.count_diff ,stat.count))                
for line in stat.traceback.format():                    
print(line)

このコードはメモリのスナップショットを取得して保存し、snapshot.compare_to(other_snapshot, group_by='filename')を使って最新のスナップショットと前回のスナップショットを比較します。結果はファイル名ごとにまとめられます。メモリが安定するまで数回反復すると、次のような出力になります。

[ Top 5 with filename tracebacks ]190.7421875 
new KiB 1356.5634765625 total KiB 1930 
new 13574 total memory blocks:      
(1)  File "/Users/mike/.pyenv/versions/3.4.2/lib/python3.4/linecache.py", 

line 02.1328125 
new KiB 12.375 total KiB 32 
new 86 total memory blocks:             

(2)  File "/Users/mike/.pyenv/versions/3.4.2/lib/python3.4/tracemalloc.py", 
line 01.859375 
new KiB 18.7001953125 total KiB 3 
new 53 total memory blocks:         

(3)  File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/packages/urllib3/connection.py", 
line 0-1.71875 
new KiB 34.5224609375 total KiB -2 
new 91 total memory blocks:   File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/packages/urllib3/connectionpool.py", 
line 01.66015625 new KiB 61.662109375 total KiB 18 new 260 total memory blocks:   
File "/Users/mike/.pyenv/versions/3.4.2/lib/python3.4/urllib/parse.py", line 0

linecache(1)とtracemalloc(2)の割り当ては計測処理に伴うものですが、さらに調査すべきrequests HTTPパッケージによるメモリ割り当て(3)も確認できます。tracemallocはメモリ使用量の正味の変化を追跡するため、これらのメモリ割り当ては反復ごとに蓄積しています。個々の割り当ては小さく、問題として目立つほどではありませんが、メモリリークが明らかになるまで数日かかるため、小さな損失が積み重なっている可能性があります。

スナップショットのフィルタリング

調査すべき箇所がわかったので、tracemallocのフィルタリング機能を使い、requestsパッケージに関連するメモリ割り当てだけを表示できます。

from tracemalloc 
import Filter    
filters = [Filter(inclusive=True, filename_pattern="*requests*")]    
filtered_stats = snapshot.filter_traces(filters).compare_to(old_snapshot.filter_traces(filters), 'traceback')    
for stat in stats[:10]:        
	print("{} 
	new KiB {} 
	total KiB {} 
	new {} 
	total memory blocks: ".format(stat.size_diff/1024, stat.size / 1024, stat.count_diff ,stat.count))        

	for line in stat.traceback.format():            
	print(line)

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()を使って、結果を前回のスナップショットと比較します。フィルタリング後の出力は次のとおりです。

48.7890625 
new KiB 373.974609375 total KiB 4 
new 1440 total memory blocks:                 

(4)  File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/structures.py", 
line 01.46875 
new KiB 16.2939453125 total KiB 2 
new 49 total memory blocks:   

File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests_unixsocket/__init__.py", 
line 0 -1.4453125

new KiB 34.2802734375 total KiB -2 
new 96 total memory blocks:                 

(5)  File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/sessions.py", 
line 0-0.859375 
new KiB 31.8505859375 total KiB -1 
new 85 total memory blocks:   

File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/packages/urllib3/connectionpool.py", 
line 00.6484375 
new KiB 20.8330078125 total KiB 1 
new 56 total memory blocks:   
File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/packages/urllib3/connection.py", line 0

Filterを適用すると、requestsがメモリをどのように使用しているかがはっきりわかります。4行目を見ると、メインループの反復ごとにrequestsで約50 KiBのメモリが失われています。(5)のような負のメモリ割り当ても、この出力に表示されます。これらは、前回までのループ反復で割り当てられたメモリを解放していることを示しています。

メモリ割り当ての発生箇所を特定する

requestsのどの使い方でメモリリークが発生しているのか特定するには、Filterで出力を絞り込みながら、compare_to()の引数にfilenameではなくtracebackを指定し、問題のあるメモリ割り当てがどこで発生しているか詳しく調べます。

   stats = snapshot.filter_traces(filters).compare_to(old_snapshot.filter_traces(filters), 'traceback')

出力の各エントリーについて、トレースバックを10フレーム分(tracemalloc.start(10)で追跡を開始したため)表示します。出力を一部省略した例を以下に示します。

5 memory blocks: 4.4921875 KiB  
File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/sessions.py", 
line 585    
r = adapter.send(request, **kwargs)  
File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/sessions.py", 
line 475    
resp = self.send(prep, **send_kwargs)  
File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests_unixsocket/__init__.py", 
line 46    
return session.request(method=method, url=url, **kwargs)  
File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests_unixsocket/__init__.py", 
line 60    
return request('post', url, data=data, json=json, **kwargs)

完全なトレースバックがあれば、メモリ割り当てから、割り当てを発生させているプロジェクトコードの行までさかのぼれます。このコンポーネントでは、requestsの使用箇所はHTTP APIを利用する内部ストレージライブラリにありました。そのライブラリをurllibを直接使うように書き換えたところ、メモリリークは解消しました。

4日間にわたり、リフレクターのメモリ使用率が8.5%~9.3%程度でほぼ安定して推移していることを示す折れ線グラフ
メトリクスから、問題が解決されたことがわかります。リクエストを削除して urllib に切り替えた後の、リフレクターが使用するシステムメモリ総量の割合。

メモリプロファイリングは技術か、それとも芸術か?

tracemallocは、Pythonプログラムのメモリ使用量を把握するための強力なツールです。このツールを使って、モジュール単位のメモリ使用量を把握し、最も多く割り当てられているオブジェクトを特定できました。また、イテレーションごとのreflectorのメモリ使用量の変化も確認できました。便利なフィルタリング機能を備え、メモリ割り当ての完全なトレースバックを確認できます。これほど多くの機能があっても、Pythonのメモリリークの特定は、科学というより芸術のように感じられることがあります。メモリプロファイラーを使えばメモリの使用状況を把握できますが、問題の原因となっているメモリ割り当てを正確に突き止めるのは、多くの場合困難です。ツールから得た情報を総合してプログラムのメモリ動作について結論を導き、そこから取るべき対策を判断するのは、私たち自身です。

Fugueのシステムの信頼性、パフォーマンス、保守性を高めるため、テストフレームワークやcProfileなど、利用可能なほぼすべてのPythonツールを活用しています。brokerとreflectorはどちらもPythonのイントロスペクションを活用して、AWS APIへの動的な呼び出しについて判断します。これにより、あらゆるケースを網羅するコードを書くのではなく、ロジックに集中できます。Fugueは、システムの中で適切な場面にPythonの強みを活用し、最終的にエンドユーザーにとっての製品の安定性と拡張性を高めています。

開発者のために設計されたIaCセキュリティ

Snykは、統合されたポリシー・アズ・コードエンジンにより、SDLCからクラウドでの実行時までInfrastructure as Codeを保護します。すべてのチームが安全に開発、デプロイ、運用できるよう支援します。

カテゴリー: