· Jeff · 教程  · 17 分钟阅读

1.3GB 内存的服务器上,浏览器自动化必然 OOM:我最后用纯 HTTP 重写了一遍签到

一个每天跑的签到脚本,用 Playwright 时 6 分钟才启动完浏览器、随后必崩。我一度以为是脚本写得不对,换了三种写法都没用。真正的结论是:在 1.3GB 内存、swap 全满的机器上,浏览器自动化注定失败——不是代码问题,是物理问题。更值得记的是第二层翻车:当时得出的「必须借浏览器上下文」这个结论本身就是错的,而它被固化进了代码,白折腾了一周。

一个每天跑的签到脚本,用 Playwright 时 6 分钟才启动完浏览器、随后必崩。我一度以为是脚本写得不对,换了三种写法都没用。真正的结论是:在 1.3GB 内存、swap 全满的机器上,浏览器自动化注定失败——不是代码问题,是物理问题。更值得记的是第二层翻车:当时得出的「必须借浏览器上下文」这个结论本身就是错的,而它被固化进了代码,白折腾了一周。

1.3GB 内存的服务器上,浏览器自动化必然 OOM:我最后用纯 HTTP 重写了一遍签到

我有一台小服务器,1.3GB 内存,跑着几个常驻服务。上面挂了个每日签到脚本——某平台的积分打卡,手动点一下的事,但天天点就烦,所以交给定时任务。

最初的实现用的是 Playwright:打开页面、点签到按钮、读结果。看着挺自然,这类”模拟人操作”的需求,第一反应就是浏览器自动化。

它跑不起来。不是偶发失败,是每次都失败,而且失败得很慢——先卡几分钟,再报一个看不出所以然的错。

这篇记录我怎么从”再调调参数”一路排查到”这台机器上浏览器自动化根本不可行”,最后用纯 HTTP 重写整个脚本,以及中间那个把我坑了一周的错误结论

环境:一台 1.3GB 内存的容器,swap 4GB,日常负载已经吃掉大部分内存。下文把平台名和具体域名做了泛化,不影响结论。

一、先看现象:失败长什么样

脚本挂在 cron 里,每天早上跑。连续几天看日志,失败形态很固定:

观察点表现
启动耗时chromium 从 launch() 到可用,约 6 分钟
之后page.evaluate()Target crashed
进程浏览器进程起得来,但页面上下文已经死了
系统侧swap 100% 占满,load average 峰值 40+
复现性100%,从不”偶尔成功”

最关键的那条线索是第 4 行:swap 全满、load 40+。这不是”脚本有 bug”的形态——脚本 bug 通常有明确的报错栈;而这个是资源被打穿的形态,慢、卡、然后崩。

一句话判据:如果失败是”先卡很久、再崩”,先去看内存和 swap,别急着读代码。

二、复现:为什么最小用例反而复现不出来

我一开始犯的错是:写了个最小用例去复现。

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("https://example.com")
    print(page.title())
    browser.close()

这个用例能跑通。于是我一度以为问题出在业务脚本的复杂度上——登录流程太长?cookie 处理不对?加了太多 wait_for_selector

全猜错了。

真相是:最小用例只开一个空白页,内存峰值远低于真实场景。真实脚本要加载目标站的完整 SPA——JS 包、字体、图片、若干第三方脚本。只有真实页面才能把内存推到临界点。

教训:复现资源类问题,必须用真实负载。最小用例通过,只说明”最小用例不越界”,不说明”业务脚本没问题”。这两件事之间没有推理关系。

三、根因一:1.3GB 内存跑不动现代浏览器

把内存曲线拉出来看,结论很干净:

阶段内存占用
容器基线(常驻服务)约 1.0 GB
chromium 启动后峰值越过物理内存上限
结果开始疯狂 swap,load 飙升
最终页面上下文被内核/浏览器自己干掉 → Target crashed

1.3GB 物理内存、日常已用 1.0GB,留给浏览器的只有 300MB 出头。而一个现代 chromium 跑一个完整 SPA,几百 MB 是起步价。

这不是”优化一下就好”的差距,是量级差距。--disable-gpu--no-sandbox--single-process 这类参数能省一点,但省不出一个数量级。

尝试结果
加启动参数(--no-sandbox 等)启动快了一点,仍然 crash
减少等待、精简流程无改善(瓶颈不在流程)
换 headless 模式无改善(headless 不等于省内存)
升配到 2GB+未验证——成本不可接受

到这一步其实已经可以下结论了:在这台机器上,浏览器自动化这条路是堵死的。 但我当时没有接受这个结论,反而往一个更错的方向走了。

四、根因二:我当时的结论本身是错的

这是整件事最值得写下来的部分。

浏览器跑不动之后,我观察到一件事:签到接口在没有正确 cookie 时返回 401。而在浏览器里,登录状态是好的——页面能正常显示已登录。

于是我得出一个”合理”的推论:

接口鉴权靠的是 SPA 运行时注入的请求头,这些头只在浏览器上下文里存在,所以必须借浏览器来发请求

