凌晨两点半被电话吵醒这种事,干过运维的都懂。去年年中我就摊上过一回:生产环境的订单服务从两点零七分开始疯狂报错,一直拖到客户投诉我们才知道。事后翻日志,从第一条 ERROR 到实际发现,中间隔了整整四十分钟。就是从那天起,我决定用 Python 写一个盯着系统日志、发现问题立刻发警报的小工具,把监控这件事彻底交出去。这篇文章就把完整方案拆给你看,包括核心代码、踩过的坑,以及为什么很多现成监控平台在这个场景下其实不划算。适合谁看?每天跟服务器日志打交道的后端开发、小团队里兼职运维的同学,以及学了 Python 想找个真实项目练手的人。
1. 为什么我不先上监控平台:自写脚本解决的真实场景
1.1 平台方案的沉重,恰恰是小团队的负担
一说到日志监控,很多人第一反应是上 ELK 或者 Prometheus,再保守一点也得上个 Zabbix。这些方案成熟吗?确实非常成熟,功能也全。但对于一个小团队、几台服务器、核心诉求只是“出错立刻通知我”的场景,这套东西显得过分沉重了。
我在这里不是要贬低平台,而是想说清楚一个真相:平台本身是需要维护的。ELK 光是 Elasticsearch 和 Logstash 的内存开销就不小,Zabbix 的服务端加 agent 配置起来也有一堆前置条件。你最后想监控日志,结果先要监控 ELK 自己。我见过不止一个小团队上了 Zabbix,半年之后除了默认模板什么规则都没配,因为界面配置太繁琐,没人愿意持续维护。对于十台以内的服务器、告警规则经常要按业务调整的团队,这个成本确实不划算。
| 方案 | 部署成本 | 规则编写方式 | 实时性 | 更适合的场景 |
|---|---|---|---|---|
| Zabbix | 需要服务端和 agent 配合 | 界面配置,规则相对粗粒度 | 秒级到分钟级 | 几十台以上、含网络设备混合环境 |
| Prometheus + Loki | 组件多,链路长 | PromQL + LogQL,有学习曲线 | 分钟级居多 | 已经重度使用 K8s/Prometheus 的团队 |
| ELK | 内存占用大,日常 8GB 以上 | 需要写 Logstash filter / Grok 规则 | 秒级 | 日志量大、以检索和分析为主 |
| Python 脚本 | 一个文件加个 systemd 服务 | 正则/JSON 规则,想怎么写就怎么写 | 秒级 | 几台到几十台、强调快速定制规则的团队 |
1.2 Python 写日志监控的天然优势
回归本质,日志监控链路其实就三件事:拿到新日志、判断是否有问题、把问题通知出来。这三件事用 Python 标准库几乎全包了:re做正则匹配,smtplib发邮件,time做时间窗口,os和文件对象做追读操作;再配合钉钉、企业微信的 webhook 接口,连第三方依赖都能省掉一大半。真正需要额外pip install的,基本只有requests一个。
还有一点我很看重:规则可以用 JSON 或 YAML 文件配置,跟着业务仓库一起走版本。现成平台的规则往往锁在界面上,改一条正则要打开浏览器找半天,改完还得担心别人不小心动到。脚本方案里,规则就在你眼皮底下,代码评审的时候顺手就能改,非常直观。所以这篇的路线很简单:少装东西、多写逻辑,把监控做成自家代码库的一部分。
2. 先摸清日志的“长相”再定规则:来源与格式盘点
2.1 四种常见日志来源与样例
动手写代码之前,先花十分钟做一件事:把机器上日志的实际情况摸清楚。不然正则写得再漂亮,匹配不到真实日志也白搭。常见的情况大致有这四类:
- Linux 系统文本日志:
/var/log/syslog或/var/log/messages,里面包含内核日志和不少系统服务日志。格式一般是Mar 12 10:15:30 hostname app[123]: message。 - 应用自有日志:很多后端服务会把日志写到自己的目录,比如
/var/log/myapp/app.log,或者干脆写到项目目录下的logs/文件夹。这类日志格式五花八门,可能是纯文本、JSON、甚至有 Java 的异常堆栈。 - Web 服务器日志:Nginx/Apache 的
access.log/error.log,里面能挖出大量业务状态信息,比如是不是 5xx 变多了。 - systemd 管理的 journal 日志:用
journalctl -u myservice -f查看。在有些发行版上,默认日志全往 journal 里走。 - Windows 事件日志:通过事件查看器查看,对应 Python 可以用
win32evtlog读取,思路和本文后面对文件操作的部分略有不同。
判断日志格式这一步很重要,因为你的正则是在和它对话。举个例子,如果你的应用已经改成输出 JSON 日志:
{"timestamp":"2024-05-11T02:07:12.345Z","level":"ERROR","service":"order","message":"db timeout"}那你最自然的解析方式就不是一行re.search,而是json.loads(line)之后按字段判断。同一个监控脚本,针对不同格式的日志要用不同的预处理分支。所以我建议你在写代码前,先用tail -n 50 /var/log/xxx看一眼实际内容,再决定用哪条解析路径。
2.2 观察基线:不观察就写规则,后面全是删规则
不要急着写规则。先做一件事:统计现有日志里各种错误出现的频率。我通常用两个命令快速摸底:
# 统计日志增长速率,看是不是每秒钟都在刷 # pv 和 bc 属于常用工具,没有就装一下 tail -n 0 -f /var/log/myapp/app.log | pv -l > /dev/null # 统计错误类型 TOP10,看哪些错误是安静的小透明、哪些是话痨 grep -oE "\[(ERROR|WARN|CRITICAL)\]" /var/log/myapp/app.log | sort | uniq -c | sort -rn | head -10统计完你通常会得到一个反直觉的结论:日志里 90% 的 ERROR 都是业务噪音。这个结论决定了后面的规则怎么定。比如第三方回调超时,业务代码里重试一次就好了,这种一条条的 ERROR 根本不需要半夜把你叫起来;真正值得叫醒你的,是“数据库连接池耗尽”“OutOfMemoryError”“大量接口连续 500”这类会持续恶化、并且需要人工介入的问题。
我的建议是,先把规则设成三种重要级别,然后对照实际日志筛选监控点:
| 监控目标 | 典型关键字/正则 | 初始级别 |
|---|---|---|
| 服务直接崩溃 | OutOfMemoryError、Segmentation fault、Traceback | critical |
| 接口大量返回 5xx | " 500或" 503 | warning |
| 数据库连接池耗尽 | connection pool exhausted | critical |
| 频繁连接拒绝 | Connection refused | warning |
| 业务重试成功 | retry success | 不监控 |
定完初始级别后,建议让脚本先跑几天“只记录不告警”的观察模式,每天看一次哪些规则匹配到但从未真正引发故障。把那些语义上是错误、实际上从未出事的规则逐步删掉。这个过程能大幅降低报警噪音,比把所有力量花在优化告警发送速度上更实在。
3. 文件追读的核心机密:偏移、inode 与日志轮转
3.1 直接读文件会漏与重复:为什么需要 tail-style 追读
最容易想到的实现方式是:每隔几分钟用open(path).read()读一遍整个日志文件,再拿正则去匹配。问题立刻浮现:读过的行会重复匹配,造成刷屏告警;如果读取间隔大,日志恰好在这一瞬间刷了好几页,你只能读到其中一部分,等同于漏报。正确的姿势应该是像tail -f那样,永远只关心“文件里新多出来的那部分”。
这里有个底层概念要先说透:Linux 文件系统里,文件对象有两层身份——路径和 inode。你在/var/log/app.log这个路径上打开文件,拿到的是一个文件描述符,它实际指向的是某个 inode;只要 inode 对应的内容在增长,你就能持续读到新追加的行。而所谓的“记住读过哪里”,在 Python 里就是记住文件对象的偏移量(offset),并用seek()跳回去,或者干脆保持文件对象不关闭,一直往后readline()就行。
当然,拖着一个永不关闭的文件对象有个代价:如果日志文件发生轮转(logrotate),旧文件被改名成app.log.1,新文件被创建在原来的路径上,你的文件描述符还指着旧 inode,自然读不到任何新内容。这就是为什么“直接读文件”的简单方案会漏掉日志轮转后的所有内容。
3.2 LogFollower 实现:inode 换手与 seek 定位
我常用的解决方案叫LogFollower,核心逻辑就两个:看到 EOF 就 sleep;每次醒来检查一下路径上的 inode 是否变了,变了就重新打开新文件。如果希望进程重启后还能接着上次的位置继续读,就要把“上一次读到的 inode + offset”落盘存起来。
import os import time OFFSET_PATH = "/var/lib/logmonitor/offset.txt" class LogFollower: def __init__(self, path, encoding="utf-8"): self.path = path self.encoding = encoding self.inode = None self.fp = None self._open_or_restore() def _open_or_restore(self): stat = os.stat(self.path) self.inode = stat.st_ino self.fp = open(self.path, "r", encoding=self.encoding, errors="backslashreplace", buffering=1) # 尝试从上次偏移处继续,失败就跳到文件末尾 saved_inode, saved_offset = load_offset() if saved_inode == self.inode and saved_offset < self.fp.seek(0, os.SEEK_END): self.fp.seek(saved_offset) else: self.fp.seek(0, os.SEEK_END) def follow(self): while True: line = self.fp.readline() if line: yield line.rstrip("\r\n") save_offset(self.inode, self.fp.tell()) continue time.sleep(0.2) try: cur = os.stat(self.path) except FileNotFoundError: # 文件可能被临时移走了,等它重新出现 continue if cur.st_ino != self.inode: # 已经发生日志轮转,关闭旧文件,打开新文件 self.fp.close() self._open_or_restore()配合的 offset 落盘函数:
def load_offset(): try: with open(OFFSET_PATH, "r") as f: inode, size = map(int, f.read().strip().split()) return inode, size except Exception: return None, 0 def save_offset(inode, offset): with open(OFFSET_PATH, "w") as f: f.write(f"{inode} {offset}")这里有一个隐藏边界想提醒你:logrotate 的copytruncate模式会把原文件复制一份后直接清空原文件,而不是改名。这种情况下 inode 不变,上面的代码会误以为文件还是同一个,结果跳到末尾后永远读不到新内容。处理办法是把记录的 offset 和当前文件大小比较:如果当前stat.st_size比记录的 offset 还小,说明文件被清理过,应该无条件跳回文件头重新读。这个是实际运维中很容易踩的坑,我在代码里用了saved_offset < self.fp.seek(0, os.SEEK_END)做了初次判断,但真要追求极致健壮,轮转分支里还得加上文件大小比较。
3.3 非文件类日志(journald、Windows 事件)怎么处理
如果你的日志在 systemd 的 journal 里,其实有两种方式:直接调journalctl -u myservice -f -o json的 stdout 流,逐行读 JSON;或者用systemd-python绑定,按journal.Reader的方式做订阅。前者简单直接,后者适合和内核的journal功能深度整合。Windows 事件日志则主要用win32evtlog的ReadEventLog做轮询,按RecordNumber记住上次读到的记录。理念上完全一致:必须维护一个“上次拿到哪”的指针,否则就会有重复或遗漏。
对大多数中小团队来说,优先考虑 Linux 文件型日志是最可控的选择。因为它的依赖最少、格式最透明、排错最直接,出了问题也能手工用tail验证脚本的行为。
4. 规则匹配后面还有三个坎:去重、分级、静默
4.1 正则别写得太粗或太细
正则匹配是告警规则的第一步,但我见到最多的失败案例其实是正则在两个极端之间翻车。太宽的正则等于没监控:任何带error的行都报警,一天能收到几百条消息,结局就是大家习惯性忽略。太细的正则用不了多久就会被日志格式变化打挂——比如某次大版本更新把日志时间戳格式改了,你的数据正则就再也匹配不上。
我自己的实践是三个原则:优先匹配稳定关键字,比如英文的系统级错误串;涉及数字时用\d{3}或[0-9]{3}这类明确的字符类,不要写.*去硬吞;大小写问题直接加re.IGNORECASE,省得日志从 ERROR 变成 Error 就漏报。
import re def match_rule(line, rules): for rule in rules: if re.search(rule["pattern"], line, re.IGNORECASE): return rule return None4.2 滑动窗口去重与警报升级
匹配到规则之后,马上报警吗?错。单次 ERROR 不一定是故障,连续出现 N 次才值得被认真对待。比如某个服务间歇性Connection refused,业务重试后恢复正常,这种错误一两分钟出现一次其实无伤大雅;但如果 10 分钟内出现超过 10 次,基本可以断定下游服务挂了。这个“次数 + 时间窗口”的判定就叫滑动窗口去重。
我实现了一个简单的AlertManager:
import time class AlertManager: def __init__(self): self.hits = {} self.suppress_until = {} def check(self, rule_name, window, threshold, t=None): t = t or time.time() # 触发过一次后,进入静默期,防止同一个问题刷屏 if t < self.suppress_until.get(rule_name, 0): return False self.hits.setdefault(rule_name, []).append(t) # 只保留窗口内的记录 self.hits[rule_name] = [x for x in self.hits[rule_name] if x > t - window] if len(self.hits[rule_name]) >= threshold: # 默认触发后 30 分钟不再重复告警 self.suppress_until[rule_name] = t + 1800 return True return False思路很直白:每个规则维护一个时间戳列表,列表里只保留最近window秒内的命中;一旦命中次数达到threshold,就触发告警,并把当前规则拉入 30 分钟静默期。这么做之后,最直接的效果是:收到一条告警,意味着这个问题已经持续存在了“一段可量化时间”,而不是一次偶发抖动。你半夜被叫醒的次数会骤然下降。
4.3 维护窗口与静默匹配规则
发布、重启、数据维护时产生的误报,是告警噪音的另一个主要来源。半夜上线系统,脚本突然因为应用的瞬时报错连续告警,很容易把值班的人吓得直接回滚。最简单的方式是给规则增加quiet_hours字段,在指定时间段直接跳过匹配。
{ "name": "db_pool_exhausted", "pattern": "connection pool exhausted", "level": "critical", "threshold": 1, "window": 60, "quiet_hours": [["02:00", "06:00"]] }逻辑上,匹配规则后在check()之前先判断当前时间是否处于静默时间段,是就直接跳过。别小看这个字段,不加它,你会发现脚本上线第一周就被人从钉钉群里踢出去了——大家宁可不要告警,也不想整晚被误报轰炸。我建议给每个规则都默认配上quiet_hours,哪怕刚开始先空着,也要把字段留出来。这是“规则工程”的一部分。
5. 让警报抵达手机:三种发送渠道实测与对比
5.1 邮件:可靠但容易被当垃圾消息的候补选手
邮件是门槛最低的报警渠道,Python 标准库smtplib就能发。但这里有个中文场景特有的坑:邮件头的Subject如果不做编码,中文会直接乱码。必须以email.header.Header方式设置:
import smtplib from email.mime.text import MIMEText from email.header import Header def send_mail(subject, body, to_addrs): msg = MIMEText(body, "plain", "utf-8") msg["Subject"] = Header(subject, "utf-8") msg["From"] = smtp_user msg["To"] = ", ".join(to_addrs) with smtplib.SMTP(smtp_host, smtp_port, timeout=10) as s: s.login(smtp_user, smtp_pass) s.sendmail(smtp_user, to_addrs, msg.as_string())实测下来,普通 163 邮箱或 QQ 邮箱作为发件人,出现退信和被扔进垃圾箱的概率不低。如果你的团队有企业邮箱,建议直接用企业邮箱发信,并在邮件正文里把告警摘要写清楚。我个人倾向于把邮件作为“事后追查摘要”的渠道,每天上午发一条前一日的告警统计,而不是每条告警都走邮件。原因你也能想到:一天五百封邮件,等于没有邮件。
5.2 钉钉群机器人:最顺手,秒级到达
我自己实测反馈最好的是钉钉群自定义机器人。申请方式不复杂,核心就是拿到一个 webhook 地址,然后把消息以 JSON POST 过去。Python 那边配合requests,代码非常短:
import requests def send_dingtalk(webhook, content): payload = { "msgtype": "text", "text": {"content": content}, } resp = requests.post(webhook, json=payload, timeout=10) resp.raise_for_status() data = resp.json() if data.get("errcode") != 0: raise RuntimeError(f"dingtalk error: {data}") return data钉钉机器人的优势是消息到达几乎是秒级,手机端通知体验很好。如果想在群里 @ 所有人,需要额外传入at参数;想把告警排得更好看,可以改用markdown消息类型。还有一点要注意:如果生产环境在纯内网、不能访问外网,钉钉 webhook 就走不通,这时候只能选内部 IM 或邮件。那场景下你直接把发送函数换成内部统一的 webhook 格式即可,其他链路完全不用改。
5.3 企业微信机器人:正好公司里都在用
企业微信群机器人和钉钉的思路几乎一模一样,同样是 webhook,只是字段名略有差异:msgtype、text、content。如果你的公司已经全员使用企业微信,优先选它,这样大家不需要为了收告警再装一个 App。代码上,你只需要把钉钉那个send_dingtalk的 URL 和字段稍微调整一下。思路通了之后,渠道本质上只是一个send_alert(content)的可替换函数。
三种渠道放在一张表里对比,会更清楚:
| 渠道 | 配置复杂度 | 消息实时性 | 被过滤概率 | 推荐场景 |
|---|---|---|---|---|
| 邮件 | 低 | 几秒到几分钟 | 较高,可能进垃圾箱 | 每日摘要、事后追查 |
| 钉钉群机器人 | 低 | 秒级 | 低 | 日常告警首选 |
| 企业微信机器人 | 低 | 秒级 | 低 | 公司已在用企业微信 |
5.4 webhook 重试的底线逻辑
任何渠道都不能保证一次性成功。网络抖动、接口临时限流,都会让你的告警丢在路上。更麻烦的是,如果这次告警没送出去,同一个错误可能又被去重逻辑拦住了,那这个问题就被彻底漏报了。所以我强烈建议对发送函数套一层带“指数退避”的重试逻辑,最多重试三次,中间分别睡 2 秒、4 秒:
import time import requests def send_with_retry(send_func, *args, max_retry=3): last_exc = None for attempt in range(max_retry): try: return send_func(*args) except Exception as e: last_exc = e time.sleep(2 ** attempt) raise last_exc使用时直接send_with_retry(send_dingtalk, webhook, content)。这个细节看起来不起眼,但它决定了你的告警系统在关键时刻是否值得信任。我的经验是:宁可多等 6 秒,也不能让告警在最后一次发送栈里默默消失。
6. 做一次就少踩一个的实战坑位
6.1 权限组配置:别让它静默失败
日志文件不是谁都能读的。/var/log/syslog默认只对syslog和adm组开放;journald 则要求用户属于systemd-journal组。如果你图省事用 root 跑脚本,权限问题确实不存在了,但 root 跑常驻服务的安全风险又上来了。更稳妥的做法是给监控脚本建一个专用用户,把它加进adm组,然后让 systemd 服务以该用户身份运行。这样日志能读、offset 目录可写、进程权限最小化,出问题也好追溯。
有个细节容易忽略:如果脚本因为权限读不了日志,它的行为往往是“打开文件失败直接退出”,如果没有告警补充机制,你会完全察觉不到监控已经下线。所以上线前的权限验证要做实:以部署用户身份手动执行一次cat /var/log/syslog > /dev/null,能正常读出才算过。
6.2 编码与时区:两个最不起眼却最致命的错位
日志编码是另一个高频坑。很多老系统输出的日志是 GBK 或 GB2312,Python 默认 UTF-8 打开会抛UnicodeDecodeError。一旦出错,你的follow()循环就会中断,监控等于失效。我给文件打开加了errors="backslashreplace",意思是遇到无法解码的字节时用转义序列替代,而不是抛异常。这样即使中间混进几行乱码,整个监控服务也不会因此挂掉。
时区问题同样隐蔽。日志里的时间戳通常是 UTC 或服务器本地时间,而 Python 这边的datetime.now()取的是运行进程的本地时间。如果你要用“每天 02:00 到 06:00 静默”这种规则,务必备注清楚这些时间基于哪个时区。最靠谱的做法是:整个脚本统一用 UTC 内部计时,只在生成告警文案时转换成目标时区,避免上半年的某一天因为夏令时问题全部错位。
6.3 行缓冲与进程守护:性能与稳定性两手抓
Python 默认的文件读写是有缓冲策略的。如果你用open(path, "r")直接读日志,然后在一个死循环里readline(),很可能会出现日志明明在增长、程序却一直读不到新行的情况。我之前就踩过这个坑,排查了半天才发现是缓冲在作怪。解决办法是打开文件时加上buffering=1,开启行缓冲;对实时追读场景这几乎必须写进代码里。另外记得设置合理的 sleep 间隔,0.2 秒是我实测过的平衡值:既能保证消息到达的实时性,又不会让进程空转浪费 CPU。
进程守护这块,nohup python monitor.py &能用,但服务器一重启,监控就没了,而且你没有通知通道知道它没了。正确做法是写一个 systemd 服务,Restart=always,RestartSec=10。这一组配置的意义是:进程崩了能被自动拉起来,日志里会有残留证据供你排查,不会再出现“半夜没收到告警,回头一看脚本早死了”的空白期。
7. 拿到就能用的骨架:monitor.py + rules.json + systemd
7.1 骨架代码
把前几节的逻辑合到一起,就是一个可以直接抄走的最小可用版本。我用钉钉 webhook 作为默认发送渠道,邮件渠道无非是把send_alert函数替换掉。
#!/usr/bin/env python3 import os import json import time import re import requests LOG_PATH = "/var/log/myapp/app.log" RULESET_PATH = "/opt/logmonitor/rules.json" WEBHOOK = "https://oapi.dingtalk.com/robot/send?access_token=your_token_here" ALERT_BLOCK_WINDOW = 1800 # 触发后静默时间:30分钟 class LogFollower: def __init__(self, path, encoding="utf-8"): self.path = path self.encoding = encoding self.inode = None self.fp = None self._open_and_seek_to_end() def _open_and_seek_to_end(self): stat = os.stat(self.path) self.inode = stat.st_ino self.fp = open(self.path, "r", encoding=self.encoding, errors="backslashreplace", buffering=1) self.fp.seek(0, os.SEEK_END) def follow(self): while True: line = self.fp.readline() if line: yield line.rstrip("\r\n") continue time.sleep(0.2) try: cur = os.stat(self.path) except FileNotFoundError: continue if cur.st_ino != self.inode: self.fp.close() self._open_and_seek_to_end() def load_rules(path): with open(path, "r", encoding="utf-8") as f: return json.load(f)["rules"] def match_rule(line, rules): for rule in rules: if re.search(rule["pattern"], line, re.IGNORECASE): return rule return None class AlertManager: def __init__(self): self.hits = {} self.suppress_until = {} def check(self, rule_name, window, threshold, t=None): t = t or time.time() if t < self.suppress_until.get(rule_name, 0): return False self.hits.setdefault(rule_name, []).append(t) self.hits[rule_name] = [x for x in self.hits[rule_name] if x > t - window] if len(self.hits[rule_name]) >= threshold: self.suppress_until[rule_name] = t + ALERT_BLOCK_WINDOW return True return False def send_dingtalk(webhook, content): payload = {"msgtype": "text", "text": {"content": content}} for attempt in range(3): try: resp = requests.post(webhook, json=payload, timeout=10) resp.raise_for_status() if resp.json().get("errcode") == 0: return True except Exception: pass time.sleep(2 ** attempt) return False def main(): rules = load_rules(RULESET_PATH) manager = AlertManager() follower = LogFollower(LOG_PATH) for line in follower.follow(): rule = match_rule(line, rules) if not rule: continue if manager.check(rule["name"], rule.get("window", 300), rule.get("threshold", 10)): content = f"[{rule.get('level', 'warning')}] {rule['name']}\n{line[:300]}" ok = send_dingtalk(WEBHOOK, content) print(f"alert sent: {rule['name']} -> {ok}", flush=True) if __name__ == "__main__": main()对应的rules.json格式如下:
{ "rules": [ { "name": "db_pool_exhausted", "pattern": "connection pool exhausted", "level": "critical", "threshold": 1, "window": 60 }, { "name": "http_5xx_flood", "pattern": "\" 500 ", "level": "warning", "threshold": 10, "window": 300 } ] }注意http_5xx_flood这条规则:" 500 "两边各带一个引号,是刻意为之,用来匹配 Nginx 访问日志里的" 500状态码片段,同时避免误匹配到其他字段里的数字。这类细节在真实日志里特别需要打磨。
7.2 部署顺序与一次完整验证
部署时按这个顺序来,能少走很多弯路:
# 1. 建目录、放代码和规则文件 mkdir -p /opt/logmonitor cp monitor.py rules.json /opt/logmonitor/ # 2. 建虚拟环境并安装依赖 cd /opt/logmonitor python3 -m venv venv ./venv/bin/pip install requests # 3. 先用前台方式跑一次,确认不会立即退出 ./venv/bin/python monitor.py确认前台能正常跑起来后,再配置 systemd 服务,文件放/etc/systemd/system/logmonitor.service:
[Unit] Description=Log Monitor Service After=network-online.target [Service] User=logmonitor Group=adm WorkingDirectory=/opt/logmonitor ExecStart=/opt/logmonitor/venv/bin/python /opt/logmonitor/monitor.py Restart=always RestartSec=10 [Install] WantedBy=multi-user.target启动服务后,最关键的一步来了:验证告警真的能送出去。手动往日志文件里写一条目标错误,等十几秒,看手机或群里的消息提示。
echo '2024-05-11 10:30:00 [CRITICAL] connection pool exhausted, waiting...' >> /var/log/myapp/app.log这个验证步骤不能省。我见过不少脚本写完直接 systemd 部署,第二天同事问“怎么没收到告警”,一查发现日志路径都填错了。只有当你亲眼看到那条测试告警出现在群里,这套监控才算真正上线。
最后再分享一个我实践中的体会:这套脚本跑久了,最大的改进其实不在代码,而在规则维护。每隔一两周,我会把过去一段时间的告警记录拉出来看一眼,把“从没触发过”和“触发了一百次但从没出过事”的规则逐一删掉或调低级别。告警规则不是越多越好,真正的目标只有一个——当屏幕上真的弹出消息时,你知道这次值得放下手头的事去看一眼。