背景
咱们(指原做者)在工做中使用 DDAgent Ver. 5 做为采集工具进行被管服务器的性能指标采集与上报,而且对 DDAgent 作了必定程度的定制。在几回特性迭代后,发现线上一批运行许久的被管服务器出现内存占用太高。分析问题机器上进程树各节点占用状况,看到 DDAgent 采集进程的内存占用居高不下。python
做为保障业务系统稳定做业的监控组件发生了内存泄漏,天然是很是严重的,因此开始咱们的“排查之旅”。web
分析
有许多工具提供了对 Python 程序内存状态的分析与导出,这里咱们使用 pyrasite,它能够 attach 到一个运行中的 Python 程序,生成一分内存快照,并查看当前有哪些对象类型分别占用了多少内存,从大到小排序。编程
使用命令很是简单:pyrasite-memory-viewer <PID>
,同时会生成一份快照文件:/tmp/pyrasite-<PID>-objects.json
。json
因为没法提供真实生产中的数据,下文说起的全部数据均来自于问题版本在测试环境中运行 12 小时以后的采样。bootstrap
在 pyrasite 提供的 CUI 视图中,咱们能够清晰地看到字典类型的对象实例占用内存最多,达到了 3.4MB,有 6621 个实例:数组
Total 60350 objects, 223 types, Total size = 10.4MiB (10857450 bytes)
Index Count % Size % Cum Max Kind
(X) 0 6621 10 3551928 32 32 98584 dict
( ) 1 21363 35 1817633 16 49 6342 str
( ) 2 291 0 902640 8 57 12624 module
( ) 3 918 1 829872 7 65 904 type
( ) 4 5605 9 717440 6 72 128 code
( ) 5 5969 9 716280 6 78 120 function
More...
惋惜 pyrasite 的 CUI 界面没有提供进一步数据透视的能力,以查看这么多字典对象究竟是哪些,有什么特征。安全
但咱们也看到,内存快照文件是 JSON 格式的可读文本,打开后为以下结构的内容:服务器
{"address": 139671453162096, "type": "instance", "size": 72, "refs": [139671565716816, 139671453172632]}
{"address": 139671453172632, "type": "dict", "size": 1048, "len": 4, "refs": [139671677239136, 29053312, 139671565997664, 139671451015864, 139671677145776, 29056072, 139671677239040, 139671674819024]}
{"address": 139671674819024, "type": "bool", "size": 24, "value": "True", "refs": []}
{"address": 139671677239040, "type": "str", "size": 43, "len": 6, "value": "closed", "refs": []}
{"address": 29056072, "type": "int", "size": 24, "value": 134, "refs": []}
...
很容易猜出,每一行表示当前内存中的一个 Python 对象,address
为该对象的内存地址,type
为该对象的类型名,size
是该对象自身占据的内存大小(不出意外该值和 sys.getsizeof
计算所得一致),若是对象类型为 int
、str
、bool
这种 Primitive Type,则经过 value
表示其值,若是对象类型为 str
、tuple
、list
、dict
等容器类型(按 Python 定义严谨地讲是实现了 __len__
方法的类型),那么经过 len
表示其元素数量,最后一个 refs
则表示这个对象上所引用其余对象的地址。微信
在对该快照文件中的字典对象作简单分析后,获得了一个很重要的情报:6621 个字典对象中有 4884 个都是空字典 ,占比 73.8%。app
不论什么业务场景,在一个正常的 Python 程序实现中,不可能有如此多的空字典。
想搞清为何,就得找出在哪里建立了这些空字典对象。
可是到目前为止 pyrasite 提供的信息都已探索完,要进一步排查,就得“另辟蹊径”。
定位
咱们针对发生泄漏的场景从新缕下思路,有以下事实和猜想:
-
一个或多个地方在持续建立空字典对象,而且没法回收它们,致使内存泄漏 -
内存泄漏量随着时间变化而增加,在指标采集业务中,极可能是在每次采集过程当中形成的泄漏,在间隔周期后又重复触发 -
并未看到当前依赖的 DDAgent 版本有未关闭的相关 Issue,极可能是咱们定制过程当中引入的 Bug
可是,哪怕一次最简单的系统基础指标采集,程序所跑过的代码行数(DDAgent 框架代码、采集 Check 插件代码)都在千级规模,想靠人力去分析定位“泄漏点”,如同大海捞针。
同时咱们还面临一个挑战:因为泄漏过程较慢,很难在本地测试环境进行快速复现和分析。
如何克服上述困难?结合形成泄漏对象很重要的画像——没法回收的空字典,咱们或许能够借助 Python 解释器的运行时修改与自省特性来排查。
即,咱们写一段追踪代码,捕捉符合如下特征的对象:
-
特征 1:字典(dict)类型 -
特征 2:字典对象长度为 0 -
特征 3:该对象的引用计数始终大于 0
对应的解决方案是:
-
响应特征 1:构造一个字典类型,其: -
咱们使用的解释器版本为 CPython 2.7.13,因此是 __builtin__
而不是 3.x 的builtins
-
该方案存在一些问题,但在咱们这个场景中刚好够用了,后面复盘时再提 -
在初始化函数 __init__
中记录本身被实例化时的堆栈信息,经过traceback
模块完成,这是实现追踪的关键 -
并经过 __builtin__
模块进行运行时替换,将内置的dict
换成该自定义类型,实现全局追踪 -
响应特征 2:这个最简单,须要时 len(dict_obj) == 0
搞定 -
响应特征 3:使用 weakref.WeakSet
实现追踪表收集字典对象,经过“弱引用”特性避免追踪代码影响正常对象的回收
接着,只要定时将追踪表中符合特征的内容进行输出,就能够达到定位建立未回收空字典对象位置的目标:
# coding: utf-8
import __builtin__
import time
import json
import weakref
import traceback
import threading
# “弱引用”特性的追踪表确保不干扰正常对象的回收
trace_table = weakref.WeakSet()
# 定时输出符合特征的内容
def exporter():
while True:
time.sleep(30)
print('writing trace infos...')
# 将追踪表中的空字典收集输出
empty_dicts = [d.trace_info for d in trace_table if len(d) == 0]
with open('traceinfo', 'w') as f:
f.write(json.dumps(empty_dicts))
threading.Thread(target=exporter).start()
class TraceableDict(dict):
idx = 0
def __init__(self, *args, **kwargs):
super(TraceableDict, self).__init__(*args, **kwargs)
# !!!获取堆栈信息!!!
self.trace_info = traceback.extract_stack()
self.trace_hash = TraceableDict.idx
TraceableDict.idx += 1
# 将本身加入到追踪表
trace_table.add(self)
def __hash__(self):
# 若是不实现 __hash__ 方法,则没法被插入到 WeakSet 中
return self.trace_hash
# !!!这只是为了定位问题!!!
# !!!平时千万不要这么用!!!
__builtin__.dict = TraceableDict
print('start tracing...')
这里须要额外说起一下,因为 dict 字典对象没有实现 __hash__
方法,所以它没法做为 Key 被插入到 dict
、set
、WeakSet
对象中,一句话测试下便知:
> python -c "{}[{}]=0"
Traceback (most recent call last):
File "<string>", line 1, in <module>
TypeError: unhashable type: 'dict'
为了使其能被顺利插入到 WeakSet
中,这里使用自增 Id 方案作最简单的 Hash 实现。
接着咱们在 DDAgent 的采集模块 collect.py
入口处启用这段追踪代码:
# coding: utf-8
# file: collect.py
import tracer # 导入即启用
import signal
# ...
将采集进程运行一段时间后,咱们获得了 traceinfo
文件:
[
[
[".../embedded/lib/python2.7/threading.py",774,"__bootstrap","self.__bootstrap_inner()"],
[".../embedded/lib/python2.7/threading.py",801,"__bootstrap_inner","self.run()"],
[".../modules/monitor/bot/schedule.py",51,"run","task.run()"],
[".../modules/monitor/bot/task.py",50,"run","super(RepeatTask, self).run()"],
[".../modules/monitor/bot/task.py",18,"run","self.check()"],
[".../modules/monitor/checks/collector.py",223,"wrapper","_check.run()"],
[".../modules/monitor/checks/__init__.py",630,"run","self._roll_up_instance_metadata()"],
[".../modules/monitor/checks/__init__.py",498,"_roll_up_instance_metadata","dict((k, v) for (k, v) in self._instance_metadata))"],
[".../modules/monitor/tracer.py",33,"__init__","self.trace_info = traceback.extract_stack()"]
],
[
[".../embedded/lib/python2.7/threading.py",774,"__bootstrap","self.__bootstrap_inner()"],
[".../embedded/lib/python2.7/threading.py",801,"__bootstrap_inner","self.run()"],
[".../modules/monitor/bot/schedule.py",51,"run","task.run()"],
[".../modules/monitor/bot/task.py",50,"run","super(RepeatTask, self).run()"],
[".../modules/monitor/bot/task.py",18,"run","self.check()"],
[".../modules/monitor/checks/collector.py",223,"wrapper","_check.run()"],
[".../modules/monitor/checks/__init__.py",630,"run","self._roll_up_instance_metadata()"],
[".../modules/monitor/checks/__init__.py",498,"_roll_up_instance_metadata","dict((k, v) for (k, v) in self._instance_metadata))"],
[".../modules/monitor/tracer.py",33,"__init__","self.trace_info = traceback.extract_stack()"]
],
[
[".../embedded/lib/python2.7/threading.py",774,"__bootstrap","self.__bootstrap_inner()"],
[".../embedded/lib/python2.7/threading.py",801,"__bootstrap_inner","self.run()"],
[".../modules/monitor/bot/schedule.py",51,"run","task.run()"],
[".../modules/monitor/bot/task.py",50,"run","super(RepeatTask, self).run()"],
[".../modules/monitor/bot/task.py",18,"run","self.check()"],
[".../modules/monitor/checks/collector.py",223,"wrapper","_check.run()"],
[".../modules/monitor/checks/__init__.py",630,"run","self._roll_up_instance_metadata()"],
[".../modules/monitor/checks/__init__.py",498,"_roll_up_instance_metadata","dict((k, v) for (k, v) in self._instance_metadata))"],
[".../modules/monitor/tracer.py",33,"__init__","self.trace_info = traceback.extract_stack()"]
],
...
不用花太多精力,就能够识别到几乎全部的空字典对象都建立自 .../modules/monitor/checks/__init__.py
文件的第 498
行,在一个名为 _roll_up_instance_metadata
的方法中:
class AgentCheck(object):
# ...
def _roll_up_instance_metadata(self):
self.svc_metadata.append(
dict((k, v) for (k, v) in self._instance_metadata))
self._instance_metadata = []
该方法在每一个采集过程当中都会被调用一次,每次调用将某些元数据插入到 svc_metadata
这个对象成员列表中。
既然有生产确定有消费,咱们紧接着该方法就找到重置 svc_metadata
列表的代码:
class AgentCheck(object):
# ...
def _roll_up_instance_metadata(self):
# ...
def get_service_metadata(self):
if self._instance_metadata:
self._roll_up_instance_metadata() # 注意:这里并非惟一调用 _roll_up_instance_metadata 的位置
service_metadata = self.svc_metadata
self.svc_metadata = [] # 重置
return service_metadata
若是 get_service_metadata
方法能在每次采集过程末被成功调用,那至少 svc_metadata
不会产生数据堆积。
可是在检查当前版本的总体实现后,咱们并没找到任何一处触发 get_service_metadata
的地方。
随后,经过对比 DDAgent 官方实现,并审查 Git 提交历史,终于一切真相大白。
DDAgent 在 checks/collector.py
的第 416
行 调用了 get_service_metadata
,对元数据进行了消费:
class Collector(object):
# ...
@log_exceptions(log)
def run(self, checksd=None, start_event=True, configs_reloaded=False):
# ...
# Collect metadata
current_check_metadata = check.get_service_metadata() # L416
# ...
然而咱们在某次特性迭代中,为了让 run
方法看上去更整洁,将一些与需求实现无关的代码所有移除了,包括对 get_service_metadata
的调用!
移除消费代码,但生产代码继续在工做,这就是致使内存泄漏的缘由!
复盘
这里就不提诸如“作好设计评审与 Code Review”、“增强测试阶段质量检测工做”等“套话”,固然这些也值得咱们反思。
内存泄漏问题几乎不可能完全预防与治理,像 Rust 这样的安全编程语言也 没法做出承诺保证程序不会发生内存泄漏。
许多触发内存不安全的行为:数组访问越界、访问释放后的内存等,均可以经过制定更严格的编程模型(如 Rust 提出的全部权+生命周期规则)来规避——甚至能够规避数据竞争(data-race)的问题。
然而触发内存泄漏的行为,和竞态条件(race-condition)同样,则须要开发人员本身结合开发组件和业务规则进行约束。试想一个须要手动触发 flush 的数据队列,结果咱们在不停推送数据的同时却忘了调用它,这种引起的内存泄漏是没法靠任何通用检查规则来甄别的。
敬畏编码。
最后聊下咱们的“排查之旅”其实很是幸运,由于触发泄漏的关键代码:
class AgentCheck(object):
# ...
def _roll_up_instance_metadata(self):
self.svc_metadata.append(
dict((k, v) for (k, v) in self._instance_metadata))
self._instance_metadata = []
刚好使用 dict 类型构造函数实例化了一个空字典!
若是直接使用字面量方式建立,如:self.svc_metadata.append({})
,则是没法被追踪到的 —— __builtin__
模块只能替换内置类型构造函数的入口,没法控制字面量。
假想经过字面量构建空字典的内存泄漏场景,咱们又该如何排查?这里提供两个思路,仅做记录:
-
修改 CPython 源码中 dict
内置类型的实现,根据前面的追踪方案给每一个dict
对象加上实例化时的堆栈信息 -
后面了解到,CPython 3.4 新增了一个 tracemalloc 模块,虽然还未实践过,但从其官方介绍来看也适用咱们此次的场景 -
Compute the differences between two snapshots to detect memory leaks -
Statistics on allocated memory blocks per filename and per line number: total size, number and average size of allocated memory blocks -
Traceback where an object was allocated -
该模块能提供一个对象被建立时的堆栈信息 -
逐文件、逐行统计已分配的内存块信息:总大小、数量、平均大小 -
能够计算两次内存快照间的差别,甄别内存泄漏
本文分享自微信公众号 - Python学习开发(python3-5)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。