我先把话说在前面,这个项目一开始压根不是什么宏大设计。当时就是服务器上跑的接口被人投诉挂了好几次,我每次都是被业务方通知了才知道出问题了,说实话挺没面子的。那时候脑子里想的很简单,搞一个东西能帮我盯着这些服务,别等用户先发现故障。这才有了PLFM_RADAR——一个轻量级的平台运行状态雷达系统,专门用来感知平台状态、主动探测异常、提前发现风险,核心就是解决“服务是不是真的活着”“负载是不是异常”“什么时候该告警”这三个问题。
PLFM_RADAR 这个名字,拆开看就是 Platform Radar,平台雷达。它不做数据清洗,也不做链路追踪,就踏踏实实干一件事——把你线上在跑的服务变成一个大屏幕上的亮点,亮的颜色不对了,它就报警。适合什么人用?如果你是那种团队不大、又不想上一套 Prometheus + Grafana 全家桶的开发者,或者你已经开始维护几个服务但每次发版上线都提心吊胆,这篇内容值得你花十分钟看完。这个系统我自己搭下来大概一天就能跑起来,后面维护成本也很低,不依赖第三方组件,一台小机器就够了。
1. 从痛点说起,为什么我会自己写一个“平台雷达”
1.1 日常运维里最扎心的三个时刻
第一个扎心时刻,是在业务高峰期群里突然有人问“那个接口是不是挂了”。你打开浏览器访问一下,发现确实超时了,然后才开始查日志、看进程、看负载。这个过程短则五分钟,长则半小时,而在这段时间里所有用户都在替你测故障。
第二个扎心时刻,是排查完发现服务早就挂了,系统负载已经爆表了一个小时,CPU打满、磁盘写满,但监控面板上没有任何反应。原因很简单,我们只看“进程在不在”,没看“进程能不能正常服务”,进程活着但端口不响应的情况太常见了,Java 进程僵死、连接池被占满、后端依赖超时,进程都没退出,可服务已经废了。
第三个扎心时刻,是告警邮件发了一堆,但没人看,关键告警被海量日志淹没了,最后真正出问题的时候反而没人响应。这种事我经历过几次之后,确定我要的监控绝对不是一个只画曲线的东西,而是一个够直接的雷达,能探测出“服务是否可用”而不是“进程是否存在”,能把异常信息压缩成一条能看懂的消息推到手机上。
1.2 为什么叫“雷达”而不叫“监控系统”
早期监控系统通常用推送模式,各种 agent 主动上报数据到中央服务,你只能看到 agent 自己收集到的内容。雷达的逻辑跟这个不太一样,它更像主动扫描,像蝙蝠一样不断往外发出探测信号,然后接收反射回来的信息,来判断哪里有障碍物、哪里是空阔区域。
PLFM_RADAR 参考的也是这个思路。对端口探活这件事,它不依赖服务内部埋点,而是外部主动去建立 TCP 连接,连接成功那就是真活着,连接超时那就是有问题,跟目标服务本身是不是愿意配合无关。这种探测方式特别适用来盯那些没有提供健康检查接口的老系统、第三方中间件、数据库这些东西。你不用改任何业务代码,就能知道它当前能不能连通。
雷达这个比喻还延伸到界面上。我不想看一堆曲线自己去判断,我想看到的是像雷达屏幕一样的全景视图,上面是各个服务的状态点,绿的正常、黄的告警、红的故障,一眼扫过去心里就有数了。这套展示逻辑,我用在了后面做的 Web 概览页上。
1.3 技术选型,为什么不用 Prometheus 全家桶
老实讲,既然要接监控这件事,按行业习惯我第一个想到的也是 Prometheus + Grafana。但认真梳理需求之后我否决了这个方案,原因有三个。
第一,Prometheus 对基础设施的要求比我们想象的高,虽然它自身很稳定,但需要维护时序数据库,数据量大了以后存储和查询都是开销。我们这里的服务数量大概几十个,需要监控的指标也就百来个,上这套全家桶有点杀鸡用牛刀。
第二,部署链路长。Prometheus 本身只是个拉取器,你还要推动 exporter、pushgateway、altermanager 这些配套组件,任何一个环节网络不通都可能造成数据断裂,排查起来并不轻松。
第三,Grafana 的面板配置是门学问,做出一套好用的面板也要折腾很久,而我最迫切的需求其实很简单:端口通不通、负载高不高、日志里有没有明显异常关键词。这些用一套小系统完全可以实现。
所以最终技术栈我选的是 Python 3.9 + FastAPI + SQLite + 原生 JavaScript 图表库。一条指令就能启动服务,零外部依赖,数据量小的情况下 SQLite 完全够用,出问题的时候只需要看一个进程。对中小规模平台来说,这种轻量级方案在维护成本上的优势非常大。
2. 核心模块的设计与实现细节
2.1 我把雷达分成了四层
整个系统我在设计上模仿雷达扫描的分层逻辑,让每一层解决一类问题,这样代码结构清晰,出了问题也容易定位。下面这四层是核心骨架:
- 第一层,探针层,负责主动发起 TCP 探测、HTTP 请求,判断目标服务是否存活、响应是否正常。
- 第二层,采集层,负责读取本机系统负载、内存、磁盘使用率,以及关键进程的运行情况。
- 第三层,检测层,负责分析采集到的数据,和预设阈值做对比,生成正常、关注、故障三个状态。
- 第四层,通知层,负责把状态变化通过 webhook 推到钉钉或企业微信群里。
这套分层思路对维护 Systemd 服务特别有用,探针层五分钟探测一次,检测层判断如果连续两次失败才进入故障状态,就不会因为偶发超时疯狂告警。
2.2 端口探测模块,最核心也最容易写错的部分
端口探测的原理很简单,就是创建一个 socket 去连接目标地址和端口。如果能 connect 成功,说明端口在监听,服务网络层是通的。如果超时,那就说明服务可能挂了,或者防火墙拦了,总之是不可用状态。
但这里有几个坑,我在第一版代码里全都踩过。首先是超时时间设置,你连接公网服务时如果超时设成 1 秒,跨网段访问可能会频繁误报。我后来把所有探活超时调整为 3 秒,并且通过连续两次失败才判定为故障,误报率大幅下降。
第二个坑,是对 TCP 半连接的判断。有些服务端由于连接数被打满,新的 TCP 连接在 accept 队列里排不上队,connect 会显示成功,但实际请求根本进不去。这里我增加了 HTTP 层的探测逻辑,如果目标是 HTTP 服务,会在 TCP 连通后额外发起一次 GET /health 请求,状态码非 2xx 或者返回耗时超过阈值,同样记为异常。
核心代码大概是这样的:
import socket import asyncio async def check_tcp(host: str, port: int, timeout: float = 3.0): loop = asyncio.get_event_loop() try: reader, writer = await asyncio.wait_for( asyncio.open_connection(host, port), timeout=timeout ) writer.close() await writer.wait_closed() return True, "tcp ok" except asyncio.TimeoutError: return False, "connect timeout" except OSError as e: return False, str(e)注意不要在线程里直接用 socket.connect,那会阻塞住整个事件循环,我后来把端口探测和 HTTP 探活都改成了异步执行,用 asyncio 统一调度。配置文件里我还会加一个并发限制信号量,避免上百个服务同时探测时把执行机负载打满。
2.3 指标采集与阈值检测
系统级指标采集我用的是 psutil 这个库,它比直接读 /proc 要省事得多,语义也清晰。需要注意的是,采集到的瞬时值抖动很厉害。比如 load1 的瞬时值可能突然冲到 8,但下一秒又回到 2,用瞬时值和阈值做比较是不合理的。我的做法是做三次采集再取平均值,间隔 10 秒采一次,30 秒出一个稳定的数据点。
阈值设计上大家习惯于写死绝对值,但不同机器的核数、内存大小不一样,同样的负载 5 在 2 核机器上已经炸了,在 16 核机器上还很轻松。所以我这里的配置项允许用百分比和绝对值两种模式。比如 CPU 使用率 80%、内存使用率 85% 属于百分比模式,磁盘 IO 等待时间超过 200ms 属于绝对值模式。每种指标都对应三个档位:warning、critical、ok,避免只有“好”和“坏”两种极端状态。
2.4 日志关键词告警,让雷达能听懂日志
只监控系统指标还不够,很多业务异常不是先用 CPU 和内存表现出来的,而是先在日志里出现大量错误。所以 PLFM_RADAR 里我专门做了日志雷达功能,它会在指定目录下轮询日志文件,用正则表达式匹配异常关键词,比如 ERROR、Exception、TimeoutException、Connection refused 这些。
实现的时候有一个细节值得分享:日志轮询不能每次从头读,要记录 inode 和 offset。如果日志文件被 logrotate 切割了,inode 会变化,如果不做处理,新一轮读取可能会从空文件头开始读,产生大量重复告警。我的做法是用一个字典保存每个文件的游标状态,每次读取后记录当前位置,同时检测 inode 变化后重置游标。
def tail_logs(path, keyword_patterns, cursor_map): stat_info = os.stat(path) inode = stat_info.st_ino size = stat_info.st_size key = path if key not in cursor_map or cursor_map[key]["inode"] != inode: cursor_map[key] = {"inode": inode, "offset": max(0, size - 8192)} with open(path, "r", encoding="utf-8") as f: f.seek(cursor_map[key]["offset"]) new_lines = f.readlines() cursor_map[key]["offset"] = f.tell() matched = [] for line in new_lines: for pattern in keyword_patterns: if re.search(pattern, line): matched.append(line.strip()) return matched上面这段代码里有个细节,文件第一次被跟踪时我没有从 0 开始读,而是从文件末尾往回读 8KB,这样跑起来时不会瞬间刷出几千条历史告警,只会关注启动之后产生的新日志。这个细节是真的从使用体验上出发加进去的,没有这一步,你接入一个已经运行很久的日志文件,第一次扫描会直接告警风暴。
3. 整体部署与配置环境详解
3.1 目录结构,一开始就按可维护性设计
项目跑起来之后第一件事就是整理目录结构。很多小项目写着写着就乱成一团,配置文件和代码混在一起,后来想加功能都不知道该改哪里。PLFM_RADAR 的目录结构从第一版开始就固定下来,尽量遵守单一职责原则:
plfm_radar/ ├── app/ │ ├── main.py # FastAPI入口 │ ├── config.py # 配置加载 │ ├── core/ │ │ ├── detector.py # 探针层 │ │ ├── collector.py # 采集层 │ │ ├── analyzer.py # 检测层 │ │ └── notifier.py # 通知层 │ ├── models.py # 数据模型 │ └── views/ │ ├── dashboard.py # 大屏数据API │ └── assets/ # 静态页面 ├── config/ │ └── plfm_radar.yaml # 唯一配置文件 ├── data/ │ └── radar.db # SQLite数据库 ├── logs/ │ └── radar.log └── requirements.txtdata 和 logs 目录是运行时动态生成的,第一次启动时通过 os.makedirs 创建。因为配置文件是唯一需要改的东西,所以我把所有可变项都收敛到 plfm_radar.yaml 里,宁可配置项多一点,也不想代码里到处埋硬编码。
3.2 配置文件里的关键参数
先贴一份简化版的配置结构,你可以参考着按自己平台的实际情况改:
platform: name: "demo-platform" check_interval: 60 # 秒,主探测间隔 services: - name: "user-service" host: "127.0.0.1" port: 8080 protocol: http health_path: "/health" expected_code: 200 timeout: 3 continuous_failures: 2 - name: "order-db" host: "127.0.0.1" port: 3306 protocol: tcp metrics: cpu_warning: 70 cpu_critical: 85 mem_warning: 80 mem_critical: 90 disk_warning: 85 disk_critical: 92 logs: - name: "api-error" path: "/var/log/api/error.log" keywords: - "Exception" - "ERROR" use_regex: false alert: webhooks: - type: "dingtalk" url: "https://oapi.dingtalk.com/robot/send?access_token=xxx" notify_interval: 300 # 相同告警最小通知间隔,单位秒注意 continuous_failures 这个参数,它表示连续多少次探测失败才真正确认故障。我设置成 2,配合 check_interval 是 60 秒,意味着机器上看到服务异常到实际发出告警,中间有大约两分钟的确认时间。这个延迟能过滤大量偶发网络抖动,对绝大多数业务来说是可以接受的。
3.3 用 systemd 管理雷达本身
雷达这种运维工具最尴尬的场景是:服务器上所有服务都挂掉了,雷达自己也不在了,因为忘了开机自启。所以我用 systemd 把 PLFM_RADAR 变成常驻服务,提供对应的 unit 文件,内容如下:
[Unit] Description=PLFM Radar Monitoring Service After=network-online.target [Service] Type=simple WorkingDirectory=/opt/plfm_radar ExecStart=/usr/bin/python3 /opt/plfm_radar/app/main.py Restart=always RestartSec=5 Environment=PYTHONUNBUFFERED=1 [Install] WantedBy=multi-user.target有个小技巧是 Environment=PYTHONUNBUFFERED=1,它强制 Python 的标准输出不缓冲,这样我们在 systemd 里用 journalctl -u plfm_radar -f 查看日志时是实时输出的,不会等半天才看到一段日志。日志轮转方面,我通过 logrotate 给 radar.log 做了按天切割,保留 7 份,避免工具本身把磁盘写满。
3.4 轻量级大屏页,不需要任何前端工程链
在 Web 展示层我没用 React、Vue 那一套,单纯是一个 HTML 页面加上原生 JS 定时拉接口。后端提供两个 API,一个是 GET /api/overview 返回所有服务的实时状态,另一个是 GET /api/trend 返回历史探测数据。前端页面每秒通过 setInterval 调一次 /api/overview,然后重新渲染状态点。
有人可能会说轮询不够实时,但在监控场景下,1 秒轮询已经非常快了,而且服务器端只有几百 KB 的数据量,根本没有性能压力。图表则优先展示了近 24 小时 CPU 和内存趋势,我用 Canvas 手动画的折线图,虽然没 ECharts 那么花哨,但核心数据表达能力完全够用,页面上还能特别标注出告警时间段,用红底背景提示。
4. 真实使用中的问题与排查心得
4.1 端口探活误报,我被防火墙坑过一整天
系统刚上线的那一周,半夜收到三条告警,说我另外一台机器的 Redis 挂了。我爬起来看,Redis 进程活着,服务也正常,但就是探活失败。排查了半天,发现那台机器上开了防火墙,并且限制了来源地址,把我这台雷达机所在的网段给忘了加白名单。TCP connect 发过去直接被 REJECT,表现就是超时。
这件事给我一个教训:端口探活问题的定位顺序应该是先确认目标端口是否处于 LISTEN 状态,再确认本机到目标机器的网络是否通,最后才考虑防火墙规则。后来我在每个探活结果里额外记录了失败阶段,比如是 connect 阶段失败的还是 HTTP 请求阶段失败的,这样出现问题的时候能快速缩小排查范围。
4.2 日志游标错乱,logrotate 是我的假想敌
日志雷达上线时没有处理 inode 变化的问题,导致每天凌晨 logrotate 切割日志后,雷达又开始从新文件头读起。新文件里第一条日志如果恰好是错误记录,就会触发一次误报。虽然不影响功能,但每天定时收到这种“幽灵告警”很影响信任感。
解决方式就是前面代码里已经展示过的,每次记录文件的 inode 信息,发现 inode 变化就重新初始化游标,并且明智地选择从新文件尾部往回读 8KB,而不是读整个文件。这块处理完之后,幽灵告警彻底消失了。
4.3 告警风暴,状态机才是最优解
最初的版本判断逻辑太直接:探测失败一次就立即发告警,恢复了又发一条恢复通知。结果是网络抖动一下,群里连续刷二十条消息,真正有价值的信息被淹没。
后来引入状态机设计,每个服务有四个状态:未知、正常、警告、故障。从正常到故障必须经过警告状态,而且警告状态只记录不通知。连续多次失败才从警告升级为故障,这时才触发通知。恢复的过程也一样,故障状态连续成功两次才能回到正常,再发一条恢复通知。
用 notify_interval 做最小通知间隔,同一服务同一级别的告警,5 分钟内不重复推送。这套双重过滤机制能减少至少百分之八十的无意义通知。
4.4 SQLite 并发写入与数据膨胀控制
系统刚跑的时候发热点问题,多个采集任务同时向 SQLite 数据库写入数据,偶尔会报 database is locked。SQLite 的常见问题,解决办法是启用 WAL 模式和设置 busy_timeout:
sqlite3.connect("radar.db", timeout=30)连接池的 timeout 参数加大到 30 秒后,写锁冲突基本没有再出现过。另外我在建表时执行了 PRAGMA journal_mode=WAL,让读和写可以并行,监控类应用读多写少,这个效果特别明显。
数据膨胀也要提前控制。探测数据每 60 秒存一条,一天下来就是 1440 条,一个月就是 43200 条。我加了一个定时清理任务,每天凌晨三点删除 14 天前的历史数据。告警表只保留一个月,保证数据库文件永远控制在几十 MB 以内。监控数据是时效性很强的数据,真正要看趋势变化,两周的粒度已经足够发现问题了。
5. 后续扩展方向
PLFM_RADAR 目前已经是平台内比较靠谱的“值班员”了。半夜服务出问题它会第一个在群里喊人,业务方再也不是第一个发现故障的人。接下来我准备扩展两个方向。
一个是把“单机雷达”升级成“多机雷达网”,每台服务器部署一个采集 agent,通过 HTTP 方式把采集数据上报给中心节点。中心节点负责统一存储和统一告警,这样一台雷达机坏掉也不会影响全局。
另一个是把告警从钉钉群扩展到更多渠道,比如企业微信群、电话告警。对于那些长时间不恢复的故障,目前企业微信的机器人已经能承担大部分场景,更严重的故障则考虑接入电话语音告警,因为真正的大故障往往发生在半夜或者节假日,光靠看群消息是不能保证一定响应的。
如果你也遇到类似的问题,平台服务不多、又不想背一套重型监控体系,PLFM_RADAR 这种轻量方案值得尝试。自己动手写一遍探测逻辑和状态判断,对运维体系的理解也会深很多。踩过几次告警风暴的坑之后,你会慢慢发现,一个能稳定报信的雷达,比一个能画出漂亮曲线的系统更让人安心。