ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

JERRY:基于Python和systemd的轻量级日志采集与告警系统

2026/9/15 23:31:14 拓冰建站 浏览量
JERRY:基于Python和systemd的轻量级日志采集与告警系统 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 的asynciowatchdog库做文件尾部监听。原理很简单对每个目标日志文件记录当前的 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?keyworderrorstart_time2024-01-01 00:00:00end_time2024-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_jsonDocker 默认日志驱动是 json-file每一行日志实际上是 JSON 格式包含log、stream、time三个字段这个解析器负责拆出真正的日志内容。raw不做结构化提取整行存原文配合后续正则规则做告警识别。3.2 解析器的正则设计与性能考量正则在这里是核心中的核心写得好不好直接影响解析准确率和性能。以 Nginx access.log 为例我最终使用的正则如下NGINX_COMMON_PATTERN re.compile( r(?Pip\d\.\d\.\d\.\d) - (?Premote_user\S) r\[(?Ptime[^\]])\] (?Prequest[^]) r(?Pstatus\d{3}) (?Pbytes\d) r(?Preferer[^]*) (?Puser_agent[^]*) )看到这里你可能会问为什么不直接用业界成熟的 Grok 模式库因为 Grok 说白了也是正则的集合用在这里反而多了一层依赖和编译开销。直接把最核心的正则模式写在程序里代码即文档排查问题的时候能看到原始正则反而更快。性能上有一个关键优化先过滤再解析。日志解析不是每一条都需要完整跑正则的。对于不需要告警和分析的普通请求日志我通过一个预过滤条件比如 HTTP 状态码 200 的 GET 请求直接存原始文本只有状态码异常或包含错误关键词才做完整解析。这样实际执行正则的行数大约只有总量的 10% 到 20%高并发场景下 CPU 占用大幅降低。关于正则本身有几个容易踩的坑想分享一下\S和[^ ]*看似等价但前者不会匹配空字符串所以表示“可有可无”的字段时必须用后者。时间字段里的[和]需要用\[和\]转义这个很基础但每天都会有人写漏。正则表达式里的re.compile一次编译、多次复用千万不要在每条日志处理循环里重新编译这是性能杀手。3.3 内存环形缓冲防止高峰日志冲垮下游存储日志系统最怕的不是平均流量而是瞬时高峰。比如某个服务突发刷屏一秒钟输出几百兆错误日志如果直接把所有内容写入 SQLite数据库会瞬间被写锁卡死。JERRY 的处理方式是在解析和存储之间加一层内存环形缓冲。环形缓冲实现思路使用collections.deque(maxlen10000)当原始日志经过解析后先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, contentlogs, content_rowidid );这里的contentlogs表示 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(maxlen10000) 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, encodingself.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(titleJERRY 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, le200), ): # 将 start_time / end_time 拼进 SQL 的 timestamp BETWEEN 条件 # 使用 fts5 MATCH keyword ...最后用 systemd 托管整个进程保证开机自启和崩溃自动拉起。unit 文件/etc/systemd/system/jerry.service内容如下[Unit] DescriptionJERRY Log Aggregator Afternetwork.target [Service] WorkingDirectory/opt/jerry ExecStart/opt/jerry/venv/bin/python /opt/jerry/jerry.py Restartalways RestartSec5 [Install] WantedBymulti-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_tokenxxx }, { 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_tokenxxx } ]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?keyword502start_time2024-01-15%2000:00:00end_time2024-01-15%2023:59:59返回里准确列出了那 30 条日志状态码、请求路径、用户 IP 都在整个链路从采集到告警到检索全部走通。5. 常见问题与排查技巧实录5.1 日志文件被清空但 offset 没重置导致漏读现象 /var/log/nginx/access.log清空文件后新日志写入 JERRY 没有采集到。排查查看state/下对应的 offset 文件发现 offset 还停在清空前的位置。因为文件没有发生 renameinode 变了只是 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 的Restartalways会在异常退出后自动拉起但要注意旧进程如果没释放端口新进程永远起不来这时需要systemctl kill之后再 start。6. 经验心得与可扩展方向JERRY 这个项目从第一行代码跑通到现在稳定运行半年我最大的体会有两个。第一是日志系统的核心不是“存得越多越好”而是“查得快、告警准”很多人一上来就折腾集群、分布式、海量存储但对于个人和中小团队本地 SQLite Webhook 告警已经能覆盖 90% 的需求。第二是方案要跟着真实场景走不要为了技术炫耀而过度设计只要能跑、能维护、能快速排查问题就算不够高大上也值得长期使用。如果后续想扩展我建议按这三个方向做接入更多解析器当前支持 Nginx、JSON、Docker 日志后续可以加 Apache、MySQL slow log、Caddy 等格式解析器本质上是正则字段映射扩展成本很低。加一个简单的前端面板做一个只读的日志查询页面输入关键词、选时间范围、点查询展示结果表格不需要图表不需要酷炫可视化但能极大提升日常检索体验。支持多机上报在每台机器上部署 JERRY 采集端采集到的日志通过 HTTP 上报到一台中心服务器存数据库。这样 JERRY 就从单机工具进化成轻量级集中日志平台但仍然绕开了 Elasticsearch 那套重组件。当然如果哪天日志量真的涨到单机 SQLite 顶不住那时候再上 ClickHouse 或 Loki 也不迟。至少在那之前JERRY 让我没为收集日志这件事操过一天心这才是这个项目最大的价值。