1. 项目缘起:为什么叫 JERRY,以及它想解决什么问题
先说结论:JERRY 是我最近在个人服务器上落地的一套轻量级日志采集与告警工具,整个项目从头到尾只依赖 Python 3.9+ 和系统自带的 systemd,没有引入 Kafka、Logstash、ClickHouse 这类重型组件。它解决的问题很具体:个人服务器上有十几个服务在跑,日志散落在不同的目录,出问题时靠 ssh 上去 grep 效率太低,但又不想为了一个小体量的日志需求去搭一整套 ELK。我需要一个“够用、省心、能告警”的日志聚合方案。
取名 JERRY 其实有两层意思。第一层很直白,就是《猫和老鼠》里那只小老鼠,它总能从复杂环境里快速找到出路,我希望这套工具也能像它一样敏捷,不笨重、不娇气。第二层是谐音,“Jerry”念起来像“接理”,就是把零散的日志“接”到统一入口、把关键信息“理”成结构化数据。所以整个项目的设计基调从一开始就定了:敏捷、轻量、按需扩展,不做全家桶,不追求大而全。
这套方案最适合三类人群参考:
- 有个人服务器或 NAS,跑着若干 Docker 容器或 systemd 服务,希望日志别再“裸奔”的人。
- 小团队或独立开发者,业务日志量每天不超过 1GB,不需要完整可观测性平台,但希望有告警和检索出入口。
- 想搞懂日志系统核心原理,不想上来就部署 Elasticsearch、Fluentd 等大组件的人,JERRY 的实现方式非常透明,能让你把日志处理链路看个底朝天。
我在这个项目里坚持了一个原则:每一个功能都先问“为什么需要它,不用行不行”。比如很多人一上来就要加全文检索引擎,但对我这个体量,直接落 SQLite 全文索引就够了。结论是,JERRY 只用了一张 SQLite 表、一个内存环形缓冲、一组正则解析规则,就把“统一采集、结构化存储、规则告警、关键词检索”四件事全办了。
2. 整体设计与方案选型:用做减法的方式搭一套日志闭环
2.1 采集层为什么不用 Filebeat,而用 Python 的异步监听
最初我纠结过要不要直接上 Filebeat + Logstash,后来算了笔账:Filebeat 本身不重,但加上 Logstash 和 Elasticsearch 之后,光内存占用就奔着 2GB 去了。我的服务器一共才 8GB 内存,还要跑数据库和业务服务,不能为了日志把主业务挤垮。
JERRY 的采集层改用 Python 的asyncio+watchdog库做文件尾部监听。原理很简单:对每个目标日志文件记录当前的 inode 和 offset,通过seek到文件尾部,然后持续读取新增行。用watchdog监听文件修改事件,一旦有写入就触发读取回调。这套思路其实就是 Logstash 里tail输入的简化版,但对我这个场景完全够用。
选型时还特意验证了几个关键点:
- 文件被轮转(rename)后能否自动追踪新文件:
watchdog的on_moved事件可以识别,重新打开新 inode 文件即可。 - 多文件并发写入时的性能:
asyncio把每个文件的 IO 操作变成协程,实测同时监听 20 个文件、每秒累计新增 200 行日志时,CPU 占用率不到 5%。 - 进程崩溃后能否从断点续读:每写入 10 条解析结果就提交一次 offset 到状态文件,重启后按照 last offset 继续读,不会丢数据也不会重复读太多。
注意:这里说的“断点续读”基于本地文件 offset,只适用于单机单实例部署。如果未来要横向扩展,必须引入 Kafka 这类消息队列来统一 offset 管理,否则每个实例的消费进度会打架。
2.2 存储层为什么选 SQLite 而不是 MySQL
有人会觉得 SQLite 太小家子气,但我觉得很多场景被低估了。我之所以选 SQLite,核心原因是写放大小、零运维、备份简单。日志数据本质上是“写多读少、按时间范围查”的时序数据,SQLite 的 WAL 模式在单机写入场景下性能非常稳,实测每秒可以写入 2000 多条结构化日志,远超个人服务器的实际负载。
另外一点,SQLite 单文件数据库让备份变得极其简单:每天凌晨用cp把 db 文件复制走即可,不需要 mysqldump,不需要考虑账号权限。配合 SQLite 自带的 FTS5 全文索引扩展,我甚至不需要额外引入 Elasticsearch 就能实现关键词检索。这是整个项目性价比最高的一次取舍。
2.3 告警链路为什么走“规则引擎 + Webhook”
告警这块我没有自研通知推送,而是统一走 Webhook。不管是钉钉、企业微信还是 Slack,都支持通过 Webhook 地址接收 JSON POST 请求,我只需要在 JERRY 里维护一张规则表,定义匹配条件,再把匹配结果组装成固定格式的 JSON 发给目标 URL。
规则引擎的实现也没用复杂组件,就是一组 Python 正则表达式 + 关键字组合。每条规则包含四个字段:规则名称、匹配模式(正则或关键词列表)、触发阈值(比如 5 分钟内出现次数)、告警级别。当解析后的日志与规则匹配且达到阈值,就触发一次告警。这个设计比直接用 Kibana 的 Alerting 更灵活,因为规则完全与业务日志格式解耦,新增一种服务的告警只需要在数据库里插一条记录,改完即时生效,不需要重启服务。
2.4 为什么把检索接口做成 HTTP API 而不是纯命令行
早期版本我只做了命令行检索,后来发现“手机不在电脑前也想查日志”是刚需。于是顺手用 FastAPI 包装了一层 HTTP API,语法是GET /api/search?keyword=error&start_time=2024-01-01 00:00:00&end_time=2024-01-02 00:00:00,返回 JSON 数组。这个接口支持关键词匹配、正则匹配、时间范围过滤、分页、排序,配合 FTS5 的隐藏索引,5 千多万行日志量下查询响应在百毫秒级。实测体验下来,这个接口成了我日常排障使用频率最高的入口。
3. 核心细节拆解与实操要点
3.1 目录结构与配置文件的组织方式
JERRY 的全部代码和配置加起来只有 6 个文件,我从一开始就不想把它搞成一个复杂包,方便复制到任何一台机器就能跑。整体结构如下:
/opt/jerry/ ├── jerry.py # 主程序,包含采集、解析、存储、检索 API ├── config.yaml # 唯一配置文件 ├── rules.json # 告警规则 ├── state/ # 程序运行时状态文件(offset 等) ├── logs/ # 程序自身运行日志 └── data/jerry.db # SQLite 数据库为什么不用 Python 工程常见的 src 分层结构?因为 JERRY 的核心逻辑撑不起那么大的划分,硬拆反而让排查问题时要多跳几层目录。对于体量在两千行以内的工具,单文件 + 配置文件是最容易维护的形态。等我某天发现它长到需要分模块了,再重构也不迟。
配置文件的 YAML 格式如下,这是整个项目最需要理解的部分:
listen: - name: nginx-access path: /var/log/nginx/access.log encoding: utf-8 parser: nginx_common - name: app-error path: /opt/myapp/logs/error.log encoding: utf-8 parser: json - name: docker-container path: /var/lib/docker/containers/*/*.log encoding: utf-8 parser: docker_json这里要重点说明path通配符的用法。JERRY 支持 glob 模式,比如docker-container监听的是/var/lib/docker/containers/*/*.log,这个通配符会在启动时展开匹配所有容器日志文件,如果后续有新的容器日志文件出现,watchdog 也会自动检测到目录变化并监听新文件,不需要改配置重启。
parser字段对应解析器名称,目前内置了nginx_common、json、docker_json、raw四种。每种解析器做的事情不同:
nginx_common:用正则解析 Nginx access.log 的$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"格式,提取出 IP、时间、请求方法、路径、状态码等结构化字段。json:把整条日志当 JSON 解析,提取出内部字段,适合结构化日志输出。docker_json:Docker 默认日志驱动是 json-file,每一行日志实际上是 JSON 格式,包含log、stream、time三个字段,这个解析器负责拆出真正的日志内容。raw:不做结构化提取,整行存原文,配合后续正则规则做告警识别。
3.2 解析器的正则设计与性能考量
正则在这里是核心中的核心,写得好不好直接影响解析准确率和性能。以 Nginx access.log 为例,我最终使用的正则如下:
NGINX_COMMON_PATTERN = re.compile( r'(?P<ip>\d+\.\d+\.\d+\.\d+) - (?P<remote_user>\S+) ' r'\[(?P<time>[^\]]+)\] "(?P<request>[^"]+)" ' r'(?P<status>\d{3}) (?P<bytes>\d+) ' r'"(?P<referer>[^"]*)" "(?P<user_agent>[^"]*)"' )看到这里你可能会问:为什么不直接用业界成熟的 Grok 模式库?因为 Grok 说白了也是正则的集合,用在这里反而多了一层依赖和编译开销。直接把最核心的正则模式写在程序里,代码即文档,排查问题的时候能看到原始正则反而更快。
性能上有一个关键优化:先过滤,再解析。日志解析不是每一条都需要完整跑正则的。对于不需要告警和分析的普通请求日志,我通过一个预过滤条件(比如 HTTP 状态码 200 的 GET 请求)直接存原始文本,只有状态码异常或包含错误关键词才做完整解析。这样实际执行正则的行数大约只有总量的 10% 到 20%,高并发场景下 CPU 占用大幅降低。
关于正则本身,有几个容易踩的坑想分享一下:
\S+和[^ ]*看似等价,但前者不会匹配空字符串,所以表示“可有可无”的字段时必须用后者。- 时间字段里的
[和]需要用\[和\]转义,这个很基础但每天都会有人写漏。 - 正则表达式里的
re.compile一次编译、多次复用,千万不要在每条日志处理循环里重新编译,这是性能杀手。
3.3 内存环形缓冲:防止高峰日志冲垮下游存储
日志系统最怕的不是平均流量,而是瞬时高峰。比如某个服务突发刷屏,一秒钟输出几百兆错误日志,如果直接把所有内容写入 SQLite,数据库会瞬间被写锁卡死。JERRY 的处理方式是在解析和存储之间加一层内存环形缓冲。
环形缓冲实现思路:使用collections.deque(maxlen=10000),当原始日志经过解析后,先append到 deque,同时有一个独立的写入协程定期从 deque 右侧popleft批量写入数据库。如果写入速度跟不上产生速度,deque 满了之后会自动丢弃最旧的缓存,保证内存不会无限增长。这个设计在突发日志风暴时会让部分日志丢失,但换来的是主服务不宕机。
我实际测试过:在一个每秒产生 5 万行错误日志的极端情况下,JERRY 内存占用保持在 100MB 以内,SQLite 写入稳定在每秒 2000 条左右,其余日志被缓冲层丢弃。这对个人服务器来说,保命比保日志更重要。
提示:如果你对日志完整性有硬性要求,可以调大
maxlen,或者把缓冲层改成磁盘文件队列(类似 Kafka 的本地模式)。但对大多数个人场景,内存环形缓冲的性价比最高,实现也最简单。
3.4 告警阈值判断:滑动窗口而不是简单计数器
告警规则里最常见的一个误区是“5 分钟内出现 10 次错误就告警”,如果直接用一个计数器统计,可能会导致重复告警轰炸。JERRY 用的是滑动窗口计数:维护一个时间戳列表,当新日志命中规则时,把当前时间戳追加到列表尾部,同时移除窗口起始时间之前的所有时间戳,最后判断列表长度是否达到阈值。
用代码表示更直观:
class SlidingWindowCounter: def __init__(self, window_seconds, threshold): self.window_seconds = window_seconds self.threshold = threshold self.timestamps = deque() def add(self, ts): self.timestamps.append(ts) while self.timestamps and ts - self.timestamps[0] > self.window_seconds: self.timestamps.popleft() if len(self.timestamps) >= self.threshold: self.timestamps.clear() # 触发后重置,避免连续告警 return True return False这段代码里有一个细节容易被忽略:触发告警后为什么要clear()清空时间戳?如果不清理,窗口里累积的时间戳会持续满足阈值条件,导致每分钟重复告警。清空之后,必须等下一轮窗口重新累积到阈值才会再次告警,这样既能防抖,又能避免告警风暴。
3.5 检索接口的 FTS5 全文索引配置
SQLite 在 3.9 版本开始支持 FTS5 全文索引扩展,JERRY 在创建表结构时直接建了一个虚拟表用于全文检索:
CREATE VIRTUAL TABLE IF NOT EXISTS logs_fts USING fts5( message, content='logs', content_rowid='id' );这里的content='logs'表示 FTS5 表是外部内容表,不会冗余存储数据,只在message字段上建立索引。数据写入时,需要用触发器同步更新 FTS 索引:
CREATE TRIGGER IF NOT EXISTS logs_ai AFTER INSERT ON logs BEGIN INSERT INTO logs_fts(rowid, message) VALUES (new.id, new.message); END;查询时通过MATCH语法:
SELECT id, timestamp, level, message FROM logs JOIN logs_fts ON logs.id = logs_fts.rowid WHERE logs_fts MATCH ? AND logs.timestamp BETWEEN ? AND ? ORDER BY id DESC LIMIT 50;这里要注意,FTS5 的MATCH语法有自己的查询语法,比如关键词中不需要加百分号做模糊匹配,直接用"error"或error AND db即可。如果你用LIKE '%error%',会走全表扫描,性能天差地别。
实测经验:在 57,233,401 行日志数据上,
MATCH 'error'查询响应时间为 0.8 秒,而LIKE '%error%'需要 3.6 秒。差距虽然不是数量级的,但在复杂查询条件下会越来越明显。
4. 实操过程:从零部署 JERRY 到接入告警
4.1 初始化环境与依赖安装
JERRY 的依赖极少,只有PyYAML、watchdog、fastapi、uvicorn四个库。安装命令也很简单:
mkdir -p /opt/jerry/{state,logs,data} cd /opt/jerry python3 -m venv venv source venv/bin/activate pip install pyyaml watchdog fastapi uvicorn创建虚拟环境是必须的一步,虽然看起来多敲了几行命令,但隔离依赖能避免和系统 Python 环境打架。我之前偷懒直接全局装过依赖,后来升级系统包时把 watchdog 版本搞坏了,JERRY 无声无息地停止工作了一整天,从那以后再也不敢不建虚拟环境。
4.2 编写核心采集循环
采集循环的逻辑核心在jerry.py里,这里贴出最关键的协程部分:
class LogTailer: def __init__(self, name, file_path, parser, offset_file): self.name = name self.file_path = file_path self.parser = parser self.offset_file = offset_file self.file_handle = None self.offset = self._load_offset() self.buffer = deque(maxlen=10000) async def start(self): self._open_file() while True: line = self.file_handle.readline() if not line: await asyncio.sleep(0.1) continue self.offset += len(line) parsed = self.parser.parse(line.strip()) if parsed: self.buffer.append(parsed) if len(self.buffer) >= 100: self._flush() if self.offset % 4096 == 0: self._save_offset()这段代码里有个小而重要的设计:每读 4096 字节才保存一次 offset。如果每条日志都写一次状态文件,频繁的小磁盘 IO 会把 SSD 的寿命磨掉不少。折中到 4KB 粒度,最多重读 4KB 数据,完全可接受。
_open_file的实现也需要注意,它必须处理文件不存在、文件被轮转、文件 inode 变化三种情况:
def _open_file(self): if not os.path.exists(self.file_path): raise FileNotFoundError(f"{self.file_path} not found") if self.file_handle: self.file_handle.close() self.file_handle = open(self.file_path, 'r', encoding=self.encoding) self.file_handle.seek(self.offset)如果程序启动时 offset 对应文件已经不存在了(比如日志轮转后新文件长度小于 offset),需要做一次越界判断,直接在seek后移到文件尾部:
if self.offset > os.path.getsize(self.file_path): self.offset = os.path.getsize(self.file_path)这个细节坑了我两个晚上。日志轮转后新文件为空,offset 还是旧文件的大小,Python 的seek并不会报错,但后续readline读到的是空行,日志就“丢”了。处理方式是在 open 之后检查 offset 与文件实际大小的关系,越界就重置到文件尾部。
4.3 启动 API 服务与 systemd 托管
采集逻辑跑通后,用 FastAPI 挂载检索接口:
from fastapi import FastAPI, Query app = FastAPI(title="JERRY Log API") @app.get("/api/search") def search( keyword: str = Query("", description="关键词"), start_time: str = Query("2024-01-01 00:00:00"), end_time: str = Query("2030-01-01 00:00:00"), limit: int = Query(50, le=200), ): # 将 start_time / end_time 拼进 SQL 的 timestamp BETWEEN 条件 # 使用 fts5 MATCH keyword ...最后用 systemd 托管整个进程,保证开机自启和崩溃自动拉起。unit 文件/etc/systemd/system/jerry.service内容如下:
[Unit] Description=JERRY Log Aggregator After=network.target [Service] WorkingDirectory=/opt/jerry ExecStart=/opt/jerry/venv/bin/python /opt/jerry/jerry.py Restart=always RestartSec=5 [Install] WantedBy=multi-user.target开机自启:systemctl enable jerry --now。到这里 JERRY 已经能在后台稳定运行了,接下来就是配置告警规则。
4.4 配置告警规则并接入钉钉群机器人
rules.json的格式我用了一个数组,每条规则有五个字段:
[ { "name": "nginx-5xx-burst", "target": "nginx-access", "match": "\\s(5\\d\\d)\\s", "window_seconds": 300, "threshold": 20, "level": "warning", "webhook": "https://oapi.dingtalk.com/robot/send?access_token=xxx" }, { "name": "app-error-keyword", "target": "app-error", "match": "FATAL|OutOfMemory|Connection refused", "window_seconds": 60, "threshold": 1, "level": "critical", "webhook": "https://oapi.dingtalk.com/robot/send?access_token=xxx" } ]match字段是正则表达式,匹配的是原始日志文本。注意在 JSON 里写正则时反斜杠要双写,比如\s要写成\\s,这个细节很容易让人抓狂。我最早写配置时漏了双重转义,结果规则永远匹配不到,排查了很久才发现是 JSON 解析把\s先转义成了s。
告警触发后,JERRY 组装一条固定格式的 JSON 通过requests.post发到 Webhook 地址。钉钉机器人的格式大概长这样:
{ "msgtype": "text", "text": { "content": "[JERRY告警] 规则: nginx-5xx-burst\n级别: warning\n内容: 最近5分钟5xx状态码请求次数超过20次\n时间: 2024-01-15 14:23:00" } }接入企业微信或 Slack 的差异只在 Webhook 地址和消息格式上,JERRY 里做了一层适配,每种渠道对应一个消息模板类。因为规则和渠道解耦,以后加新的通知渠道只需要写一个新的消息模板类即可。
4.5 验证部署结果
全部配置完成后,我模拟了一次故障:往 Nginx 访问日志里压了 30 条 502 响应,结果 10 秒内钉钉群就收到了告警通知。用检索接口验证:
curl "http://127.0.0.1:8000/api/search?keyword=502&start_time=2024-01-15%2000:00:00&end_time=2024-01-15%2023:59:59"返回里准确列出了那 30 条日志,状态码、请求路径、用户 IP 都在,整个链路从采集到告警到检索全部走通。
5. 常见问题与排查技巧实录
5.1 日志文件被清空但 offset 没重置,导致漏读
现象:> /var/log/nginx/access.log清空文件后,新日志写入 JERRY 没有采集到。
排查:查看state/下对应的 offset 文件,发现 offset 还停在清空前的位置。因为文件没有发生 rename(inode 变了),只是 truncate 了,watchdog 的on_modified事件触发了读取,但seek到了文件末尾之后,新数据写入的偏移量小于 offset,导致一直读不到。
解决:在on_modified事件处理时,加一个“文件当前大小是否小于已记录 offset”的判断,如果小,重置 offset 为 0。这个判断逻辑见 4.2 节,代码上几行就搞定,但很容易被忽略。
5.2 正则匹配不到日志,但用工具测是通的
现象:规则是.*error.*,模拟日志里有 “an error occurred”,但告警没有触发。
排查:首先确认解析后的日志内容是什么。JERRY 在解析阶段可能会把原始日志截断或改变了大小写,比如某服务日志里统一把 error 转成了 ERROR,正则没开启re.IGNORECASE。另外一个常见原因是,日志中携带了 ANSI 颜色转义字符,比如\x1b[31m,这些不可见字符夹杂在文本里,导致.*error.*匹配不上。解决方式是在解析器里加一个strip_ansi的净化步骤。
5.3 告警风暴
现象:某服务出现持续故障,错误日志每小时几千条,JERRY 几乎每分钟都在发告警。
解决:先在窗口计数器触发后立刻clear()(见 3.4 节),然后给规则增加一个cooldown_seconds字段,触发告警后的冷却时间内不再响应同一规则。冷却时间按级别设置:warning 30 分钟,critical 10 分钟,这样重大故障能及时通知,普通噪音不会把群里刷屏。
5.4 SQLite 数据库膨胀
现象:一开始数据库只有几 MB,跑了几个月发现已经 2GB,查询开始变慢。
解决:日志不建议永久保存。JERRY 内置了一个保留期限参数,默认 30 天,每天凌晨启动一个清理任务:删除timestamp < datetime('now', '-30 day')的旧日志。同时清理 FTS 索引中对应的条目。这一步必须用事务包裹,防止数据库处于不一致状态。
5.5 端口被占用的启动失败
现象:uvicorn报address already in use。
解决:JERRY 的 API 服务只在本地 127.0.0.1 上监听,不暴露公网。启动前检查ss -lntp | grep 8000,如果有残留进程先停掉。systemd 的Restart=always会在异常退出后自动拉起,但要注意旧进程如果没释放端口,新进程永远起不来,这时需要systemctl kill之后再 start。
6. 经验心得与可扩展方向
JERRY 这个项目从第一行代码跑通到现在稳定运行半年,我最大的体会有两个。第一是日志系统的核心不是“存得越多越好”,而是“查得快、告警准”,很多人一上来就折腾集群、分布式、海量存储,但对于个人和中小团队,本地 SQLite + Webhook 告警已经能覆盖 90% 的需求。第二是方案要跟着真实场景走,不要为了技术炫耀而过度设计,只要能跑、能维护、能快速排查问题,就算不够高大上,也值得长期使用。
如果后续想扩展,我建议按这三个方向做:
- 接入更多解析器:当前支持 Nginx、JSON、Docker 日志,后续可以加 Apache、MySQL slow log、Caddy 等格式,解析器本质上是正则+字段映射,扩展成本很低。
- 加一个简单的前端面板:做一个只读的日志查询页面,输入关键词、选时间范围、点查询,展示结果表格,不需要图表,不需要酷炫可视化,但能极大提升日常检索体验。
- 支持多机上报:在每台机器上部署 JERRY 采集端,采集到的日志通过 HTTP 上报到一台中心服务器存数据库。这样 JERRY 就从单机工具进化成轻量级集中日志平台,但仍然绕开了 Elasticsearch 那套重组件。
当然,如果哪天日志量真的涨到单机 SQLite 顶不住,那时候再上 ClickHouse 或 Loki 也不迟。至少在那之前,JERRY 让我没为收集日志这件事操过一天心,这才是这个项目最大的价值。