ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

自建轻量级服务监控系统:Python主动探测与告警实践

自建轻量级服务监控系统:Python主动探测与告警实践 PLFM_RADAR 这个名字听起来挺唬人其实是我花了两周多时间给自己手头一堆分散在不同环境里的服务做的一套平台雷达监控系统。PLFM 我取的是 Platform 的意思RADAR 就是雷达组合起来就是“平台雷达”——像雷达一样周期性扫过各个服务把可用性、响应时间、证书状态这些关键信息全部拉到一个面板上出问题的时候第一时间告诉我是哪个环节挂了。做这个项目之前我手里维护着公司正式环境、测试环境、客户现场环境加起来几十个 Web 服务和 API 接口。最大的痛点不是服务本身多难维护而是它们太分散了没有一个统一入口。经常出现的情况是客户先发现“你们这个页面打不开了”我才跑去挨个查是哪台机器、哪个进程出了问题。被动响应特别消耗精力而且没有一个历史数据可以复盘这个月到底哪个接口最不稳定平均响应时间是不是在慢慢劣化全靠感觉。PLFM_RADAR 就是冲着这三个痛点的主动探测、统一看板、历史留存。如果你也是自己维护多个站点、多个接口的开发、运维或者独立开发者这篇项目总结应该能给你一个可以直接抄作业的轻量方案。我会把设计思路、核心探测逻辑、完整代码、踩过的坑一次讲清楚。1. 项目整体设计与思路拆解1.1 雷达系统的核心需求虽然叫雷达但本质上是“定时探测 状态判定 告警通知”这套逻辑。我梳理需求的时候给这个系统定了四个必须满足的基础能力。第一是可配置的探测目标列表。我得能随时加减服务改探测频率不用改代码。第二是周期性的探测任务调度不同目标可以有不同的探测间隔比如核心支付接口每 30 秒探测一次内部管理系统 5 分钟一次就够了。第三是异常状态自动判定与告警不能每次抖动都报警但也不能真挂了半天不通知。第四是历史数据留存至少要能统计出日维度和周维度的可用率这样才能知道服务是否真的在变稳定。那段时间我也对比过市面上现成的方案。Prometheus 加 Blackbox Exporter 功能确实强但需要额外维护一套时序数据库、告警规则和 Grafana 面板对小规模场景来说有点重了。UptimeRobot、Uptime Kuma 这类工具用过一些有的数据在国外节点有的自定义探测层级太浅没法做证书有效期监控也没法接入我们内部的告警渠道。综合下来我决定自己搭一套足够轻的把控制权完全握在自己手里。1.2 为什么选择“主动探测”而不是被动监控这里有个关键设计决策值得展开说。监控体系通常分为主动和被动两条路线。被动监控依赖真实流量比如访问日志分析、APM 链路追踪都是等着用户请求进来才能发现问题。问题在于很多内部系统在业务低峰期根本没有流量你无法从日志里感知服务是否健康或者服务已经挂了但没人访问也就没人发现。主动探测的逻辑刚好相反它是系统自己定期向目标发起真实请求不管有没有用户流量都要按时“敲”一下每个服务。像巡检电路一样不等灯泡坏了才找电工而是定期把线路走一遍。这样做的好处是能独立发现很多层面的问题DNS 解析是否异常、网络链路是否不通、服务进程是否崩溃、证书是否即将过期。从实现成本来说主动探测也非常适合轻量项目。不需要接入业务代码不需要埋点一个探针进程加一张配置表就能跑起来。我做的就是主动探测这条路。1.3 技术选型与底层逻辑技术栈方面我最后选的是 Python 3.11 httpx asyncio数据库用 SQLite看板用 Flask 加一个极简网页。核心理由有三条。第一Python 生态对这类“请求发送 数据处理 小服务展示”的场景非常成熟httpx 同时支持同步和异步客户端写起来顺手。第二asyncio 可以让我用并发方式同时探测几十个目标而不是串行排队整个扫描周期可以控制在几秒内。第三SQLite 在单机场景下足够可靠探测结果属于低频写入一天最多几十万条记录SQLite 完全能扛住还能省掉维护 MySQL 或者 Redis 的成本。这里有个取舍想多说一句。很多人一提到监控系统就想到时序数据库但小项目中引入新组件本身就是运维负担。我的原则是能用文件型数据库解决的问题绝对不引入需要单独部署的服务。SQLite 看起来不够“高级”但跑了一年后我发现它对这个场景来说正好备份就是把文件拷贝走恢复也一样简单。2. 核心细节解析与实操要点2.1 探测层次与状态判定逻辑一个探测目标不能只检查“能不能返回状态码”那样信息量太少了。我的探针分四层收集数据TCP 层连通性、HTTP 状态码、响应时间、TLS 证书有效期。TCP 层连通性解决的是网络是否可达的问题HTTP 状态码解决的是服务是否存在以及业务是否正常响应响应时间解决的是服务是否在变慢比如状态码 200 但耗时已经到 10 秒用户体感就是打不开页面证书有效期解决的是 HTTPS 证书是否会在未来某个时间点过期。状态判定我用了“正常 / 警告 / 故障”三档而不是简单的“好 / 坏”。正常的定义是返回预期状态码且响应时间低于阈值警告是响应时间超过 2 秒或者证书剩余有效期不足 7 天故障是连续 3 次探测失败。这个“连续 3 次”非常重要后面我专门讲误报问题时会再展开。2.2 超时参数设计与底层原理超时参数是主动探测里最容易忽略、也最容易出问题的点。如果超时时间设得太大一次僵死的连接可能把探测队列拖住几十秒如果设得太小网络稍微抖一下就会被误判为故障。我实测下来一套比较稳的参数连接超时 3 秒、读取超时 5 秒、整体超时 8 秒。对应到 httpx 的写法是一个httpx.Timeout对象可以分别控制连接、读取、写入和连接池等待。为什么拆分这么细因为不同层的失败原因完全不一样。连接超时通常意味着对端 IP 不可达读取超时说明请求已经发过去了但服务迟迟不给响应这两种故障的排查方向完全不同。另外我做了个决定网络层错误不自动重试。很多监控工具会做三次重试再判定故障但这会掩盖短时抖动的真实情况。我宁愿让它快速记录下来然后用状态窗口去判断“是抖动还是真故障”这样数据也更真实。2.3 状态窗口与告警冷却机制前面提到连续 3 次失败才判定故障这个机制本质上是一个“状态窗口”。比如探测间隔是 60 秒连续 3 次失败就意味着服务至少在 3 分钟窗口内持续异常基本排除了偶发抖动。恢复则相对宽松连续 2 次成功就恢复避免故障状态一直卡住。告警通知这里我用了一个状态机思路每个目标维护当前的状态和一个“上次状态变化时间”只有状态发生跳变时才触发通知。比如从正常变为故障发一次告警从故障恢复再发一次恢复通知。中间过程的每一次失败都不再重复发这样自然就避免了告警轰炸。我个人还想提醒一个问题告警冷却和状态判定是两个维度的东西不要混在一起。状态判定回答的是“服务现在是不是真的出问题了”冷却时间回答的是“这个问题最多多长时间通知我一次”。把这两个逻辑解耦之后后续调参会舒服很多。2.4 数据存储设计与历史复盘数据层我设计了三个表targets存目标的静态配置probe_results存每一次探测的结果alert_events存状态变化的事件流水。probe_results是核心表字段包括目标名、URL、探测时间、状态、状态码、响应时间、证书剩余天数和错误信息。为什么坚持存原始探测结果而不是存每分钟聚合后的指标因为原始数据是排障时的“黑匣子”。比如用户说昨天下午 3 点页面很卡我可以直接查那个时间段的原始探测记录精确到秒看响应时间的分布。如果只存了聚合数据很多线索就被抹掉了。针对这个项目的规模SQLite 表里加几个常用索引查询性能完全够用。CREATE TABLE IF NOT EXISTS probe_results ( id INTEGER PRIMARY KEY AUTOINCREMENT, target_name TEXT NOT NULL, url TEXT NOT NULL, ts TEXT NOT NULL, status TEXT NOT NULL, status_code INTEGER, response_time_ms REAL, cert_days_left INTEGER, error TEXT ); CREATE INDEX IF NOT EXISTS idx_ts ON probe_results(ts); CREATE INDEX IF NOT EXISTS idx_name_ts ON probe_results(target_name, ts);要注意的是时间字段统一存 UTC 的 ISO 字符串。踩过一次坑当时本地时间写进去服务器时区一变所有统计就乱了。用 UTC 存展示层再转本地时区逻辑就干净了。3. 实操过程与核心环节实现3.1 环境准备与项目目录结构硬件方面我用的是一台 1C2G 的 Linux 小机器跑这个监控系统绰绰有余。日常占用非常低探针只在每次扫描周期内活跃几秒钟其余时间基本都在等待。如果你手头没有单独的机器用一台常开的办公电脑也可以只要能稳定联网就行。项目目录结构我组织成这个样子每个模块职责清晰后面扩展也不会乱plfm_radar/ ├── config.yaml ├── targets.yaml ├── radar/ │ ├── __init__.py │ ├── probe.py │ ├── storage.py │ ├── notifier.py │ └── scheduler.py ├── webapp.py └── requirements.txtconfig.yaml是全局配置比如告警阈值、冷却时间、Web 服务端口targets.yaml是探测目标列表radar包下面是核心逻辑webapp.py是提供给人类看的看板服务。3.2 探针代码实现与并发模型探针模块我用 httpx 的异步客户端实现。核心函数接收一个目标配置返回一条完整的探测结果。为了让代码容易读懂我尽量把逻辑拆成单层异常捕获放在最外层保证无论探针怎么失败结果记录里都有一个明确的 reason 字段。import asyncio import time from datetime import datetime, timezone import httpx import yaml def build_timeout(target: dict) - httpx.Timeout: return httpx.Timeout( connecttarget.get(connect_timeout, 3.0), readtarget.get(read_timeout, 5.0), writetarget.get(write_timeout, 5.0), pooltarget.get(pool_timeout, 3.0), ) async def probe_one(target: dict) - dict: url target[url] result { name: target[name], url: url, ts: datetime.now(timezone.utc).isoformat(), status: ok, status_code: None, response_time_ms: None, cert_days_left: None, error: None, } headers {User-Agent: PLFM_RADAR/1.0} start time.perf_counter() try: timeout build_timeout(target) async with httpx.AsyncClient( timeouttimeout, follow_redirectsTrue, headersheaders, ) as client: resp await client.get(url) expected target.get(expected_status, [200]) result[status_code] resp.status_code if resp.status_code not in expected: result[status] fail result[cert_days_left] await get_cert_days_left(target.get(host, url.split(/)[2])) except Exception as exc: result[status] fail result[error] f{type(exc).__name__}: {exc} result[response_time_ms] round((time.perf_counter() - start) * 1000, 1) return result这个函数有一个细节值得注意follow_redirectsTrue。很多站点首页会做 301 或 302 跳转如果不跟随状态码 301 会被判定成异常。但我同时要求配置里写明expected_status这样如果我想把 301 当正常也完全可控。证书剩余天数的获取我单独写了一个函数用 asyncio 原生的open_connection带上 SSL 上下文直接取peercert。import ssl async def get_cert_days_left(hostname: str, port: int 443) - int | None: try: ctx ssl.create_default_context() reader, writer await asyncio.open_connection(hostname, port, sslctx) cert writer.get_extra_info(peercert) writer.close() if not cert: return None not_after cert.get(notAfter) if not not_after: return None # notAfter 是 ASN.1 时间字符串这里用 ssl 模块辅助解析 import datetime as dt from cryptography import x509 from cryptography.hazmat.backends import default_backend cert_obj x509.load_der_x509_certificate( writer.get_extra_info(ssl_object).getpeercert(binary_formTrue), default_backend(), ) expire cert_obj.not_valid_after_utc days (expire - dt.datetime.now(dt.timezone.utc)).days return days except Exception: return None这段代码里我用了cryptography库来解析证书时间比手工解析 ASN.1 字符串靠谱得多。如果目标不是 HTTPS这个函数直接返回None不会影响主流程。3.3 调度与主循环实现调度模块我没有用 apscheduler而是直接写了一个asyncio常驻循环。原因很简单一个监控任务队列里只需要支持“每个目标按自己的间隔到期后执行”这种场景标准库就能做到少一个依赖少一份维护。async def radar_loop(targets: list[dict]): for t in targets: t[next_run] 0.0 while True: now time.time() ready [t for t in targets if now t[next_run]] for t in ready: t[next_run] now t.get(interval, 60) if ready: results await asyncio.gather(*(probe_one(t) for t in ready)) await save_results(results) await check_alerts(results) await asyncio.sleep(1)这个主循环的设计有一个很实用的地方asyncio.gather会把这一轮所有到期的目标并发执行几十个目标全部探测完只需几秒而不是串行等几十秒。同时调度纪律是“按各自间隔算下一次运行时间”而不是“每 60 秒统一扫一次”。这样核心接口可以高频探测普通页面低频探测互不干扰。3.4 看板与数据查询接口数据可视化我保留了最小可用方案Flask 提供两个接口一个返回当前各目标的最新状态另一个返回指定时间范围的聚合统计前端用一个简单的 HTML 页面每 10 秒刷新一次。from flask import Flask, jsonify, request import sqlite3 app Flask(__name__) DB_PATH radar.db app.route(/api/status) def api_status(): rows sqlite3.connect(DB_PATH).execute( SELECT target_name, status, response_time_ms, ts, error FROM probe_results WHERE id IN (SELECT MAX(id) FROM probe_results GROUP BY target_name) ORDER BY target_name ).fetchall() return jsonify([{ name: r[0], status: r[1], response_time_ms: r[2], time: r[3], error: r[4], } for r in rows])前端的逻辑就一句话定时用fetch拉取这个接口把状态映射成绿色、黄色、红色三种圆点显示在表格里。我没有用任何前端框架因为项目目标是给人类快速看状态不是做一个数据可视化大屏。用过之后你会发现简单的表格比花哨的仪表盘更实用。4. 常见问题与排查技巧实录4.1 高频问题速查表这个项目从第一版到现在我在实际运行中踩了不少坑。我把最高频的问题整理成了一张速查表方便你对照排查。现象常见原因处理方式服务正常但频繁告警状态窗口设置太小单次抖动就被判定故障调大连续失败次数我最终用 3 次网络波动期被误判整体超时时间过短读取超时调到 5 秒整体 8 秒证书检测一直报错有的 CDN 只返回中间证书链用cryptography库解析叶子证书不要自己解析字符串探测不通但浏览器能打开探测机 IP 被防火墙拦截或 IPv6/IPv4 切换问题检查防火墙在配置里强制指定 IP 类型SQLite 报数据库锁定多个进程同时写同一个数据库文件让探针、调度、写入全部逻辑跑在同一个进程里历史统计时间对不上存储时用了本地时间换时区后全乱统一存 UTC 时间展示层再转换目标返回 301 被判定失败没有跟随重定向或 expected_status 没包含 301打开follow_redirectsTrue或按需配置期望状态码4.2 误报与漏报的调优心得误报和漏报是监控系统的天平两端每次调参都要在这两个方向做取舍。我第一版把探测间隔设成 15 秒连续 2 次失败就判定故障结果每个周末凌晨都会收到一波误报。查下来发现是目标服务在做定期重启和备份这个过程会短暂拒绝连接但业务并不受影响。后来我把探测间隔调整为 60 秒、连续失败次数调整为 3 次告警数量立刻降了下来。漏报的问题更容易被忽略。一开始我只关注状态码某天一个同事反馈“你们监控怎么没告诉我接口变慢了”查询历史数据发现响应时间从 300ms 慢慢爬到了 3 秒状态码一直是 200系统当然不会报故障。所以我后来加了响应时间阈值连续 3 次探测响应时间都超过 2 秒就进入“警告”状态超过 5 秒则直接算故障。这个设计帮我提前发现了不少潜在问题。4.3 部署与日常维护的注意事项关于探测机的位置有一个非常重要的原则监控探针要和被监控服务尽量不在同一台机器、同一个网络环境里。如果探针和服务部署在同一台宿主机上那么宿主机宕机时就没人能告诉你服务挂了如果只在公司内网探测生产环境那你在家就完全看不到生产环境的真实状态。我把探针放在独立的云服务器上看到的是接近用户视角的访问质量。日志处理也踩过一个坑。最初我把每日探测日志直接输出到标准输出交给 shell 重定向到一个大文件结果这个文件一个月涨到了几个 GB。后来我加了一个日志轮转逻辑按天分文件保留最近 30 天磁盘占用立刻稳定下来。监控系统自己不能成为运维事故的源头这是所有同类项目都要刻在心里的底线。5. 进阶扩展思路5.1 从 HTTP 探针扩展为全链路探测基础版只做 HTTP 请求但实际生产环境里很多故障不是简单的“页面能否打开”。比如有些核心业务需要登录后才能操作只探测首页登录页意义不大。我后来给目标配置增加了一个type字段支持http、tcp、icmp、dns四种类型。TCP 探测用asyncio.open_connection建立连接测端口是否可达ICMP 探测用来检查网络设备DNS 探测则验证某个域名的解析是否正常。这些扩展的核心思路是共用的每种类型最后都输出状态、耗时、错误信息三个字段存储层完全不用改。对这个项目来说最有价值的一个扩展是探测关键业务链路。例如对电商站点不要只探测首页而是用一个脚本模拟“搜索商品、加入购物车、发起下单”的完整流程把这个流程封装成一次 HTTP 会话作为一个整体目标来监控。业务链路的可用性才是用户真正关心的可用性。5.2 多区域探测与结果对比单个探测点看服务状态只能证明“从某个位置访问是正常的”但用户分布在多个区域地域性的网络问题非常常见。我的方案复杂度可控在另一个云区域也部署一套探针进程两边都往同一个数据库写入探测结果看板查询时按来源字段分组叠加展示。页面上的效果是一个目标对应多个状态点华东节点正常、华南节点超时。这种情况下业务方看到的是用户真实分布的访问状态而不是某个理想网络环境下的状态。多区域部署带来的成本很小但解决问题时的信息量会大很多这个投入非常值得。5.3 通知渠道与升级机制告警通知模块我封装成了独立的notifier里面几个发送函数分别对接钉钉、飞书、企业微信和邮件。通知的目标不是把消息发出去就完事而是要让正确的信息在正确的时间触达正确的人。我设计了一套简单的升级机制目标进入故障状态后如果 5 分钟内没有恢复发第二次通知给更高级别的负责人进入故障后 30 分钟仍未恢复直接把消息推到故障群。冷却时间保证同一个级别最多每 30 分钟重复一次。这个逻辑的收益是在告警准确的前提下真正保证“故障不被淹没在消息海里”。写代码实现这套系统的时候我最大的体会其实不是技术问题而是监控阈值和规则的设计。探针代码写一次就稳定了真正需要反复调的是“什么是异常”的定义。每个业务系统都有自己的正常波动范围上线后的第一周我基本每天都会看一下告警记录对照着当时的业务活动调整参数。那个阶段留下的经验比代码本身值钱得多。你现在照着这个方案搭一套也建议预留出这一周的时间去观察和校准而不是指望第一天就能收到完全准确的告警。
返回列表