DAIAN 是我给自己那套日志异常检测脚本起的名字,全称我拆成 Data Assisted Intelligent Alert Network,中文谐音叫“大安”,意思是让系统安安稳稳地跑,我不用老惦记它。故事的起点很朴素:家里一台树莓派一年到头不关机,上面挂着下载、转发、网页、数据库这些小服务,放在以前,出问题只能手动登上去翻日志,可日志不出事的时候没人看,一出事一晚上能刷几百行,靠肉眼找异常等于大海捞针。DAIAN 要解决的就是这件事:定时采集各类日志文件,先建立正常状态下的统计基线,然后用滑动窗口发现偏离,再经由 Webhook 把告警推到手机上。它不做完整的大数据存储,也不做花哨的界面,就是一身轻巧的“值守员”。
如果你也属于这几类人,我觉得这篇内容会有点用:一是手上跑着一个或几台小服务器、不想再单独扛一套重型监控平台的人;二是想把“日志异常”从经验判断升级成数据判断的开发者;三是刚开始接触时序数据统计,想找一个能动手练的项目的人。下文我会按整体设计、核心算法、关键代码、参数调试、踩坑实录这几个部分来讲,DAIAN 从一开始的几十行脚本,慢慢长成了现在的样子,整个过程都有可复现的依据。
1. 整体定位与技术选型:为什么自己写,而不是上现成平台
1.1 这个脚本到底在解决什么问题
日志监控这件事,听起来简单,做起来有讲究。最简单粗暴的做法是写几个关键词告警,比如日志里出现“ERROR”就通知我。但实际场景里,真正的故障往往不以单条 ERROR 的形式出现,它更像一种“节奏被打破”。比如某个服务平时每小时产生 200 条访问记录,突然改成每 10 分钟 3000 条请求,这说明可能在被刷;又比如平时每秒钟都有心跳日志,突然彻底安静了,这大概率是进程挂死。这两类异常,关键词告警一个都拦不住。
DAIAN 的核心价值,就是把这些“偏离正常节奏”找出来。它的工作方式不是判断单条日志健不健康,而是把日志变成数字,再看数字有没有跳出合理范围。这样一来,它不需要知道业务规则,也不需要为每一种故障写死判断逻辑,只需要统计规律就能发现大多数问题。从工程角度看,这也降低了扩展成本:新增一个服务,只要把日志路径写进配置,它自己会去学习什么是正常。
不过必须承认,它不是万能的。DAIAN 适合那些日志格式相对稳定、有明确时间字段、流量有统计特征的环境。如果你的日志一天只产生三五条、且内容完全随机,统计方法就没法建模,这时候该做关键词告警还是得做。DAIAN 的做法是在统计之上保留规则层,两种手段一起用,互补短板。
1.2 技术选型:为什么不上 ELK、Prometheus 之类的体系
说到日志异常监控,很多人第一反应是上一套 ES + Logstash + Kibana,或者 Prometheus + Alertmanager。这些工具确实强,但放到树莓派和家庭服务器这个场景里,有点“大炮打蚊子”。ES 本身就吃内存,树莓派那点资源跑个完整 ELK,基本没余力跑业务;Prometheus 体系擅长指标监控,对原始日志的文本解析并不直接,配起来要先做指标化改造,约束也不少。
云平台自带的监控服务当然也可以,我最初也考虑过,但有两个现实问题:一是日志出网的延迟和费用,二是这些服务大多要求把日志上传到远端,等于把运维凭证交了一部分出去,心里总不踏实。更关键的是,我想让 DAHAN 的学习、告警、存储全部落在本地,断网状态下也能工作。
所以最后的选型定为:Python 标准库负责文件读取和进程管理,NumPy 负责统计计算,requests 负责推送告警,SQLite 负责保存历史快照。没有引入消息队列,没有引入时序数据库。实现的工具链总共三个第三方库,安装非常简单,不管在 x86 机器还是 arm 架构的树莓派上都能稳定运行。采集方式也刻意选了简单的轮询,而不是 inotify 或 systemtap 这种内核级监听。轮询确实不那么“实时”,但胜在跨平台、无外部依赖、代码容易看懂。对告警类任务来说,延迟 2 到 5 秒完全可以接受,换来的却是部署成本的大幅下降。
这也是整个项目的一设计原则:能用一行代码解决的问题,绝不用一个框架;能用标准库搞定的事情,绝不引入重依赖。项目需要的不是功能堆叠,而是维护者能充分理解每一行代码。
2. 核心设计思路:采集、聚合、统计偏差
2.1 日志采集层的落地细节
采集层是 DAIAN 与日志文件打交道的部分,最容易出问题的也在这里。日志文件不是静态的,它会被写入、被轮转、被压缩,甚至突然清空。如果只按照普通的文件读取逻辑来处理,第一天可能没问题,第二天就能看到各种诡异现象。
我的处理思路是把每个日志任务看成一个位点:记录它在操作系统里的 inode、文件当前偏移位置、上次读取的文件大小。每一轮采集时,先重新 stat 一下文件,发现 inode 变了,说明日志轮转了,比如 logrotate 把文件改名并生成了新文件,那就关闭旧句柄,打开新文件,并直接跳到文件尾部。如果 inode 没变,但文件大小比记录的位置还小,说明有人截断或清空了文件,这时也需要把文件指针重新定位到结尾。有了这两条保护,采集过程就不会把陈旧内容当成新日志处理。
轮询间隔我默认设成 2 秒。间隔太长,突发流量的统计颗粒度会变差;间隔太短,空载时也会产生无意义的开销。实测下来,树莓派上挂 6 个日志任务,每轮读取几百行,CPU 占用率维持在百分之二以内,完全可以接受。
编码问题也需要提前处理。大多数系统的日志是 UTF-8,但总有些第三方程序输出 GBK,或者日志中间夹杂二进制乱码。如果按严格的 UTF-8 去读,可能一整行都被编码错误中断。我的做法是统一指定 errors='replace',读取时遇到无法解码的字节用占位符代替,宁可损失一行信息,也不能让整个采集线程崩掉。
采集到的日志不会直接进入检测引擎,它会先经过一个“解析口”。解析规则一般两种形式,要么按正则从文本里抽时间戳和级别,要么在配置里写明“无视时间戳,按整体内容处理”。前者的好处是能拿到真实的日志时间,不依赖系统当前时间;后者的好处是兼容毫无结构的输出。DAIAN 对两种都支持,但推荐尽量用正则解析,因为时间戳对异常归因太重要了。
2.2 滑动窗口与统计基线的算法原理
接下来是整个项目的核心:怎么判断“异常”。我采用的是“前 N 个观测值建立基线,当前值与之比较”的方法。具体来说,每一轮采集都会得到一个新产生的日志数量 delta,把最近的 60 个 delta 存成数组,时间窗口就是这个数组对应的长度。算法流程是:
- 计算前 59 个观测值的平均值 mu 和标准差 sigma。
- 取最后 1 个观测值作为当前值 current。
- 计算 Z-score:z = (current - mu) / (sigma + 1e-6)。
- 当 z 大于阈值时,认为日志量异常飙升;当 z 小于负阈值时,认为日志量异常停滞。
这里为什么用前 59 个而不是全部 60 个计算基线,是为了避免把当前值自身纳入统计。如果当前值本身就是一次暴涨,把它算进均值里,均值会被抬高,Z-score 反而变小,异常就被稀释没了。这一点很多简单实现没注意到,结果就是永远报不出告警。
再解释为什么需要 sigma 而不是只看均值。假设一个服务日志量本身就是抖动的,每秒 10 到 30 条来回跳,这时第二秒变成 35 条,单独看确实比均值高,但仍在日常抖动范围内,不该告警。sigma 刻画的就是抖动幅度,Z-score 把“数值高”转化为“相对正常波动偏移了几个身位”,这样阈值设置才有跨服务的通用性。
除了统计量之外,DAIAN 还有一个指纹聚合层。它会把日志行做归一化,比如把 IP 地址、数字端口、请求耗时统一替换为占位符,然后计算指纹。同一指纹出现的频率如果短时间成倍上升,即便总量没达到统计告警阈值,也一样会触发规则告警。这实际上是在统计异常之外补了文本聚类能力,用来捕捉“同一种错误忽然多次出现”这类典型故障模式。
对小项目而言,这套组合已经覆盖了大部分真实故障。用户要做的就是根据服务特点,设定好窗口长度和阈值,剩下的交给统计去判断。
3. 关键代码实现:检测引擎与告警推送
3.1 检测引擎的完整实现
下面给出 DAIAN 中检测引擎的部分核心代码,逻辑上拆成三个类:Tailer 负责读文件,MetricsBuffer 负责保留滑动窗口数据,Detector 负责判断异常。先看 Tailer:
import os import time from pathlib import Path class Tailer: def __init__(self, path, encoding="utf-8"): self.path = path self.encoding = encoding self.fp = None self.ino = None self.position = 0 self._open() def _open(self): self.fp = open(self.path, "r", encoding=self.encoding, errors="replace") self.fp.seek(0, os.SEEK_END) self.position = self.fp.tell() self.ino = os.stat(self.path).st_ino def read_new_lines(self): current_stat = os.stat(self.path) if current_stat.st_ino != self.ino: self.fp.close() self._open() return [] if current_stat.st_size < self.position: # 文件被截断 self.fp.close() self._open() return [] lines = self.fp.readlines() self.position = self.fp.tell() return lines这段代码里我特意把文件被截断的场景补上了。实际运行中,很多程序会定时把日志清空,如果不做大小判断,文件指针会一直停留在旧位置,可能导致重复读取或什么都不读。处理过这个问题之后,Tailer 在常规轮转和截断场景下都很省心。
接着是统计缓冲区和检测逻辑:
import numpy as np from collections import deque class MetricsBuffer: def __init__(self, window=60): self.window = window self.counts = deque(maxlen=window) def append(self, delta): self.counts.append(delta) def is_ready(self, min_samples=20): return len(self.counts) >= min_samples class Detector: def __init__(self, z_threshold=3.5): self.z_threshold = z_threshold def check(self, buffer): if not buffer.is_ready(): return None arr = np.asarray(buffer.counts, dtype=float) previous = arr[:-1] current = arr[-1] mu = previous.mean() sigma = previous.std(ddof=1) if sigma < 1e-9: # 完全没有抖动的场景,只要不等于均值就认为异常 sigma = 1e-9 z = (current - mu) / sigma if z > self.z_threshold: return {"type": "surge", "z": z, "current": current, "mean": mu} if z < -self.z_threshold: return {"type": "silence", "z": z, "current": current, "mean": mu} return None这里有几个细节值得说明。第一,deque 对象天然支持 maxlen,超出长度会自动丢弃旧值,比手写 list 切片方便得多。第二,ddof=1 表示计算样本标准差,当样本量小时会略微放大抖动幅度,让基线更保守。第三,日志完全没有抖动时 sigma 会等于 0,必须加上一个极小值防止除零,同时把它转成“只要偏离就告警”的模式。
因为这类任务会有单位换算问题,建议在把 delta 送入缓冲区前先做一个标准化,把每轮采集的实际行数换算成每秒行数。换算公式是 delta / 实际间隔秒数。这样即便采集线程偶尔被阻塞,波动也不会被误判。
3.2 告警通知、去抖与静默窗口
检测出异常后,不能立刻把消息发出去,否则一次持续几分钟的故障就会带来几十条重复告警。DAIAN 引入了两个机制:告警去抖和静默恢复。
告警去抖的逻辑是为每个检测规则维护一个时间戳,同一规则触发后,在设定间隔内不会再推送第二遍。间隔默认 600 秒,意思是同一类问题最多十分钟报一次,但内部状态持续更新。
静默恢复则解决另一个痛点:连续异常期间,规则一直处于触发状态,等故障恢复后,用户其实想知道的是“是否已恢复正常”。所以 DAIAN 在告警去抖之外,还挂了一个“恢复通知”逻辑:某规则之前处于异常状态,后来连续两个检测周期都回归正常,就主动发一条“已恢复”的消息。看起来很简单,但对使用体验的提升非常明显。
通知通道采用通用 Webhook,避免绑定特定平台:
import requests def push_alert(title, content, webhook_url): payload = { "title": f"[DAIAN] {title}", "content": content, "type": "text" } try: resp = requests.post(webhook_url, json=payload, timeout=5) resp.raise_for_status() except requests.RequestException as exc: # 通知失败不要影响主流程,写本地日志即可 print(f"push alert failed: {exc}")项目经理在选型时把通知设计成通用格式,宿主环境只需要根据 webhook 平台文档做字段映射,就能接到企业微信、钉钉、自建 IM 或邮件网关。甚至可以用开源消息推送程序,把告警变成手机应用推送通知。
3.3 配置文件与部署方式
DAIAN 用一份 YAML 配置来管理所有目标,比把配置写死在代码里更合适。示例配置:
targets: - name: nginx-error path: /var/log/nginx/error.log encoding: utf-8 parse: timestamp: '^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})' format: '%Y/%m/%d %H:%M:%S' - name: app-server path: /srv/app/logs/*.log glob: true parse: timestamp: '^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]' format: '%Y-%m-%d %H:%M:%S' time_window: 60 min_samples: 20 z_threshold: 3.5 alert_interval: 600 webhook_url: "https://example.com/webhook" exclude_patterns: - "health check" - "readiness probe" - "?topic=heartbeat"exclude_patterns是我在实际使用中加上去的字段。很多服务的健康检查会周期性刷日志,导致统计基线被大量无意义心跳抬高,真正业务异常反而被淹没。把这些固定文本模式过滤掉,基线会干净很多。
部署使用 systemd 是最稳妥的,服务随系统启动,异常退出后自动拉起。一个最小化的 service 文件如下:
[Unit] Description=DAIAN Log Watcher After=network.target [Service] ExecStart=/usr/bin/python3 /opt/daian/main.py Restart=always RestartSec=5 User=daian [Install] WantedBy=multi-user.target注意 User=daian 那里,不要用 root 运行。日志文件如果受保护,就通过把用户加入相应组,或者调整日志目录的读取权限,而不是放开整个 root 权限来运行监控脚本。
4. 参数调试与效果优化
4.1 阈值、窗口长度怎么给
DAIAN 最常被问到的不是代码能不能跑,而是参数怎么调。参数不会有一个万能答案,但有通用的调法。我建议第一次跑时,不要马上开启告警,而是让 DAIAN 进入“学习模式”:只记录统计量,不推送告警。持续采集至少 24 小时,最好覆盖一个完整的业务周期,把这段时间的日志节奏数据导出来,观察均值、标准差、最大值,然后根据这些数字定阈值。
窗口长度和阈值的关系可以参考以下规律:
| 场景 | 窗口长度 | 阈值 | 说明 |
|---|---|---|---|
| 流量平稳的内部服务 | 30 到 60 | 3.5 | 抖动小,阈值可以略低 |
| 有明显早晚高峰的业务 | 120 到 180 | 4 到 5 | 窗口长才能建出稳定的基线 |
| 心跳类日志,平时极平稳 | 60 | 2.5 | 只要停跳就很反常 |
| 日志本身噪声很大 | 60 到 120 | 6 以上 | 避免被正常抖动刷屏 |
窗口短的优点是响应快,缺点是容易把正常的瞬时波动当作异常。窗口长能容忍更大抖动,但异常发生之后,要等足够多的正常样本把异常数据挤出窗口,才能恢复基线。所以不建议把窗口设成超过 180,否则一次持续十分钟的故障会长时间影响后续的“正常”。
阈值这个数字本质上是你对误报的容忍度。设了 3.5,理论上意味着“偏离平均 3.5 个标准差”才告警,按照正态分布的经验,发生概率本来就低于千分之一。若是在消息推送成本极低的场景,可以适当降到 3;若推送通道容易让人麻痹,就调大到 4.5,宁可漏掉一些小事,也要保证报上来的每条都值得处理。
4.2 异常日志保留和误报抑制
还有一个不太起眼但影响很大的设计:DAIAN 在触发告警时,会把触发前后各 5 分钟内与触发指纹相关的原始日志摘要写入 SQLite 表。这样等用户看到告警并去查日志的时候,原始内容可能已经轮转消失了,但 DAIAN 保留了上下文,能直接定位当时发生了什么。
这个保留策略不是把全部日志存起来,而是只保留异常现场。它存储量很小,一个月最多几十 MB,却非常管用。有一次我排查一个偶发连接数暴增的问题,翻轮转后的日志已经找不到线索,最后是 DAIAN 保存的现场摘要里发现是某个爬虫的 user-agent 引起的,证据非常明确。
误报抑制方面有三件顺手的事可以做:第一,把已知的定期任务时间改成检测期外,比如数据库备份本来就在每天凌晨 2 点,日志会流量暴涨,与其调阈值,不如把备份挪个时间。第二,对于周期性特别强的任务,给日志目标单独开一个配置,用较小的窗口和较高的阈值,不让它的整体节奏影响主统计轮廓。第三,善用排除规则。大多数“误报”都来自无价值却高频的日志,把它们准确滤掉,统计模型的效果能提升一个量级。
5. 踩过的坑与问题排查实录
5.1 高频问题排查速查表
DAIAN 从雏形到现在,经历过不少奇奇怪怪的问题,我把高频的几个整理成了表格,按“症状、成因、对策”三条展开:
| 症状 | 成因 | 对策 |
|---|---|---|
| 日志正常但每轮都收到告警 | sigma 接近 0,微略抖动被放大 | 提高 z_threshold,或在配置中增加最小抖动值 |
| 服务确实挂了,但没收到告警 | 日志停更好久,历史窗口还没被挤掉,统计认为 0 仍正常 | 增加日志停止更新的独立检测,比如最后写入时间超过 180 秒直接告警 |
| 日志文件经常轮转,漏掉一部分 | inode 判断逻辑缺失,旧文件句柄继续读新内容造成的错位 | 确认实现里有 inode 和文件大小双重判断 |
| 日志太多导致 CPU 高 | 每轮都全量读取大文件 | 增加行数上限,一轮最多读 5000 行,超出部分留到下一轮 |
| 多行日志被拆成多条处理 | 默认按行分割,无法处理 Java 栈轨迹 | 在 regex 里开启多行匹配,或配置跨行拼接规则 |
| 告警推送频繁重复 | 缺去抖逻辑 | 开启 alert_interval 去重,配合恢复通知 |
| 中文日志乱码 | 日志编码不是 UTF-8 | 把 encoding 改成 GBK 或使用 errors=replace |
这些内容听起来琐碎,但每一项背后都对应一次实际故障。比如“日志文件轮转漏数据”这一点,最初版本只判断了文件大小增大方向,没有判断 inode 变化,遇到按天切割的日志时就发现当天的时段数据总是丢,最后把 tail 的核心逻辑重写才解决。
5.2 部署过程中的几点执行建议
如果你要把 DAIAN 部署到小内存机器上,有几个执行层面的建议直接生效。轮询线程数不要按任务数无脑创建,一个线程轮询所有文件就够。检测计算在单线程里执行,瓶颈几乎不会出现在这里。定时任务和告警推送要解耦,推送是网络操作,延迟波动大,如果把它放在检测线程里,会导致下一轮检测被卡住。DAIAN 的做法是推送任务走一个单独的队列和后台线程,检测结束只负责放消息进队列,发送失败也不影响采集。
还有一点容易被忽略:磁盘告警。日志监控本身不会导致磁盘问题,但日志增长异常是很多系统变慢的根源。建议给 DAIAN 同时加一个简单的磁盘占用检查:当某日志目录超过预设阈值时,推一条警告,而不是等到磁盘写满才被系统强制杀掉。
部署完成后,先用故障演练验证一下整体链路。我自己的做法是向测试日志文件手动写入 1000 条“假错误”,看告警是否在预期时间到达,再清空日志,模拟服务静默,确认恢复通知是否发出。演练时间不到十分钟,但能把配置错误暴露在真正事故之前。
这套检测逻辑现在还在我家里的那台小服务器上运行,中间跨过几个月没去碰它,依然稳如老狗。如果你也打算自己写一套这样的日志守夜人,我的建议是先松后紧:第一天把阈值调高一点、通知间隔调长一点,让它在后台安静地收集样本;等统计表现足够稳定了,再慢慢压紧阈值。日志异常监控的本质,不是追求用一串公式取代经验,而是把经验数字化之后,变成可持续维护的系统能力。有了基线,有了现场,问题出现的时候,你手里就多了一张不那么慌的底牌。