日志这东西,平时没人看,出问题的时候又恨它写得太少。我早年间维护几台业务服务器,基本处于"用户反馈登不上,我才手忙脚乱去翻日志"的救火状态。后来痛定思痛,写了这套用 Python 监控系统日志并自动发送警报的小系统,实际跑了快两年,确实在磁盘写满、内存溢出、接口频繁报错这些大事发生之前,提前逮住了好几次苗头。这篇把整个项目的设计思路、落地代码和踩坑过程完整梳理一遍,给同样被日志淹没的运维和开发同学一个可以直接抄作业的参考。
这套东西能做什么?简单说就是:替你盯着一个或多个日志文件,增量读取新增内容,按预设规则做关键字或正则匹配,一旦命中就把告警通过邮件或群机器人 Webhook 推到你手机。它解决了什么痛点?不是你天天去tail -f守着,也不是等监控平台告警了才去看一眼,而是把"主动找问题"变成"问题来找你"。适合谁看?适合日志量不大不小、暂时没条件上整套日志平台的团队,也适合想在服务器上快速搭一个轻量告警工具的个人维护者。
1. 日志监控的整体思路与方案选型
1.1 要监控的日志,到底有哪些坑
先说一个容易被忽略的现实:日志文件不是乖乖躺在原地让你读的。线上服务器几乎都开了 logrotate,日志文件会在某个时间点被改名、压缩、再新建一个同名文件;有些程序还会在运行时直接 truncate 清空文件。如果脚本老老实实打开一个文件流然后 readline,跑几个小时后大概率遇到要么读重复、要么读到一半文件被轮转就断了的情况。这比写正则匹配规则更早需要处理,而且处理不好,整个监控就废了。
另一层痛点是日志的"语言"混乱。业务系统既有 INFO、WARN、ERROR 这种标准级别,也有OutOfMemoryError、Connection refused、Query_time: 3.2s这种散落的关键词。单靠简单grep ERROR会漏掉很多有效信息,而把所有日志都当关键字扫描又太粗。所以规则引擎必须支持正则,而且要能区分告警级别——有的日志命中后只需要在群里刷一条弹窗,有的则需要发邮件抄送所有人。
1.2 为什么不用现成的日志采集方案
肯定有人问:ELK、Loki、云厂商的日志服务不香吗?香,但重。在只有几台机器、日志量一天几十 MB 的场景下,上一套采集端再部署服务端,光是维护 Kafka 或者 Elasticsearch 集群的人力成本就超过收益了。而且很多日志服务默认是"集中存储和搜索",告警规则配置起来并不灵活,想实现"同一条错误 5 分钟内只提醒一次"这种业务逻辑,还得写一堆查询表达式。
我当时的需求很朴素:轻量、能自定义逻辑、改规则不用重启主程序。用 Python 脚本跑一个 2 秒轮询的循环,加上 YAML 配置文件,正好满足。现在回看,这个选择很务实——脚本占用资源不到 50MB 内存,部署就是把代码拉下来、pip install pyyaml、配好 config、supervisor 拉起,十分钟搞定,完全没有历史包袱。
1.3 核心系统流程拆解
整个系统的数据流是这样的:主循环每隔几秒遍历被监控的日志文件,从上次记录的位置继续读新增行;每一行进入规则引擎做匹配;命中规则的日志进入一个队列,由独立的消费线程负责去重和发送告警。
循环读取日志文件 -> 规则匹配 -> 入队 -> 去重判断 -> 发送告警 -> 记录状态拆开来有四个核心环节:增量读取器(LogReader)、规则匹配器(RuleMatcher)、去重器(Deduplicator)、通知器(Notifier)。任何一个环节做不好都会出故障,比如读取器不记录位置会重复告警,去重器不写会告警轰炸,通知器不重试会漏告警。接下来我会按顺序把每个模块的细节讲清楚。
2. 核心细节与实操要点解析
2.1 增量读取:怎么做到只读新日志,且重启后还能续读
增量读取的关键不只是在内存里记住"上次读到了哪一行",而是要解决重启续读。linux 环境下没有内置的 API 说"从某个 offset 读这个文件"——所有读取都要先open再seek。我的做法是:
- 每次读完一批日志,把当前文件读写位置用
tell()拿到; - 写到一个状态文件(如
/var/lib/log-watcher/positions.json)中; - 程序启动时读取这个状态文件,
open日志文件后直接seek到记录的偏移量。
这里有一个新手最容易踩的坑:如果日志文件非常紧凑,一行几十 MB 巨大日志(比如某次错误堆栈一次性打印几万行),连续多次读取但中间程序崩溃,位置信息没来得及落盘,重启后就会从头重读所有旧日志,导致历史告警重放一遍。解决办法有两个:一是写完每批日志立即落盘状态,不要攒着批量写;二是如果发现某天的日志文件已经非常大,宁可丢弃旧日志,也不要在启动时做全量扫描。
2.2 日志轮转与文件截断:inode 才是判断文件身份的"身份证"
在 Linux 下,文件名只是一个索引,真正代表文件身份的是st_ino(inode 编号)。logrotate 执行时,原日志被改名为app.log.1,程序接着再创建一个新的app.log——文件名没变,但 inode 变了。所以按文件名重新打开还不够,必须比较文件状态中的 inode。
具体判断逻辑是:
- 每次读取前用
os.stat()获取当前文件的st_ino、st_size; - 如果
st_ino和之前记录的不一样,说明文件被轮转了,此时位置状态归零,重新从新文件开头读; - 如果
st_size小于之前记录的 position,说明文件被截断了(比如启动时执行了> app.log),同样把位置归零,否则seek会超出文件末尾导致报错。
这个逻辑解决了"文件换了个马甲"的问题,也解决了"被 truncate 后文字错乱"的问题。有一个细节要单独提醒:如果两个日志文件引用了同一个 inode(比如通过 hard link 创建的两个文件名),按 inode 判断就失效了,需要额外把st_dev设备编号加入比较。实际场景很少碰见,但知道有这么回事,排查问题时不至于懵。
2.3 规则引擎设计:正则匹配之外的三个进阶策略
先说最基础的:每条规则包含一个name和一个pattern(正则表达式)。日志行逐条用re.search()匹配,命中则触发送告警。这个方案能覆盖 80% 的场景。但只做到这一步,用一段时间就会发现几个问题:
问题一:无法区分"同一条日志第一次出现"和"重复刷屏"。某次严重故障背景下,程序可能在 5 秒内打出 1000 条相同的 OutOfMemoryError。如果每条都告警,你的手机啥也不用干了。所以需要时间窗口去重:同一个规则在 N 秒(我配置成 300 秒)内最多告警 1 次,并记录首次触发时间,方便排查。
问题二:数值型规则没法直接匹配。比如慢查询日志Query_time: 3.2s,正则能匹配到,但想判断"时间大于 2 秒才告警",就得把匹配出的字符串转成浮点数再比较。我在规则配置里加了一个threshold_group字段:如果指定了某个捕获分组,就解析该分组为 float,与threshold比较后才决定是否触发。
问题三:必须支持排除项。有些关键字本身就是误报源,比如业务代码里经常出现SQLException来处理预期内的错误,但真正需要关心的是连续出现超过某个次数。排除关键字(exclude)能把这些干扰项滤掉,减少无效告警对运维注意力的消耗。
2.4 告警去重与静默期:控制告警风暴的设计
去重的核心数据结构其实很简单:一个字典,key 是rule_name + 日志内容 hash,value 是上一次告警时间。每次命中规则先查字典,如果当前时间距离上次告警时间小于静默期,就丢弃;否则重新发送并更新时间。为了防止这个字典无限膨胀,每隔一段时间要清理掉太久没命中的 key。
静默期数值怎么定比较合理?我用的经验值:普通 WARNING 级别静默期 900 秒(15 分钟),ERROR 级别 300 秒(5 分钟),FATAL 级别 60 秒(1 分钟)。这样既能捕捉反复出现的严重错误,又不会让正常的噪音告警打扰太久。另外,如果同一个错误在超过 10 个静默周期内还在持续出现,我会手动去排查——这种"持续不断重复"比"偶发一次"往往更说明问题。
3. 实操环节:核心代码实现与部署
3.1 项目结构与被监控文件假设
先明确一下这套脚本的运行环境:Python 3.6 以上,Linux 服务器,需要安装pyyaml(读配置文件用)和requests(发 Webhook 用)。实际部署时,日志文件多数是/var/log/下的 system log 或者应用自定义的xxx.log目录。为了演示,我假设监控两个文件:
/var/log/app/error.log:业务系统错误日志;/var/log/app/access.log:接口访问日志(用来做慢请求告警)。
项目结构:
log_watcher/ ├── main.py # 入口 ├── config.yaml # 规则和通知渠道配置 ├── watcher/ │ ├── __init__.py │ ├── reader.py # 日志读取 │ ├── matcher.py # 规则匹配 │ ├── notifier.py # 告警通知 │ └── state.py # 位置管理3.2 配置文件设计:改规则不用碰代码
配置文件用 YAML,因为可读性好,写规则的人不懂 Python 也能改。核心配置如下:
loop_interval: 2 # 主循环轮询间隔(秒) position_file: /var/lib/log-watcher/positions.json log_files: - /var/log/app/error.log - /var/log/app/access.log rules: - name: 应用致命错误 source: /var/log/app/error.log pattern: "(?i)(fatal|critical|panic|out of memory|oom-kill)" level: critical dedup_interval: 300 actions: [webhook, email] - name: 慢接口请求 source: /var/log/app/access.log pattern: "response_time=([0-9]+\\.[0-9]+)" threshold_group: 1 threshold: 3.0 level: warning dedup_interval: 60 actions: [webhook] notify: email: smtp_host: smtp.example.com smtp_port: 465 use_ssl: true username: alerts@example.com password_env: SMTP_PASSWORD from_addr: alerts@example.com to_addrs: ["ops@example.com"] webhook: url: "https://webhook.example.com/hooks/xxx"几个设计要点:
source字段限定该规则只匹配指定的日志文件,避免误报;threshold_group和threshold配合实现数值判断,避免正则匹配出文本却没法做大小于比较;- 邮件密码不直接写在 YAML 里,而是从环境变量读取(
SMTP_PASSWORD),防止配置泄露。
3.3 核心读取模块:LogReader 的完整实现
import os import json import time class LogReader: def __init__(self, filepath, state): self.filepath = filepath self.state = state self.last_ino = state.get(filepath, {}).get("ino") self.position = state.get(filepath, {}).get("pos", 0) def read_new_lines(self): if not os.path.exists(self.filepath): return [] stat = os.stat(self.filepath) cur_ino = stat.st_ino # 文件被轮转:inode 变化了,从头开始读新文件 if self.last_ino and cur_ino != self.last_ino: self.position = 0 self.last_ino = cur_ino # 文件被截断:新文件比当前位置还小,强制回到开头 if self.position > stat.st_size: self.position = 0 with open(self.filepath, "r", encoding="utf-8", errors="replace") as f: f.seek(self.position) lines = f.readlines() self.position = f.tell() self.save_state() return lines def save_state(self): data = self.state.get_all() data[self.filepath] = {"ino": self.last_ino, "pos": self.position} with open(self.state.state_file, "w") as f: json.dump(data, f)说三个实现细节:
encoding="utf-8", errors="replace"。我的服务器日志从来不是纯 UTF-8,总有几条 GBK 或乱码夹杂其中。如果不用errors参数,遇到非法编码直接抛异常,整个监控就崩了。用replace把非法字符替换成?,保证程序永远不断。st_size与position比较。如果文件被truncate -s 0清零了,老的位置值会大于新文件大小,直接seek会抛OSError: Invalid argument。所以先判断,位置非法就重置为 0。- 状态的强一致性。
save_state()在每批日志读完就调用一次。虽然频繁写盘有轻微的 IO 开销,但在日志量一般的情况下(每分钟几十行)完全可以忽略,换来的是进程崩溃后最多重复读一次日志,不会重放过量告警。
3.4 规则匹配与去重实现:Matcher + Deduplicator
import re import time import hashlib class RuleMatcher: def __init__(self, rules_cfg): self.rules = [] for cfg in rules_cfg: compiled = { "name": cfg["name"], "source": cfg.get("source"), "pattern": re.compile(cfg["pattern"], re.IGNORECASE), "level": cfg.get("level", "warning"), "exclude": re.compile(cfg["exclude"], re.IGNORECASE) if cfg.get("exclude") else None, "threshold_group": cfg.get("threshold_group"), "threshold": cfg.get("threshold"), "dedup_interval": cfg.get("dedup_interval", 300), } self.rules.append(compiled) def match(self, line, source): for rule in self.rules: if rule["source"] and rule["source"] != source: continue if rule["exclude"] and rule["exclude"].search(line): continue m = rule["pattern"].search(line) if not m: continue if rule["threshold_group"] and rule["threshold"] is not None: try: val = float(m.group(rule["threshold_group"])) except (ValueError, IndexError): continue if val < rule["threshold"]: continue return rule return Noneclass Deduplicator: def __init__(self): self._cache = {} # key -> timestamp self._max_keys = 10000 def is_dup_and_update(self, rule_name, content, interval): key_hash = hashlib.md5( f"{rule_name}|{content}".encode("utf-8") ).hexdigest() now = time.time() last_time = self._cache.get(key_hash) if last_time and (now - last_time) < interval: return True self._cache[key_hash] = now self._cleanup(now) return False def _cleanup(self, now): if len(self._cache) <= self._max_keys: return expired = [k for k, v in self._cache.items() if now - v > 3600] for k in expired: self._cache.pop(k, None)两个点值得展开:
- hash 内容做 key 而不是只拿 rule_name 做 key。如果只按规则去重,那么一个规则下任何不同的日志内容都会互相挤掉告警机会,导致"错误 A 出现一次,10 秒内错误 B 不告警"这种误抑制。hash 整行内容,就能保证只有完全相同的错误才会在静默期内被合并,而不同内容的错误都能独立告警。
- 缓存清理。去重字典如果不清理会无限增长,所以我设置了一个上限,超过 1 万个 key 就清掉 1 小时前的旧记录。我跑了半年多,实测内存占用稳定在 30MB 上下。
3.5 通知模块:邮件和 Webhook 的发送与失败重试
通知模块我封装成Notifier,支持两个后端:SMTP 邮件和 HTTP Webhook(适配常见的群机器人)。
import smtplib import requests from email.mime.text import MIMEText class Notifier: def __init__(self, notify_cfg): self.email_cfg = notify_cfg.get("email", {}) self.webhook_url = notify_cfg.get("webhook", {}).get("url") def send_email(self, subject, body): cfg = self.email_cfg msg = MIMEText(body, "plain", "utf-8") msg["Subject"] = subject msg["From"] = cfg["from_addr"] msg["To"] = ", ".join(cfg["to_addrs"]) if cfg.get("use_ssl"): server = smtplib.SMTP_SSL(cfg["smtp_host"], cfg["smtp_port"]) else: server = smtplib.SMTP(cfg["smtp_host"], cfg["smtp_port"]) server.starttls() server.login(cfg["username"], os.environ.get(cfg["password_env"], "")) server.sendmail(cfg["from_addr"], cfg["to_addrs"], msg.as_string()) server.quit() def send_webhook(self, content): if not self.webhook_url: return payload = {"text": content} requests.post(self.webhook_url, json=payload, timeout=5)邮件配置里最坑的一个点:很多邮箱服务商要求使用"授权码"而不是登录密码。一开始我以为密码错了,反复排查几分钟才发现是授权码的问题。另外 SMTP_SSL 和 STARTTLS 的端口不一样:SSL 一般是 465,STARTTLS 一般是 587,搞混了会连接超时。我的代码里通过use_ssl做了区分,实测两种都稳定。
发送失败怎么办?网络抖动,SMTP 服务器临时拒收,都可能导致告警丢失。我在主循环里封装了一个send_alert_with_retry方法,发送失败就重试 3 次,间隔 2 秒、4 秒、8 秒(指数退避)。如果最终失败,就把告警写进本地/tmp/alert_failed.log,至少保证现场有记录。
3.6 主程序:多线程架构避免日志量大时卡死
主循环如果采用单线程,那么日志读取正常,但发邮件(网络 IO)可能会阻塞几秒钟,这几秒内新日志如果密集产出,位置记录就会滞后,下次循环会读取大量积压行,变成"告警集中爆发"。为了解决这个问题,我把系统拆成两个线程:
- 主线程:轮询读日志、规则匹配,将告警放入
queue.Queue; - 通知线程:从队列取告警、做去重、发送通知。
队列可以缓冲突发流量,通知线程慢只影响队列积压,不会导致日志读取停滞。实现很简单:
import queue import threading alert_queue = queue.Queue(maxsize=1000) notifier = Notifier(config["notify"]) dedup = Deduplicator() def notifier_worker(): while True: item = alert_queue.get() rule, line = item["rule"], item["line"] if dedup.is_dup_and_update(rule["name"], line, rule["dedup_interval"]): continue content = f"[{rule['level']}] {rule['name']}\n内容: {line.strip()}" for action in rule["actions"]: try: if action == "webhook": notifier.send_webhook(content) elif action == "email": notifier.send_email(f"[日志告警] {rule['name']}", content) except Exception: # 重试逻辑 pass def main(): state = PositionState(config["position_file"]) readers = {path: LogReader(path, state) for path in config["log_files"]} matcher = RuleMatcher(config["rules"]) t = threading.Thread(target=notifier_worker, daemon=True) t.start() while True: for path, reader in readers.items(): for line in reader.read_new_lines(): rule = matcher.match(line, path) if rule: try: alert_queue.put({"rule": rule, "line": line}, timeout=1) except queue.Full: print("alert queue full, dropping alert", flush=True) time.sleep(config["loop_interval"])这个架构在单机日志量每天几 GB 的场景下都够用。如果日志量再大一个量级,可以考虑把读取也改成多线程,每个文件一个线程,但那种情况我建议直接考虑上正式的日志系统,没必要在脚本层面硬扛。
4. 常见问题与排查技巧实录
4.1 日志读着读着就重复了,而且总会重读最后几行
症状:每次轮询,最后几行日志反复被打印和触发。原因几乎都是读取位置记录错误。最常见的是在save_state()里面把整个readlines()之后的tell()存下来了,但读完后文件又被写入了新行,下一次重新打开时seek到旧位置,又把刚读过的内容读了一遍。
我的解决办法是在读取时先把position = f.tell()存下来,等拿到新行再更新。上面实现里我在with块内已经做了正确顺序:先readlines(),再tell()。但如果你改了代码结构,比如先取出内容再计算位置,就会出现这个 bug。排查方法很直接:加日志,打印每个文件的pos和size,对比看是不是每次都在同一个值上转圈。
4.2 程序连续跑几天后内存飙到几百 MB
第一个怀疑对象是去重缓存没有清理。如果没有_cleanup,那个_cache字典会无限增长。第二个怀疑对象是日志行太大导致内存峰值。比如一个日志文件某行是一个 50MB 的 JSON 打印,readlines()一次性把它读进内存,峰值瞬间爆掉。解决办法是给read_new_lines加上行数上限,比如每次最多读取 1000 行就返回,循环再来一次。对 99% 的场景,每批 1000 行已经够用,却能把内存峰值控制在几十 MB 内。
4.3 正则匹配把 CPU 吃满了,日志处理不过来
正则匹配慢一般有两个原因:一是有人把.*和贪婪匹配写得很随意,在某些长行上出现灾难性回溯;二是日志量大而re.search一直在做无谓的扫描。
排查方式:用pcre2grep或者 Python 自带的re模块加时间统计,先确认是哪条规则最耗时。优化手段有三种:
- 把前缀匹配改成更精确的定位,比如不要
.*开头,而是^2025.*ERROR; - 编译正则一次,复用全局对象;
- 日志量大时,先做一个快速
in关键字预筛,命中后再走正则。比如规则里如果配置了必含关键字 "FATAL",就先判断行里有没有 FATAL,没有就不跑正则,能省 80% 的匹配时间。
4.4 告警发送经常失败,丢了关键信息
我遇到的主要问题是 SMTP 服务器对短时间大量发送做了流量限制。每次故障时统一的错误日志会一次性命中几十次,虽然去重器合并了告警,但通知线程偶尔还是短时间内发出过多邮件,触发服务商限流。
对策:
- 合并次数上限:同一告警在 1 分钟窗口内最多发送 2 条,之后的只记录到日志;
- 失败重试:重试要做,但不能死循环,否则主线程被阻塞,日志位置就落后了;
- 降级方案:邮件彻底挂了,就把同样的内容发到 Webhook,保证至少有一个渠道送达。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 告警重复触发 | 位置落盘不及时或顺序错误 | 检查tell()是否在读取后立即保存 |
| 告警不触发 | 规则正则写错或日志文件没匹配到source | 先用grep -E验证正则能匹配该日志 |
| 日志轮转后丢日志 | inode 判断缺失 | 使用st_ino判断文件身份 |
| 文本乱码 | 日志编码非 UTF-8 | 读取时用errors="replace" |
| 邮件发不出去 | 端口错误 / 未用授权码 | 465 用 SSL,587 用 STARTTLS;确认授权码 |
| CPU 爆满 | 正则灾难性回溯 / 无预筛 | 优化正则,追加关键字预筛 |
4.6 告警内容里别带敏感信息
日志经常会包含 IP、手机号、内部表名。发到办公群里的 Webhook 告警,最好做一次脱敏再发送。我的做法是在通知器前面加一个过滤器:把 IP 的最后一段替换成x,把明显的手机号中间四位打码。这个动作虽然简单,但能避免开发同学直接在群里晒出一堆隐私数据,被安全审计时追着问。
5. 这个项目还能怎么扩展
5.1 告警风暴治理升级:按紧急程度分级
当前的方案是单规则静默期,实际运维中往往需要"全局风暴控制"。比如某台机器网线被拔,所有应用同时报错,即使每个规则都做了五分钟静默,总告警数仍然非常大。可以在入口加一个全局计数器:最近 10 分钟内如果告警总数超过 50 条,就进入"沉默模式",只保留最严重的 FATAL 级别告警,把其他告警统一汇总成一条"XX 条告警因风暴被合并"。
5.2 从"单行匹配"到"会话聚合":错误事件的上下文
真实排障时,单行日志往往不够用。比如一条 FATAL 前面可能有十几行堆栈信息。单纯匹配到 FATAL 并告警,用户还得自己翻日志找上下文。更好的玩法是把命中告警前 20 行的内容抓出来一起发送。实现不复杂:LogReader 返回行的时候,顺带维护一个环形缓冲,存最近 N 行;命中规则时取缓冲区内容一并交给通知器。
5.3 与现有监控体系的协作方式
如果团队已经有 Prometheus 或 Zabbix,这个脚本不应该替代它们,而是做互补。脚本管"日志层面"的异常,指标监控管"资源层面"的异常。两者告警渠道可以保持完全一致,都打到同一个群里,但脚本内部带上日志文件和时间戳,方便快速定位。
还可以把脚本的读取接口封装成 HTTP,统一给 CI/CD 平台调用,实现"每次发布后自动拉取最近 ERROR 日志补充到发布单"之类的内部工具。
个人体会
整套系统最难的不是写正则,也不是发邮件,而是把"读了不重读、匹配不误报、告警不轰炸"这三个基本约束想清楚。我第一版脚本只花了一个小时写出来,结果上线后先是被日志轮转搞得重复告警,然后被同一错误刷屏刷到手机静音,再后来因为 SMTP 端口配置错误,关键故障一封邮件都没发出来。后来花了整整一个下午重写位置管理、去重和重试逻辑,才真正稳定下来。
如果你打算在自己的环境里搭一套,我的建议是:先别追求复杂功能,把这套基础流程跑通跑稳,再逐步加告警风暴控制、上下文快照这些进阶能力。日志监控本身不是目的,减少无谓的告警打扰、让真正关键的信息直达需要的人,才是它最大价值所在。