VPS上运行AI代理无头浏览器的部署与排错
Chromium在VPS上常因/dev/shm仅64 MB、沙箱参数、缺失字体和残留进程崩溃。本文介绍Playwright安装、资源限制与端点安全配置。
运行环境
VPS 上的无头浏览器是指不显示窗口的 Chromium。它由代码驱动,而不是由人员操作。在服务器上,它是一个长期运行的进程树,您的代理通过本地套接字与其通信。安装只需一条命令。真正的工作都在安装之后:限制浏览器可占用的系统资源,并确保其控制端点不暴露在公网中。
本指南假设您已经确定了工具,现在需要对其进行运维。如果您仍在比较爬虫和提取工具,请先从自托管的 Firecrawl 替代方案开始,然后再回来。以下内容均使用 Playwright 的 Chromium,因为 Playwright 自带浏览器构建版本和依赖项安装程序。因此,在裸 Ubuntu VPS 和容器中都可以使用相同的命令。版本信息截至 2026 年 8 月。
无需猜测依赖项即可安装 Chromium
npm i -D playwright@1.62.0
npx playwright install --with-deps chromium--with-deps 会运行 apt,安装 Chromium 所需的共享库和字体,并在执行到需要 root 权限的步骤时请求相应权限。浏览器构建版本本身会下载到执行该命令的用户的 ~/.cache/ms-playwright 中。在服务器上,这一点很重要,因为服务用户通常不是您登录时使用的用户。使用 sudo npx playwright install-deps chromium 以管理员身份一次性安装系统软件包,然后在安装命令和服务单元中都设置 PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers,让所有服务共用同一份浏览器。服务无法访问浏览器时,会在启动时失败,并显示其搜索过的路径。
固定 Playwright 版本。每个版本都对应一个浏览器构建版本,因此未固定版本的 npm update 可能会在运行中的服务下替换浏览器。截至 2026 年 8 月,Playwright 1.62 是当前版本。
Chromium 有两个构建版本,二者不是同一个程序。默认下载的是无头 shell,这是一个只能以无头模式运行的较小二进制文件,npx playwright install --with-deps --only-shell 只会安装它。使用 chromium 通道时,安装的是完整浏览器。Playwright 的浏览器文档称其为“真正的 Chrome 浏览器,因此更加真实、可靠,并提供更多功能”。批量抓取时使用 shell。如果网站的行为不同,并且您需要查明原因,请使用完整浏览器。
容器中的无头浏览器为何会崩溃
Docker 为每个容器提供 64 MB 的 /dev/shm。Docker 文档明确说明:“如果完全省略大小,系统将使用 64m。”Chromium 通过这块共享内存在其进程之间传递渲染内容,因此一个复杂页面就可能将其占满。随后渲染器进程退出,客户端报告目标已崩溃,而同一页面在笔记本电脑上运行正常。修改任何配置前,先在容器内部确认该区域的大小。
df -h /dev/shm有两种真正的修复方法,二者只能选其一,不能同时使用。--ipc=host 会让容器加入主机的 IPC 命名空间,因此使用主机的 /dev/shm,其大小通常为内存的一半。Playwright 的 Docker 指南推荐此方法,因为不使用该选项时,“Chromium 可能耗尽内存并崩溃”。代价是容器与主机之间不再具有 IPC 隔离。--shm-size=1g 则保留私有命名空间,只增大挂载点。
docker run --rm -it --init --ipc=host --user pwuser mcr.microsoft.com/playwright:v1.62.0-noble /bin/bash在大多数搜索结果中,你会看到 --disable-dev-shm-usage 这个选项,但它的作用不同:它会将这些文件从 /dev/shm 移到临时目录。如果 /tmp 位于磁盘上,你只是将崩溃换成了更慢的渲染和磁盘写入。如果 /tmp 是 tmpfs,数据仍会存放在内存中,而且完全没有大小限制;这可能导致浏览器耗尽小型 VPS 的内存。应改为正确设置 /dev/shm 的大小。
--no-sandbox 的实际代价
Chromium 会使用基于 Linux 用户命名空间构建的沙箱隔离每个渲染器。该沙箱是恶意网页与服务器之间的边界。沙箱无法启动时,Chromium 会拒绝运行,日志中通常会出现类似以下内容的一行:
Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permitted通常的建议是 --no-sandbox。Chromium 自身的安全文档明确说明了代价:该标志“会禁用 Chromium 的关键安全功能,浏览开放网络时绝不应使用”。代理跟踪链接时,本质上就是在浏览开放网络。应找出真正的原因。
几乎所有情况都由两个原因造成。以 root 身份运行浏览器会禁用沙箱,因为进程无法放弃自己已经拥有的权限。因此,Playwright 镜像提供了一个名为 pwuser 的普通用户。Ubuntu 24.04 及更高版本会限制非特权用户命名空间;如果 Chromium 二进制文件所在路径不受任何已提供的 profile 覆盖,就会被拒绝访问。Playwright 下载到 ~/.cache/ms-playwright 下的文件正是这种路径。检查以下两项:
id -u
sysctl kernel.apparmor_restrict_unprivileged_userns
sudo dmesg | grep -i userns_createsysctl 输出中的 1 与包含 apparmor="DENIED" operation="userns_create" 的内核日志行同时出现,即可确认第二个原因。在 /etc/apparmor.d/pw-chromium 中仅允许该二进制文件,从而继续对服务器上的其他程序实施限制:
abi <abi/4.0>,
include <tunables/global>
profile pw-chromium /home/*/.cache/ms-playwright/*/chrome-linux/{chrome,headless_shell} flags=(unconfined) {
userns,
}使用 sudo apparmor_parser -r /etc/apparmor.d/pw-chromium 加载它。该路径包含浏览器版本号,因此每次升级 Playwright 时都会变化。上面的 glob 可以跨版本继续匹配。针对某个固定路径编写的 profile 会静默失效;浏览器会在一次看似无关的更新后再次启动失败。
为什么截图为空白或充满方框
空白截图或充满空白矩形的截图,通常是字体问题,而不是渲染错误。install-deps 会拉取一组可用的基础字体:fonts-liberation、fonts-freefont-ttf、fonts-noto-color-emoji,日语使用 fonts-unifont、fonts-ipafont-gothic,中文使用 fonts-wqy-zenhei,泰语使用 fonts-tlwg-loma-otf。该字体集不包含 Noto CJK,因此韩语和其他几种文字会回退到 fontconfig 能找到的字体。请查询 fontconfig,不要凭猜测判断:
fc-match "sans-serif:lang=ko"
fc-match "sans-serif:lang=ar"
fc-list | wc -l如果所需语言解析为 unifont,或回退到不包含实际字形的字体,请安装 fonts-noto-core 和 fonts-noto-cjk,然后重新运行检查。fontconfig 会缓存查询结果,因此安装字体后请重启浏览器。完全不包含字体的精简镜像会在启动时记录 Fontconfig error: Cannot load default config file,并将每个页面渲染为空白。
Locale 和时区与字体无关。它们会改变页面显示的内容,而不仅是外观。容器通常未设置 LANG,并将 TZ 设为 UTC,因此网站会提供英文内容并打印 UTC 时间戳,而您的代理报告的时间也会与该国家或地区用户看到的时间不一致。请按浏览器上下文设置这些参数,而不是按机器设置,这样同一个浏览器可以为不同地区执行任务。
const context = await browser.newContext({
locale: 'en-GB',
timezoneId: 'Europe/Paris',
});泄露的浏览器进程为何会导致服务器使用交换空间
两类不同的问题都被称为“僵尸”。真正的僵尸进程是已结束、但其父进程从未调用 wait() 的进程。它只保留一个 PID 条目,不再占用其他资源,因此不会消耗内存。当浏览器在容器中作为 PID 1 运行时,您会收集到这类进程,因为 PID 1 默认不会回收子进程。Docker 的 --init 标志正是为此设计的,它会运行一个小型 init,用于“转发信号并回收进程”。在 Compose 中,对应的配置是 init: true。
真正导致服务器使用交换空间的泄漏不同:这是无人关闭的活动 Chromium 进程。当任务在 newContext() 和 close() 之间抛出异常,或控制脚本被终止并遗留孤立的浏览器进程树时,就会发生这种情况。最严重的情况是每个请求都启动一个新的浏览器。统计进程数量:
pgrep -c -f 'headless_shell|chrome'
ps -eo pid,ppid,rss,etime,comm --sort=-rss | head -20任务处于空闲状态时,该数量应恢复到原来的值。如果数量在一天内持续上升,问题出在代码中,而不是启动标志:在 finally 块中关闭上下文,在 SIGTERM 时关闭浏览器,并在处理固定数量的任务后回收浏览器,不要让同一个浏览器连续运行一个月。在 systemd 下,停止或重启会终止该单元 cgroup 中的所有进程,因此 sudo systemctl restart browser.service 是一种可靠的重置方式。手动在终端复用器中启动的浏览器没有这一保障,其孤立进程会在会话结束后继续运行。
一个浏览器上下文需要多少 RAM
提问时要明确,因为“一个浏览器”并不是一个进程。Chromium 会运行浏览器进程、GPU 进程、实用工具进程,以及每个站点对应的一个渲染器进程。站点隔离还会让跨站 iframe 使用独立的渲染器。一个 BrowserContext 是同一进程树中的独立 Cookie jar 和存储区域,因此创建第二个上下文的开销很小。第二个页面则不同,因为它会启动渲染器进程,而广告很多的页面还会启动多个渲染器进程。
因此,应测量的是整个进程树在您自己的工作负载下的峰值内存。其他人的博客给出的数值在这里没有参考价值,因为代理打开哪些页面才决定实际需求。请在将要使用的机器上,针对将要访问的站点进行测量:
sudo systemd-run --unit=browser-probe -p MemoryMax=2G -p MemorySwapMax=0 -p WorkingDirectory=/srv/agent /usr/bin/node worker.js
systemctl status browser-probe在 Ubuntu 24.04 上,该输出中的 Memory: 行会报告此单元的当前使用量和峰值使用量。让工作进程一次只打开一个页面,记录峰值;然后在同时打开两个页面的情况下重复测试,以了解第二个页面的实际开销。并发数可以通过算术计算:用总 RAM 减去系统其他部分所需的内存,保留几百 MB 的余量,再除以每个工作进程测得的峰值。要估算底层机器的配置,请参阅代理 VPS 需要多少 RAM 和 CPU。
请在两个位置限制这个数值。在代码中使用固定的工作进程池或信号量,使代理请求突发时进入队列,而不是启动更多浏览器。在操作系统中使用 cgroup 限制,避免队列中的错误连带拖垮整台机器:
[Service]
MemoryMax=2G
MemorySwapMax=0
TasksMax=512
Restart=alwaysMemorySwapMax=0 的作用比看起来更重要。如果没有它,cgroup 达到限制后会将内存页换出到 swap。这样机器仍能运行,但每个请求都会变慢,比直接失败更难诊断。启用它后,内核会终止该 cgroup 中的浏览器进程树,systemd 会重启此单元,而 sshd 仍能继续运行。Compose 中对应的控制项是 mem_limit、shm_size 和 init,详见在 Docker Compose 中设置内存限制。
不要将浏览器端点暴露到公网
Playwright 可以将浏览器作为服务器运行,并向您的代理提供一个 WebSocket URL:
const { chromium } = require('playwright');
const server = await chromium.launchServer({ port: 3000 });
console.log(server.wsEndpoint());该端点没有登录验证。Playwright API 文档对此有明确说明:“任何知道 wsPath 的进程或网页(包括在 Playwright 中运行的进程或网页)都可以控制操作系统用户。”默认主机为 localhost,“只接受来自回环接口的连接”。文档还警告,传入 0.0.0.0 等显式地址会“将浏览器 RPC 暴露给任何能够访问监听端口的对象”。Chrome 自带的 --remote-debugging-port 风险更高。DevTools 协议完全没有任何身份验证,只依赖绑定到回环接口来提供保护。
检查您实际发布了哪些端口,并从另一台机器和 VPS 本机分别进行检查:
ss -ltnp任何绑定到 0.0.0.0 的浏览器端口都属于安全问题。请记住,大多数云服务商会在控制面板中运行独立的网络防火墙,而您的 ufw 规则无法感知该防火墙。应改用 SSH 隧道或专用 VPN,从另一台机器访问该端点:
ssh -N -L 3000:127.0.0.1:3000 you@your-vps这里的风险不只是有人占用浏览器运行时间。一个可以由远程方驱动的浏览器,就是位于您网络内部的请求伪造工具。任何能够访问该套接字的对象,都可以让它请求 http://127.0.0.1:8080、数据库管理页面或云元数据地址 169.254.169.254,然后从页面中读取响应。您的防火墙会将该请求视为来自 VPS 本机的请求,因此允许它通过。应将控制端点视为等同于对该服务器具有 shell 访问权限。
MCP 服务器的情况相同。npx @playwright/mcp@latest --headless --port 8931 在 localhost 上通过 HTTP 提供服务,而 --host 0.0.0.0 是将本地工具变为公网服务的标志。项目 README 明确说明,Playwright MCP “不是安全边界”。将该端口保持在回环接口上,并让代理通过同一条隧道访问它。
代理读取的页面是不可信输入
浏览开放网络的代理会将陌生人编写的文本输入模型,而模型同时也持有您的指令。页面可能包含直接写给模型的文本,要求模型放弃当前任务、调用工具,或将数据发布到某个 URL。模型接收到的两者都是文本,因此无法可靠地区分页面内容和您的指令。应设计运行环境,限制恶意页面可利用的资源。
- 让浏览器使用独立的 OS 用户运行,并确保其环境中没有 SSH 密钥和云凭据。
- 每个任务使用全新的上下文,并在 Playwright MCP 中使用
--isolated,这样一个站点的会话不会被后续页面访问。 - 在任务允许的情况下维护来源允许列表。Playwright MCP 使用
--allowed-origins和--blocked-origins接收以分号分隔的列表。 - 在执行任何会改变状态的操作前要求人工确认,例如发送邮件或支付费用。
更好的做法是将整个浏览器运行在可随时销毁并重建的机器上。这与在一次性 VM 中运行编码代理的原则相同。如果代理的实际任务是搜索,而不是开放式浏览,那么使用更受限的工具比使用完整浏览器更安全:由您自己的 SearXNG 支持的搜索技能只返回结果,不会加载恶意页面。
FAQ
为什么 Chromium 在 Docker 中崩溃,但直接运行在同一台 VPS 上却正常?
因为容器默认只有 64 MB 的 /dev/shm,而主机分配的空间大得多。Chromium 会通过这块共享内存区域处理渲染内容,因此复杂页面可能将其占满,导致渲染器退出。先在容器中运行 df -h /dev/shm 进行确认,然后使用 --ipc=host 启动浏览器,让它使用主机的共享内存;或者使用 --shm-size=1g,增大容器自身的共享内存。--disable-dev-shm-usage 只会将问题转移到 /tmp。
如果 VPS 上没有运行其他程序,使用 --no-sandbox 是否安全?
不安全。沙箱用于阻止恶意页面访问机器的其他部分。Chromium 文档明确说明,该标志“会禁用 Chromium 的关键安全功能,浏览开放网络时绝不应使用”。会跟随链接的代理正在浏览开放网络。应修复根本原因:不要以 root 身份运行浏览器;在 Ubuntu 24.04 上,为浏览器二进制文件路径配置包含 userns, 的 AppArmor 配置文件,使非特权用户命名空间仅对该程序可用。
在小型 VPS 上可以运行多少个浏览器?
应进行测量,不要直接套用某个数字。Chromium 会为每个站点启动一个渲染器进程,因此具体数量取决于打开的页面。使用设置了 MemoryMax 的 systemd-run 运行一个 worker,从 systemctl status 中的 Memory: 行读取峰值,然后用可用 RAM 除以该峰值,并预留余量。在代码中使用队列,并在单元文件中配置 MemoryMax,从两个层面限制并发数。这样请求突增时会排队,而不会导致整台机器进入交换。
我的代理可以从另一台机器连接浏览器吗?
可以,但绝不能将端口绑定到 0.0.0.0。Playwright 服务器端点和 Chrome DevTools 端口都会接受任何能够访问它们的客户端,而且没有密码保护。让监听器绑定到 127.0.0.1,再通过 SSH 隧道或私有 VPN 传输连接。使用 ss -ltnp 在服务器上验证,并从外部检查端口;同时检查服务提供商单独配置的网络防火墙。
页面明明已加载,为什么截图却是空白?
缺少字体。如果没有覆盖页面所用文字脚本的字体,文本会渲染为空白方框,甚至完全不显示,因此以图片为主的页面返回的截图看起来会是空白。针对抓取的每种语言运行 fc-match "sans-serif:lang=ko";如果结果是通用回退字体,则安装 fonts-noto-core 和 fonts-noto-cjk,然后重启浏览器,使 fontconfig 重新加载缓存。完全没有字体的容器会在启动时记录 Fontconfig error: Cannot load default config file。