从tail -f到流式过滤:轻量级日志采集工具colibri的设计与实践
先交代一下背景我最初折腾 colibri纯粹是因为受不了每天在服务器上开一排 tmux 窗口轮流切到各个日志文件里tail -f。后来这个烦躁点变成了一款轻量级日志实时采集过滤工具被我命名成 colibri。蜂鸟这东西体型小、翅膀扇得快但该停的时候又能稳稳悬停——我希望这个命令行工具也有同样的脾气日志流过得快你想盯住一行时又能稳稳拦住。这篇文章就把它的设计思路、核心实现、常见用法和踩坑记录完整摊开适合正在为日志排查效率头疼的开发、运维同学也适合准备自己写命令行小工具的人参考。1. 我从“tail -f | grep”里逃出来的过程一个日志工具诞生的起点1.1 现有方案让我抓狂的具体场景先说我每天面对的真实场景。测试环境有三四个微服务同时跑日志分散在各自的目录里tail -f /var/log/app/order/order.log tail -f /var/log/app/pay/pay.log tail -f /var/log/app/user/user.log开三个终端窗口并不是不能忍忍不了的是要排查问题时你根本不知道问题会出现在哪个服务里。你想看“所有服务里所有带 ERROR 的行”对不起标准工具做不到这种跨文件过滤。你只能三四个窗口来回切眼睛盯着满屏滚动的日志手指一直按 CtrlC。有人会说那你写个 shell 管道组合啊tail -f /var/log/app/*/*.log | grep --line-buffered ERROR这样确实能过滤但代价是失去了每个日志来自哪个文件的原始信息。tail合并多个文件时默认输出格式是“文件头 日志行”这种方式对人和机器都不友好。等你需要按正则过滤、排除健康检查类噪音、再加几行上下文的时候命令会膨胀成很难维护的一长串而且每次排查都要重新想一遍。1.2 集中式日志平台为什么对个人和小团队不友好我也认真考虑过搭建一套集中式日志平台。ELK 这套东西确实强大但体感太重了。filebeat 还算轻logstash 是 JVMelasticsearch 也是 JVM再加上 kibana光内存就得预留个 4G 以上。个人开发机、测试环境的小集群根本承受不起这个开销维护成本更不划算——版本升级、索引生命周期、磁盘水位、JVM 调优一个个都是时间黑洞。云厂商的日志服务也试过。Agent 一装日志确实上传了但排查问题时要等日志从远端检索出来延迟让人着急。另外很多日志包含内部请求参数、调试信息全量上传到外部服务心里总是不踏实。我需要的不是一套“平台”而是一个本地优先、即用即走、单文件可执行的小工具。1.3 蜂鸟这个名字代表的设计取向因为上面的原因我决定自己写一个。一开始它只是我电脑上的脚本集合后来发现脚本集合也有问题依赖太多、速度不够、跨机器部署麻烦。最终我把它收敛成一个单二进制的命令行工具取名 colibri。名字不是随便起的。蜂鸟有几个特性正好对应我对工具的期望一是飞行速度快对应日志处理吞吐要够高二是悬停能力对应过滤和高亮要能“盯住”关键行;三是体型小对应工具本身内存占用要低、二进制要小。后文所有实现细节其实都是在围绕这三个特性做取舍。坦白说如果你需要的是集中式日志检索、权限管理、告警这类平台能力colibri 现阶段并不适合你。但如果你像我一样多数时候只是想快速看明白服务器上正在发生什么那这个工具能省下大量时间。2. Colibri 的内部到底怎么转流式管线与关键选型2.1 总体架构Tailer、Filter、Sink 三段式流式处理colibri 的核心模型很简单只有三个阶段Tailer负责打开文件、读取新增内容或者从标准输入读取。Filter负责对每一行做包含、排除、正则等规则匹配。Sink负责把通过过滤的行按指定格式输出到标准输出或文件。三个阶段通过固定容量的 channel 连接每一段都是一个独立的 goroutine整个处理是流式的不会因为日志文件过大而把内容全部读进内存。举个例子你给 colibri 一个 2GB 的日志文件路径它不会先读 2GB 再处理而是类似于tail -f那样从文件尾部开始持续读取一行一行进入管道内存占用始终维持在很低的水平。当初设计时我把这三个阶段称为“管道”而不是“架构”因为它本质上就是 Unix 哲学里管道思想的变体。每个阶段只做一件事通过标准接口对接好处是每一段都能单独测试、单独替换。后来加新的输入源或者新的输出格式不需要动其他部分的代码。2.2 为什么选 Go 而不是 Rust 或 Python选型时我认真考虑过三种方案。Python 写起来确实最快但它的正则引擎是回溯型的处理某些复杂正则时会出现灾难性回溯一个看起来正常的表达式能把 CPU 打满这个问题在日志场景里非常致命。还有人会说 Python 有第三方库可以解决但多一个依赖就多一份部署负担不符合“单二进制”的诉求。Rust 性能非常好但它太“较真”了借用检查器逼着你每一步都要想清楚生命周期。工具类项目追求的是快速迭代我不想把大部分时间花在跟编译器搏斗上。Go 刚好卡在中间。它编译出的二进制是单文件交叉编译方便CGO_ENABLED0之后几乎没有平台依赖goroutine 和 channel 用来搭管道非常顺手标准库的regexp是基于 RE2 的能保证正则匹配在线性时间内完成不会出现 Python 那种灾难性回溯问题。另外Go 内存模型对高并发管道非常友好写出来的代码也容易维护。对 colibri 这个场景来说Go 是最顺手的选择。2.3 流式处理与固定容量缓冲的平衡日志工具最容易翻车的地方是内存控制。如果处理速度跟不上日志产出速度缓冲区会不断膨胀最后把内存吃满。colibri 的处理原则是管道容量固定不提供无限队列。具体来说Tailer 和 Filter 之间的 channel 容量默认是 4096 行Filter 和 Sink 之间也是 4096 行。当 Sink 输出变慢比如终端滚动渲染跟不上时后续的 channel 会被填满此时 Tailer 会选择“阻塞”而不是“堆积”——这正是流式处理的正确姿势生产者必须感知消费者的处理能力。但阻塞也有副作用如果下游长时间不消费上游读取也可能被卡住。所以 colibri 提供了一个丢弃策略参数当 channel 满到一定水位时可以选择丢弃新进来的日志行同时在 stderr 输出丢弃计数。这听起来有点反直觉但在高吞吐场景下“丢日志但保留程序活性”比“程序死于 OOM”更有价值。实际使用中大多数时候你并不需要丢弃因为日志行的处理往往比磁盘读取更快瓶颈基本都在终端渲染上。2.4 性能目标的设定与实测我给自己定的性能目标很简单在一台普通 4 核云服务器上处理单个 500MB 级别的日志文件内存占用不超过 80MB过滤吞吐至少达到 50MB/s。实测下来在过滤规则比较简单多关键字 OR 匹配的情况下单核能跑出 80MB/s 左右的吞吐内存峰值在 40MB 上下。加正则表达式后会慢一些但这是因为正则需要做更多计算跟语言无关。这里要说清楚这个数字不是 benchmark 竞赛只是为了证明这个工具在真实场景中不会成为你的瓶颈。日志排查的瓶颈从来都在人眼过滤的速度上而不是工具跑得快不快。设计 colibri 时我始终记得这件事。3. 逐行看代码跟随、过滤与输出这三个环节的实现细节3.1 文件跟随与日志轮转识别Tailer 最核心的问题是如何确定“新日志到哪里了”。tail -f的做法是打开文件时直接 Seek 到文件末尾后续每次 Read 都能拿到新内容。但事情没那么简单因为日志文件经常会轮转——logrotate 可能把当前日志重命名成app.log.1再创建一个新的app.log。如果只是打开文件后一直读轮转发生后你其实还在读旧文件对应的已删除 inode屏幕上会出现“日志停了”的假象。colibri 的 Tailer 会周期性检查当前打开文件的文件状态func checkRotation(f *os.File) bool { stat, _ : f.Stat() current, _ : os.Stat(f.Name()) if current nil || !os.SameFile(stat, current) { // 文件已被重命名需要重新打开同名文件 return true } return false }os.SameFile比较的是底层 inode 和设备号。一旦发现当前打开的文件和路径上的文件不是同一个就重新打开路径并继续从尾部读取。对于copytruncate模式的轮转文件没有重命名而是直接截断这时需要判断当前 offset 是否大于文件当前大小如果大于说明被截断了直接把读取位置 Seek 回文件开头。这段逻辑是所有日志工具最容易做错的地方。很多自制的 tail 脚本跑一段时间后突然不输出日志了十有八九是轮转场景没处理好。colibri 把这块做成默认能力不需要用户手动感知。3.2 过滤规则的求值顺序过滤引擎支持 include 和 exclude 两类规则顺序是先 include 后 exclude。include 只要有一条命中就放行exclude 只要有一条命中就丢弃。这个语义跟 grep 的-e和-v组合一致符合直觉。在实现上有一个重要的性能细节不是所有规则都第一时间跑正则。我加了一层“预筛”先用字符串子串匹配快速判断如果日志行里连关键词都不包含就直接跳过后续复杂的正则匹配func match(line []byte, rule Rule) bool { if rule.Substr ! { // 快速子串匹配避免直接掉进正则引擎 if !bytes.Contains(line, []byte(rule.Substr)) { return false } return true } return rule.Regex.Match(line) }这在真实日志场景中收益非常明显。日志内容有大量常规信息真正触发复杂正则的行只占一小部分。预筛可以把绝大多数行挡在正则引擎外面速度能快一个数量级。3.3 输出格式与背压丢弃策略Sink 支持三种输出格式human 格式带颜色高亮和时间戳适合人看json 格式把每一行解析成结构化对象方便管道给 jq 等工具继续处理template 格式允许你自定义输出模板。human 和 json 是使用频率最高的两种。输出端还有一个容易忽视的点stdout 是阻塞 IO写入速度受终端渲染速度影响。colibri 在 Sink 里做了两件事一是给 Writer 包一层带缓冲的 bufio.Writer每 100ms flush 一次避免每行都触发一次系统调用二是当日志流量特别大时允许配置丢弃策略。我在使用中遇到过一次场景某个服务短时间内疯狂打印异常堆栈每秒输出量达到几十万行这时候终端已经完全跟不上磁盘文件倒是能继续写。如果你在管道里接了其他命令下游消费慢的问题会更明显。这时候丢弃策略能保证 colibri 进程本身不挂同时把“丢弃了多少行”显示出来让你对损失有数。3.4 一段核心读取循环的示意下面这段代码是 Tailer 读取循环的简化版本去掉了一些错误处理和配置项保留了最关键的骨架func (t *Tailer) follow(ctx context.Context, path string, out chan- Line) error { f, err : os.Open(path) if err ! nil { return err } defer f.Close() // 默认从尾部开始读取便于模拟 tail -f 的行为 _, _ f.Seek(0, io.SeekEnd) r : bufio.NewReaderSize(f, 32*1024) for { select { case -ctx.Done(): return nil default: } line, err : r.ReadBytes(\n) if len(line) 0 { out - Line{Path: path, Content: line} } if err ! nil { if err io.EOF { if checkRotation(f) { return t.follow(ctx, path, out) // 重新打开 } time.Sleep(t.interval) // 日志未产生时的轮询等待 continue } return err } } }这段代码在 Windows 上要注意一点\n之前如果有\r会残留在行尾。colibri 会在进入过滤引擎前统一去除\r避免影响关键词匹配和 JSON 输出。这种细节不处理你在 Windows 上跑就会莫名出现匹配不到的问题。4. 四种最常见用法从微服务联调到线上按 traceId 捞日志4.1 配置文件长什么样colibri 默认不需要配置文件大部分参数可以通过命令行直接传。但如果你经常固定盯某几个服务的日志写成配置文件会更省事。下面是一份示例配置sources: - name: order path: /var/log/app/order/order.log - name: pay path: /var/log/app/pay/pay.log filter: include: - ERROR - orderId[a-f0-9]{16} exclude: - healthcheck output: format: human color: auto context_lines: 3这样你只需要执行colibri --config colibri.yaml就可以同时跟踪 order 和 pay 两个服务的日志只显示包含 ERROR 或指定 orderId 的行并自动排除 healthcheck 产生的噪音。配置文件语法上参考了常见 YAML 习惯但支持环境变量替换方便在不同环境间复用。4.2 场景一本地微服务联调时一屏看所有服务本地启动一堆服务很常见API 网关、用户服务、订单服务、支付回调服务。每个服务的日志文件位置都不一样排查问题时来回切换非常烦。colibri 支持同时指定多个文件或目录通配符colibri -p /var/log/app/*/*.log --match ERROR|WARN|orderId --ctx 2输出时每一行会带上来源文件名前缀并且不同来源用不同颜色区分。一次联调过程中我只需要一个终端窗口就能看完所有服务的异常和关键路径日志。这对排查“订单服务调用支付服务失败”这类跨服务问题特别有效你不用再手动打开三四个窗口对照时间线。4.3 场景二线上事故按 traceId 还原链路线上排查问题最常见的需求是“根据一个 traceId 把所有相关日志捞出来”。以前我习惯这样grep traceId8a2f0c1e app.log | head -50如果日志文件还在持续写入我会换成tail -f app.log | grep traceId...。colibri 提供了更顺手的参数并且能控制上下文行数colibri -f app.log --match traceId8a2f0c1e --ctx 5--ctx 5表示命中行前后各显示 5 行。在 panic 现场或者异常堆栈场景中这个参数比单纯看命中行有用得多你能直接看到异常发生前做了什么、异常抛出后完整堆栈是什么。还可以结合--tail 5m只关注最近 5 分钟的日志。这个参数在定位“刚刚发生的异常”时非常实用能缩小范围、减少输出量。4.4 场景三CI 日志里精准捞出编译错误CI 构建日志动辄几千行只看报错时传统做法是grep -E error|Error但这样会把构建过程中的 warnings、普通 error-level 信息全部带出来噪音很大。colibri 支持 include exclude 组合可以过滤得更精准colibri -f build.log \ --match FAIL|Error:|error:|Build FAILED \ --not warning:|WARNING \ --ctx 3exclude 的优先级高于 include表示“即使命中了 include 规则但如果也命中了 exclude 规则依然丢弃”。这在 CI 脚本里可以快速定位真正导致失败的错误信息而不是被一堆 warning 干扰判断。4.5 场景四把日志变成 JSON 喂给 jqcolibri 不只是一个给人看的工具。它支持把日志行解析成结构化 JSON 输出方便跟 jq 这类工具联动。比如某服务日志格式是标准字段拼接colibri 可以把时间戳、日志级别、消息逐字段提取出来colibri -f app.log --match ERROR --format json | jq {time: .timestamp, level: .level, msg: .message}这里要说明一下colibri 默认不会对任意日志做语义解析它只会在配置里指定了日志格式模板时按模板提取字段。但如果你只是想做管道处理--format json也能把整行日志作为一个 JSON 字符串输出后续加工交给 jq 即可。这保持了工具的专注度——我负责流式读取、过滤、高亮、简单结构化复杂的分析交给更专业的工具。5. 上线半年踩过的五类坑轮转、背压、编码和跨平台文件监听5.1 logrotate 后日志“原地消失”这是我在最初版本里踩的第一个大坑。当时 colibri 打开一个日志文件后就一直持有这个文件的句柄。某天测试环境跑了 logrotate日志被重命名新日志写入了新的文件。但 colibri 还在继续读旧文件句柄看起来像是日志突然不再输出了。排查过程很有意思tail -f在同一台机器上表现完全正常因为 coreutils 的 tail 会检测文件是否被重命名并自动重开。而我最初认为这不可能出错直到我用ls -l /proc/pid/fd查看 colibri 进程持有的文件描述符才发现 fd 指向的 inode 和当前路径下的日志文件已经不是同一个了。修复方案就是前面代码里展示的checkRotation用os.SameFile对比当前 fd 的 stat 和路径上的 stat。这里还要单独处理一种情况某些 logrotate 配置使用copytruncate即先复制再清空文件的 inode 不变但 offset 已经大于文件大小了这时应该 Seek 回 0。两种轮转模式都支持之后这个问题才算彻底解决。5.2 stdout 被阻塞后内存飙高的真相另一个印象深刻的问题出在输出端。早期版本我把 Sink 的 channel 设置成无界或者非常大认为“反正日志处理很快队列大一点没事”。结果在一次日志洪峰中进程内存从几十 MB 一路涨到几个 GB最终被系统 OOM Killer 杀掉。根本原因不是过滤变慢了而是终端渲染速度跟不上。我当时的终端是 iTerm2 开了即时回显每一行日志都要渲染颜色、滚动、更新屏幕。当天某个服务抖动疯狂打日志速度远超人眼消费速度channel 里堆积了上百万行日志内存自然爆掉。修复方式就是前面说的固定容量 channel 加背压。这里我想强调做流式处理工具时永远不要假设“我的处理速度足够快所以不会堆积”。下游消费速度才是决定系统能否稳定的关键变量。现在 colibri 在 Sink 满了之后会先阻塞阻塞超过阈值再触发丢弃丢弃数量会显示在 stderr。用这个策略之后再也没出现过 OOM。5.3 非 UTF-8 编码日志导致乱码与匹配失败国内很多老旧系统里的日志是 GBK 编码。colibri 最初默认按 UTF-8 处理直接导致 GBK 日志行显示乱码更麻烦的是--match匹配 UTF-8 关键词时会失败——因为日志行的字节序列里根本不存在那个关键词对应的 UTF-8 字节序列。开始我想偷懒让用户提前自己转编码iconv -f gbk -t utf-8 app.log | colibri --stdin这条命令能解决问题但每次都要手动拼接太麻烦而且iconv依赖在最小化服务器上不见得安装。后来 colibri 增加了--encoding参数支持从 GBK、GB18030、BIG5 等常见编码转换为 UTF-8处理逻辑放在读取和过滤之间。这个功能在 Windows 和部分旧业务服务器上救了很多次急。5.4 文件监听在不同平台上的表现差异我最初用 fsnotify 库来监听文件变化想着这样能比轮询更快拿到新日志。实际使用后发现问题很多Linux 上 inotify 对 rename 事件的反应和预期不完全一致日志轮转时经常会丢失通知macOS 上 kqueue 需要手动持有文件描述符行为也很微妙Windows 上 ReadDirectoryChangesW 又是一套完全不同的逻辑。最后我干脆做了一个决定默认不用系统级文件监听而是用 100ms 的轮询间隔。不要觉得轮询很笨实际操作中 100ms 轮询的延迟在日志场景完全可接受而且它让所有平台逻辑统一。省下来的复杂度换来了跨平台行为的一致性。作为命令行工具保底性能和跨平台一致性比“极致的毫秒级延迟”更重要。5.5 一个正则性能陷阱Go 的regexp虽然是 RE2没有灾难性回溯但有些“看似简单”的正则依然会消耗大量 CPU。比如.*error.*这种写法虽然不会卡死但在大日志量下非常拖吞吐。我做过一次对比同样从 1GB 日志里匹配包含 error 的行用子串匹配耗时 2 秒用.*error.*正则耗时 18 秒。原因在于 RE2 虽然线性但每处理一个字符都有更多状态转换要执行。所以 colibri 的执行策略是能用子串匹配就绝不跑正则只有配置了真正的正则表达式时才使用正则引擎。这个策略让大多数日常使用保持在最快路径上。6. 什么场景不该用 colibri以及后续怎么扩展6.1 边界不做什么比做什么更重要colibri 发展到现在我一直在刻意控制功能边界。我明确不打算把它做成常驻后台的 agent也不会给它加多节点日志聚合能力。如果你需要的是从 100 台机器上把日志统一收集到中心端然后做全文检索和可视化colibri 不是你要找的东西。那种场景应该交给专门的日志平台比如 Loki、Elasticsearch Stack或者云服务商提供的托管方案。我还刻意不做全文索引。历史日志检索和实时日志查看是两个不同的问题。实时查看需要的是低延迟流式处理历史检索需要的是预建索引和查询引擎。把两者塞进同一个工具只会让工具变得臃肿。目前 colibri 处理历史日志的方式就是直接读文件加流式过滤不建索引好处是简单、不耗磁盘坏处是超大文件反复搜索时效率不如索引方案。6.2 后续规划历史回放与更丰富的查询语法虽然不做全文索引但历史回放功能已经在规划中。目前的实现是从文件尾部开始跟踪或者是直接从头读取全量过滤。计划里会增加一个时间范围参数比如--since 2025-01-01 00:00:00 --until 2025-01-01 02:00:00通过对日志时间戳做快速定位减少无效读取。这需要日志行有时间字段所以会把它设计成可选能力不影响普通场景。查询语法方面现在只有 include/exclude 这种简单规则。后续想引入类似 SQL 的子集语法比如levelERROR AND path like /api/order/%但实现时会非常克制避免为了语法而语法。我的判断是一个工具如果 80% 的需求用简单参数就能解决就不要让那 20% 的需求把复杂度带进来。6.3 一个建议小工具先解决自己的痛点最后聊点个人的体会。colibri 之所以能满足我这么多需求核心原因在于它是我自己每天都要用的东西。每次觉得“这个操作好烦”我就给它加一个小特性。比如--ctx参数就是那天排查异常堆栈时发现只看匹配行看不到前因后果才加上的。工具跟着真实使用场景长出来的功能比一开始设计一个大而全的方案靠谱得多。如果你也想写类似的小工具我的建议是先把自己真正用烦的场景列成一个清单然后一个个解决。不要一开始就想着做得像商业产品那么完整先满足自己再考虑别人怎么用。colibri 现在虽然已经有了一些用户但每次新功能进来我第一个问的问题还是“这能不能让我自己查日志更轻松”。答案如果是肯定的才值得继续往下做。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →