从一条「任务没跑完」的告警说起
站点原来一切正常,某天开始陆续出现异常:新文章发出去十分钟还没进 sitemap,评论通知邮件延迟半小时才到,图片的 WebP 转换任务堆着不动。查每一条链路都「看起来正常」——cron 在跑、队列进程活着、日志没报错。但整体就是慢半拍。
这类问题最难受的地方在于:它不在任何一个组件的监控视野里。监控告警系统只会在「进程死了」「错误率飙升」时响,而队列积压是「进程活着、任务在办、但办得比产生得慢」。这种状态可以持续几天不报警,直到用户明显感知。
本文讲的是我给自己这套单人维护的小站定的一套最小可用健康度方案:不算完美监控,但能让我每天早上花两分钟就知道「这台机器今天还健康吗」,并且这套东西的总成本不到 100 行脚本。
先把「健康」拆成可测的六件事
「监控告警」这个词太大了,容易一上来就装 Prometheus + Grafana + Alertmanager,然后把两天时间耗在调面板上。对个人站来说,先想清楚要监测什么更划算。我把「健康」拆成六类,每一类只用一个数字代表:
| 类别 | 代表指标 | 多久看一次 | 报警阈值 |
|---|---|---|---|
| 活着 | HTTP 200 + 正文含关键字 | 每分钟 | 连续 3 次失败 |
| 资源 | 根分区使用率 / 内存可用 / load1 | 每 5 分钟 | 85% / 10% / 核数×2 |
| 服务 | Nginx、MySQL、PHP-FPM、Redis 的 active 状态 | 每 5 分钟 | 任一非 active |
| 数据 | 备份文件更新日期、大小变化 | 每小时 | 超过 26 小时未更新 |
| 业务 | 队列积压数、定时任务耗时 | 每 5 分钟 | 积压 > 100 或耗时 > 2 倍基线 |
| 外部 | 域名解析、证书剩余天数、被墙检测 | 每天 | 证书 < 21 天 |
关键原则:每一类只保留一个数字,而且这个数字必须能用一个 shell 命令拿到。如果一个指标需要写 30 行 SQL 或者装一个 exporter 才能取到,那它在单人维护的环境里活不过一个月——不是因为难,而是因为「出问题时你自己都不想去看它」。
下面按这六类,逐条讲怎么测、阈值怎么定、以及我实际踩过的误判。
第一类:活着——不能只看 200
最简单的探针是 curl -I 看状态码,但这有三种典型的假阳性:
假阳性一:Nginx 活着、PHP 死了,仍然返回 200。如果站点目录里有静态的 index.html,或者 try_files 兜底到了缓存页,Nginx 会直接返回 200,用户看到的却是过期内容。所以探针必须落到真正走 PHP/数据库的页面,并且校验正文内容。
假阳性二:CDN 的缓存页在替源站应答。如果探针走公网域名,命中的可能是 CDN 边缘缓存,源站已经挂了也返回 200。这时探针要分两路:一路走 CDN(模拟用户),一路走 --resolve 直连源站 IP(`curl --resolve www.zz1984.com:443:1.2.3.4`),两者都 OK 才算健康。
假阳性三:200 但内容是错误页。PHP 报致命错误时,框架可能返回 200 + 一段「系统繁忙」。所以要 grep 一个只在正常页出现的字符串(比如文章页里的某个固定 H2 标题),而不是只看状态码。
#!/bin/bash
# /opt/health/probe.sh —— 每分钟由 cron 调用
set -uo pipefail
URL="${1:-https://www.zz1984.com/}"
EXPECT="${2:-草根站长}"
TMP=$(mktemp)
trap 'rm -f "$TMP"' EXIT
code=$(curl -s -o "$TMP" -w '%{http_code}' --max-time 15 -A 'HealthProbe/1.0' "$URL")
size=$(wc -c < "$TMP")
hit=$(grep -c "$EXPECT" "$TMP" || true)
if [ "$code" != "200" ] || [ "$size" -lt 2000 ] || [ "$hit" -eq 0 ]; then
echo "FAIL url=$URL code=$code size=$size keyword=$hit"
exit 1
fi
echo "OK url=$URL code=$code size=$size time=$(date +%s)"
注意 -w '%{http_code}' 与 -o 必须同时用——只用 -I 时很多 WAF 会对 HEAD 请求直接返回 405,造成误报。--max-time 15 是必须的,否则 DNS 卡死时探针会挂在那里永远不返回,最后把 cron 进程都堆起来。
还有一个细节:set -e 不要加在这里。grep -c 找不到时返回 1,加了 set -e 脚本会中途退出,你会得到「什么都没输出」而不是一条 FAIL 记录。用 set -uo pipefail 更合适。
第二类:资源——三个数字的坑
根分区使用率:用 df -P 而不是 df -h。-P(POSIX 输出)保证每行固定六个字段且长设备名不折行,脚本解析才稳。折行是历史上最常见的误判来源:设备名太长时 df -h 会把数值挤到下一行,awk '{print $5}' 拿到的就是乱码。
df -P / | awk 'NR==2 {gsub("%","",$5); print $5}'顺带一定要测 inode:df -Pi / | awk 'NR==2 {gsub("%","",$5); print $5}'。inode 满的时候磁盘可能只用了 30%,但任何写操作都返回 No space left on device——这个坑我踩过一次,排查了半小时。
内存可用量:free 的 free 列包含可回收的 page cache,看起来很少其实是正常的。available 列才是「还能给新进程用多少」。判据用 available 占总量比例,低于 10% 才告警。
free -m | awk 'NR==2 {printf "%.1f", $7/$2*100}'但要注意:Linux 会在内存紧张时自动回收 cache,所以「available 低」本身不等于要出事。真正该看的是 /proc/vmstat 里的 pgscan_direct——非零说明进程被迫同步回收内存,这才是真压力的证据。日常巡检里加上这一条,能提前半天发现内存泄漏:
awk '/pgscan_direct/ {print $2}' /proc/vmstatload average:load1 的绝对数字毫无意义,必须除以核数。4 核机器 load 8 就是 2.0,属于「排队但还能忍」;2 核机器 load 8 就是 4.0,已经明显卡了。判据用 load1 / nproc > 1.5 连续三次。另外单看 load 会误判:磁盘 IO 卡死时 load 会飙到几十但 CPU 只有 5%(D 状态进程也算 load),这种情况要看 vmstat 1 5 的 wa 列。
cores=$(nproc)
load1=$(awk '{print $1}' /proc/loadavg)
awk -v l="$load1" -v c="$cores" 'BEGIN {printf "load=%.2f cores=%d ratio=%.2f\n", l, c, l/c}'
第三类:服务——systemctl 的 is-active 不够
systemctl is-active nginx 只回答「进程在不在」,不回答「能不能干活」。三种典型状态它是绿的:
状态一:进程在、端口没监听。配置写错时,Nginx 主进程可能活着但 worker 全挂了。这种要测端口:ss -lnt 里有没有 443。
状态二:进程在、reload 没生效。改了配置忘了 nginx -t,reload 静默失败,旧配置继续跑。这种只能靠「用探针验证新配置的特征」,比如改了某个响应头就去 curl 看头在不在。
状态三:服务反复重启(crash loop)。is-active 在两次重启之间可能刚好报 active,但 NRestarts 在飙升。这个指标藏在 systemctl show -p NRestarts 里,非常值得监控——它是「服务在慢性自杀」的唯一信号。
for svc in nginx mysql php8.3-fpm redis-server; do
state=$(systemctl is-active "$svc" 2>/dev/null)
restarts=$(systemctl show -p NRestarts --value "$svc" 2>/dev/null)
printf '%-16s %-10s restarts=%s\n' "$svc" "$state" "${restarts:-0}"
done
还有一条容易被忽略:systemctl is-failed 会返回 failed 即使服务后来被手动拉起来过。看 is-active 更准,配合 NRestarts 就够判断。
第四类:数据——备份的「三看」
备份是最容易「看起来在做、实际早断了」的东西。我的脚本检查三件事,缺一件都会静默失败:
看文件在不在且够新。find /backup -name 'db-*.sql.gz' -mtime +1 能返回结果就是有问题(说明最新的一份超过 24 小时)。但只看时间不够,因为 cron 跑了一个晚上失败,文件还是昨天那个。
看大小有没有异常变化。正常的每日备份大小会在 ±20% 内波动。如果今天只有昨天的 5%,说明 mysqldump 中途失败但写了半个文件;如果比昨天大 10 倍,说明日志表爆了。用 stat -c %s 比较就行,不需要精确。
看文件完整性。gzip 文件用 gzip -t 验证,一行命令就能发现截断。这是唯一能证明「备份可用」而不只是「备份存在」的方法。
latest=$(ls -t /backup/db-*.sql.gz 2>/dev/null | head -1)
[ -z "$latest" ] && { echo "FAIL no backup"; exit 1; }
age_h=$(( ( $(date +%s) - $(stat -c %Y "$latest") ) / 3600 ))
size=$(stat -c %s "$latest")
gzip -t "$latest" 2>/dev/null && ok=1 || ok=0
echo "backup=$latest age=${age_h}h size=$((size/1048576))MB intact=$ok"
阈值我定的是 26 小时而不是 24,因为 cron 有执行抖动(机器重启时 timer 补跑、备份本身跑 40 分钟),24 小时必然会有误报。留 2 小时余量刚好。
第五类:业务——队列积压才是真信号
这一类最容易被跳过,但它是「用户能感知到的问题」最早出现的地方。我的站有三条异步链路:markdown 转 HTML 的文章预处理、图片转 WebP、评论通知邮件。三条都用简单的「表 + 状态字段」实现,没有引入消息队列中间件——对一个单人站来说,Redis list 或 MySQL 表足够。
监测方式就是数积压行数,但阈值不能拍固定数字,要按「最老一条的年龄」判。因为积压 500 条如果每 5 秒处理一条也就 40 分钟,如果积压 50 条但每 10 分钟一条,就是 8 小时延迟。
-- 队列积压与最老任务年龄(秒)
SELECT COUNT(*) AS backlog,
TIMESTAMPTZ_DIFF() AS dummy
FROM jobs WHERE status = 'pending';实际上 MySQL 里写成这样更直接:
SELECT COUNT(*) AS backlog,
COALESCE(TIMESTAMPDIFF(SECOND, MIN(created_at), NOW()), 0) AS oldest_sec
FROM jobs WHERE status = 'pending';判据:oldest_sec > 1800(半小时)就告警,比任何数量阈值都准。这条规则帮我抓到过一次真实的 bug:某个转换任务因为遇到一张 CMYK 色彩空间的图片抛异常,重试逻辑把它放回队列但没改状态,于是它被反复取出、反复失败,积压数不涨但整个队列卡在那个任务上。如果只监控数量,永远发现不了。
定时任务耗时同理:systemd 的 timer 不记录耗时,得自己在脚本里埋时间戳。我做的最小方案是每个任务开头写开始时间到 /var/lib/tasks/,结尾写结束时间,巡检脚本比较「最近一次耗时 / 最近 7 次中位数」。超过 2 倍就报——这个阈值能抓住「数据量增长导致变慢」和「外部依赖变慢」两类问题,误报率很低。
第六类:外部——最容易被忘,也最容易出大事故
前三类都是「机器内部」的检查,但真正会让站点从搜索引擎里消失的问题都在外部:域名过期、证书过期、DNS 解析错误、被安全软件标记。
证书:不要依赖 certbot 的自动续期报告,直接读线上证书的到期时间,这是唯一可信的口径:
echo | openssl s_client -servername www.zz1984.com \
-connect www.zz1984.com:443 2>/dev/null \
| openssl x509 -noout -enddate -subject -issuer然后再算剩余天数。-servername 必须加,否则拿到的是默认证书(多域名站点上会拿到别人的)。21 天阈值是有理由的:Let's Encrypt 证书 90 天有效,续期在剩 30 天时触发,加上一次失败重试的窗口,21 天就是这个「续期机制已经坏了两周」的信号。
DNS:用两个不同的公共 DNS 分别解析,结果必须一致。不一致说明你改了记录但还没传播完,或者某个 DNS 服务商出问题:
dig +short www.zz1984.com @1.1.1.1
dig +short www.zz1984.com @8.8.8.8外部可用性:如果站点套了 CDN,还要测「从外部看起来是否正常」。我用的是一个极简办法:从第三方的 HTTP 探测服务(比如 https://httpbin.org/status/200 之类的公共接口反向使用,或者任何一个在线可用性检测 API)拉一次自己的首页,比对状态码。但这需要外部服务,成本较高,单人站可以用「换个网络环境手动访问」代替,不必自动化。
把六类合成一个日报:一行输出,一眼看完
单点监控最致命的失败模式是告警疲劳:一开始设了 30 条规则,每天响 20 次,一周后你把通知静音了,然后真正的事故来的时候你什么都没看到。我的做法是:只有「今天必须动手」的问题才推送,其余全部进日报。
日报格式我刻意做成一行一个指标,全部通过就一行 OK:
#!/bin/bash
# /opt/health/daily.sh
set -uo pipefail
out=""
die() { out="${out}[$1] "; }
dfp=$(df -P / | awk 'NR==2 {gsub("%","",$5); print $5}')
[ "$dfp" -lt 85 ] || die "disk=${dfp}%"
ino=$(df -Pi / | awk 'NR==2 {gsub("%","",$5); print $5}')
[ "$ino" -lt 85 ] || die "inode=${ino}%"
mem=$(free -m | awk 'NR==2 {printf "%d", $7/$2*100}')
[ "$mem" -ge 10 ] || die "mem_avail=${mem}%"
ratio=$(awk -v l="$(cut -d' ' -f1 /proc/loadavg)" -v c="$(nproc)" \
'BEGIN {printf "%d", l/c*100}')
[ "$ratio" -lt 150 ] || die "load_ratio=${ratio}%"
for svc in nginx mysql php8.3-fpm; do
systemctl is-active --quiet "$svc" || die "svc=${svc}"
done
bk=$(ls -t /backup/db-*.sql.gz 2>/dev/null | head -1)
if [ -z "$bk" ]; then
die "backup=none"
else
bh=$(( ( $(date +%s) - $(stat -c %Y "$bk") ) / 3600 ))
[ "$bh" -lt 26 ] || die "backup_age=${bh}h"
fi
cert_days=$(echo | openssl s_client -servername www.zz1984.com \
-connect www.zz1984.com:443 2>/dev/null \
| openssl x509 -noout -enddate \
| sed 's/notAfter=//' | xargs -I{} date -d {} +%s \
| awk -v now="$(date +%s)" '{printf "%d", ($1-now)/86400}')
[ "${cert_days:-0}" -gt 21 ] || die "cert_days=${cert_days}"
if [ -z "$out" ]; then echo "OK disk=${dfp}% mem=${mem}% load=${ratio}% cert=${cert_days}d"; else echo "WARN $out"; fi
这个脚本 40 行左右,把所有检查串起来,输出要么是 OK ... 要么是 WARN [disk=91%] [svc=mysql]。cron 每天早上 8 点跑一次,结果发到我手机上。全部健康时我扫一眼那行 OK 就完事,有问题时方括号里的内容直接告诉我该干什么。
告警的三个铁律
铁律一:只推送「今天必须动手」的事。磁盘 60% 不该推(虽然它每天涨 1%),磁盘 91% 必须推。证书还剩 60 天不该推,还剩 12 天必须推。判断标准很简单:如果我现在不处理,明天会不会出事。会,才推。
铁律二:状态变化才发,不重复发。一个磁盘从 91% 到 92% 没必要再报一次。我用一个状态文件记住上次的告警内容,内容没变就不发,变了才发。恢复时也发一条「已恢复」——这条比告警本身更重要,因为它告诉你「可以不用管了」,否则你会一直惦记着。
铁律三:告警必须带上下文。「disk 91%」没用,「disk 91%,/var/log 占 12G,最大的是 /var/log/mysql/error.log 8G」才有用——收到短信的一瞬间就知道怎么办。这需要多写十几行,但它把「排查时间」从 20 分钟压到 1 分钟。对单人维护来说,这是最划算的投入。
要不要上 Prometheus?
经常有人问这个。我的判断是基于「你有几个指标要看」:
如果指标数量少于 20 个、而且都是离散的快照值(磁盘、内存、证书天数),那么 cron + shell + 一行输出 就够,投入 2 小时,维护成本接近 0。
如果你想看趋势(比如「过去 30 天磁盘增长曲线」),那么 Shell 就不好用了,需要时序库。这时候 node_exporter + Prometheus + Grafana 三件套是标准答案,内存占用大约 300MB 起——对 2G 内存的小机器是一笔真实开销。
中间的折中方案是我现在用的:关键指标每天写一行到 CSV,需要看图时用 gnuplot 或者干脆丢进表格软件看。一行 CSV 的价值被严重低估了——它让你能回答「这台机器的磁盘在过去三个月是怎么涨的」这类问题,而这是任何实时监控面板都不擅长的(保留期通常 15 天)。
echo "$(date +%F),$(df -P / | awk 'NR==2 {gsub("%","",$5); print $5}'),$(free -m | awk 'NR==2 {print $7}'),$(cut -d' ' -f1 /proc/loadavg)" \
>> /var/log/health/daily.csv最后:健康度的目标是「敢重启」
所有监控的终极检验只有一个:你敢不敢在业务时间里重启这台机器?如果答案是「不敢,我不知道重启后能不能起来」,那说明这些监控只是摆设,真正的问题(配置漂移、依赖没记录、单点故障)一个都没被解决。
我给自己定的验收标准是:对一个无状态的 Web 服务,reboot 之后 90 秒内站点应该自动恢复,所有服务 active、探针全绿、队列继续消费。为了达到这个状态,比监控更重要的是把「开机自启」「依赖顺序」「配置基线」理清楚——健康度 Monitoring 只能告诉你「现在坏了」,不能替你修好。
所以我把这套脚本的输出固定成一个数字:「上次成功重启到全绿用了多少秒」。这个数字每月测一次,它在变小,说明这套系统在变健康;它在变大,说明又有什么东西偷偷变成了手工步骤。这比任何花哨的仪表盘都更能反映真实状况。