如何在生产系统中查找Python进程的内存使用情况?

52 投票
7 回答
37651 浏览
提问于 2025-04-11 09:26

我的生产系统偶尔会出现内存泄漏的问题,但我在开发环境中无法重现这个问题。我在开发环境中使用过一个叫做Python内存分析工具(具体来说是Heapy),效果还不错,但它对我无法重现的问题没有帮助。而且我不太想在生产系统中使用Heapy,因为它的运行速度比较慢,而且它的远程接口在我们的服务器上表现得不好。

我想要的其实是一个方法,可以抓取生产环境中Python进程的快照(或者至少是gc.get_objects),然后离线分析一下,看看它是怎么使用内存的。我该如何获取这样的Python进程的核心转储呢? 一旦我得到了这个转储,我该如何有效地利用它呢?

7 个回答

5

你能在你的生产网站上记录流量(通过日志)吗?然后在你的开发服务器上重放这些流量,同时使用一个Python内存调试工具?我推荐使用dozer:http://pypi.python.org/pypi/Dozer

41

使用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)
35

我想分享一下我最近的经验,来补充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个终端:

  1. procpath record -d celery.sqlite -i1 "$..children[?('celery' in @.cmdline)]"来记录Celery节点的进程树统计信息
  2. docker run --rm -it -p 6379:6379 redis来运行Redis,作为Celery的代理和结果后端
  3. celery -A demo worker --concurrency 2来运行节点,使用2个工作进程
  4. 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=tsY=rss,并添加了分割转换By=stat_pid。结果图表是:

Celery node leak

这个形状对于任何与内存泄漏作斗争的人来说都很熟悉。

查找泄漏对象

现在是时候使用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 pillowdozer用于绘图)
  • 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,应该能看到类似的内容:

dozer

之后我再次运行python demo.py(来自(4)),并等待它完成。然后在Dozer中将“Floor”设置为5000,这时我看到:

dozer shows Celery leak

与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=0filter=AsyncResult
  • 然后点击“TRACE”,应该会得到

trace

然后在Pyrasite shell中运行:

objgraph.show_backrefs([objgraph.at(140254427663376)], filename='backref.png')

PNG文件应该包含:

backref chart

基本上,有一个Context对象,里面包含一个名为_childrenlist,而这个列表又包含许多泄漏的celery.result.AsyncResult实例。在Dozer中将Filter=celery.*context更改后,我看到的是:

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,显示的内存消耗如下。

solved

问题解决了!

撰写回答