nodriver 内存泄漏排查实录:每小时 1GB,罪魁是一个模块级全局 Set

本文最后更新于:2026年8月13日 上午

前言

一台 8GB 的 Windows 采集机反复 OOM:SSH 连上去停在 SSH2_MSG_KEXINIT 之后就断,
事件日志里 Resource-Exhaustion-Detector(EventID 2004)一天报 150 多次,
Kernel-Power 41(非正常重启)隔几天来一次。

排查最后落到 nodriver 上:
它每跑一轮采集就永久泄漏一整个 Browser 对象,实测约 1GB/h。

这篇记录泄漏是怎么定位的(方法比结论更值钱)、根因是什么、以及怎么修。

版本:nodriver 0.50.3,Python 3.10,Windows Server。

一、症状:内存只涨不落

先明确一个基本功:量内存要看 Private(私有字节),不要看 WorkingSet。
WS 降下去往往只是被换出到页面文件,不代表内存被回收。

1
2
3
pid=3108  cp_others   age=3.1h  private=1022MB   → 332 MB/h
pid=8028 albertsons age=3.1h private= 149MB → 48 MB/h
firefox=0 chrome=0

两个刺眼的地方:

  • 一个浏览器进程都没有存活,python 自己攥着 1GB,所以不是「孤儿浏览器没清理」那类问题。
  • 把进程杀掉后,可用内存立刻回吐 1.5GB,说明是进程生命周期内的泄漏,
    不是内核对象/句柄泄漏(那种杀进程也不一定还)。

二、定位方法:按后端分组比增速

这一步是整件事里最值得抄走的部分。

采集守护进程是一个大杂烩:一个进程轮着采十几个站点,背后有 5 种不同的浏览器后端
(ruyipage / nodriver / turnstile / cloak / invisible)。直接去读代码猜「哪里可能没释放」,
效率极低而且容易先入为主。

换个思路:让它们分开跑,用增速把嫌疑人指出来。

站点选择本来就支持 --exclude,于是用互补的排除列表把一个进程拆成三个,
所有站点不重不漏、生产采集全程不中断:

后端 增速
g1 ruyipage ~30 MB/h
g2 nodriver + cloak 819 MB/h
g3 turnstile + invisible ~30 MB/h

差 24 倍。这种量级的差异不需要长时间观察,16 分钟就能定案,也不用担心噪声。

再切一刀,把 g2 里的两个后端分开:

后端 增速
g2a nodriver 1076 MB/h
g2b cloak 0 MB/h

cloak 那一组一个字节都没涨。到这里嫌疑人只剩一个。

二分的好处是:它对「你猜得对不对」完全免疫。你不需要预先知道哪里可能漏,
只需要能把系统切开、并且能量化每一块。

三、根因:stop() 从不把自己从全局注册表摘掉

翻 nodriver 源码,Browser.start() 里有这么一行(nodriver/core/browser.py:368):

1
util.get_registered_instances().add(self)

get_registered_instances() 返回的是一个模块级全局集合(nodriver/core/util.py):

1
__registered__instances__: Set[Browser] = set()

再看 Browser.stop()browser.py:589):它 terminate 子进程、关闭连接,
但从头到尾没有把自己从这个集合里移除

这个集合唯一被清空的地方,在 util.deconstruct_browser() 的结尾:

1
__registered__instances__.clear()

deconstruct_browser() 是进程退出路径(atexit 一类)才会走的。
对于跑几天几周的采集守护进程或价格追踪爬虫来说,这行代码永远不会执行。

于是形成一条完整的泄漏链:

1
2
3
4
5
6
7
8
uc.start()  →  Browser 加入全局 set(强引用)
browser.stop() → Chrome 进程没了,但 Browser 对象还被 set 拴着

Browser 持有 Connection(websocket 收发缓冲)
+ 全部 Tab / Target 对象
+ CDP 事件历史

gc 无能为力:set 是强引用,且 Browser.__del__ 是 pass

每采一轮,就永久多留一整个 Browser 及其挂着的所有东西。
about:blank 页面看不出多少,但真实电商页面的 CDP 事件缓冲很可观,线上实测约 1GB/h。

最小复现

同一个进程里连跑 6 轮,前 3 轮原样、后 3 轮加上修复:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import asyncio, gc, os, sys
import nodriver as uc
from nodriver.core import util as uc_util

