把浏览器自动化搬上 Linux 服务器:Xvfb、systemd 和时区给我上的五堂课

举报
yd_254048033 发表于 2026/10/27 10:12:45 2026/10/27
【摘要】 一套在 Windows 上跑得很顺的自动化脚本,搬到 Linux 云服务器后连续踩了五个坑:headless 模式反而更容易被识别、h264 缺失导致视频卡在 0 秒、nohup 加 & 不是部署方式、systemd timer 的 Persistent 补跑语义理解错、以及 toISOString() 在凌晨把任务记到了前一天。每个坑都附带真实的报错现象和最终的处理方式。

前情

我在 Windows 上写了一层很薄的浏览器驱动(直连 CDP,两百多行,没有依赖),用来每天跑一批固定动作:签到、读几个页面上的数字、发一篇文章。本机跑了两周没出过问题,于是决定搬到云服务器上,让它每天定时自己跑。

想法很简单:装个 Chrome,把脚本拷过去,加个 crontab。实际落地花了整整一天,绝大部分时间不在写代码上,而在搞明白「服务器和桌面电脑到底哪里不一样」。

下面按被坑的顺序写。

坑一:没有显示器这件事,会从三个方向咬你

第一反应当然是 headless。--headless=new 一加,脚本在 SSH 里跑得飞快,没有任何报错。看起来问题解决了,其实只是把故障推迟到了没人看的时候。

三个延迟爆发的症状:

1. 视频永远停在 0 秒。 目标站点有大量课程视频,脚本判断「播放完成」的方式是读 video.currentTime。headless 下这个值一直是 0,readyState 爬到 1 就不动了。查了很久以为是自动播放策略被拦,加了一堆 --autoplay-policy=no-user-gesture-required 无效。真正的原因是编解码器:服务器上装的是发行版仓库里的 chromium,这个包默认不带专有的 H.264 / AAC 支持,视频轨道解不出来,自然永远是 0 秒。

换成 Google 官方源的 google-chrome-stable 之后立刻正常。如果你在 x86 Linux 上做任何涉及音视频的自动化,别用发行版自带的 chromium,也别用 Playwright 附带的那个(同样不含 H.264)。

2. UA 里带着 HeadlessChrome。 哪怕是 --headless=new,默认 UA 依然是 HeadlessChrome/xxx。这个字符串在风控特征里排名很靠前。必须手动覆盖:

--user-agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"

顺带两个配套的:--disable-blink-features=AutomationControlled 去掉 navigator.webdriver,以及启动参数里不要带 --enable-automation(那是测试框架常用的,等于自报家门)。

3. 后台标签页被限速。 这点在桌面环境里也会遇到,但服务器上更致命:没有窗口,所有标签页天然都是「后台」。Chrome 对不可见页面的 requestAnimationFrame 会降到 1 秒一次,setTimeout 最小间隔被拉长,某些懒加载的图片和视频分片直接不请求。表现是「本地能跑完的流程,服务器上卡在某个步骤」。

在 CDP 里能救回来一半——每切换一次目标页就调一次 Page.bringToFront:

c.front = () => send('Page.bringToFront', {});

但如果浏览器根本没有可见窗口,bringToFront 的作用有限。所以最终还是上了 Xvfb:

apt install -y xvfb
xvfb-run -a --server-args="-screen 0 1440x900x24" \
  google-chrome-stable --remote-debugging-port=9333 --user-data-dir=/srv/profile

Xvfb 就是一个假的 X server,没有物理显示器,但对 Chrome 来说「存在一个 1440x900 的屏幕」。于是有头浏览器可以正常工作,视频会播,标签页可见性正常,UA 也不用伪造了。

一个容易忽略的细节:xvfb-run 会把它启动的进程绑在自己的生命周期上。如果你用 xvfb-run node run.mjs,脚本执行完 Xvfb 就跟着退出,浏览器一起死。正确的做法是让 Xvfb 和 Chrome 常驻,脚本只是连上去用。

坑二:nohup 加 & 不叫部署

第一次「上线」,我写的是:

nohup google-chrome-stable --remote-debugging-port=9333 ... &

当天能跑,第二天发现端口不通了。ps 里进程没了,dmesg 里能看到 OOM 记录。原因很朴素:Chrome 是内存大户,几十个标签页跑一天,几个 G 就出去了,机器内存不够被内核杀掉。nohup 既不重启也不告警,你只会发现「今天没跑」。

