常驻浏览器跑定时任务,内存从 1.5G 涨到 5.6G:一次标签页泄漏的排查与回收
这台机器没有显示器,但有一个常驻的 Chromium:Xvfb 提供虚拟屏,noVNC 让用户随时接管,Playwright 通过 connect_over_cdp 连上去跑定时任务。跑了半个月都挺稳,直到某天 free -h 显示可用内存只剩四百多兆。
ps 一排序,答案很直白:
$ ps -eo rss,comm --sort=-rss | head -5
5610234 chrome
412884 chrome
...
$ ls /root/.remote-chrome | grep -c . ; ps -ef | grep -c "[c]hrome"
27
一个「只跑几个每日任务」的浏览器,占了 5.6 GB、27 个进程。而它上一次重启是两天前——也就是说这些是两天里一点点攒出来的。
一、先把账算清楚
排查内存问题,第一步不是猜原因,而是把「有多少页面」和「占多少内存」这两个数分开量。
页面数走 CDP 最准,它列的是浏览器自己认为还活着的 target:
curl -s http://127.0.0.1:9222/json/list | jq 'length'
curl -s http://127.0.0.1:9222/json/list | jq -r '.[] | "\(.type)\t\(.url[0:80])"'
当时这条命令吐出来 20 行 page,内容长这样:/grow、/usercenter/mycommunity/blog 三份、/dynamics 两份、/mysuggestion 两份、几个 about:blank,还有两个停在 bbs.huaweicloud.com/blogs/article 的编辑器页。
内存按进程量:
ps -eo rss=,args= | awk '/[c]hrome/ {s+=$1} END {printf "%.0f MB\n", s/1024}'
这里有个容易误判的点:进程数不等于页面数。Chromium 开了站点隔离,同一站点的多个标签经常合到一个渲染进程里,所以「20 个标签」可能只对应十几个渲染进程。反过来,一个页面里嵌的 iframe 如果是跨站的,又会额外拉起进程。所以别拿进程数当标签数用,两者要分别量。
二、页面为什么不会自己消失
我的任务脚本大致都长这样:
browser = pw.chromium.connect_over_cdp("http://127.0.0.1:9222")
ctx = browser.contexts[0]
page = ctx.new_page() # 开一个新的
page.goto(url)
...
写完就忘了 page.close()。在普通 Playwright 脚本里这不是大问题——脚本结束、browser.close(),整个浏览器进程退出,什么都没了。但 connect_over_cdp 是完全不同的语义:
它连的是一个已经存在的浏览器,browser.close() 只是断开 WebSocket 连接,浏览器本身和里面所有标签都继续活着。 我最初的脚本里还有 browser.close(),看着像是在收尾,实际上一行都没回收。
于是每跑一次任务就多留三五个页面,两天下来就是 20 个。而这些页面不是「空壳」:博客编辑器页里还挂着自动保存的定时器,/grow 页面上有轮询任务状态的 setInterval,/dynamics 会持续拉信息流。页面不关,这些定时器就一直在跑,每个渲染进程还各自持有一份 JS 堆和渲染缓存。内存是这么涨上去的,不是一次性泄漏,而是每天多几个持续工作的小进程。
还有一个细节值得记:我自己在排查时踩过一次——有脚本在 finally 里 close() 了刚开的页面,但那页面正好是任务流程还在用的(点「去完成」后跳转出来的那个)。结果后续脚本取不到 target,报「no page found」。回收页面必须和业务状态解耦:写页面的脚本只负责开和用,回收统一交给一个独立的清理脚本,按 URL 白名单决定留谁。
三、回收脚本的设计要点
清理脚本只做三件事,代码很短:
KEEP_PREFIX = ("https://developer.huaweicloud.com/grow",)
LOGIN = "auth.huaweicloud.com"
pages = ctx.pages
keep = []
for p in pages:
u = p.url
if any(u.startswith(k) for k in KEEP_PREFIX):
keep.append(p)
if not keep: # 全都不匹配时至少留一个
keep = pages[:1]
for p in pages:
if p in keep: continue
try:
p.close()
except Exception:
pass # 已经崩掉/正在导航的页面关不掉,忽略
几个实测出来的判断:
- 按 URL 前缀留,不按序号留。 序号会变,
/grow那页是任务的入口,必须留。 - 未登录时要多留一个登录页,否则清理完用户从 VNC 看过来是一片空白,还得自己找地址。
close()要允许失败。 正在导航或被弹窗阻塞的页面偶尔会抛异常,包一层try继续清理剩下的,别让一个坏页面中断整轮回收。- 一定提供
--dry-run。 清理是不可逆操作,先把「将关闭哪些、保留哪些」打出来看一遍,再真正执行。我第一版没做这个,直接把用户正在看的页面关了。
收尾后实测:
before: pages=20 chrome_rss=5610 MB
after : pages=1 chrome_rss=1479 MB
注意第二行的量法:关完不要立刻 ps,渲染进程退出要一两秒,中间还有个「僵尸期」。我是静置 25 秒之后量的。如果马上量,会看到四五千兆,误以为回收没生效。
四、别在任务运行中清理
这个脚本后来挂到了 systemd timer 上,每天 07:10 跑——也就是每日任务(06:00 开始)之后。
时间点不是随手定的。清理脚本会 close() 页面,而定时任务脚本此时可能正拿某个页面在点击。在任务运行中清理,等于从别人手里把页面抢走,表现为 Target closed 之类的报错,而且极难复现。
判断有没有任务在跑,别去看进程名,去看调度系统自己的状态(我用的是执行记录里的 status=running)。要观察正在运行的浏览器,最安全的办法是截图,而不是通过 CDP 去导航或点元素——那会直接打断正在跑的那次任务。
五、小结
这次的问题没有一行代码是「错」的:每个脚本都能跑通,每个 click() 都成功,日志干干净净。它只是没有归还资源。
三个可复用的结论:
connect_over_cdp下的browser.close()只断开连接,不关浏览器,别指望它收尾。- 常驻浏览器里的页面是「持续工作」的,有定时器、有轮询,不关就一直占内存和 CPU。
- 回收要有独立脚本、URL 白名单、
--dry-run、允许close()失败,并且跑在业务任务之外的时间窗。
从 5.6 GB 降到 1.5 GB 只花了十来行代码,但它换来的是这台机器不必再去讨论「要不要加内存」。
- 点赞
- 收藏
- 关注作者
评论(0)