热更新模式下python内存泄漏分析
概述
一次投产后,监控告警,虚拟机物理内存使用率一直在上升。通过 nmon 数据分析,发现业务进程占用的内存比投产前有明显的增加。
热更新机制
开始之前,先介绍下运行平台的架构。平台使用 c 语言
编写,通过 python
解释器执行业务代码。为了支持热更新,平台会在出口卸载 py 模块,这样下次请求进来就会重新加载 py 模块,代码变更就可以及时生效。
简化版本的平台代码(plat.py)
#!/usr/bin/env python |
业务代码(busi.py)没有复杂逻辑,简单的一个 import+print
#!/usr/bin/env python |
问题重现
检查进程内存:
cat /proc/$(ps aux | grep plat | grep -v grep | awk '{print $2}')/status | grep VmRSS |
只占用了 7M 内存:
VmRSS: 7608 kB |
试试改成调用 1000 次:
def main(): |
可以观察到此时内存占用飙升到了 558M:
VmRSS: 558148 kB |
比较 LOW 的排查办法
当时线上发现问题之后,用的是最LOW的排查方法:把版本差异代码逐段注释
,最终定位到问题所在:
# from logging.handlers import RotatingFileHandler |
那么为什么简单的一行 import 会导致内存泄漏呢?通过分析 logging.__init__.py
,发现问题所在
import atexit |
logging 模块被 import 过程中会调用atexit.register(shutdown)
,后者会将这个函数保存在atexit._exithandlers
平台热更新机制导致logging模块循环构造->注册->GC 无法回收,引起物理内存占用飙升。
通过增加清理代码可以发现内存泄漏问题得到解决,再次验证这个结论:
from logging.handlers import RotatingFileHandler |
比较不 LOW 的排查办法
分析大对象
python 内置了 API 获取内存对象信息:
# 获取所有GC对象 |
我们先根据对象大小排序,看看是否有大对象:
def showMemoryBySize(): |
由于笔记本性能太拉胯,循环 1000 次要跑很久,这里改成了循环 100 次。可以看到没有明显的大对象:
OBJ: 119037464, TYPE: <type 'dict'>, SIZE=0M, REPR={'SocketType': <class 'socket._socketobject'>, 'ge |
输出末尾可以看到有 7.8W 个对象未被回收,考虑是不是有大量小对象,我们按类型合并排序:
def showMemoryByType(): |
大头在dict/function/type
:
TYPE: <type 'dict'> , COUNT= 6444, SIZE=10M |
我们按类型分别统计:
def showMemoryByType2(): |
输出如下:
TYPE: dict |
func/type
大部分都跟logging
有关,那么dict
里面的又是什么呢?
dictList = [] |
下断点查看dictList
,发现都是ModuleType
,我们分析下都有哪些模块:
def showMemoryByModule(): |
不出所料,logging
赫然在列:
KEY: logging , COUNT=1500, SIZE=2423K |
查找引用关系
到了这里,我们已经知道logging存在泄露,那么具体是哪行代码导致的呢?
python 内置了 API 获取内存对象
# 获取入参对象引用的其他对象 |
因为对象引用关系是一张图,手撸代码来排查会比较繁琐,我们可以直接使用现成的库objgraph
,只需要传入对象,就会直接生成一张可视化的引用关系图
pip install objgraph |
需要额外安装graphviz
和xdot
,官网指引是使用pip安装,我试过之后报错。这里直接使用apt安装
sudo apt install graphviz xdot |
要调用的核心方法
def show_backrefs(objs, # 对象列表 |
默认情况下,生成的引用关系图会很庞大,我们需要编写一个过滤器,把无关对象过滤
def _filter(target): |
logging模块下面的function非常多,但是绝大部分都是function与module之间的互相引用,所以引用计数为1的function不是我们关注点,需要排除。但是我们不能直接判断sys.getrefcount(target) <= 1
,因为对象遍历过程objgraph
和我们自己的filter
也会引用这个对象。通过调试,只要把1改成6即可。
def showRefRel(): |
使用浏览器打开生成的ref.svg
,可以直观看到问题的引用关系链路
atexit._exithandlers |
总结
- 不完善的模块热更新机制很容易引发内存泄露问题,对应的业务代码躺枪(内心无辜独白:我就是老老实实地按照规范写着代码,为什么也会中枪呢?)。
- 未来容器化之后,每次都是全量版本,就用不到这个热更新机制了。
- 临时解决,只要在业务代码执行前
import
好对应的模块,后续不要反复加载/卸载。 - 这个模块热更新机制其实还引发了另外一个问题,后文继续讨论。