◈ 夜宵吧

小服务器的自我体检

后台首页有四格:内容、流量、安全、健康。都是数字,没有图表,看着朴素。

朴素是有意的——每个数字背后是一次查询、一次磁盘统计、一次证书解析,全都发生在后台刷新那几秒里,不影响任何人看文章。

这篇记的是做这四格时踩的坑:三个数字差点算错,一个定时任务实际只跑了一半的次数。如果你也要在小机器上做这种自检面板,这些坑大概都会遇到。

三件事不能放在请求路径里

第一版做完,页面打开后三四秒内四格全是"—",然后才出数。

查下来是健康检查里有一句"查有没有待安装的更新",它会跑一次「模拟升级」来数待装的包——0.5 到 1 秒,机器忙的时候更久。而这个接口每次被访问都要跑一遍。

修法不是加缓存,是改结构:把这件事从请求路径里搬出去,启动 5 秒后在后台刷一次,之后每小时刷,接口只读内存里的结果。接口耗时从"看机器心情"变成 21~70 ms。

同理还被清出去两件:数据库备份、磁盘扫描。

自检面板的数据来源:请求路径上只读内存缓存,最贵的模拟升级被搬到后台预热里,每小时跑一次
请求路径上只允许留下「读一个变量」。贵的活全部搬到后面去。

日界:所有时间戳都是 UTC

datetime('now') 存的是 UTC,而我在东八区。

于是"今日访问"在当地时间凌晨 0 点到 8 点之间,统计的其实是昨天的数据——因为 UTC 日期还没翻页。这个 bug 不会报错,只会让某几个小时的数字看起来莫名其妙。

修法是查询时就地换算:

SELECT COUNT(*) FROM visits
WHERE date(ts, '+8 hours') = date('now', '+8 hours')

反过来也要注意:前端显示时间时,Date.parse('2026-10-20 15:30') 会把这种没有时区的字符串当本地时间解析。必须显式加 Z:

new Date(s.replace(' ', 'T') + 'Z')

这条踩过一次,表现是"页面显示的时间比实际晚 8 小时",一度以为是监控停了。

WAL 涨到主库的四倍

site.db 只有 1064 KB,而 site.db-wal 有 4039 KB。

WAL 模式的工作方式是把改动先写进 -wal 文件,等 checkpoint 时再合并回主库。合并是自动的,但阈值是页数,一个写入密集的小库很容易把 WAL 撑得比主库还大。

手动跑一次 PRAGMA wal_checkpoint(TRUNCATE) 立刻成功,返回 {busy:0, log:0, checkpointed:0}——说明不是失败,是没人叫它。

原来只在每天备份时做一次。改成每小时一次,一次几毫秒:

setInterval(() => {
  const r = db.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get();
  if (r.busy) console.warn('[db] checkpoint busy', r);
}, 60 * 60 * 1000);

同时把告警阈值也修了。原来写的是 max(512KB, db*0.5)——主库才 1 MB 时,500 KB 的 WAL 就报警,于是状态页天天亮黄灯,久了就没人看了。改成 max(4MB, db*2):绝对值和相对值同时兜底,小库不会误报,大库也不会漏。

修完 WAL 稳定在 124~149 KB。

备份:VACUUM INTO 而不是 cp

cp 一个正在写的 SQLite 文件是不安全的——可能只拷到一半页。备份要用 VACUUM INTO:

db.prepare('VACUUM INTO ?').run(file);

它会读一个一致快照写进新文件。参数可以绑定(实测可用),所以文件名能动态带日期。

文件名还有一处时区坑:原来用 toISOString() 取日期,那是 UTC——在凌晨 0 到 8 点之间跑,文件会被命名成前一天。改成按东八区取:

new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' })

sv-SE 这个 locale 的好处是格式天然就是 YYYY-MM-DD,不用自己拼。保留最近 7 份。

内核装了但没生效

系统升级之后待更新数是 0,一切看起来正常。但运行中的内核还是旧的——内核包不会热替换正在跑的版本。

麻烦的是它没有标准信号。很多人以为 /var/run/reboot-required 会出现,实际上那是 needrestart 这类工具写的,内核包本身不写。

所以自己比对:

const running = os.release();                    // 例如 6.1.0-18-amd64
const installed = readdirSync('/boot')
  .filter(f => f.startsWith('vmlinuz-'))
  .map(f => f.slice(8));                         // 每个内核包一条 vmlinuz-*
const pending = installed.filter(v => v !== running);

有差异就在首页亮一个琥珀色胶囊:「内核跑的是旧版本,机器上已经装了新的 —— 等重启」。什么时候重启是我的事,但得有人提醒我。

监控每小时只采了 57 次

状态页上写着"每分钟采样一次",实际数字对不上:24 小时只该 1440 次,实测 1238 次。

原因是 timer 写的是 OnUnitActiveSec=60s——上一次启动之后 60 秒。脚本本身要跑 2 秒,于是每次都在漂移,一小时累积少 3 次。

改成绝对时间对齐:

[Timer]
OnCalendar=*:0/1
OnBootSec=45s
AccuracySec=5s

每个整分钟触发一次,和脚本跑多久无关。

后台入口不挂在导航上

后台首页没有任何菜单项指向它,得手输地址。不是忘了加,是有意的:这个页面能看到访问日志、能进游戏、能改分类,挂在导航上等于在告诉每个访客"这里有个后台"。

四张卡片是工具入口,下面四格是概览,最底下一行是警示——只有真的有问题时才出现,比如上面那个内核待重启、证书剩余不足 30 天、磁盘超 85%、WAL 异常增长、超过两天没备份。

没有问题的日子,这一行是空的。 这是有意的设计:一个天天亮黄灯的面板,最后会被无视,和没有告警一样。

这些检查的代价

四格数据每三十秒刷一次,每次约 20 毫秒。其中最贵的一步是查待装更新,所以它不在请求路径上跑,而是每六小时后台预热一次,页面直接读缓存。

代价是数字可能过期六小时。对「有没有待装更新」这个问题,六小时滞后可以接受;对「站点是否还活着」,一分钟都不能等,所以那部分指标必须实时。

另一个限制:这些检查全在应用进程里做。进程本身挂了,面板也跟着打不开——所以真正的生死判断交给了外部的一次性进程,那是监控那篇写的事。

内核装了但没在跑

系统升级之后,待更新数是 0,其它几项也都正常。

但 uname -r 和 /boot 里的内核对不上——新内核装好了,跑的还是旧的。

麻烦的是它没有标准信号。很多人以为 /var/run/reboot-required 会出现,实际上那是 needrestart 这类工具写的,内核包本身不写。

所以自己比对:

const running = os.release();              // 例如 6.12.88+deb13-amd64
const installed = readdirSync('/boot')
  .filter(f => f.startsWith('vmlinuz-'))
  .map(f => f.slice(8));                   // 每个内核一条 vmlinuz-*
const pending = installed.filter(v => v !== running);

有差异就在首页亮一条琥珀色提示:「内核跑的是旧版本,机器上已经装了新的——等重启」。

什么时候重启是我的事,但得有人提醒我。

顺带记一句:这种「装好了但没生效」的状态,在自检面板里比任何性能数字都重要。因为它是唯一一个只有主动查才能发现的问题——其它指标自己会报警,这个不会。