网站别等用户来报障:用 Python 写一个 HTTP 与证书到期巡检脚本

网站别等用户来报障:用 Python 写一个 HTTP 与证书到期巡检脚本

背景:两种故障,用户总比你先知道

VPS 就是一台租来的远程服务器,按 CPU、内存、硬盘和流量计费。很多人的博客、小服务就跑在上面。跑起来之后最尴尬的两种故障是:服务挂了返回 500,或者 HTTPS 证书过期、浏览器直接拦截。前者用户打不开页面,后者用户打开看到的是安全警告,两种情况往往都是用户先发现、再来告诉你。

HTTPS 证书可以理解为网站的身份证:浏览器靠它确认“这个域名确实属于这台服务器”,证书都有到期日,到期不续就失效。自动续期也可能失败——定时任务挂了、验证文件被删了、域名解析变了,都会让续期悄悄断掉。

市面上有现成的外部监控服务,但自己写一个巡检脚本也有好处:不注册第三方账号、不把站点列表交给别人、跑在自己的 VPS 上,逻辑完全可控。下面这个脚本只用 Python 标准库,不装任何依赖,核心逻辑已经在本地实测通过。

前置知识:会写基础 Python,会用 crontab -e 加一条定时任务。文中路径都以 /home/user/ 为例,换成你自己的实际路径。

巡检分三档:结果一眼能分轻重

脚本对每个站点输出一行,级别按下面这张表定。分级的目的很实际:每天早上扫一眼日志,只看 CRIT 和 WARN 就够了,不用逐行读 OK。

级别触发条件含义
OKHTTP 状态码 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 列表抽到单独的配置文件里,改站点不用动代码。先把最简单的版本跑起来,再谈扩展——巡检脚本的价值不在功能多,而在每天真的在跑。