上周三早上打开博客,浏览器直接甩我一个红色警告页:证书已过期。
翻了下 certbot 日志,续期任务两个月前就静默失败了——那台机器我改过一次 nginx 配置,webroot 路径没对上,certbot 每次跑都报错,但报错只写进日志文件,没人看。站挂了大概十几个小时,我是靠自己手动打开博客才发现的。
这事让我很不爽。不是因为掉了点访问量,而是——我居然是全世界最后一个知道自己站挂了的人。
于是花了一个下午写了个监控脚本。没上 Prometheus,没装 Zabbix,就一个 Python 文件,跑在 cron 里,60 行左右。宕机、超时、证书快到期,都会主动来找我。
我的需求特别朴素,就三条:
-
- HTTP 状态码不是 200 → 告警
-
- 响应超过 5 秒 → 告警(慢等于半死)
-
- SSL 证书剩余天数 < 15 天 → 告警
-

不需要历史曲线,不需要 Web 面板。那些东西装起来占内存,我那台 2G 内存的轻量服务器已经跑着 MySQL 和 PHP-FPM 了,再塞个监控套件纯属自找麻烦。
HTTP 检测这块没什么难度:
import requests
def check_http(url, timeout=5):
try:
r = requests.get(url, timeout=timeout,
headers={"User-Agent": "maoge-monitor/1.0"})
if r.status_code != 200:
return False, f"状态码 {r.status_code}"
if r.elapsed.total_seconds() > 5:
return False, f"响应慢 {r.elapsed.total_seconds():.1f}s"
return True, "ok"
except requests.exceptions.Timeout:
return False, "请求超时"
except Exception as e:
return False, f"异常 {type(e).__name__}"
证书检测稍微绕一点,用标准库的 ssl 直接握手拿证书:
import ssl, socket
from datetime import datetime
def check_cert(host, port=443):
ctx = ssl.create_default_context()
with socket.create_connection((host, port), timeout=8) as sock:
# server_hostname 必须传,不然多站点的机器给你返回错证书
with ctx.wrap_socket(sock, server_hostname=host) as ssock:
cert = ssock.getpeercert()
expire = datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z")
days = (expire - datetime.utcnow()).days
return days

第一版我没写 server_hostname=host,测自己站好好的,测另一台跑了三个域名的机器时,返回的证书域名完全不对,剩余天数也是错的。
原因是那台机器一个 IP 挂多站,nginx 靠 SNI 分发。不带 SNI 握手,它就把默认站点(default_server)的证书扔给你。只要目标机器有虚拟主机,server_hostname 就是必填项,别省。
上线第一晚,凌晨 3 点收到两条告警,爬起来一看站好得很。就是运营商线路抖了一下,单次请求超时。
解决办法很土但有效:失败不立即报,隔 20 秒重试两次,三次全挂才算真挂。
import time
def check_with_retry(url, times=3, gap=20):
last = ""
for i in range(times):
ok, msg = check_http(url)
if ok:
return True, "ok"
last = msg
if i < times - 1:
time.sleep(gap)
return False, last
加上这段之后,误报直接归零。监控脚本最大的敌人不是漏报,是狼来了喊多了你就不看了。
写完在终端跑得很顺,塞进 cron 之后一条告警都没有。查了半天,是两个经典问题叠在一起:
-
- cron 的
PATH 极其贫瘠,python3 找不到; -
- 脚本里用了相对路径写状态文件,cron 的工作目录是
$HOME,文件写到别的地方去了。 -
修法就是全部写死绝对路径,顺手把输出重定向到日志:
# crontab -e
*/5 * * * * /usr/bin/python3 /opt/monitor/site_check.py >> /var/log/site_check.log 2>&1
0 9 * * * /usr/bin/python3 /opt/monitor/site_check.py --cert-only >> /var/log/site_check.log 2>&1
HTTP 检测 5 分钟一次,证书检测每天早上 9 点跑一次就够了——证书不会在一小时内突然过期。
站挂了如果不处理,5 分钟一条消息,一小时 12 条,手机直接废掉。所以要记状态:只在「状态发生变化」时才推送。
import json, os
STATE = "/opt/monitor/state.json"
def load_state():
if os.path.exists(STATE):
with open(STATE) as f:
return json.load(f)
return {}
def save_state(s):
with open(STATE, "w") as f:
json.dump(s, f)
# 主逻辑里
state = load_state()
prev = state.get(url, "up")
now = "up" if ok else "down"
if now != prev:
send_alert(f"[{now.upper()}] {url} {msg}") # 挂了报一次,恢复了也报一次
state[url] = now
save_state(state)
send_alert 我接的是企业微信机器人 webhook,一个 POST 就完事,比配 SMTP 省心得多。想用 Telegram、Bark、钉钉都一样,换个 URL 的活儿。
-
- 抓到 1 次真实宕机:PHP-FPM 因为内存吃满被 OOM Killer 干掉,站返回 502,我在两分钟内收到消息,重启完事
-
- 提前 14 天收到证书到期提醒,顺手把 certbot 那个 webroot 路径配错的问题彻底修了
-
- 误报 0 次
-
监控这事的价值不在「看到数据」,而在「出事时有人告诉你」。前者是仪表盘,后者才是监控。
-
- 不要一上来就搞全家桶。一个 Python 文件 + cron 能覆盖 90% 的个人站需求,等真需要看趋势了再上重家伙。
-
- 重试机制比检测逻辑更重要。误报会直接毁掉监控的可信度,一旦你开始习惯性忽略告警,这套东西就等于没有。
-
- 状态变化才推送,别做无脑循环通知。
-
- cron 环境和你的 shell 环境是两个世界,凡是路径全写绝对,凡是输出全落日志。
-
- 证书这类「慢性问题」最容易被忽视,因为它平时完全没症状,爆的时候直接全站不可用。宁可提前 15 天啰嗦一次。
-

脚本很糙,但它替我盯着机器,这就够了。哪天它自己挂了怎么办?我在 NAS 上又加了一条 cron,反过来监控这台服务器的心跳文件——这就是另一篇的事了。