常驻浏览器跑定时任务,内存从 1.5G 涨到 5.6G:一次标签页泄漏的排查与回收

举报
yd_276135645 发表于 2026/10/30 06:08:00 2026/10/30
【摘要】 没有一行代码报错,浏览器却两天吃掉 5.6GB。本文记录一次常驻 Chromium 的内存排查:怎么分别量页面数和进程数、为什么 connect_over_cdp 下的 browser.close() 根本不回收、以及一个带白名单和 dry-run 的标签回收脚本。

这台机器没有显示器,但有一个常驻的 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() 都成功,日志干干净净。它只是没有归还资源。

三个可复用的结论:

  1. connect_over_cdp 下的 browser.close() 只断开连接,不关浏览器,别指望它收尾。
  2. 常驻浏览器里的页面是「持续工作」的,有定时器、有轮询,不关就一直占内存和 CPU。
  3. 回收要有独立脚本、URL 白名单、--dry-run、允许 close() 失败,并且跑在业务任务之外的时间窗。

从 5.6 GB 降到 1.5 GB 只花了十来行代码,但它换来的是这台机器不必再去讨论「要不要加内存」。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。