背景
咱們(指原做者)在工做中使用 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源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。