こんにちは、バックエンドエンジニア・SREのカーキです。 最近くらしのマーケットのシステムで一部の Python アプリケーションにあったメモリリークを検証した時に学んだ検証方法について書きたいと思います。
メモリリークとは?
メモリリークはプログラムが確保したメモリを使用後に解放されず、プログラムのメモリ使用率がどんどん上がり続ける現象です。メモリリークがあると該当のプログラムがシステムのすべてのメモリを使い切って、システムがクラッシュする可能性があるので少し面倒なバグです。
リークの再現
弊社では現在Python 3.5.0を使っていますが、便宜のため以下のようにPython2系のdel関数の落とし穴を使ってメモリリークを再現します。
import time class MyLeakyObject(object): def __init__(self, parent=None): self.parent = parent self.children = [] self.value = 'x' * 100000 def __del__(self): print("deleting") def main(): for i in range(2000): a = MyLeakyObject() a.children.append(MyLeakyObject(parent=a)) time.sleep(3) if __name__ == "__main__": main()
Python 3.4以降は上記__del__関数の落とし穴
が解消されてるみたいです。
Python 2系でメモリリークを起こさないようにするには__del__
関数使わない、または他のオブジェクトのレファレンスを持つ時にweakref
モジュールを使うのがおすすめです。
コードをいじらずにメモリリークを検証する方法
アプリケーションのコードにメモリプロファイラを入れて検証することが難しいケースもあると思って (例えばプロダクションでしか再現しないケースなど)、コードをいじらずにメモリリークの検証方法を調べたところ Pyrasiteという素晴らしいライブラリーを見つけました。Pyrasiteを使うと動いてるPythonのプロセスにコードを注入することができるので、Pyrasite + Pythonの好きなメモリプロファイラの組み合わせで簡単にメモリリークの検証ができます。
では、早速先程メモリリークを再現したコードで検証したいと思います。
1. Gdbをインストールする
Gdb はunix系のシステムのデバッガーです。PyrasiteがGdbに依存してるのでインストールが必要です。インストール方法はOSによって違いますが、少し調べたら簡単にできるので割愛します。
2. Pyrasiteと好きなメモリプロファイラをインストールする
メモリ検証のツールとして今回Objgraphという便利なライブラリーを使います。
$ pip install pyrasite objgraph
3. Pyrasite shellを使ってプロファイラのコードを注入して検証する
まず、検証したいPythonプロセスのpidを調べます (以下の leak.py
は上記のメモリリーク再現用のコードと同様のものです)
$ ps aux | grep leak.py ec2-user 32497 0.0 0.0 132752 6224 pts/0 S+ 04:46 0:00 /home/ec2-user/.pyenv/versions/2.7.10/bin/python leak.py ec2-user 32541 0.0 0.0 119392 984 pts/4 S+ 04:46 0:00 grep --color=auto leak.py
そして、pidを使ってpyrasiteのシェルに入ってプロファイラのコードを注入します。
今回はpyrasiteのシェルの中でobjgraph
を使ってメモリリークの検証したいと思います。
$ pyrasite-shell 32497 Pyrasite Shell 2.0 Connected to '/home/ec2-user/.pyenv/versions/2.7.10/bin/python leak.py' Python 2.7.10 (default, Nov 30 2020, 02:13:01) [GCC 7.3.1 20180712 (Red Hat 7.3.1-9)] on linux2 Type "help", "copyright", "credits" or "license" for more information. (DistantInteractiveConsole) >>> import objgraph >>> objgraph.show_growth() function 1492 +1492 wrapper_descriptor 1050 +1050 builtin_function_or_method 724 +724 method_descriptor 590 +590 dict 587 +587 tuple 524 +524 weakref 496 +496 list 271 +271 getset_descriptor 222 +222 type 214 +214
この段階でまだメモリリークしてることがはっきり見えませんが、数秒後にまた objgraph.show_growth()
を実行してみると以下のような結果になります:
>>> objgraph.show_growth() MyLeakyObject 16 +2 list 275 +2 dict 594 +2
メモリリークを再現したMyLeakyObject
クラスのオブジェクトがgcされずに増えてることがわかるかと思います。数秒後にまたチェックすると同じく増え続けます。
>>> objgraph.show_growth() MyLeakyObject 18 +2 list 277 +2 dict 596 +2 ....数秒後 >>> objgraph.show_growth() MyLeakyObject 20 +2 list 279 +2 dict 598 +2 ....数秒後 >>> objgraph.show_growth() MyLeakyObject 24 +4 list 283 +4 dict 602 +4
これで明らかにMyLeakyObject
にどこかメモリリーク発生してることがわかるかと思います。
それでは、メモリリークを直してみましょう。__del__関数の落とし穴
によってリークしていたので、__del__
関数を削除するだけで直るはずです。
import time class MyLeakyObject(object): def __init__(self, parent=None): self.parent = parent self.children = [] self.value = 'x' * 100000 #def __del__(self): # print("deleting") def main(): for i in range(2000): a = MyLeakyObject() a.children.append(MyLeakyObject(parent=a)) time.sleep(3) if __name__ == "__main__": main()
修正後に同じくpyrasite
+ objgraph
で検証してみた結果は以下の通りです:
>>> import objgraph >>> objgraph.show_growth() function 1491 +1491 wrapper_descriptor 1050 +1050 builtin_function_or_method 724 +724 method_descriptor 590 +590 dict 577 +577 tuple 524 +524 weakref 496 +496 list 261 +261 getset_descriptor 222 +222 type 214 +214 >>> objgraph.show_growth() wrapper_descriptor 1062 +12 getset_descriptor 226 +4 member_descriptor 214 +3 weakref 499 +3 dict 580 +3 method_descriptor 591 +1 >>> objgraph.show_growth() >>> objgraph.show_growth() >>> objgraph.show_growth() >>> objgraph.show_growth() >>> objgraph.show_growth()
ご覧の通り、objgraph.show_growth()
を何回打ってもMyLeakyObject
が上の方に上がって来なくなったことがわかるかと思います!
以上、コードをいじらずにPythonアプリケーションのメモリリークを検証する方法の紹介でした。 Pyrasite
と objgraph
を使ってみて個人的にすごく便利だと思ったので、みなさんもぜひ機会があれば使ってみてください!