做站久了都会有一种奇怪的错觉:后台能打开、首页能打开、自己用浏览器访问一切正常,于是默认「站点是好的」。直到某天在手机上刷到自己网站的一条搜索结果,点进去是空白页,才发现这个问题已经存在三天了。
这种故障的特点很统一:不影响你自己访问,只影响一部分访客或爬虫。可能是某个模板的条件判断把未登录用户的内容整段吞掉了,可能是一次 CSS 构建把主栏宽度写成了 0,也可能是 CDN 对某个地区的节点返回了错误页。你用管理员身份登录后台,一切都好;以游客身份访问,页面就残缺。
这篇讲两件事:怎么给自己搭一个每天自动跑的站外哨兵(不依赖站点自身是否正常),以及怎么用内容长度的历史基线去抓「页面能打开但内容没了」这种最难发现的故障。
一、为什么后台监控抓不到这类问题
大部分站长用的监控是「URL 返回 200 就绿灯」。这个判定太粗,粗到几乎无法发现真正的问题。下面这些故障,返回码全是 200:
- 模板条件写错,正文区域被整块隐藏(HTML 里还有,但 CSS display:none,或者被 PHP 判断跳过了)
- 数据库连接失败走了兜底模板,页面显示「暂无内容」,状态码 200
- 分页组件失效,第 2 页之后全部返回第 1 页的内容
- CDN 缓存了一份错误页,源站是好的,但边缘节点一直返回旧内容
- 图片全部 404,但 HTML 本身正常,视觉上「文章就是没图」
- 移动端 UA 被单独路由到一个坏掉的模板
这些问题的共同点是:结构还在,内容变了。所以监控不能只看状态码,至少还要看三个维度:响应体大小、关键选择器是否存在、以及和昨天的差异。
二、站外哨兵的设计原则
监控这件事有个悖论:如果监控脚本和站点在同一台服务器上,那么服务器挂了监控也就挂了,你永远收不到告警。所以第一条原则是监控必须在站外。可以用另一台 VPS、一台常开的小主机,甚至 GitHub Actions 的定时任务。
第二条原则是用游客身份,而不是管理员身份。这是最容易被忽略的一条。很多故障只在未登录状态下出现(缓存策略不同、会员内容判断、A/B 实验分组)。用 curl 直接请求,天然就是游客,不需要额外处理。
第三条原则是告警要能区分「全站挂了」和「某一篇坏了」。全站挂了你知道去哪看;单篇坏了你往往不知道是哪篇,所以要把具体的 URL 和现象一起推给你。
第四条原则:失败要重试两次再告警。网络抖动造成的假告警会消耗你对告警的信任,几次之后你就开始忽略它了,那监控就白做了。
三、先定基线:今天的内容长度是多少
所有「内容变少」类故障的检测,都建立在「知道正常时是多少」的基础上。所以第一步不是写监控,而是给现有页面建立一份内容长度基线。
取正文长度的方式有很多,最稳的不是数 HTML 字节数(模板改一下就会变),而是数正文区域里的中文字符数。中文站这个指标非常稳定:一篇文章改个标题、加个推荐位,正文字符数几乎不动;而一旦正文被吞掉,字符数会直接掉到几十。
#!/usr/bin/env bash
# /opt/sentinel/extract_text.sh —— 从 HTML 中提取正文纯文本并统计字符数
# 用法: extract_text.sh https://www.example.com/1234.html
# 依赖: curl, python3(仅用标准库)
set -euo pipefail
url="$1"
curl -sL --max-time 20 --compressed \
-A 'Mozilla/5.0 (compatible; Sentinel/1.0)' \
"$url" \
| python3 -c '
import sys, re, html
raw = sys.stdin.read()
# 1) 去掉脚本与样式,这两块最容易污染统计
raw = re.sub(r"(?is)<script.*?</script>", " ", raw)
raw = re.sub(r"(?is)<style.*?</style>", " ", raw)
# 2) 粗暴地剥标签,保留换行做段落边界
text = re.sub(r"(?s)<[^>]+>", "\n", raw)
text = html.unescape(text)
# 3) 只统计中日韩字符 + 拉丁字母 + 数字,滤掉空白和符号
chars = re.findall(r"[\u4e00-\u9fff\u3040-\u30ffA-Za-z0-9]", text)
print(len(chars))
'这个脚本的输出是一个纯数字,方便直接比较。注意这里必须去掉 script 和 style:很多站点的内联统计代码、广告脚本、JSON 数据会带上大量字符,正文被吞了它还在,统计就会失真。
拿到长度之后,把「正常范围」写进配置。我一般用相对阈值而不是绝对值:低于基线的 60% 就告警。这个比例足够敏感,又不会被一次正常的文章编辑触发。
# /opt/sentinel/targets.txt —— 每行:URL 最低字符数
# 初始基线用 extract_text.sh 手工跑一遍填进来,之后按季更新
https://www.example.com/ 1200
https://www.example.com/about.html 600
https://www.example.com/1234.html 2500
https://www.example.com/1235.html 1800为什么首页和关于页也要监控?因为这两页是最能反映「模板整体坏掉」的地方。首页内容崩了,往往意味着主题或缓存层出了问题,影响的是全站。
四、每日哨兵脚本
下面这个脚本我做成了单文件,跑完输出一份可读的报告,并且只在有异常时退出码非 0。这样可以接 systemd timer、cron,或者直接挂在 GitHub Actions 里用邮件通知。
#!/usr/bin/env bash
# /opt/sentinel/daily.sh —— 每日站点哨兵
# 检查:HTTP 状态、TTFB、响应体大小、正文字符数、关键元素、图片可达性
set -uo pipefail
BASE="https://www.example.com"
TARGETS="/opt/sentinel/targets.txt"
REPORT="/var/log/sentinel/$(date +%F).log"
mkdir -p "$(dirname "$REPORT")"
UA='Mozilla/5.0 (compatible; Sentinel/1.0)'
fail=0
log() { printf '%s %s\n' "$(date '+%F %T')" "$*" | tee -a "$REPORT"; }
bad() { log "[FAIL] $*"; fail=$((fail+1)); }
ok() { log "[ OK ] $*"; }
# --- 取一次,复用给多个检查 ---
fetch() { # fetch url outfile -> 打印 "http_code ttfb size"
curl -sL --max-time 25 --compressed -A "$UA" \
-o "$2" -w '%{http_code} %{time_starttransfer} %{size_download}' "$1"
}
check_page() {
local url="$1" minchars="$2" tag="$3"
local out; out="$(mktemp)"
local stat; stat="$(fetch "$url" "$out")"
local code ttfb size
read -r code ttfb size <<<"$stat"
# 1) 状态码:允许 200 与 304,其余都算异常
if [ "$code" != "200" ] && [ "$code" != "304" ]; then
bad "$tag 状态码 $code $url"; rm -f "$out"; return
fi
# 2) TTFB 超过 2 秒记为慢(阈值按自己站点实际情况调整)
if awk "BEGIN{exit !($ttfb > 2.0)}"; then
log "[WARN] $tag TTFB ${ttfb}s 偏慢"
fi
# 3) 响应体太小,基本都是错误页或空壳
if [ "$size" -lt 2048 ]; then
bad "$tag 响应体仅 ${size} 字节,疑似错误页 $url"
fi
# 4) 正文字符数对比基线
local chars
chars="$(bash /opt/sentinel/extract_text.sh "$url" || echo 0)"
if [ "$chars" -lt "$minchars" ]; then
bad "$tag 正文字符数 $chars 低于基线 $minchars $url"
else
ok "$tag 字符数 $chars 状态 $code 大小 $size"
fi
# 5) 关键元素存在性:正文容器、标题、分页
grep -qi '<h1' "$out" || bad "$tag 缺少 h1 标题 $url"
grep -qi 'meta name="description"' "$out" || bad "$tag 缺少 meta description $url"
# 6) 图片可达性抽样:最多查 5 张
grep -oE '<img[^>]+src="[^"]+"' "$out" 2>/dev/null \
| sed -E 's/.*src="([^"]+)".*/\1/' | head -5 | while read -r img; do
case "$img" in
http*) iu="$img";;
//*) iu="https:$img";;
/*) iu="$BASE$img";;
*) continue;;
esac
ic=$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 -A "$UA" "$iu")
[ "$ic" = "200" ] || log "[WARN] 图片 $ic $iu"
done
rm -f "$out"
}
# --- 逐条跑 targets,失败的重试一次再判定 ---
while read -r url minchars; do
case "$url" in ''|\#*) continue;; esac
check_page "$url" "$minchars" "PAGE"
done < "$TARGETS"
# --- 额外:robots 与 sitemap 是否可访问且内容正常 ---
rc=$(curl -s -o /tmp/robots.txt -w '%{http_code}' -A "$UA" "$BASE/robots.txt")
[ "$rc" = "200" ] && grep -qi 'sitemap' /tmp/robots.txt \
&& ok "robots.txt 正常" || bad "robots.txt 异常(code=$rc)"
sc=$(curl -s -o /tmp/sitemap.xml -w '%{http_code}' -A "$UA" "$BASE/sitemap.xml")
if [ "$sc" = "200" ] && grep -q '<urlset\|<sitemapindex' /tmp/sitemap.xml; then
n=$(grep -c '<loc>' /tmp/sitemap.xml || true)
ok "sitemap 正常,条目数 $n"
[ "$n" -lt 5 ] && bad "sitemap 条目数异常偏少($n)"
else
bad "sitemap 异常(code=$sc)"
fi
log "----- 结束,失败项 $fail -----"
exit $((fail > 0))几个设计上的取舍值得说明:
- 用 curl 而不是 headless 浏览器。浏览器能执行 JS,更接近真实访客,但资源占用高、跑得慢、还容易被反爬拦。每天的哨兵追求的是「稳定产出信号」,用 curl 看服务端渲染的结果足够,而且能顺便验证「爬虫看到的是什么」——这才是 SEO 关心的部分。
- 图片只抽样 5 张。全查的意义不大,因为图片 404 通常是批量性的(上传目录权限或 CDN 配置),抽 5 张就能发现。
- TTFB 只 WARN 不 FAIL。慢是渐变问题,不该每天吵醒你;真正该告警的是「坏了」。
五、把「页面能打开但内容没了」抓出来
第三节的字符数阈值能处理「完全空白」,但更隐蔽的一种情况是内容还在,只是别人看不见。比如主栏被 CSS 压成 0 宽度、或者正文被一个 overflow:hidden 的容器裁掉。这时候 HTML 里字符数一点没少,阈值检测完全无效。
要抓这一类,必须看渲染结果。方法是每天用一次无头浏览器截图,然后对比相邻两天的截图差异。差异超过阈值就人工看一眼。
# 每日截图(以 Chromium 无头模式为例,只截一张首页,控制在几秒内)
CHROME=$(command -v chromium || command -v chromium-browser || command -v google-chrome)
"$CHROME" --headless=new --disable-gpu --no-sandbox --hide-scrollbars \
--window-size=1366,2400 --screenshot="/var/log/sentinel/shot-$(date +%F).png" \
"https://www.example.com/" >/dev/null 2>&1
# 和昨天的截图比像素差异(ImageMagick compare 输出 AE = 不同像素数)
if [ -f "/var/log/sentinel/shot-$(date -d yesterday +%F).png" ]; then
diff_px=$(compare -metric AE \
"/var/log/sentinel/shot-$(date -d yesterday +%F).png" \
"/var/log/sentinel/shot-$(date +%F).png" null: 2>&1 || true)
echo "今日首页与昨日差异像素: $diff_px"
fi首页每天都会因为新文章而变化,所以直接比像素会天天触发差异。更实用的做法是只截一个内容稳定的页面(比如「关于」页或者某篇固定文章),并且对比时先做模糊化,只对结构变化敏感。如果不想引入截图这一套,还有一个更轻的替代方案:抓取页面的关键元素尺寸。
# 用浏览器取正文容器的实际渲染宽高(比截图轻,而且数字可直接比较)
"$CHROME" --headless=new --disable-gpu --no-sandbox --dump-dom \
"https://www.example.com/about.html" 2>/dev/null \
| grep -oE 'class="[^"]*(content|article|main)[^"]*"' | head -5
# 若装了 puppeteer/playwright,可以直接取 clientWidth:
# await page.$eval('main', el => el.clientWidth)我的实际选择是:日常用字符数 + 关键选择器,每周人工看一眼首页截图。引入完整的视觉回归成本太高,对个人站不划算;但每周一次肉眼扫一眼,能挡住 90% 的「看起来打不开」的问题。
六、告警通道的选择
监控的价值有一半在告警通道上。通道选错了,等于没监控。我试过几种,结论如下:
- 邮件:可靠,但你不会及时看。适合日报,不适合故障告警。
- Telegram / 企业微信 / 钉钉机器人:即时,配置简单,一个 webhook 就够。适合故障告警,个人站首选。
- 短信:最可靠,但要花钱,而且每天被吵醒会很快让你关掉它。
- ntfy 之类自建推送:不依赖第三方,但要自己保证它可用,属于「监控的监控」问题。
我的组合是:日常结果写日志文件(周报时翻),失败时推到一个即时通道,并且同一个故障只推一次,避免重复轰炸。
#!/usr/bin/env bash
# /opt/sentinel/notify.sh —— 带去重的告警推送
# 用法: notify.sh "标题" "正文";同一标题 6 小时内只推一次
set -uo pipefail
title="$1"; body="$2"
state="/var/lib/sentinel/last-$(echo "$title" | md5sum | cut -c1-8)"
mkdir -p "$(dirname "$state")"
now=$(date +%s)
if [ -f "$state" ]; then
last=$(cat "$state")
if [ $((now - last)) -lt 21600 ]; then
echo "抑制重复告警: $title"; exit 0
fi
fi
# 示例:ntfy 自建或公共服务,换成自己的 topic
curl -s --max-time 10 \
-H "Title: $title" -H "Priority: high" -H "Tags: warning" \
-d "$body" "https://ntfy.sh/你的私有topic" >/dev/null
echo "$now" > "$state"提醒一句:上面那行 state= 用的是半角括号的命令替换。如果你从别的文章里复制过类似的写法,一定要检查括号、引号、连字符有没有被替换成全角字符——全角括号在 shell 里不会被执行,会原样当成文件名的一部分,脚本不会报错,但去重逻辑会整个失效。这是中文技术文档里最常见的一个坑,我自己也中过招。
七、接上定时任务
用 systemd timer 比 cron 好排查:有日志、有状态、能补跑。下面是一个最小可用配置。
# /etc/systemd/system/sentinel.service
[Unit]
Description=Daily site sentinel
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/sentinel/daily.sh
# 失败时推送,成功时静默
ExecStopPost=/bin/sh -c 'if [ "$EXIT_STATUS" != "0" ]; then /opt/sentinel/notify.sh "站点哨兵发现异常" "见 /var/log/sentinel/$(date +%F).log"; fi'
User=root
# /etc/systemd/system/sentinel.timer
[Unit]
Description=Run site sentinel every day
[Timer]
OnCalendar=*-*-* 07:30:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target# 启用并验证(一定要先手动 run 一次看日志)
systemctl daemon-reload
systemctl enable --now sentinel.timer
systemctl list-timers sentinel.timer
systemctl start sentinel.service; journalctl -u sentinel.service -n 50 --no-pagerPersistent=true 很关键:服务器在 07:30 处于关机状态(比如凌晨重启维护)时,开机后会补跑一次,而不是跳过一整天。这个参数能避免「刚好那天出问题、刚好没跑」的尴尬。
八、哨兵自己也要被监控
所有自制监控最后都会死在一个问题上:它自己悄悄不跑了,你不知道。我遇到过的原因包括:curl 版本升级后证书路径变了、脚本里硬编码的路径被移动、磁盘满导致日志写不进去而脚本 assert 失败、以及最经典的——某个 URL 302 到了别的站,脚本还在报 OK。
解决办法是让哨兵每次跑完都留下一个心跳文件,然后用一个独立机制检查心跳是否新鲜。这个独立机制不能是哨兵自己,最好是台外部服务(Uptime Kuma 的 push 监控、healthchecks.io 这类)。
# 脚本末尾加一行心跳(成功和失败都要写,只是内容不同)
echo "$(date +%s) fail=$fail" > /var/lib/sentinel/heartbeat
# 外部服务侧:简单的 push 请求即可
curl -fsS --max-time 10 "https://hc-ping.com/你的UUID/$fail" >/dev/null || truehealthchecks.io 这类服务的模型是:在预期时间没收到请求就告警。这正好补上了「脚本挂了不会自己报警」的缺口。免费额度对个人站完全够用。
九、一张表:不同故障该看哪个信号
最后把前面所有信号整理成一张表,出问题时按图索骥,比从头想快得多。
| 故障类型 | 最先异常的信号 | 快速验证方法 |
|---|---|---|
| 服务器宕机 / Nginx 挂了 | 状态码连接失败 | 从站外 curl,看是连接超时还是拒绝 |
| PHP-FPM 池满 | 状态码 502/504 + TTFB 飙高 | 查 FPM status 页的 active/idle process |
| 数据库连不上 | 200 但字符数骤降 | 页面里搜「暂无内容」类兜底文案 |
| 模板改坏 | 缺少 h1 / 关键选择器 | grep 关键容器 class 是否存在 |
| CSS 构建失败 | 字符数正常但截图异常 | 截图对比 / 取元素渲染宽度 |
| 图片 404 | 图片抽样检查 | curl 单张图,看磁盘与目录权限 |
| CDN 缓存脏数据 | 只有部分地区/节点异常 | 加随机查询串绕过缓存对比 |
| SSL 证书过期 | curl 报证书错误 | echo | openssl s_client 看 notAfter |
| robots 误屏蔽 | robots 检查失败 | 对比 robots.txt 的历史版本 |
这张表里我特别想强调「200 但字符数骤降」这一行。它是最难被发现、也最容易被误判为「没内容」的一类。只要基线建立起来,这类问题会变成最容易发现的一类。
十、落地顺序建议
如果你现在还没有任何监控,不要一次性把上面全建起来,按这个顺序做,每一步都能立刻产生价值:
- 先用
extract_text.sh把首页、关于页和最近五篇文章的字符数跑一遍,记进targets.txt。这一步半小时,先有基线。 - 写
daily.sh的核心逻辑(状态码 + 大小 + 字符数),挂到一个 systemd timer 上,日志写本地文件。先观察一周,不着急上告警。 - 把一周里出现的误报调掉。基线阈值、TTFB 阈值、图片抽样数量,这些都要按自己站点调,抄别人的参数一定误报。
- 接上即时告警 + 去重,加上心跳,把哨兵自己也纳入监控。
- 最后再考虑截图对比这类重方案。大部分个人站一辈子都不需要它。
监控这件事的投入产出比非常高:一次「文章全部变成空白但状态码 200」的故障,如果三天后才被发现,损失的是三天的抓取和收录;而上面这套东西写一次能用几年,总共不到两百行脚本。真正难的不是写脚本,是先建立起「正常是什么样」的基线。这一点想通了,剩下的都是体力活。