这就是 systemd 存在的意义。一个能用的 unit:

[Unit]
Description=Controlled Chrome for automation
After=network-online.target

[Service]
Type=simple
User=bot
Environment=DISPLAY=:99
ExecStartPre=/usr/bin/Xvfb :99 -screen 0 1440x900x24
ExecStart=/usr/bin/google-chrome-stable \
  --remote-debugging-port=9333 \
  --user-data-dir=/srv/profile \
  --no-first-run --no-default-browser-check \
  --disable-features=Translate \
  about:blank
Restart=always
RestartSec=5
MemoryMax=3G
KillMode=control-group

[Install]
WantedBy=multi-user.target

几个参数值得说明。

Restart=always 是核心,进程被 OOM 杀掉之后 5 秒自动拉起。KillMode=control-group 保证 stop 的时候把 Chrome 派生的所有渲染进程一起收掉,否则重启后端口被僵尸进程占着,新实例起不来(--remote-debugging-port 绑定失败时 Chrome 不会报错退出,它会静默地不开调试端口继续跑,排查起来非常浪费时间)。

MemoryMax=3G 是给自己一个下限保护:让它被 systemd 杀掉并重启,好过让整个系统的 OOM killer 随机挑一个进程杀——如果被挑中的是数据库,损失比「今天任务没跑」大得多。

ExecStartPre 起 Xvfb 这种做法比较糙(Xvfb 已经开着的时候会失败退出,导致整个 unit 起不来)。干净一点的做法是给 Xvfb 单独写一个 unit,然后 Requires= 加 After=。我图省事在 ExecStartPre 前面加了 - 前缀,让失败被忽略:

ExecStartPre=-/usr/bin/Xvfb :99 -screen 0 1440x900x24

还有一点:不要让任务脚本自己去启动浏览器。脚本里一旦有「探测不到端口就 spawn 一个」,在 systemd 环境下会变成双实例互相干扰。浏览器归 systemd 管,脚本只负责连上去干活,连不上就退出报错。

坑三:Persistent=true 不是「补跑所有错过的」

任务调度用了 systemd timer,因为不想再引一个 cron 或 Node 调度库:

[Unit]
Description=Daily automation task

[Timer]
OnCalendar=*-*-* 08:30:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target

我最初的期待是「机器关了几天,开机后把落下的都补上」。实际语义是:只补一次。如果连续错过三天,开机后只触发一次,因为 timer 记的是「上次触发时间」,而 OnCalendar 的判定是「是否存在一个被错过的触发点」——存在就立刻跑一次,然后回到正常节奏。

这不是 bug,是合理设计(否则一次宕机后积压几十个任务同时跑更糟),但你必须知道,并且任务脚本自己要能判断「今天该做的事做完了没有」。我的做法是脚本维护一份状态文件,每个任务记最后一次成功的时间戳:

const doneToday = (name) => {
  const t = state.tasks[name];
  if (!t?.at) return false;
  return new Date(t.at).toDateString() === new Date().toDateString();
};

这样无论被触发几次、无论中间掉了几天,都不会重复做也不会有遗漏。

另外两个和 timer 相关的坑:

  • RandomizedDelaySec 不要省。 固定 08:30:00 触发意味着请求特征极其规律,对任何有风控的站点都是明显信号。几百秒的抖动成本为零。
  • OnCalendar 和 OnUnitActiveSec 不要混用。 同时写两个触发条件会变成「两者都满足才触发」或者产生意外的叠加,具体行为看版本,总之不是你想要的。要「每天固定时间 + 启动后也跑一次」,写两个 OnCalendar 加上 Persistent=true 就够了。

调试 timer 用这两条,比看日志快:

systemctl list-timers --all
systemd-analyze calendar "*-*-* 08:30:00"

第二条会直接告诉你这个表达式下一次、下下一次触发的时间,以及它认为的「当前时区」——这个提示很重要,见下一个坑。

坑四:toISOString() 在凌晨把任务记到了前一天

这个是成本最低但最有教育意义的一个。

日志文件按日期命名,代码是这么写的:

const file = `logs/run-${new Date().toISOString().slice(0, 10)}.json`;

服务器在东八区。某天凌晨 00:20 手动跑了一次,结果日志写进了前一天的文件夹。查了一下才反应过来:toISOString() 返回的是 UTC 时间。北京时间 00:20 是 UTC 前一天 16:20,所以日期少了一天。

