くらしのマーケット開発ブログ

「くらしのマーケット」を運営する、みんなのマーケット株式会社のテックブログです

コードをいじらずにPythonアプリケーションのメモリリークを検証する方法

こんにちは、バックエンドエンジニア・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をインストールする

Gdbunix系のシステムのデバッガーです。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アプリケーションのメモリリークを検証する方法の紹介でした。 Pyrasiteobjgraph を使ってみて個人的にすごく便利だと思ったので、みなさんもぜひ機会があれば使ってみてください!