跳至正文

站点半夜挂了没人知道?我用 60 行 Python 把宕机和证书到期都管了

上周三早上打开博客,浏览器直接甩我一个红色警告页:证书已过期。

翻了下 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

踩的三个坑

坑一:SNI 不传,拿回来的是别人的证书

第一版我没写 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 之后一条告警都没有。查了半天,是两个经典问题叠在一起:

  • 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,反过来监控这台服务器的心跳文件——这就是另一篇的事了。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注