3.4 日志归集:多机日志统一进 CLS
场景:四台机器各自为政
分布式改造之后,最直接的不适是排障方式变了。单机时代出问题,SSH 上去 tail 一下日志十分钟搞定;多机之后出一个故障,master 一台、worker 三台、数据库一台,挨个登四遍机器 grep 关键词,等凑齐现场黄花菜都凉了。有一次半夜排障,我们在四台机器之间复制粘贴日志文本,拼出一个完整的时间线花了一个多小时。
那天下班前我们就决定上日志服务 CLS。目标只有两个:所有机器的日志能在一处搜索,搜的时候能按时间、机器、任务过滤。
顺带一个落地建议:日志归集项目上线前,先花半天把检索场景列一张清单——团队最常问的十个问题是什么(哪个任务失败了、哪台机器慢了、哪个站点在报错)。清单反过来决定了字段约定与索引建设。我们最初略过这步,上线后才发现关键字段漏了两个,返工的成本远高于先想清楚。
说个细节感受:没上 CLS 之前,半夜的故障值班是整个团队最排斥的事,不是因为问题难,而是因为流程太折磨。上了之后,同样一个故障从穿睡衣爬起来到定位完成,平均时间从一个小时降到十分钟。工具选型的收益有时候不在性能,在于人的体验。
方案:日志归集的三个路线
| 路线 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 应用直写 | 程序直接调 CLS SDK | 链路最短 | 改造成本高 |
| 采集客户端 | 机器上跑采集 agent | 应用无感 | 多一个进程要维护 |
| 自建中转 | 先入 Kafka 再消费 | 吞吐高 | 组件太重 |
中间路线最适合我们:worker 不动代码,每台机器装一个采集客户端,把本地日志文件送进 CLS。 唯一要在应用侧做的配合是把日志写成结构化 JSON——这件事在 1.4 节选 zap 的时候已经提前做了,这里直接兑现。
为什么应用不直接写 CLS SDK?主要是耦合,日志 SDK 进了进程就要跟应用一起编译、一起升级、一起背 crash 风险。采集客户端的方式让应用保持纯粹,日志链路出问题时业务进程毫发无损。这个分层在多服务环境里非常值得坚持。
日志字段我们统一了约定,这张表是落库检索的前提:
| 字段 | 含义 | 例子 |
|---|---|---|
| ts | 时间 | 精确到毫秒 |
| level | 日志级别 | info |
| host | 机器名 | worker-02 |
| service | 服务名 | crawler |
| task_id | 任务编号 | 10234 |
| site_id | 站点编号 | site-07 |
| msg | 内容 | fetch ok |
task_id 和 site_id 是搜索利器:一个任务出问题,按 task_id 过滤立刻看到跨机器的完整链路。这两个字段也让日志能和 MySQL 里的任务表对账。
日志检索场景举两个实例。一次是某站点连续三日数据下跌,打开 CLS 按 site_id 过滤近三天 level 等于 warn 的记录,一眼看到大量 403 从周一开始出现——原来是对方改了风控规则。另一次是某台 worker 突然变慢,按 host 过滤发现它的 fetch 耗时分布整体右移,查到最后是那台机器换了机房后的网络问题。两个案例的共同点:从问题到定位只用了两条查询,这在以前要登四台机器。
配置与代码:zap 的结构化输出
zap 的配置只列出关键字段,其余省略:
cfg := zap.NewProductionConfig()
cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
cfg.EncoderConfig.LevelKey = "level"
logger, _ := cfg.Build(zap.Fields(
zap.String("host", hostname),
zap.String("service", "crawler"),
))
调用处带上任务与站点上下文:
logger.Info("fetch ok",
zap.Int("task_id", taskID),
zap.String("site_id", siteID),
zap.Int("status", resp.StatusCode),
zap.Duration("dur", elapsed),
)
一行代码一行 JSON,采集客户端原样上送。在 CLS 控制台按 task_id 搜索,所有机器的相关记录按时间排成一条线,从前在四台机器间跳来跳去的排障方式彻底结束。
默认级别我们开 info,调试时临时开到 debug 要走控制台而不是改代码重启。 日志等级动态调整是 5.1 节运维工具箱的一部分。
一个小的反模式提醒:别把 zap 的 SugaredLogger 用得到处都是。它的性能比结构化 Logger 慢一个量级,在高频路径上悄悄吃 CPU。我们的约定是低频路径随便用,抓取主循环一律用强类型 Logger。
采集客户端侧的配置也值得花一刻钟。日志主题按服务拆分而不是按机器拆分——crawler 一个主题、master 一个主题,这样按服务检索时不需要跨主题_union 查询。机器维度靠 host 字段区分,完全够用。索引方面,task_id 和 site_id 一定要建索引,这两个字段是日常检索的入口,没索引的检索语句会慢到让人想退回 SSH 时代。
存储周期我们也踩过一次坑才定下来:一开始所有日志统一保留三十天,结果第二个月账单让人心疼。后来按价值分三档:业务关键日志(含 task_id 的抓取记录)九十天,便于回查长期问题;普通运行日志七天;调试日志一天。分档之后存储费降了六成,检索体验反而更好——低价值日志不再稀释搜索结果。
踩坑:日志归集的四个坑
第一个坑是日志量。全量 info 级别一开始没在意,一个月后 CLS 存储费跳了一截。翻账单发现调试期开的 debug 忘了关,日志量翻了十倍。对策:存储周期分级,业务关键日志保留九十天,普通日志保留七天,调试日志保留一天。
第二个坑是时间戳。不同机器的本地时间有细微偏差,按时间排序时偶尔错位。全部走标准时间协议之后消停,和 3.3 节租约问题是同一个根源。
第三个坑是采集客户端自身的故障。有次 agent 挂了半天,worker 日志默默丢了几小时,排障时才发现中间空了一段。对策是本地日志保留二十四小时作为缓冲,agent 启动时能补传。
顺带一个成本提醒:采集客户端的实例按机器数线性增长,每台机器一个,五台以上就值得用配置管理工具统一下发。早期我们手动一台台装,某次发现两台机器的 agent 配置版本不同,采集的主题名不一致,日志凭空少了一路。
第四个坑是日志里的敏感信息。某次调试把完整 URL 连带参数打进了日志,里面有合作方的 token。立刻清洗了那批日志并加了敏感字段过滤规则。安全团队后来要求所有 URL 入日志前先脱敏,正则替换 token 类参数,这个过滤器在 5.4 节合规护栏里有完整清单。
第五个坑是日志检索不会用。工具上了但没人学检索语法,大家还是登机器。我们花了一小时做了一次内部培训,只讲五条最常用查询(按 task_id、按 host 加 level、按时间段、按站点、按关键词),从此 CLS 的使用率才真正上来。再好的工具也要配套文化,否则就是摆设。
补一个告警联动的经验:日志归集做好之后,告警的原料也就有了。我们在 CLS 上配了两条简单告警,一条是某站点五分钟内 403 超过阈值,一条是任一 worker 连续十分钟无 info 输出(意味着进程假死)。这两条告警在头三个月各救过一次场,成本几乎为零。日志系统不要只当事后查账的工具,它同时也是最便宜的实时监控。
关于选型还想补一句:我们选 CLS 而不是自建 ELK,核心考量是运维带宽。小团队把日志栈交给云服务,换来的是不用管索引分片、磁盘扩容、版本升级。自建方案的上限确实更高,但那需要专人投入,我们的精力只能花在业务本身。这种取舍在 5.3 节成本复盘里有更细的账目对比。
小结
日志归集改变的不是日志,是排障方式:从登机器找日志,变成搜索日志找问题。结构化字段是这一切的前提,采集客户端是载体,分级存储是成本控制。第三篇到此结束,平台的多机骨架已经完整。第四篇讲数据怎么走出去:清洗、导出、控制台与 CDN,从 4.1 节开始。
再补一个采集客户端的坑。agent 按行偏移读日志,有次 worker 日志产生速度超过 agent 消费速度,agent 重启时按旧偏移重读,恰好跳过了一截未刷盘的行,日志里空了一段。对策是 agent 只在行完整写入后才记录偏移,并且我们给每条日志加了序号字段,缺失的序号在检索界面一眼可见。丢没丢日志以前要靠人眼比对,加了一列序号之后变成数数。可观测性的最小颗粒,往往就是一个自增编号。日志体系必须假设每个环节都会丢,丢不可怕,看不见才可怕。序号字段还附带给了一个意外收获:日志乱序问题一眼可见。有次某 worker 的日志时间戳比别台晚了两分钟,靠序号与时间戳对不上立刻定位到是机器时钟漂移,顺手把全部机器的时钟统一校准了一遍。
