做后端或者运维的同学,应该都经历过这种深夜场景:群里突然有人开始@你,说线上接口挂了,你一边道歉一边爬服务器,翻日志才发现错误已经堆了几十分钟。我早期也被这么坑过好几回,后来就专门做了一个“Python 盯日志”的小工具:持续跟踪系统日志文件,一旦出现 ERROR、Traceback 这类异常特征,立刻通过邮件和 Webhook 把警报推到该去的地方。这个项目就叫“用 Python 监控系统日志并发送警报”,核心价值就是把“人肉巡检日志”变成“机器自动兜底”,让异常暴露时间从小时级、分钟级缩短到秒级。
这种工具适合两类人:一是服务器上有业务日志、但还没接入完整监控体系的运维和开发,二是想通过一个可落地的小项目掌握文件追踪、正则匹配、告警去重这些基础工程的 Python 学习者。下面把我整个实现思路、代码细节和上线后踩过的坑一次讲完,代码可以直接抄走,改配置就能用。
1. 方案设计:日志监控到底要解决什么问题
1.1 先把需求拆清楚
开头讲的场景,核心需求一句话:主动、及时、准确地发现日志中的异常,并通知到人。拆开看有这几条硬性指标:
- 实时性:日志从写入到被识别为异常,间隔要控制在秒级,不能等用户投诉才发现。
- 准确性:过滤掉健康检查、心跳包这类噪声,只告警真正的异常。
- 可靠性:监控进程自己挂掉时能自动拉起,或者至少有能让人发现的迹象。
- 降噪能力:同一类错误连续刷屏时,不能一分钟发几十条告警把群炸掉。
- 可配置:换一台机器、换一批日志路径、换一个告警渠道,都不应该动代码。
这些需求直接决定技术选型。如果只是临时看看日志,直接在终端里跑 tail -f 加 grep 就完事,但要做成一个持续性服务,就得有独立进程、状态管理、通知渠道这些完整工程。我之所以一开始就想着自研而不是直接上重型监控平台,是因为很多小团队的场景根本犯不上铺一套完整的可观测性基础设施,一个轻量脚本完全够用,后面真要升级再平滑迁移。
1.2 技术选型:轮询读文件还是事件驱动
实现文件跟踪有两条主流路线:轮询读(polling)和内核事件通知(inotify)。inotify 能拿到内核级的文件变更事件,响应快、CPU 占用低,但实现复杂度明显更高,要处理事件丢失、环形缓冲溢出、重入等边界情况。轮询就简单很多:每隔几百毫秒去 readline 一次,有内容就处理,没有就睡一小会儿。对日志写入频率不算极端夸张的业务场景,轮询完全够用,而且进程就算偶尔卡了一下,最多延迟几百毫秒,不会像 inotify 那样直接丢事件。
Python 里这两条路都有现成库。inotify 依赖第三方库,还需要系统权限配合;轮询方案只用标准库 open 和 readline 就能实现,跨平台表现更好。选型对比大概是这样:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| shell tail -f 管道 grep | 零依赖、快速验证 | 状态难管理、无告警、无去重 | 临时排查问题 |
| Python 轮询读取 | 代码量小、行为可控、易扩展 | 有几百毫秒延迟 | 中小规模日志监控 |
| inotify 事件驱动 | 响应快、资源占用低 | 实现复杂、边界情况多 | 大规模实时系统 |
| 完整监控平台加日志聚合 | 功能全、可视化、统一告警 | 部署成本高、学习曲线陡 | 团队级基础设施 |
我的结论很明确:个人或小团队的日志监控场景,把 Python 轮询方案做成一个常驻 systemd 服务,性价比最高。这个脚本后续就算被完整监控平台替代,也可以作为过渡方案保留,逻辑不冲突。
1.3 整体结构与数据流
项目拆成四个模块:配置解析、文件读取、异常匹配、告警通知。数据流是一条直线:
配置文件(config.yaml)→ 文件读取模块(跟踪日志文件、处理轮转)→ 匹配模块(正则判断是否命中异常)→ 告警模块(去重、发送邮件和 Webhook)
每个模块只专注一件事,替换起来也方便。比如后面想把邮件渠道换成短信,只需要改告警模块,其他不用动。这种“管道式”结构是我做自研小工具时最推荐的方式,千万别把读取、匹配、告警全部揉进一个 while 循环里,否则后面每一次需求变更都会让你怀疑人生。
2. 工程结构与配置骨架
2.1 依赖与目录布局
整个项目运行时依赖极少,只需 Python 3.6 以上标准库,外加 requests 发 Webhook 用;配置解析我用 PyYAML,嫌麻烦也可以改成 JSON。安装命令:
pip install requests pyyaml推荐目录结构如下:
/opt/log-monitor/ ├── config.yaml ├── monitor.py # 主程序:编排调度 ├── tailer.py # 文件跟踪器:读取与轮转处理 ├── notifier.py # 通知器:邮件、Webhook、去重 └── log_monitor.service # systemd 单元文件部署很简单,整个目录拷到服务器上,改 config.yaml 测试跑通,再交给 systemd 托管。后面我会详细讲托管步骤。
2.2 配置文件的设计思路
配置文件是整个工具的“业务中心”。我采用 YAML,结构大致如下:
files: - path: /var/log/app/error.log patterns: - "ERROR" - "CRITICAL" - "Traceback" ignore: - "healthcheck" - "heartbeat" context_lines: 2 min_events: 3 window_seconds: 60 notify: email: enabled: true smtp_host: "smtp.example.com" smtp_port: 465 username: "monitor@example.com" password: "此处填授权码" to: - "ops@example.com" webhook: enabled: true url: "https://hook.example.com/robot/send" token: "可选"几个设计点我展开讲一下。
path 支持配置多条文件,每条都有自己的 patterns。patterns 是命中即告警的正则列表,ignore 是反向过滤,先过 ignore,再匹配 patterns,能把健康检查、心跳日志这类噪声干净地滤掉。context_lines 表示告警时带上命中行的前 N 行和后 N 行,排障的时候作用巨大。min_events 和 window_seconds 是聚合阈值:60 秒窗口内同一文件累计命中 3 条异常才告警,可以显著降低偶发随机错误带来的误报。
为什么阈值放配置而不是写死在代码里?因为不同业务差异太大。支付服务一分钟出现 1 条 ERROR 就可能要命,内容服务一分钟 10 条也未必是真故障。做成配置项,上线时直接调参,不需要改代码重新发布,这个收益在排障的深夜体现得淋漓尽致。
3. 核心模块:文件读取与轮转处理
3.1 从“tail -f”说起:轮询读取器的基本写法
文件读取的核心就是模拟 tail -f 的行为。tail -f 会持续输出文件新增内容,我们用 readline 实现一个等价物:
import os import time class FileTailer: def __init__(self, path, encoding='utf-8', poll_interval=0.3): self.path = path self.encoding = encoding self.poll_interval = poll_interval self.inode = None self.fp = None self._open(seek_end=True) def _open(self, seek_end=True): stat = os.stat(self.path) self.inode = stat.st_ino self.fp = open(self.path, 'r', encoding=self.encoding, errors='ignore') if seek_end: self.fp.seek(0, os.SEEK_END) def read_new_lines(self): if self.fp is None: self._open() while True: line = self.fp.readline() if line: yield line.rstrip('\n') else: cur_size = os.path.getsize(self.path) if self.fp.tell() > cur_size: self.fp.seek(0) self._check_rotation() time.sleep(self.poll_interval) def _check_rotation(self): if not os.path.exists(self.path): return new_inode = os.stat(self.path).st_ino if new_inode != self.inode: self.fp.close() self._open(seek_end=True)第一个细节:seek_end。启动时游标直接放到文件末尾,表示只关心启动之后产生的新日志。如果你想启动时顺便扫一遍历史日志,可以把参数改成 seek_end=False,并配合起始时间过滤来做一次性分析。
第二个细节:readline 返回空串,说明暂时没有新内容。这时要做两件不起眼但很关键的事情:检查当前文件实际大小与游标位置的比较。如果游标超过文件实际字节数,说明文件被清空过,比如 logrotate 配置了 copytruncate 模式,把游标从越界位置拨回文件开头,否则清空后写入的新内容会一直在游标之后,永远读不到。
第三个细节:encoding 和 errors='ignore'。业务日志经常混着几种编码,个别字节解析不了时,errors='ignore' 能防止整个进程因为一个 UnicodeDecodeError 崩溃。这个参数救过我好几次,千万别省。
3.2 用 inode 识别日志轮转
日志轮转是监控工具最容易翻车的地方。logrotate 常见两种方式:rename 后新建同名文件,或者 copytruncate 直接清空原文件。处理 rename 最可靠的标志是 inode——Linux 下文件的内核索引节点号。rename 之后,新文件的 inode 一定会变化。所以每次读不到数据时,用 os.stat 重新取一次 inode,发现变了就说明发生过轮转,关闭旧句柄,重新打开新文件。
代码里 _check_rotation 做的就是这个事。注意一个细节:轮转后新文件要从末尾开始读,避免把旧日志再告警一遍。我见过有人忘记这一句,每次日志轮转后线上群就炸一次,全是重复邮件。
3.3 断点续读:进程重启后不丢内容
运维工具自己也会重启,如果每次重启都从文件末尾读,重启窗口期产生的日志就全丢了。我的做法是引入“读取位置”存档,每隔 30 秒把每个文件的 inode 和偏移量写进一个 JSON 文件:
import json class PositionStore: def __init__(self, db_path='/var/lib/log-monitor/positions.json'): self.db_path = db_path self.data = self._load() def _load(self): try: with open(self.db_path, 'r', encoding='utf-8') as f: return json.load(f) except Exception: return {} def save(self, path, inode, offset): self.data[path] = {'inode': inode, 'offset': offset} with open(self.db_path, 'w', encoding='utf-8') as f: json.dump(self.data, f)启动时如果存档存在且 inode 没变,就把游标 seek 到保存的偏移量;inode 变了说明轮转过,仍然从末尾开始。这个设计让“进程重启”这种常见故障不再产生告警盲区。我实测过线上重启十几次,日志一次都没丢。
4. 告警模块:去重降噪与多渠道发送
4.1 命中判定与上下文收集
文件读取器拿到新行后,交给匹配器处理。匹配顺序很重要:先判断 ignore 正则,命中了直接跳过;再判断 patterns,命中才算异常。代码实现如下:
import re class Matcher: def __init__(self, patterns, ignores): self.patterns = [re.compile(p) for p in patterns] self.ignores = [re.compile(p) for p in ignores] def is_ignored(self, line): return any(p.search(line) for p in self.ignores) def match(self, line): return any(p.search(line) for p in self.patterns)这里有个经验:正则一律用 search,不能用 match。match 只从字符串开头匹配,但日志行前面往往带时间戳、模块名、请求 ID 等前缀,异常关键字通常在中后段,用 search 才能保证命中。另外注意正则里的特殊字符转义,比如日志里的括号、点号,写模式时不留神就会导致永远匹配不上。
上下文收集用 deque 维护一个“上一段缓冲区”,只保留最近 N 行。命中异常时,把缓冲区里的历史行、命中行、再加上后续读取的若干行,一起拼成告警文本。很多告警单看一行根本定位不了问题,但带上前面几行调用栈上下文,一眼就能看出来是哪段逻辑崩了。
4.2 告警去重:频率限制与指纹识别
最烦人的情况是:一个异常每两秒刷一次,如果每行都发告警,群消息量直接爆炸,真正重要的问题反而被淹没。我做了两层降噪。
第一层,聚合计数。按配置里的 min_events 加 window_seconds:窗口时间内累计命中 N 条才触发一次告警。偶发抖动基本被滤掉,持续故障不会错过。
第二层,指纹去重。对“上下文加命中行”的完整文本做 MD5,取前 16 位作为指纹。同一指纹如果在去重窗口内已经发过告警,就不再发。我的去重窗口默认设 10 分钟:
import hashlib import time class AlertDedup: def __init__(self, window=600): self.window = window self.last_alert = {} def should_alert(self, fingerprint): now = time.time() last = self.last_alert.get(fingerprint, 0) if now - last < self.window: return False self.last_alert[fingerprint] = now return True两层都做完之后,线上群从“一分钟几十条刷屏”降到“同类异常 10 分钟最多一条”,而新出现的异常依然秒级触达。我把调用顺序固定成:先 ignore,再聚合计数,再指纹去重,最后真正发送。这套顺序调换任何一个,降噪效果都会打折扣。
4.3 邮件通知的 SMTP 实现
邮件是实现成本最低、覆盖面最广的告警渠道。Python 标准库的 smtplib 加 email 就能搞定,完整实现如下:
import smtplib import ssl from email.mime.text import MIMEText from email.header import Header def send_email(config, subject, body): msg = MIMEText(body, 'plain', 'utf-8') msg['Subject'] = Header(subject, 'utf-8') msg['From'] = config['username'] msg['To'] = ', '.join(config['to']) context = ssl.create_default_context() with smtplib.SMTP_SSL(config['smtp_host'], config['smtp_port'], timeout=10, context=context) as server: server.login(config['username'], config['password']) server.sendmail(config['username'], config['to'], msg.as_string())几个注意事项:
- 推荐 SMTP_SSL,端口通常 465;如果邮件服务商只支持 STARTTLS,就改成 SMTP 加 starttls 方式。
- SMTP 密码建议填“客户端授权码”,而不是邮箱登录密码,防止授权范围过大。
- 发送函数必须带超时和重试。SMTP 偶尔会瞬断,我的策略是发送失败后等待 5 秒重试两次,仍然失败就把告警文本追加到本地 fail_queue,落盘不丢。
- 邮件正文别原样贴日志里的敏感信息,比如密钥、身份证号、内部 Token,发送前先做一次脱敏。
4.4 Webhook 群机器人的实现
邮件是“可能看一眼”,Webhook 是“直接到手机”。很多团队的工作群机器人就是干这个的,实现本质是 POST 一个 JSON 到固定 URL:
import requests def send_webhook(config, title, body): url = config['url'] payload = { 'title': title, 'content': body[:3000], 'type': 'alert' } resp = requests.post(url, json=payload, timeout=5) resp.raise_for_status()几个关键点:
- 不同 IM 工具自定义机器人协议细节不一样,但基本都是“POST JSON 到固定 URL”的思路,拿到协议文档后改 payload 字段即可。
- 正文截断到 3000 字内,避免超长消息被平台限制或刷屏。
- requests 必须显式加 timeout。有一次我忘了加,上游接口挂了,所有告警线程都卡在连接上,结果真正的故障没发出来,堆积的连接反而把网络占满。这个教训很深刻:所有外部调用一律写 timeoute,没有任何例外。
- Webhook URL 本身包含鉴权信息,别提交到公共仓库。建议通过环境变量注入,或者只保存在服务器本地的只读配置文件里。
5. 部署为系统服务与运行效果
5.1 用 systemd 托管进程
命令行直接 python3 monitor.py 能跑,但服务器重启后没有人手动拉起。正规做法是交给 systemd。单元文件如下:
[Unit] Description=Log Monitor Daemon After=network-online.target [Service] Type=simple WorkingDirectory=/opt/log-monitor ExecStart=/usr/bin/python3 /opt/log-monitor/monitor.py Restart=always RestartSec=10 Environment=PYTHONUNBUFFERED=1 [Install] WantedBy=multi-user.target几个配置的用意:Restart=always 表示进程异常退出时自动拉起,RestartSec=10 防止进程陷入疯狂重启循环;PYTHONUNBUFFERED=1 让 print 输出实时落到日志,方便用 journalctl 排查。启用命令:
sudo cp log_monitor.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now log-monitor上线后我建议每天检查一次服务状态和告警数量。另一个经验:如果预算和条件允许,监控端尽量和被监控服务器分离。同机部署的话,“服务器挂了连告警都发不出来”这种尴尬场景是真实存在的;只能同机时,至少保证告警走外部渠道,别依赖本机其它服务才能发出去。
5.2 脚本自身的可观测性
监控工具最怕“静默失效”:进程看着在跑,实际早不干活了。我的对策有三层。
第一层,脚本自己写运行日志。每天一个文件,记录每次告警内容发送成功与否、文件跟踪状态、位置存档更新时间,出问题时有据可查。
第二层,内置健康心跳。每 5 分钟往固定的 Webhook 发一条心跳消息。心跳丢失,说明脚本挂了、网络断了,或者上游接口出问题了,值班的人能第一时间发现“监控者”挂了。
第三层,在 systemd 里配置资源限制。比如内存泄漏导致 CPU 飙升时,可以通过 cgroup 配额兜底,至少不会拖垮同机其它业务。
这三层做完,基本能保证“监控者被监控”,而不是让监控工具成为新的盲区。
5.3 性能与资源占用实测
我把这个脚本挂在一台轻量服务器上,同时盯 4 个文件,运行一周,记录到的资源占用如下:
| 指标 | 实测值 |
|---|---|
| 常驻内存 | 约 18MB |
| CPU 占用 | 日常接近 0,写入高峰约 2%-5% |
| 告警延迟 | 平均小于 1 秒 |
| 位置存档大小 | 约 2KB |
这点开销对一台业务服务器来说基本可以忽略。内存消耗主要是 Python 解释器加正则编译缓存,文件读取用的是游标式流读,不会把整个日志文件载入内存。这也是我坚持用 readline 而不是 read 整个文件再切分的原因,后者在大日志文件上内存会直接失控。
6. 常见问题与排查经验
6.1 收不到任何告警
先看脚本有没有真的跑起来,用 systemctl status log-monitor 查进程状态和最近日志输出。再看文件路径权限,日志通常只有 root 或所属用户能读,脚本运行用户没有读权限时,读取器会一直拿不到有效数据。最后考虑游标位置:如果脚本在日志写入之前启动,游标已经到文件末尾,新日志出现后才会被读到,这是符合预期的,不用慌。
6.2 告警重复轰炸
大多数情况是去重逻辑没生效。排查方法:打开脚本的运行日志,看指纹是否一致。如果同一段文案每次指纹都不同,八成是告警内容里带了时间戳、PID、毫秒级序号这类动态字段,导致指纹随内容变化而漂移。解决办法是计算指纹前先把这些动态字段用正则归一化掉,让“同一类”异常产生同一个指纹。
6.3 日志轮转后读不到新内容
这是最典型的翻车场景。检查顺序:确认 logrotate 是 rename 还是 copytruncate。如果直接清空原文件,inode 不变,但游标已经跑到文件之外,需要靠文件大小比对把游标拨回 0,这就是 3.1 节里 cur_size 判断的作用。如果是 rename,inode 变了,需要靠 inode 比对重新打开文件。两个机制必须都写上,只写一个迟早会踩坑。
6.4 中文或特殊字符乱码
日志编码不统一是常态。我的建议:读取时统一用 UTF-8 加 errors='ignore',发送时也统一 UTF-8,正则默认就是 Unicode 模式,中文关键字匹配没问题。如果确实有 GBK 日志,就在配置文件里给对应文件单独指定 encoding 字段,一行改动就能解决,不需要动代码。
6.5 告警延迟比预期高
延迟主要来自三块:轮询间隔、SMTP 发送耗时、Webhook 响应时间。轮询间隔 0.3 秒已经能保证秒级发现;SMTP 首次发送有时候要两三秒,这是邮件网关的解析时间,压不下来;真正让延迟异常高的往往是外部通知接口响应慢。所以 Webhook 和 SMTP 都应该异步发送,让监控主循环不被网络 I/O 阻塞。
顺手整理一个速查表方便对照:
| 现象 | 常见原因 | 处理办法 |
|---|---|---|
| 完全收不到告警 | 服务未启动、文件权限不足、位置存档损坏 | 检查服务状态与运行用户权限,清理异常存档 |
| 告警刷屏 | 指纹去重失效、上下文包含动态字段 | 指纹归一化,增加聚合阈值 |
| 轮转后丢日志 | 未处理 inode 或游标越界 | 同时实现 inode 比对与截断回拨 |
| 乱码 | 文件编码非 UTF-8 | 配置文件按文件指定编码,读取时忽略坏字节 |
| 延迟偏高 | 通知发送阻塞主循环 | 邮件与 Webhook 改异步线程池 |
7. 写在最后的实战心得
这个项目我从最早的一个 while 循环加 print 一路改到现在的多模块加 systemd 托管,最深的感觉是:监控工具的第一敌人不是日志里的异常,而是它自身不可观测。所以最后再分享两个小技巧。
第一个,开发调试时别干等线上故障。我用一个简单的 fake 日志生成器,定时往测试文件里写错误行,几秒钟就能触发一次完整链路,验证轮转、去重、通知全部正常。测试通过后再对真实日志放开,比上线后抓瞎强太多。
第二个,告警内容一定要带链接和上下文,而不是只丢一行 ERROR。我在告警正文里会拼接日志文件路径、命中的正则、上下文代码块、服务器标识,以及一个日志平台的检索链接(如果有的话)。收告警的人少了“这到底发生在哪个系统”的连环追问,处理故障的时间能省下一大截。
这套工具的扩展上限也完全可以想象:给聚合计数加趋势分析,连续多次递增告警;命中关键字后自动执行一个重启脚本;把告警记录写入本地数据库做周报统计。但骨架就是上面这些,文件跟踪、轮转识别、去重降噪、多渠道通知,把这四块吃透,你就能在任何一个真实环境里快速搭出自己的日志告警系统。