把浏览器自动化搬上 Linux 服务器:Xvfb、systemd 和时区给我上的五堂课
前情
我在 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,没有时区直觉所以处处要显式指定,没有人所以所有判断都得写进代码——包括「今天做过了没有」「登录还活着没有」「失败了谁知道」。
把这三件事补上,同一套脚本在服务器上才算真的能跑。
- 点赞
- 收藏
- 关注作者
评论(0)