这类问题之所以讨厌,是因为它在一天中的 16 个小时里表现完全正常。只有 UTC+8 的 00:00 到 08:00 之间才会错,而定时任务恰恰经常安排在这个区间(避开白天高峰)。

修正很简单,但要注意不能用 toLocaleDateString()——它的输出格式依赖 locale 和 ICU 数据,在精简容器里可能连时区数据库都没有。手动拼最稳:

const now = new Date();
const p2 = (n) => String(n).padStart(2, '0');
const stamp = `${now.getFullYear()}-${p2(now.getMonth() + 1)}-${p2(now.getDate())}`;

getFullYear / getMonth / getDate 取的都是本地时区的值,这才是「按人的日历分天」的正确语义。

同一类坑还有:systemd 的 OnCalendar 用的是系统时区。容器镜像默认 UTC,timedatectl 一看是 UTC,你写 08:30 实际在北京时间 16:30 跑。上线前一定确认:

timedatectl
# 或
cat /etc/timezone

顺带一提,日志里的时间戳建议一律存带偏移量的 ISO 格式(2026-10-27T00:20:00+08:00),不要存 toISOString()。后者丢掉偏移信息之后,跨时区看日志全靠猜。

坑五:会话型 Cookie 决定了「无人值守」不成立

最后一课和代码无关,和预期有关。

我原本的目标是「服务器上全自动,一个月不用管」。探活的时候发现,登录态大约 8 小时就失效了。

一开始的判断是「换浏览器版本导致 cookie 加密解不开」。这个判断是错的。实际验证:登录态失效后,把 cookie 全部导出来看,98 条 huaweicloud 域的 cookie 一条不少,值也都能正常读出来。真正的问题是关键的几个 cookie——SessionID、CASTGC——是 session cookie(expires=0,没有过期时间),有效与否由服务端说了算,浏览器只是容器。服务端 8 小时没动静就把它作废了,客户端怎么都续不回来。

对应的三条规则:

1. 每次任务前探活,不许假设。 「昨天登录过所以今天还有效」这个假设会让你在失效状态下跑完一整轮,得到一堆「元素找不到」「按钮点了没反应」的假故障,然后你去改选择器——方向全错。

2. 探活要有明确信号。 不要用「有没有报错」判断,用页面上的确定文案,比如匹配 登录 / 注册 出现即视为失效,直接 exit 2,让上层把它当成「需要人来」而不是「代码有问题」。

3. 失效不重试。 会话失效是确定性结果,重试只会让账号风控记一笔「短时间大量异常请求」。

所以这套东西的真实形态是:常驻浏览器 + 定时任务 + 探活告警 + 人工扫码。浏览器不退出是为了让会话有持续活动、尽量延长寿命;但不可能取消人工环节,不可能因为没有报错就认为是全自动。

如果目标是「无人值守」,正确方向不是优化脚本,而是换一条不需要用户会话的路——服务端 API 加自己的凭证。这个结论不性感,但省时间。

上线前的检查清单

按这个顺序确认,能挡掉上面九成的坑:

  • [ ] google-chrome-stable 而不是发行版 chromium(涉及音视频时必查)
  • [ ] Xvfb 常驻,DISPLAY 在 unit 的 Environment 里显式声明
  • [ ] 浏览器由 systemd 管,Restart=always + KillMode=control-group
  • [ ] 任务脚本只连端口,不 spawn 浏览器
  • [ ] timedatectl 确认时区,systemd-analyze calendar 确认下一次触发时间
  • [ ] 所有按日归档的路径用本地时间拼,不用 toISOString()
  • [ ] 任务脚本自己判重,不依赖调度器「只跑一次」
  • [ ] 探活失败 exit 2,和「代码异常 exit 1」区分开
  • [ ] 有告警通道(webhook / 邮件),失败时能推出来

最后一条最容易被跳过,但它决定了这套东西是「自动化」还是「你不知道它坏了」。

小结

服务器和桌面机的差别,不在 CPU 和内存,在没有三个东西:显示器、时区直觉、和人。没有显示器所以要 Xvfb,没有时区直觉所以处处要显式指定,没有人所以所有判断都得写进代码——包括「今天做过了没有」「登录还活着没有」「失败了谁知道」。

把这三件事补上,同一套脚本在服务器上才算真的能跑。

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

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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