
背景:两种故障,用户总比你先知道
VPS 就是一台租来的远程服务器,按 CPU、内存、硬盘和流量计费。很多人的博客、小服务就跑在上面。跑起来之后最尴尬的两种故障是:服务挂了返回 500,或者 HTTPS 证书过期、浏览器直接拦截。前者用户打不开页面,后者用户打开看到的是安全警告,两种情况往往都是用户先发现、再来告诉你。
HTTPS 证书可以理解为网站的身份证:浏览器靠它确认“这个域名确实属于这台服务器”,证书都有到期日,到期不续就失效。自动续期也可能失败——定时任务挂了、验证文件被删了、域名解析变了,都会让续期悄悄断掉。
市面上有现成的外部监控服务,但自己写一个巡检脚本也有好处:不注册第三方账号、不把站点列表交给别人、跑在自己的 VPS 上,逻辑完全可控。下面这个脚本只用 Python 标准库,不装任何依赖,核心逻辑已经在本地实测通过。
前置知识:会写基础 Python,会用 crontab -e 加一条定时任务。文中路径都以 /home/user/ 为例,换成你自己的实际路径。
巡检分三档:结果一眼能分轻重
脚本对每个站点输出一行,级别按下面这张表定。分级的目的很实际:每天早上扫一眼日志,只看 CRIT 和 WARN 就够了,不用逐行读 OK。
| 级别 | 触发条件 | 含义 |
|---|---|---|
| OK | HTTP 状态码 200,且证书剩余不少于 30 天 | 正常 |
| WARN | 状态码不是 200,或证书剩余不足 30 天,或证书查不到 | 需要看一眼 |
| CRIT | 站点连不上,或证书剩余不足 7 天 | 今天就得处理 |
注意一个细节:天数是向下取整的。证书还剩 20 小时,算出来是 0 天,会直接进 CRIT——这正是想要的效果,证书过期前一天才发现和过期当天才发现,区别不大。
代码:只用标准库的巡检脚本
把下面这段存为 /home/user/check_sites.py,把 SITES 里的地址换成你自己的站点:
import socket
import ssl
import sys
import urllib.request
from datetime import datetime, timezone
WARN_DAYS = 30
CRIT_DAYS = 7
SITES = [
("主站", "https://www.example.com"),
("博客", "https://blog.example.com"),
]
def check_http(url):
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.status
def check_cert(host):
ctx = ssl.create_default_context()
with socket.create_connection((host, 443), timeout=10) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as ssock:
cert = ssock.getpeercert()
not_after = datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z")
not_after = not_after.replace(tzinfo=timezone.utc)
days = (not_after - datetime.now(timezone.utc)).days
return days, not_after.date().isoformat()
def main():
worst = 0
for name, url in SITES:
host = urllib.request.urlparse(url).hostname
try:
status = check_http(url)
except Exception as e:
print(f"[CRIT] {name} {url} HTTP 检查失败: {type(e).__name__}: {e}")
worst = max(worst, 2)
continue
try:
days, expiry = check_cert(host)
except Exception as e:
print(f"[WARN] {name} {url} HTTP {status},证书检查失败: {type(e).__name__}: {e}")
worst = max(worst, 1)
continue
if status != 200:
level, worst = "WARN", max(worst, 1)
elif days < CRIT_DAYS:
level, worst = "CRIT", max(worst, 2)
elif days < WARN_DAYS:
level, worst = "WARN", max(worst, 1)
else:
level = "OK"
print(f"[{level}] {name} {url} HTTP {status},证书剩余 {days} 天,到期 {expiry}")
return worst
if __name__ == "__main__":
sys.exit(main())
这里特意用 GET 而不是 HEAD 去查状态:有些站点对 HEAD 请求返回 405,明明服务正常却会被误判为故障。urlopen 默认会跟随跳转,所以拿到的状态码是最终落点页面的,不是跳转中间页的。每次请求都设了 10 秒超时,某个站点卡住不会拖垮整轮巡检。
证书那一步在干什么
check_cert 里最容易抄错的一行是 wrap_socket 的 server_hostname 参数。一台服务器一个 IP 上可能挂着多个网站,握手时不告诉服务器要访问哪个域名(这个机制叫 SNI),服务器就不知道该拿出哪张证书,轻则拿到错误的证书、误报过期,重则握手直接失败。写巡检脚本时这个参数必须传,缺了它,查出来的证书未必属于你要查的站点。
拿到证书后,notAfter 字段形如 Nov 21 05:16:38 2026 GMT,是英文月份缩写加格林尼治时间。strptime 的格式串 %b %d %H:%M:%S %Y %Z 就是按这个固定格式解析的,解析完统一换算成 UTC 再和当前时间相减,天数才不会因为时区差一天。
退出码也有讲究:0 表示全部正常,1 表示有 WARN,2 表示有 CRIT。这样 cron 日志之外,还能用退出码接其他告警渠道——比如在外层脚本里判断非零就发一封邮件,而不是每天人工翻日志。
放进 cron,每天早上跑一次
0 8 * * * /usr/bin/python3 /home/user/check_sites.py >> /home/user/check_sites.log 2>&1
每天早上 8 点跑一轮,结果追加写进日志。证书 30 天预警期足够留出处理时间:自动续期失败了有时间修,手动续期也有时间走流程,不用在到期当天手忙脚乱。
不过 cron 本身也有坑:上一轮没跑完下一轮又启动、任务卡住不退、悄悄失败没人知道。巡检脚本跑在 cron 里,同样会被这些问题影响,锁、超时、心跳这三件套的做法可以参考本站另一篇:定时任务别裸奔:给 cron 加一把锁、一个超时和一张心跳。
三个常见坑
第一,HTTP 通了不等于证书没问题。脚本把两项检查分开、分开定级,就是因为“页面能打开”和“证书没过期”是两回事:证书检查失败时只报 WARN,不会因为 HTTP 200 就判 OK,也不会因为查不到证书就直接判站点挂了。
第二,这个脚本是给公网站点用的。默认上下文会校验证书链,自签证书、内网证书握手会直接失败。内网站点要另建信任自签 CA 的上下文,千万别图省事关掉校验——关掉校验的巡检查不出证书问题,等于白跑。
第三,403 不等于挂了。有些站点会对机房 IP 或没有浏览器特征的请求返回 403,站点本身其实正常。第一次出现非 200 的结果,先用浏览器人工确认一次站点是否真的有问题,确认是反爬拦截后再调整请求方式,不要一上来就把 403 当成故障去“修”站点。
还能往哪扩展
跑稳一周之后,可以按需加三样东西:异常时发邮件或 Webhook 告警,不用每天翻日志;把每天的结果追加写进一个状态文件,攒够一个月就能看出站点的故障规律;站点多了之后,把 SITES 列表抽到单独的配置文件里,改站点不用动代码。先把最简单的版本跑起来,再谈扩展——巡检脚本的价值不在功能多,而在每天真的在跑。