基于这个推论,我把架构改成:Playwright 起浏览器 → 在浏览器上下文里执行 fetch() 调签到接口。逻辑上绕开了”浏览器点按钮”,但仍然需要浏览器——于是仍然 crash。

这个推论是错的。 后来(在别人提醒下)重新抓包才发现:那个 401 是因为我用的那份 cookie 里,网关自己签发的那个会话 cookie 早就过期了(快两个月前),跟”请求头必须由 SPA 注入”毫无关系。纯 cookie 就能鉴权,不需要浏览器,也不需要任何注入的 header。

一周时间,就耗在这个自己造出来的错误前提上。

为什么这个错误结论这么难推翻

三个原因叠加:

  1. 它”解释”了现象。401 是真的,浏览器里登录态正常也是真的。错误结论能把两个真实观察串起来,所以看起来可信。
  2. 它导向一个可执行的方案,而不是一句”再查查”。人天然倾向于接受能立刻动手的结论。
  3. 它被写进了代码和注释。一旦落地,后来的每次排查都会先读那段注释,然后顺着错误前提继续往下想——错误前提会被代码不断”复述”给自己听

教训:当结论是”必须用某种重方案”时,先反问一句——有没有一个更简单的解释能同时解释所有现象? 本例中,“会话 cookie 过期”这个解释同样能解释 401,而且它导向的方案轻得多。

五、正解:OIDC 静默重放,不用验证码也能续会话

既然鉴权纯靠 cookie,问题就简化成:会话 cookie 过期了,怎么续?

这里有个关键区分,是整件事的技术核心:

组件状态
身份层身份提供商(OIDC)的 SSO 会话一直有效
网关层网关自己签发的会话 cookie已过期

也就是说:“你是谁”这件事,服务端一直记得;只是网关那张”通行证”到期了。 而 OIDC 协议本来就为这种情况准备了静默重放(silent re-auth):拿身份层的会话去换一张新的网关通行证,全程不需要用户交互,也就不需要短信验证码

流程三步:

1) GET  /console/accounts
    -> 302 到身份提供商的授权端点(带上 client_id / redirect_uri / scope)

2) GET  <授权端点>
    -> 身份层发现 SSO 会话仍在,直接 302 回业务站,
       带上 ?code=<authorization_code>

3) GET  <业务站回调地址>?code=...
    -> 网关用 code 换发新的 session cookie(有效期 7 天)

urllib 就能实现,不需要任何浏览器。核心是把”不自动跟随重定向”打开,手动接管每一步:

class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *a, **kw):
        return None          # 不让 urllib 自动跳,自己读 Location 一步步走


def refresh_session(opener):
    """OIDC 静默重放:拿身份层会话换新的网关会话 cookie"""
    # 1) 触发跳转,取出授权地址
    req = urllib.request.Request(BASE + "/console/accounts")
    req.add_header("User-Agent", UA)
    loc = _follow(opener, req)                      # 返回 302 的 Location
    if not loc:
        return False

    # 2) 走授权端点,身份层仍在会话中 -> 直接回调带 code
    req = urllib.request.Request(loc)
    req.add_header("User-Agent", UA)
    req.add_header("Accept", "text/html")
    cb = _follow(opener, req)
    if not cb:
        return False        # 身份层会话也失效了,只能人工重新登录

    # 3) 回调,网关换发新 cookie(CookieJar 自动收下)
    req = urllib.request.Request(urllib.parse.urljoin(BASE, cb))
    req.add_header("User-Agent", UA)
    req.add_header("Accept", "application/json")
    return _follow(opener, req) is not None


def _follow(opener, req):
    """跟一次请求,只取 Location;3xx 不当异常处理"""
    try:
        with opener.open(req, timeout=30) as r:
            return r.headers.get("Location")
    except urllib.error.HTTPError as e:
        if e.code in (301, 302, 303, 307, 308):
            return e.headers.get("Location")
        return None

主流程也就变得很短——先探活,401 才刷新,然后重试:

status, body = post_json(opener, BASE + "/billing/meter/checkin-status")
if status == 401:
    if not refresh_session(opener):
        return 1                      # 需要人工介入
    save_state_cookies(jar, st)        # 刷新后的 cookie 立刻落盘
    status, body = post_json(opener, BASE + "/billing/meter/checkin-status")

# 签到接口同理:401 -> 刷新 -> 重试一次

整套东西的内存占用:几十 MB 的 Python 进程。对比之前的 6 分钟启动 + 必崩,这是从”不可用”到”毫无存在感”的差别。

刷新后要把新 cookie 写回快照文件。如果直接拿 CookieJar 覆盖整个文件,其它域名下的 cookie(第三方统计、CDN 之类)会一起丢失——有些站点的后续请求会因此出现莫名其妙的行为。正确做法是只替换目标域,其余原样保留:

def save_state_cookies(jar, st, path=STATE_FILE):
    kept = [c for c in st.get("cookies", [])
            if "example.cn" not in c.get("domain", "")]      # 保留其它域
    out = [{...} for c in jar if "example.cn" in c.domain]   # 只取目标域
    if not out:
        return False                                          # 没拿到新 cookie 就别写
    st["cookies"] = kept + out
    json.dump(st, open(path, "w", encoding="utf-8"),
              ensure_ascii=False, indent=2)
    return True

六、我的排查清单(可直接抄)

步骤动作判据
1看失败形态先卡很久再崩 → 资源问题,不是逻辑 bug
2看内存与 swap物理内存余量 < 浏览器最低需求 → 这条路不通
3别用最小用例复现必须用真实页面/真实负载,否则必然”通过”
4抓一次真实请求确认鉴权到底靠什么(cookie?header?)
5反问简化假设有没有更简单的解释能同时解释所有现象?
6找协议的官方机制会话续期通常有标准解法(如 OIDC 静默重放)
7回写持久化状态只替换目标域,其余保留

第 4 步是治本的。前三步只是止损——确认”浏览器这条路堵死”;而第 4 步一旦做对,整个需求可能根本不需要浏览器,问题直接消失。

我这一周的时间,全部浪费在跳过第 4 步、直接相信一个未经抓包验证的推论上。

七、最该记住的:错误结论会被代码固化

这次有两个层次的翻车,第二个更值得记。

第一层:在 1.3GB 机器上坚持跑浏览器自动化,反复调参数。这一层好改——承认资源不够,换方案就行。

第二层:我基于一个未经验证的推论(“必须借浏览器上下文发请求”)重构了架构,还把它写成注释留在代码里。这条注释后来成了后续每次排查的起点——我每次重读代码,都先看到”必须借浏览器”,于是继续在错误方向上找原因。

也就是说:错误的结论不只误导当下,它还会通过代码把自己反复讲给你听。

从那以后我给自己定了一条:凡是”因为 X 所以必须用重方案”的结论,落进代码之前必须有一次可复现的抓包/日志证据。 拿不出证据,就只能在排查笔记里写成”待验证的猜测”,不能写进代码注释。

还有一个更实用的自检动作:把结论里的”必须”换成”可能”,看它还能不能解释现象。 “必须借浏览器”换成”可能是会话过期”之后,两个现象照样解释得通——而这个更弱的版本,指向的方案轻了一个数量级。

八、小结

决策选择理由
1.3GB 机器上跑浏览器自动化放弃,换纯 HTTP是量级差距,不是参数问题
复现资源类问题真实负载,不用最小用例最小用例通过 ≠ 业务脚本没问题
鉴权方式判定抓包,别推论一次抓包省一周
会话续期OIDC 静默重放身份层会话仍在,无需验证码
状态回写只替换目标域否则会冲掉其它域 cookie
重方案结论必须附可复现证据才能进代码错误注释会不断复述自己

一句话版本:“卡很久然后崩”是资源问题,“必须用某个重方案”是待验证的猜测——前者要去看内存,后者要去看抓包。我这次两样都判断错了,代价是一周。

相关文章:

☕ 如果这篇文章对你有帮助

欢迎请 Jeff 喝杯咖啡,支持我持续分享更多软件技巧~

打赏功能即将上线,先点个赞也是支持 ❤️

返回博客

相关文章

查看全部 »
进程还在,端口已死:一个单线程 HTTP 服务的「假活」陷阱,和我的四层加固

进程还在,端口已死:一个单线程 HTTP 服务的「假活」陷阱,和我的四层加固

我自建的一个统计面板曾经挂了整整 16 天我才发现:进程从没退出、端口一直在 LISTEN、CPU 占用是零,看上去「健康得不得了」,可所有新连接都拿不到响应,公网一律 504。根因是单线程 HTTPServer 没有 socket 超时,被一条半开连接永久阻塞;更值得记的是第二层——进程监督器只认「进程存活」,这种假活对它完全不可见。这次我给它上了四层加固,也第一次想明白:为什么「重启脚本」这种修复,会被下一次部署悄悄冲掉。

AI Agent 定时任务为什么静默失败:两个我踩过的坑与实测数据

AI Agent 定时任务为什么静默失败:两个我踩过的坑与实测数据

定时任务连续几天不出活,日志里却看不出错——这是跑 LLM 自动化最常见的一种失败。本文是我排查一个每日定时任务的完整记录,两个真实根因:推理型模型的 token 预算被思维链烧穿(实测 4096 下 2/6 空回复、8192 下 0/6),以及框架的「模型漂移保护」fail-closed 静默跳过任务。附复现方法、修法和一份模型横向压测表。

FileBrowser 免登录图片直链:容器部署 + Notion 嵌入完整指南

FileBrowser 免登录图片直链:容器部署 + Notion 嵌入完整指南

想让 Notion 页面直接嵌入服务器上的图片,又不想把 FileBrowser 的管理登录暴露给所有人?核心思路不是 FileBrowser 自带功能,而是 nginx 层用 alias 把图片目录直接映射出来绕过认证。这篇是完整实战指南,含部署、直链、验证和排障。