async def one_round(apply_fix: bool):
browser = await uc.start(headless=True)
page = await browser.get("about:blank")
await page.sleep(1)
browser.stop()
if apply_fix:
uc_util.get_registered_instances().discard(browser) # ← 关键一行
del page, browser
await asyncio.sleep(0.3)
gc.collect()

输出:

1
2
3
4
5
6
7
8
start                registry=  0  rss=   39.1MB
round 1 fix=False registry= 1 rss= 40.9MB
round 2 fix=False registry= 2 rss= 41.1MB
round 3 fix=False registry= 3 rss= 41.4MB
--- now apply the discard fix ---
round 4 fix=True registry= 3 rss= 41.4MB
round 5 fix=True registry= 3 rss= 41.4MB
round 6 fix=True registry= 3 rss= 41.5MB

registry 单调递增,修复后不再增长。注册表这个「计数器」比 RSS 更早、更干净地暴露问题:
about:blank 时 RSS 每轮才涨 0.3MB,光看 RSS 很容易以为是抖动。

四、修复

不改第三方库(下次 pip install 就没了),在自己的封装里收尾时摘掉即可:

1
2
3
4
5
6
7
def _unregister_browser(browser) -> None:
"""把用完的 Browser 从 nodriver 的模块级全局注册表里摘掉 —— 不摘就是纯内存泄漏。"""
try:
from nodriver.core import util as _uc_util
_uc_util.get_registered_instances().discard(browser)
except Exception:
pass

调用点放在 finally 里,紧跟 stop() 之后:

1
2
3
4
5
6
7
8
finally:
if browser is not None:
try:
browser.stop()
except Exception:
pass
_unregister_browser(browser) # ★ 必须
await asyncio.sleep(0.3) # 给后台 CDP 监听器/管道时间收尾

discard() 而不是 remove():对象不在集合里时不抛异常,天然幂等。

验证

走真实的后端入口验证(不是另写一个循环,那样测的是测试脚本,不是线上代码路径):

1
2
3
4
5
start            registry=0  rss=39.8MB
round 1 registry=0 rss=43.7MB
round 2 registry=0 rss=43.7MB
round 3 registry=0 rss=43.8MB
round 4 registry=0 rss=43.8MB

registry 恒为 0,RSS 43.7 → 43.8MB 持平。

另外值得一提:全仓库只有一处 uc.start(),都收敛在这个后端模块里。
所以这一处修复同时覆盖了 cookie 池采集端、一个长跑的价格追踪爬虫、以及另一个会话获取脚本。
其中价格追踪那个是跑了很久的长驻进程,一直在漏,只是没人往那儿看。

把第三方库的调用收敛到一个封装层,平时看不出好处,出事的时候能省掉一场大规模改造。

五、几条可以带走的经验

1)长跑进程里用第三方库,先查它有没有全局注册表。

__instances___registry_all_* 这类模块级容器是重灾区。
很多库的设计前提是「一个进程跑一次就退出」,收尾逻辑挂在进程退出路径上。
一旦你把它放进 while-True 里,它的假设就不成立了。搜一下 .add(self) / .append(self) 通常很快。

2)__del__pass 不代表没问题,反而是信号。

它说明作者知道析构时机不可控、干脆放弃在那里做事,那么资源释放一定在别的地方。去把那个地方找出来,
并确认你的调用方式会走到它。

3)量内存看 Private,不看 WorkingSet。

4)定位泄漏优先用「切开比增速」,而不是读代码找可疑点。

只要系统能按维度切开、每块能量化,二分几轮就能收敛,而且结论不依赖你的先验判断。
配合两条旁证会更稳:

  • 采样时子进程数为 0 而主进程仍在涨 → 漏在自己这一侧;
  • 杀进程后内存立刻回吐 → 进程生命周期内泄漏。

5)定时重启是止血,不是治疗。

出事之后加了个「每 2 小时重启一次」的任务,机器确实不崩了,但泄漏一直在那儿。
这类补丁的副作用是让问题不再痛,于是也不再有人查。这次泄漏能拖这么久,一半是这个原因。


nodriver 内存泄漏排查实录:每小时 1GB,罪魁是一个模块级全局 Set
https://kingjem.github.io/2026/08/07/nodriver-内存泄漏排查-全局注册表/
作者
Ruhai
发布于
2026年8月7日
许可协议