CloddsBot:轻量级服务器监控告警机器人的设计与实践

发布时间:2026/9/13 5:38:36
CloddsBot:轻量级服务器监控告警机器人的设计与实践 1. 为什么会有 CloddsBot一次凌晨三点重启数据库之后的产物凌晨三点十七分手机连续震动把吵醒。客户那边说后台登不进去了我爬起来打开电脑SSH 连上服务器发现磁盘被日志塞满了数据库进程直接 OOM 被内核杀掉。重启、清理、恢复全程大概四十分钟。第二天我查监控才发现磁盘使用率从周四下午就开始一路爬坡整整爬了一天半没有任何人收到通知。这就是 CloddsBot 出现的直接原因。那段时间我手里管着七八台云服务器有客户的业务系统也有自己跑的小工具用的监控方案非常原始——云厂商自带的基础告警只盯着 CPU 和网络流量磁盘、内存、进程存活这些通通不管。也考虑过直接上一套 Prometheus Grafana Alertmanager但对我来说实在太重了要维护时序数据库、要写一堆 PromQL 告警规则、要配好几个组件一个人折腾下来小半个月没了而且针对业务可用性这类偏应用的监控写起来并不顺手。我当时需要的其实是一个特别朴素的东西能定时采集服务器的 CPU、内存、磁盘、进程状态能对业务 URL 做可用性探测能在指标越界或服务不可用时第一时间把告警推到手机上的轻量机器人。它不需要有多漂亮的图表不需要海量的历史数据但必须满足三个条件——部署够简单、规则够灵活、告警够及时。CloddsBot 就是按这三个条件写的。名字是 Clodds 和 Bot 拼出来的Clodds 取 Cloud 和 Odds 的双关含义是云上异常几率的守望者。一开始只是自己用后来陆续给身边几个同样被服务器问题折腾过的朋友部署过几套反馈都还行所以我把完整的实现思路、代码结构和踩坑记录整理出来给有类似需求的人一个参考。如果你也是那种服务器数量在个位数到十几台之间、暂时不想为监控这件事引入一套重型系统、但又不想继续裸奔的开发者或运维这篇文章应该对你有用。2. 整体设计从采集到动作的四层模型以及我没有选重框架的原因CloddsBot 最核心的设计思路是把整个监控流程拆成四个独立的层采集层、判定层、通知层、动作层。每一层只负责一件事层与层之间通过内部消息队列解耦。这样做的好处很直接——当我想加一种新的采集指标时完全不影响通知和动作的逻辑当我换通知渠道时也不需要动采集和判定的代码。项目从零到第一个可用版本我大概花了两个周末之后所有的改动都是在某个层内部做加法基本没有推倒重来过。2.1 四层模型各自负责什么采集层解决的是数据从哪来的问题。我支持两类数据源一类是本地采集用 psutil 直接读取当前服务器自身的 CPU、内存、磁盘、网络连接数、关键进程状态另一类是远程探测通过 HTTP/HTTPS 请求去检查目标 URL 的返回码、响应耗时和 SSL 证书剩余天数。这两类数据源对应两种完全不同的监控场景本地采集管的是这台机器还健康吗远程探测管的是这个业务还活着吗。判定层解决的是数据是否异常的问题。它拿采集层产出的数据跟配置文件中预设的阈值做比较。这里最关键的设计是引入了持续 N 次才触发的机制目的是过滤掉偶发抖动。比如 CPU 使用率瞬时冲到 95% 并不一定代表出了问题但如果连续 5 次采集每次间隔 30 秒都超过 95%那基本可以确认有问题了。这块我后面会单独详细讲。通知层解决的是异常怎么让人知道的问题。我封装了一个统一的通知接口底层支持多种渠道钉钉机器人 Webhook、企业微信机器人、Telegram Bot、以及最朴素的邮件 SMTP。通知层做了一件很重要的事——告警去重和升级。同一类告警在未恢复前不会重复轰炸但如果持续超过一定时间会再次发送一条仍然未恢复的提醒并附带当前最新值。这个机制单纯靠配置文件的布尔值做开关不搞太复杂的策略够用就好。动作层解决的是能不能自动处理的问题。比如磁盘空间不够了自动执行一条清理命令MySQL 进程挂了自动执行 systemctl restart或者单纯转发到一个运维工单系统由人来处理。动作层设计上唯一的要求是幂等——同一个告警重复触发时同一个动作不能被执行两次避免出现重启命令连发三五次这种雪上加霜的情况。2.2 为什么配置用 YAML调度用 APScheduler 而不是上 Celery很多同样体量的项目会顺手把调度体系直接交给 Celery 或者 ARQ但我坚持用 APScheduler 就够了。原因其实很简单CloddsBot 是单机部署、单进程运行的监控机器人它不需要分布式任务队列。Celery 要额外跑一个 Redis 或者 RabbitMQ这等于给监控系统本身增加了一个需要监控的依赖——这在我看来是非常不划算的监控系统自身必须皮实、简单、尽量少依赖。选 YAML 而不是 JSON 或者直接写在 Python 模块里纯粹是从非技术使用者的角度考虑的。我给朋友部署 CloddsBot 时他们也要偶尔改一下监控项或者阈值YAML 的注释能力、多行文本支持、漏写逗号也能通过 lint 尽早发现这些体验对不写代码的人来说友好太多。后来我养成了一个习惯配置文件里能写注释的地方都写注释包括每个监控项的解释、阈值设置的依据、告警级别对应的处理优先级。结构化配置文件的可维护性就直接体现在这种半年后打开还能一眼看懂的体验上。2.3 状态存储SQLite 够用不需要单独数据库告警历史、恢复记录、动作执行日志这些数据我统一存在 SQLite 里。之所以不引入 MySQL 或 PostgreSQL是因为 CloddsBot 的数据量极小一天几百条告警事件已经算很多了SQLite 单文件存储、零维护、自动事务的特性在这个量级下体验非常好。唯一要注意的是写并发控制这个我在后面第 5 节的踩坑记录里详说。最核心的两张表一张是alerts记录每次告警的产生和恢复CREATE TABLE alerts ( id INTEGER PRIMARY KEY AUTOINCREMENT, monitor_name TEXT NOT NULL, alert_key TEXT NOT NULL, alert_level TEXT NOT NULL, message TEXT NOT NULL, status TEXT NOT NULL DEFAULT firing, first_triggered_at DATETIME NOT NULL, last_triggered_at DATETIME NOT NULL, recovered_at DATETIME, UNIQUE(monitor_name, alert_key) );另一张是action_logs记录动作层每次执行的情况方便事后审计CREATE TABLE action_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, alert_id INTEGER, action_name TEXT NOT NULL, command TEXT NOT NULL, exit_code INTEGER, output TEXT, executed_at DATETIME NOT NULL );UNIQUE(monitor_name, alert_key)这个约束是整个告警去重的基础。同一个监控项、同一个原因只要当前状态还是firing就不会再插入新记录只会更新last_triggered_at。这套表结构从项目初期用到现在没改过说明它不需要更复杂的设计。3. 核心功能逐个落地采集、探测、告警判定的实现细节3.1 服务器指标采集psutil 的正确打开方式采集层的第一个任务是本机指标采集。Python 生态里 psutil 是当之无愧的首选它把 CPU、内存、磁盘、网络这些系统信息封装成非常友好的 API。CloddsBot 里我开了一个异步采集任务默认每 30 秒跑一次import asyncio import psutil async def collect_system_metrics(): loop asyncio.get_running_loop() # psutil 是同步阻塞库放到线程池里执行避免卡住事件循环 metrics await loop.run_in_executor(None, get_metric_snapshot) return metrics def get_metric_snapshot(): cpu_percent psutil.cpu_percent(interval1) mem psutil.virtual_memory() disk psutil.disk_usage(/) return { cpu_percent: cpu_percent, mem_percent: mem.percent, mem_available_mb: mem.available / 1024 / 1024, disk_percent: disk.percent, disk_free_mb: disk.free / 1024 / 1024, }这里有一个我一开始就踩进去的坑psutil.cpu_percent()第一次调用时如果不传interval参数返回的是 0.0因为内部没有历史参考值。正确做法是在程序启动时先调用一次做预热或者像我上面这样传interval1让它在内部阻塞一秒采集真实值。每秒阻塞一次对监控机器人来说完全没有压力但换来的是准确的 CPU 使用率。内存和磁盘的采集相对简单但要注意磁盘这一项。psutil.disk_usage(/)只能看到根分区的使用情况如果你的业务数据挂在单独的数据盘比如/data一定要在配置里单独加一条该路径的监控项。我的配置文件里支持配置多个磁盘路径每个路径可以设置独立的告警阈值比如根分区超过 80% 告警数据盘超过 90% 再告警——因为数据盘的实际可用空间大相对可以容忍更高的使用率。3.2 HTTP 探测与 SSL 证书到期检测两种最容易忽略的告警源本地指标能反映机器健康但无法反映业务真实状态。最典型的情况服务器 CPU、内存、磁盘全部正常但业务进程处于假死状态端口不响应了。这时候只能靠 HTTP 探测来发现。CloddsBot 的 HTTP 探测模块支持配置多个 URL每个 URL 可以设置期望的状态码默认是 200和超时时间默认 5 秒import httpx from datetime import datetime, timezone async def probe_url(name: str, url: str, expected_code: int 200, timeout: float 5.0): try: async with httpx.AsyncClient(timeouttimeout, verifyFalse) as client: resp await client.get(url, follow_redirectsTrue) return { name: name, ok: resp.status_code expected_code, status_code: resp.status_code, elapsed_ms: int(resp.elapsed.total_seconds() * 1000), } except httpx.TimeoutException: return {name: name, ok: False, error: timeout} except httpx.RequestError as exc: return {name: name, ok: False, error: str(exc)}探测结果里除了关注返回码我还会把耗时存下来。对于核心业务接口我会把响应时间超过 1500ms 也作为一种告警条件——它可能意味着数据库慢查询、网络拥堵或者应用层逻辑性能劣化。虽然这类告警有时候会吵但相比完全不知道接口已经慢到用户无法忍受还是值得的。SSL 证书到期检测是我在真实业务中吃过亏后加的功能。有一次客户的 HTTPS 证书过期了浏览器直接显示红屏警告业务基本瘫痪而我竟然完全不知道什么时候过期的——因为服务器指标一切正常。get SSL 证书剩余天数我用了ssl标准库加socketimport socket, ssl, asyncio from datetime import datetime, timezone def get_cert_expiry_days(hostname: str, port: int 443) - int: ctx ssl.create_default_context() with socket.create_connection((hostname, port), timeout5) as sock: with ctx.wrap_socket(sock, server_hostnamehostname) as tls_sock: cert tls_sock.getpeercert() expiry datetime.strptime(cert[notAfter], %b %d %H:%M:%S %Y %Z).replace(tzinfotimezone.utc) return (expiry - datetime.now(timezone.utc)).days注意证书的notAfter字段格式里带了一个%ZPython 解析时不会自动转换时区所以需要手动指定tzinfotimezone.utc否则和当前时间相减时会报错或者得到错误天数。证书剩余天数小于 14 天我就开始告警小于 7 天会把告警级别提到高这样接到告警后还有充分的缓冲时间去操作证书续期。3.3 告警判定与状态机如何避免抖动的 CPU把你吵死监控系统最让人头疼的问题之一就是告警抖动CPU 瞬时冲到 90% 触发了告警两分钟后恢复正常然后 CPU 又冲到 90%再次触发告警。如果每次瞬时越界都推送一天能收到上百条垃圾消息最后的结果是所有人把通知屏蔽了真正的告警反而没人看。我的解决方案是一个简单的三态状态机每个监控项有三种状态——NORMAL正常、PENDING待确认、FIRING已触发。数据每次进入判定层时走下面这个流程当前值在阈值内状态置为NORMAL计数器清零。当前值超出阈值但状态是NORMAL状态变成PENDING计数器加 1继续观察。当前值超出阈值且状态是PENDING计数器加 1如果连续次数达到配置要求我默认设置 3 次状态变成FIRING触发通知。如果状态是PENDING但值恢复到正常状态回到NORMAL计数器清零。在整个采集体系里通知层只响应NORMAL - FIRING这一次转换恢复通知也只响应FIRING - NORMAL这一次转换。中间无论 PENDING 状态维持多久、计数器加了多少次都不会发任何消息。这样做的结果很直观告警消息量减少了八成以上而且剩下的告警每一个都是值得人工看一眼的真实问题。判定层还处理了恢复通知的逻辑。当连续两次采集值都回到正常范围后状态机从FIRING转回NORMAL推送一条告警已恢复的消息。注意恢复判定也是两次而不是一次防止指标在阈值边缘来回横跳时反复收发触发/恢复的通知对。4. 通知路由与自动修复让告警从发现走向闭环4.1 多渠道通知的封装思路告警已经判定了接下来要做的是让人知道。CloddsBot 的通知层设计了一个非常简单的基类class Notifier(ABC): abstractmethod async def send(self, title: str, content: str, level: str) - bool: ...DingTalkNotifier、WeComNotifier、TelegramNotifier、EmailNotifier各自实现这个基类。发送时根据配置文件里的channels列表把所有渠道并发发送。这里的经验是不要只配一个渠道如果那个渠道本身出了故障告警就发不出来了。我自己最常用的是钉钉 Webhook 加邮件双通道钉钉保证能第一时间弹出手机通知邮件作为一个不会丢的备份。Webhook 的签名机制加签模式一开始也让我花了一点时间——钉钉的加签逻辑是在 Webhook URL 后面追加timestamp和sign两个参数签名算法是对timestamp \n secret做 HMAC-SHA256然后 Base64 编码最后做 URL 编码。第一次对接时对着文档调了半天才跑通所以这里建议直接用官方 SDK 或者查一下语言对应的库别像我一样手撸 HMAC。4.2 自动修复动作哪些操作可以交给机器人哪些不行动作层是整个 CloddsBot 里最需要克制的一部分。自动修复能力是把双刃剑配置得当能把很多被动的深夜重启变成主动的秒级自动恢复配置不当则可能引发更严重的问题。我目前在生产环境托管的自动动作只有三个第一个是磁盘清理。当磁盘使用率超过 90% 且持续触发告警时执行一条预设的清理命令比如docker system prune -f --volumes或者find /var/log -name *.log -mtime 7 -delete。磁盘满导致服务挂掉是运维里最常见的故障这个动作风险低、收益高值得自动化。第二个是服务重启。当 HTTP 探测连续失败超过 5 次时执行systemctl restart或者docker compose restart。这个动作相对激进我加了两个保护条件同一服务的重启动作30 分钟内只能执行一次如果 30 分钟内同一个服务第二次触发重启需求自动升级为高优先级告警并通知人工介入不再自动操作。第三个是转发工单。对于无法自动处理的动作CloddsBot 可以生成一条标准格式的文本POST 到内部的工单系统 API 上。这等于是在告警和人工处理之间加了一道自动流转。其余的自动动作比如自动扩容云服务器、自动修改防火墙规则、自动回滚代码版本我全部不建议直接交给机器人。原因很朴素这些操作一旦出错影响面往往比原始故障还要大而且恢复起来非常困难。自动化的边界应该设在可逆、低风险、执行时间短这三条都满足的操作上。4.3 确认机制人工确认不应该是可选项自动动作不能是触发即执行必须有一个确认机制兜底。CloddsBot 对每个自动动作都配置了require_approval开关即使打开了自动修复在真正执行动作前也会先发送一条计划执行以下命令的审核通知等待 60 秒。如果在 60 秒内有人在运维群里回复了取消字样本次动作会被取消告警直接升级为人工处理。这个设计借鉴了变电站保护装置里重合闸的思想保护动作可以自动完成但必须有手动闭锁的途径。实际使用下来60 秒的等待时间对磁盘清理和自动重启来说几乎感觉不到延迟但给了人一个关键的反悔窗口。有一次我部署的测试环境里清理命令的正则表达式写错了差点删掉了另一个目录下的数据就是靠这个确认机制拦截下来的。从那之后我所有自动动作的require_approval都保持开启状态。5. 部署到生产前我踩过的三个坑5.1 容器内 psutil 数据不准的问题一开始我把 CloddsBot 直接打包成 Docker 镜像部署但很快就发现一个严重的问题容器内用 psutil 读取到的 CPU 和内存数据是容器自身的配额数据而不是整个宿主机的。比如宿主机有 16 核 32G我的容器只分配了 1 核 512M那 psutil 读到的内存总数就是 512M而不是 32G。这样设置告警阈值时就会很困惑——你到底是该按容器规格配还是按宿主机规格配阈值大小取决于容器配额一旦容器配额调整告警阈值也得跟着改这是非常糟糕的维护体验。我的解决方案CloddsBot 默认以宿主机模式运行读取宿主机的/proc和/sys目录而不是容器隔离后的视图。具体做法是在 Docker 启动时把宿主机的根文件系统只读挂载进容器volumes: - /:/hostfs:ro - /var/run/docker.sock:/var/run/docker.sock然后在采集时设置psutil的环境变量PROC_FS_PATH/hostfs/proc或者干脆用挂载宿主机 PID 命名空间的方式pid: host。这个改动之后容器内的指标就和宿主机完全一致了。如果你只是监控容器自身的资源配额那直接用默认模式没问题但如果你像我一样需要综合判断宿主机是否有磁盘或内存瓶颈就必须处理这个问题。5.2 SQLite 的锁与 asyncio 的冲突CloddsBot 的采集、判定、通知、动作四条链路是并发的多个协程可能同时往 SQLite 里写数据。SQLite 在同一时刻只允许一个写事务如果多个线程/协程同时写就会出现database is locked的错误。我在本地测试时因为并发量小一直没有触发这个问题直到部署到一台告警比较频繁的服务器上才密集出现报错。解决办法是给数据库操作加一个全局的asyncio.Lock确保同一时间只有一个协程执行写操作_db_lock asyncio.Lock() async def write_alert(alert_data): async with _db_lock: # execute INSERT or UPDATE ...由于 CloddsBot 的写入频率本来就很低这个全局锁完全不会成为性能瓶颈。此外我还把 SQLite 连接设置为check_same_threadFalse配合WAL模式进一步减少读写的互相阻塞。设置了PRAGMA journal_modeWAL之后database is locked的出现频率也降低了不少。5.3 时区问题为什么告警时间总是比北京时间晚 8 小时这是另一个几乎每个自建监控系统都会踩、但文档里很少强调的坑。如果服务器时区是 UTC而 CloddsBot 直接取datetime.now()去记录告警时间那么所有告警的时间戳都会比北京时间少 8 小时。排查问题的时候看到一条凌晨 3 点的告警实际上发生时间是前一天晚上 19 点——这种错位对快速定位故障非常有误导性。我的做法是两处统一第一在 Dockerfile 里设置ENV TZAsia/Shanghai第二在 Python 代码里用zoneinfo明确指定默认时区from zoneinfo import ZoneInfo from datetime import datetime def now_local(): return datetime.now(ZoneInfo(Asia/Shanghai))所有时间记录、通知内容里的时间字段都调用now_local()获取不依赖系统默认时区。这样即使在不同的服务器上部署告警消息里显示的时间也都是同一个时区不需要再去换算。6. 半个多小时后我还是要说一句这类工具的核心不在代码在克制CloddsBot 从第一个版本到现在经历过几次比较大的调整大部分都不是因为代码写错了而是因为我对监控这件事的理解变了。最早我以为监控系统的衡量标准是覆盖面全不全——各种指标都采集各种阈值都配上。后来发现完全不是衡量标准应该是告警有效率和信噪比。一个每天推 50 条消息的监控系统不如一个一周只推 3 条消息但每条都能牵引人解决问题的系统。为了达到这个效果我做的很大一部分工作其实是在做减法给瞬时抖动加确认机制、给自动修复加人工确认窗口、把重复告警合并成一条持续通知。每一次克制都是在降低人对告警的麻木感。如果你准备在自己的环境里搭一套类似的轻量监控我最实在的建议是第一从最痛的一条告警开始先解决你最频繁遇到的那类故障不要一开始就把所有指标都配满第二告警阈值宁可设松一点也不要设得让人习惯性忽略第三每周花五分钟翻一下历史告警哪些是真实影响业务的、哪些是纯噪音然后动手调整配置。监控系统是养出来的不是搭出来的。最后分享一个部署上的小技巧CloddsBot 的配置文件和 SQLite 数据文件我都放在宿主机的一个独立目录里和容器生命周期彻底解耦。这样每次更新镜像、重建容器所有历史告警和配置都不会丢。升级时只需要拉新镜像重新docker compose up -d数据完整还在。就这一条至少让我省了两次恢复监控系统配置的额外工作量。