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 去读可能一整行都被编码错误中断。我的做法是统一指定 errorsreplace读取时遇到无法解码的字节用占位符代替宁可损失一行信息也不能让整个采集线程崩掉。采集到的日志不会直接进入检测引擎它会先经过一个“解析口”。解析规则一般两种形式要么按正则从文本里抽时间戳和级别要么在配置里写明“无视时间戳按整体内容处理”。前者的好处是能拿到真实的日志时间不依赖系统当前时间后者的好处是兼容毫无结构的输出。DAIAN 对两种都支持但推荐尽量用正则解析因为时间戳对异常归因太重要了。2.2 滑动窗口与统计基线的算法原理接下来是整个项目的核心怎么判断“异常”。我采用的是“前 N 个观测值建立基线当前值与之比较”的方法。具体来说每一轮采集都会得到一个新产生的日志数量 delta把最近的 60 个 delta 存成数组时间窗口就是这个数组对应的长度。算法流程是计算前 59 个观测值的平均值 mu 和标准差 sigma。取最后 1 个观测值作为当前值 current。计算 Z-scorez (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 负责判断异常。先看 Tailerimport os import time from pathlib import Path class Tailer: def __init__(self, path, encodingutf-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, encodingself.encoding, errorsreplace) 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, window60): self.window window self.counts deque(maxlenwindow) def append(self, delta): self.counts.append(delta) def is_ready(self, min_samples20): return len(self.counts) min_samples class Detector: def __init__(self, z_threshold3.5): self.z_threshold z_threshold def check(self, buffer): if not buffer.is_ready(): return None arr np.asarray(buffer.counts, dtypefloat) previous arr[:-1] current arr[-1] mu previous.mean() sigma previous.std(ddof1) 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 切片方便得多。第二ddof1 表示计算样本标准差当样本量小时会略微放大抖动幅度让基线更保守。第三日志完全没有抖动时 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, jsonpayload, timeout5) resp.raise_for_status() except requests.RequestException as exc: # 通知失败不要影响主流程写本地日志即可 print(fpush 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 - ?topicheartbeatexclude_patterns是我在实际使用中加上去的字段。很多服务的健康检查会周期性刷日志导致统计基线被大量无意义心跳抬高真正业务异常反而被淹没。把这些固定文本模式过滤掉基线会干净很多。部署使用 systemd 是最稳妥的服务随系统启动异常退出后自动拉起。一个最小化的 service 文件如下[Unit] DescriptionDAIAN Log Watcher Afternetwork.target [Service] ExecStart/usr/bin/python3 /opt/daian/main.py Restartalways RestartSec5 Userdaian [Install] WantedBymulti-user.target注意 Userdaian 那里不要用 root 运行。日志文件如果受保护就通过把用户加入相应组或者调整日志目录的读取权限而不是放开整个 root 权限来运行监控脚本。4. 参数调试与效果优化4.1 阈值、窗口长度怎么给DAIAN 最常被问到的不是代码能不能跑而是参数怎么调。参数不会有一个万能答案但有通用的调法。我建议第一次跑时不要马上开启告警而是让 DAIAN 进入“学习模式”只记录统计量不推送告警。持续采集至少 24 小时最好覆盖一个完整的业务周期把这段时间的日志节奏数据导出来观察均值、标准差、最大值然后根据这些数字定阈值。窗口长度和阈值的关系可以参考以下规律场景窗口长度阈值说明流量平稳的内部服务30 到 603.5抖动小阈值可以略低有明显早晚高峰的业务120 到 1804 到 5窗口长才能建出稳定的基线心跳类日志平时极平稳602.5只要停跳就很反常日志本身噪声很大60 到 1206 以上避免被正常抖动刷屏窗口短的优点是响应快缺点是容易把正常的瞬时波动当作异常。窗口长能容忍更大抖动但异常发生之后要等足够多的正常样本把异常数据挤出窗口才能恢复基线。所以不建议把窗口设成超过 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 或使用 errorsreplace这些内容听起来琐碎但每一项背后都对应一次实际故障。比如“日志文件轮转漏数据”这一点最初版本只判断了文件大小增大方向没有判断 inode 变化遇到按天切割的日志时就发现当天的时段数据总是丢最后把 tail 的核心逻辑重写才解决。5.2 部署过程中的几点执行建议如果你要把 DAIAN 部署到小内存机器上有几个执行层面的建议直接生效。轮询线程数不要按任务数无脑创建一个线程轮询所有文件就够。检测计算在单线程里执行瓶颈几乎不会出现在这里。定时任务和告警推送要解耦推送是网络操作延迟波动大如果把它放在检测线程里会导致下一轮检测被卡住。DAIAN 的做法是推送任务走一个单独的队列和后台线程检测结束只负责放消息进队列发送失败也不影响采集。还有一点容易被忽略磁盘告警。日志监控本身不会导致磁盘问题但日志增长异常是很多系统变慢的根源。建议给 DAIAN 同时加一个简单的磁盘占用检查当某日志目录超过预设阈值时推一条警告而不是等到磁盘写满才被系统强制杀掉。部署完成后先用故障演练验证一下整体链路。我自己的做法是向测试日志文件手动写入 1000 条“假错误”看告警是否在预期时间到达再清空日志模拟服务静默确认恢复通知是否发出。演练时间不到十分钟但能把配置错误暴露在真正事故之前。这套检测逻辑现在还在我家里的那台小服务器上运行中间跨过几个月没去碰它依然稳如老狗。如果你也打算自己写一套这样的日志守夜人我的建议是先松后紧第一天把阈值调高一点、通知间隔调长一点让它在后台安静地收集样本等统计表现足够稳定了再慢慢压紧阈值。日志异常监控的本质不是追求用一串公式取代经验而是把经验数字化之后变成可持续维护的系统能力。有了基线有了现场问题出现的时候你手里就多了一张不那么慌的底牌。