如何在生产系统中查找Python进程的内存使用情况?
我的生产系统偶尔会出现内存泄漏的问题,但我在开发环境中无法重现这个问题。我在开发环境中使用过一个叫做Python内存分析工具(具体来说是Heapy),效果还不错,但它对我无法重现的问题没有帮助。而且我不太想在生产系统中使用Heapy,因为它的运行速度比较慢,而且它的远程接口在我们的服务器上表现得不好。
我想要的其实是一个方法,可以抓取生产环境中Python进程的快照(或者至少是gc.get_objects),然后离线分析一下,看看它是怎么使用内存的。我该如何获取这样的Python进程的核心转储呢? 一旦我得到了这个转储,我该如何有效地利用它呢?
7 个回答
你能在你的生产网站上记录流量(通过日志)吗?然后在你的开发服务器上重放这些流量,同时使用一个Python内存调试工具?我推荐使用dozer:http://pypi.python.org/pypi/Dozer
使用Python的 gc
垃圾回收接口和 sys.getsizeof()
,我们可以获取所有Python对象及其大小。下面是我在生产环境中用来排查内存泄漏的代码:
rss = psutil.Process(os.getpid()).get_memory_info().rss
# Dump variables if using more than 100MB of memory
if rss > 100 * 1024 * 1024:
memory_dump()
os.abort()
def memory_dump():
dump = open("memory.pickle", 'wb')
xs = []
for obj in gc.get_objects():
i = id(obj)
size = sys.getsizeof(obj, 0)
# referrers = [id(o) for o in gc.get_referrers(obj) if hasattr(o, '__class__')]
referents = [id(o) for o in gc.get_referents(obj) if hasattr(o, '__class__')]
if hasattr(obj, '__class__'):
cls = str(obj.__class__)
xs.append({'id': i, 'class': cls, 'size': size, 'referents': referents})
cPickle.dump(xs, dump)
需要注意的是,我只保存那些有 __class__
属性的对象,因为这些才是我关心的对象。理论上可以保存所有对象的完整列表,但你需要自己选择其他属性。此外,我发现获取每个对象的引用者非常慢,所以我选择只保存被引用的对象。无论如何,在崩溃后,得到的序列化数据可以这样读取:
with open("memory.pickle", 'rb') as dump:
objs = cPickle.load(dump)
添加于2017-11-15
Python 3.6版本的代码在这里:
import gc
import sys
import _pickle as cPickle
def memory_dump():
with open("memory.pickle", 'wb') as dump:
xs = []
for obj in gc.get_objects():
i = id(obj)
size = sys.getsizeof(obj, 0)
# referrers = [id(o) for o in gc.get_referrers(obj) if hasattr(o, '__class__')]
referents = [id(o) for o in gc.get_referents(obj) if hasattr(o, '__class__')]
if hasattr(obj, '__class__'):
cls = str(obj.__class__)
xs.append({'id': i, 'class': cls, 'size': size, 'referents': referents})
cPickle.dump(xs, dump)
我想分享一下我最近的经验,来补充Brett的回答。Dozer包维护得很好,尽管Python 3.4引入了tracemalloc
这样的新功能,但我还是最常用gc.get_objects
的计数图来处理内存泄漏的问题。下面我使用的是dozer > 0.7
,这个版本在写这篇文章时还没有发布(因为我最近贡献了一些修复)。
示例
我们来看一个不简单的内存泄漏案例。我这里使用的是Celery 4.4,最后会发现一个导致内存泄漏的特性(因为这是个bug/特性的问题,可以说是由于配置错误造成的)。我在Python 3.6的一个venv环境中,执行pip install celery < 4.5
,并有以下模块。
demo.py
import time
import celery
redis_dsn = 'redis://localhost'
app = celery.Celery('demo', broker=redis_dsn, backend=redis_dsn)
@app.task
def subtask():
pass
@app.task
def task():
for i in range(10_000):
subtask.delay()
time.sleep(0.01)
if __name__ == '__main__':
task.delay().get()
基本上是一个调度一堆子任务的任务。那会出什么问题呢?
我将使用procpath
来分析Celery节点的内存消耗。执行pip install procpath
。我有4个终端:
procpath record -d celery.sqlite -i1 "$..children[?('celery' in @.cmdline)]"
来记录Celery节点的进程树统计信息docker run --rm -it -p 6379:6379 redis
来运行Redis,作为Celery的代理和结果后端celery -A demo worker --concurrency 2
来运行节点,使用2个工作进程python demo.py
来最终运行示例
(4)将在2分钟内完成。
然后我使用sqliteviz(预构建版本)来可视化procpath
记录的数据。我把celery.sqlite
放进去,并使用这个查询:
SELECT datetime(ts, 'unixepoch', 'localtime') ts, stat_pid, stat_rss / 256.0 rss
FROM record
在sqliteviz中,我创建了一个折线图,X=ts
,Y=rss
,并添加了分割转换By=stat_pid
。结果图表是:
这个形状对于任何与内存泄漏作斗争的人来说都很熟悉。
查找泄漏对象
现在是时候使用dozer
了。我将展示一个未插桩的案例(如果可以的话,你也可以用类似的方式插桩你的代码)。为了将Dozer服务器注入目标进程,我将使用Pyrasite(更新:如果Pyrasite不工作,可以试试一个分支版本Pyrasite-ng)。关于它,有两点需要知道:
- 要运行它,ptrace必须配置为“经典ptrace权限”:
echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
,这可能会带来安全风险 - 你的目标Python进程崩溃的可能性不为零
有了这些警告,我:
pip install https://github.com/mgedmin/dozer/archive/3ca74bd8.zip
(这是我之前提到的即将发布的0.8版本)pip install pillow
(dozer
用于绘图)pip install pyrasite
之后,我可以在目标进程中获取Python shell:
pyrasite-shell 26572
并注入以下代码,这将使用标准库的wsgiref
服务器运行Dozer的WSGI应用。
import threading
import wsgiref.simple_server
import dozer
def run_dozer():
app = dozer.Dozer(app=None, path='/')
with wsgiref.simple_server.make_server('', 8000, app) as httpd:
print('Serving Dozer on port 8000...')
httpd.serve_forever()
threading.Thread(target=run_dozer, daemon=True).start()
在浏览器中打开http://localhost:8000
,应该能看到类似的内容:
之后我再次运行python demo.py
(来自(4)),并等待它完成。然后在Dozer中将“Floor”设置为5000,这时我看到:
与Celery相关的两种类型在调度子任务时不断增长:
celery.result.AsyncResult
vine.promises.promise
weakref.WeakMethod
的形状和数字也相同,肯定是由同样的原因造成的。
查找根本原因
此时,从泄漏的类型和趋势来看,你的情况可能已经很清楚了。如果还不清楚,Dozer为每种类型提供了“TRACE”链接,可以追踪(例如,查看对象的属性)所选对象的引用者(gc.get_referrers
)和被引用者(gc.get_referents
),并继续遍历图形。
但是,一幅图胜过千言万语,对吧?所以我将展示如何使用objgraph
来渲染所选对象的依赖图。
pip install objgraph
apt-get install graphviz
然后:
- 我再次运行
python demo.py
(来自(4)) - 在Dozer中设置
floor=0
,filter=AsyncResult
- 然后点击“TRACE”,应该会得到
然后在Pyrasite shell中运行:
objgraph.show_backrefs([objgraph.at(140254427663376)], filename='backref.png')
PNG文件应该包含:
基本上,有一个Context
对象,里面包含一个名为_children
的list
,而这个列表又包含许多泄漏的celery.result.AsyncResult
实例。在Dozer中将Filter=celery.*context
更改后,我看到的是:
所以罪魁祸首是celery.app.task.Context
。搜索这个类型肯定会引导你到Celery任务页面。快速搜索“children”,这里面说:
trail = True
如果启用,请求将跟踪由此任务启动的子任务,并将此信息与结果一起发送(
result.children
)。
通过将trail=False
来禁用跟踪,如下所示:
@app.task(trail=False)
def task():
for i in range(10_000):
subtask.delay()
time.sleep(0.01)
然后重启(3)中的Celery节点,再次运行(4)中的python demo.py
,显示的内存消耗如下。
问题解决了!