· Jeff · 教程 · 17 分钟阅读
1.3GB 内存的服务器上,浏览器自动化必然 OOM:我最后用纯 HTTP 重写了一遍签到
一个每天跑的签到脚本,用 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。
一周时间,就耗在这个自己造出来的错误前提上。
为什么这个错误结论这么难推翻
三个原因叠加:
- 它”解释”了现象。401 是真的,浏览器里登录态正常也是真的。错误结论能把两个真实观察串起来,所以看起来可信。
- 它导向一个可执行的方案,而不是一句”再查查”。人天然倾向于接受能立刻动手的结论。
- 它被写进了代码和注释。一旦落地,后来的每次排查都会先读那段注释,然后顺着错误前提继续往下想——错误前提会被代码不断”复述”给自己听。
教训:当结论是”必须用某种重方案”时,先反问一句——有没有一个更简单的解释能同时解释所有现象? 本例中,“会话 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 时别把别的域的 cookie 冲掉
刷新后要把新 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 |
| 重方案结论 | 必须附可复现证据才能进代码 | 错误注释会不断复述自己 |
一句话版本:“卡很久然后崩”是资源问题,“必须用某个重方案”是待验证的猜测——前者要去看内存,后者要去看抓包。我这次两样都判断错了,代价是一周。
相关文章:
- AI Agent 定时任务为什么静默失败——本文这个签到任务也是 cron 任务,同一类问题:失败不一定有报错,得自己加校验
- 用 Docker 部署 Hermes Agent:从安装到多 Profile 管理——本文这套脚本运行的环境,也是”小内存容器怎么压榨资源”的另一面
- Docker 镜像清理:把磁盘和内存腾出来——小机器上资源永远紧张,清理和瘦身是长期功课
☕ 如果这篇文章对你有帮助
欢迎请 Jeff 喝杯咖啡,支持我持续分享更多软件技巧~
打赏功能即将上线,先点个赞也是支持 ❤️