◈ 夜宵吧

每分钟采一次样,然后闭嘴

站点挂过一次半个多小时,是去看别的东西时才发现的。所以做了监控。

这篇记几个关键决定:为什么不用常驻进程、内存该看「可用」还是「空闲」、连续几次失败才算真的挂、以及一个差点漏掉的阈值 bug——它让某类告警永远不会触发。

如果你也要给一台小机器加告警,这几条能省掉我踩过的坑。

巡检流水线:定时器每分钟触发一次采集,采集结果每次都写进指标表,判定环节要连续 N 次失败才切换状态并发出告警
采集和判断分开:数据每次都留,告警只在状态翻转时发。

不做常驻进程

监控最自然的形态是常驻进程:循环、睡眠、采集、报警。

没这么做,理由是三条:

  1. 常驻要占三五十兆常驻内存,而它监控的对象里恰好包括「内存够不够」;
  2. 它自己崩了就没人知道——因为负责发现问题的那位已经不在了;
  3. 修它的时候要重启它,正好在需要它的时刻失去监控。

改成一次性进程,由定时器每分钟拉起:

[Timer]
OnBootSec=45s
OnUnitActiveSec=60s
AccuracySec=5s
Persistent=false

实测单次 CPU 1.152 秒、峰值 RSS 20.8 MB,跑完退出,内存全部归还。

Persistent=false 是有意的:错过的采样不该补跑。监控关心的是「现在」,回补一条十分钟前的记录只会污染曲线。

内存要看可用,不是空闲

第一版判断内存紧不紧张用了空闲内存,结果永远报警。

在一台跑了一阵的 Linux 上,MemFree 常年接近零——页缓存会把空闲内存吃满,这是设计如此,不是问题。

换成 MemAvailable(把可回收的缓存算回来)才正常。这个数字才是「还能再申请多少」的答案。

指标语义选错,比阈值选错更糟:一个整天误报的监控,两次之后就被无视了,等于没有。

磁盘用 fs.statfsSync('/') 现算,不解析 df 的输出——那个输出的人类可读格式一变就得跟着改。

怎么判断一个进程还活着

有个第三方服务,非特权用户用 ss -p 看不到端口归属(内核不把 socket 到 pid 的映射给非特权进程),所以没法「查端口属于谁」。

拆成三个都能观测到的事实:

const alive = pgrep(svc.name)              // 进程名在
           && ssWithPort(svc.port)         // 端口在监听
           && svc.port === portFromConfig; // 端口号和预期一致

三条同时成立才算活着。不优雅,但每条都是确实能读到的。

端口从配置文件读,不从进程命令行猜——那个服务支持自动换端口,硬编码会误报。

连续两次才算失败

第一版是「一发现异常就发信」。

结果是网络抖一下收到两封:一封说挂了,一封说好了,间隔四十秒。这种信看两次就没人看了。

改成按连续次数判定:

项 需要的连续失败次数 理由
站点进程 / 网页 / 代理 2 偶发超时很常见
内存 / 磁盘 3 这两个指标本身变化慢
响应变慢 5 慢的判定最主观,最容易抖

恢复则立刻生效:失败可能是偶然,成功不可能是偶然。

再加一层:同一种故障 30 分钟内反复开关超过 2 次,只发第一次通知。

差点漏掉的 bug

上线两天后自查,发现「响应变慢」这一类告警永远不会触发。

代码是这样的:

const rows = S.recentRows(cfg.failThreshold + 1);  // 取 3 条
const n = streak('slow', rows, cfg);               // 但 slow 的阈值是 5
if (n >= 5) open('slow');

取样条数按「最短的那个阈值」算的,而响应变慢的阈值是 5。三条样本永远凑不出五连败。

这种 bug 不报错、不崩、也不产生任何异常日志。它的表现是**「从来没出过这种问题」**——而实际上它从上线起就没在观察。

修法是把取样条数取成所有阈值里的最大值,并且写死一个下限:

const rows = S.recentRows(Math.max(cfg.failThreshold + 1, 6));

探活把自己的统计搞坏了

监控每分钟发三个请求:直连应用端口、经网关、走公网域名绕回来。

三个都会写进访问日志。一天 4320 条,于是那个「今日访问 2170 次」的数字里绝大部分是自己。

修法很轻:探针带一个自定义请求头,记录函数看到就跳过。

代价是这个头必须由网关透传,否则走网关的那条探针会丢掉它。改完把那批自访问记录一次性删掉(带确认条件,只删本机加命令行 UA 的那些)。

手写邮件客户端

发信本来想引库,最后手写了——和数据库驱动同样的理由:为一件很窄的事引一整棵依赖树。

协议本身不长,花时间的是几个细节:

  • 主题里的中文要按 RFC 2047 编码成 =?UTF-8?B?...?=,否则很多客户端显示乱码;
  • 正文一律 base64,一次避开三类问题:八位字符、以点号开头的行(会被当成结束符)、单行超长(上限 998 字符);
  • 多行应答要靠分隔符判断「结束没有」。服务端会回 250-xxx 若干行再接 250 xxx,只认「三位数字加空格」的那一行;
  • 端口 465 是隐式 TLS(连上就握),587 是明文连上再 STARTTLS,两条路径都要走。

不支持附件、抄送、DKIM——告警邮件用不到。

现在的样子

采集写入两张表:metrics(一行一次采样,含负载、内存、磁盘、三个探针的结果码与耗时)和 incidents(一次故障一行,开时间、闭时间、已通知标记)。

大屏上四张卡:整体状态、24 小时可用率、平均响应(本机与外网分开)、最后一次采样。下面是响应曲线、24 个小时格、最近事件。

保留 30 天,每次采样顺手清理过期行。

页面上那个可用率 84% 是真实的——我自己重启服务的次数都算在里面,没有做排除。

这套东西的边界

它不能替你看。 告警能说「502 了」,说不出「文章读起来别扭」。可用率 100% 和站点有用是两件事。

阈值是猜的。 内存 45%、磁盘 80%、响应 3000 毫秒——这三个数没有理论依据,是先跑一周看分布再定的。换一台机器就得重定。

采样间隔就是盲区。 每分钟一次,意味着 59 秒内的抖动会被折叠成一条记录。要更细就得更密的定时器,而定时器本身也消耗资源。

可用率是保守的。 大屏上那个 84% 把服务重启的时间都算了进去,没有做排除。美化它很容易,但那样它就不再是可信指标了。