掘金 后端 ( ) • 2024-05-14 13:44

本文为日志采集漫谈,并不涉及技术选型,没有开源产品比对,望周知。

因为我并不是日志采集系统的开发人员,本文现学现炒,班门弄斧,如有问题欢迎留言(轻喷)。

故事还得从本周我搞了个 panic 开始说起,在我发布失败要排查为什么失败了时,我惊讶的发现我竟然要上容器才能看到 panic 日志,我工作这么久还是很少见到这种场面的,经过和基建同学的深入畅谈,我要上容器这件事情合不合理抛开不谈,但我意识到,虽然大家都有日志采集,但似乎每家公司的实现却都略有差异,因此今天就来讲讲关于日志采集的一些个人想法。

在过去的文章中,我们提到过好几次关于系统的稳定性建设,而稳定性建设的第一步,就是要采集数据,关于采集数据的每一个环节,我们都可以花很多篇幅去讲解,今天我们要介绍的「日志(Log)」就是其中的一环。(另外两个重要的方向是「链路追踪(Trace)」和「度量(Metrics)」)

农耕时代

所谓农耕时代,就是说如果没有「日志采集」系统,那么我们要怎么看日志。

上文中我们其实也提到了,那就上机器呗——它的流程大概是:

# 输入到文件流
./service > stdout.log

# 查看日志流
tail -f stdout.log

# 搜索
cat stdout.log | grep panic

在自己开发的一些图一乐小服务中,我也经常简单粗暴的用这种方式 Debug 和查找结果(比如直接 stdout 的话日志一口气输出太多,那么暂存到文件后 grep 就方便很多),但对于线上来说显而易见的就会有很多问题:

  1. 非结构化随便打的日志不好检索
  2. 常驻服务日志量大了怎么办
  3. 我感觉出了点问题,可是我一个服务有三个容器,到底日志在哪台容器上

也就是说,在现代化系统中,依靠上机操作的这个想法只能说:尊重、祝福。

升级之路

分析出了要解决的问题之后,接下来我们就可以顺着思路设计系统了,对于单一操作系统来说来说,一共有三种方式可以解决:

  1. 写入 stdout
  2. 写入文件(磁盘 IO)
  3. 推往采集服务(网络 IO)

严格来说,许多日志系统都可以监听不同的数据源,这取决于你自己的配置,它们也不是完全冲突的,比如即使我选择直接推往采集服务的简单方式,但为了避免数据丢失,我仍然可以写入磁盘作为一个备份和恢复手段。

接下来叠加的正交问题是:大部分情况下我们都用了 K8S,站在集群的角度上来说,我们解锁了新的口径,如何收集集群中所有的日志:

  1. 将日志直接从应用程序中推送到日志记录后端。
  2. 在应用程序的 Pod 中,包含专门记录日志的边车(Sidecar)容器
  3. 使用在每个节点(Node)上运行的节点级日志记录代理。

由于选择的多样性,导致了我前面所说的每家公司的实现差异性。

首先先从最简单粗暴的设计开始说起,也就是直接将日志从应用程序推到日志系统中,K8S 文档中也有提及,但这完全取决于日志系统的实现,因此它并没有展开:

17155148349313.jpg

实际上,单看这种简单的架构,就会存在不少问题,如果没有一些补偿手段,非常容易丢失日志,而如果容器内异常,也就意味着收集不到对应的日志,而未发送的日志也可能会丢失——我明明是希望通过日志排查为什么挂了,但因为挂了日志丢失,这就和「可观测性」离得太远了。

你也可以使用边车(Sidecar)容器来挂载日志系统:

17155153302821.jpg

这样的好处是每个容器都有属于自己的日志收集器,独立启动,意味着可以独立配置,相当灵活。

当然,也有一个类似的方法,就是我构建镜像的时候就把 Agent 一起打进去就行了,但是这样的话对于 Agent 的单独管理(重启、恢复、升级)就必须要将你整个集群中所有镜像全部打个包了,Sidecar 的好处就在于它有独立的生命周期,和主容器互不干扰的同时又共享了网络和存储。

但缺点是每个容器都有一个独立的 agent 相较于其他方法,开销会成倍的增长,对于大型集群来说到了一个很难忽视的程度。

改进的一套边车容器运行方案是,边车容器只负责从各处搜刮日志,统一的输出到 stdout 和 stderr,这样的话整体的日志处理还是交给了 k8s,而相比原来使劲往 stdout 和 stderr 输出内容,边车容器会让我们对于日志流的生产和消费有着更灵活的把控,适合一些你无法把控整个容器内输出(比如底层有些日志往文件里写),或者需要一个归档能力的情况:

17155160266509.jpg

此外,因为它本质上只是一个简单的日志流的重定向,因此 sidecar 的整体开销相比前一个方案要小很多。

当然,这种方案相当于多了一步写入到容器内的文件,因此相比直接推 stdout 和 stderr,相当于需要额外的存储空间。因此如果并不需要一些复杂的分类文件记录,或者本身只写入到一个固定的文件中,那么 k8s 仍然推荐直接写到 stdout。

另一方面,如果你只写入一个文件,但却没有实现日志轮转(根据时间切割日志、自动回收)的话,sidecar 也可以负责做这件事情(但相比之下,或许直接让 kubelet 去做会更简单)。

17155169539521.jpg

最后我们再来说说「使用在每个节点(Node)上运行的节点级日志记录代理」的意思,这一套方案很显然是最环保的,,只需要看 K8S 画出来的图就知道:

使用这种方式,你可以拥有:

  1. 最低成本的开销:宿主机级别的 Agent
  2. kubelet 负责处理日志轮转
  3. 完全不被任何多余进程挤兑资源的纯净容器

如果有一个方案印证「降本提效」的话,那它肯定当之无愧。

当然,和前面几个方案对比,Agent 从容器挪到了宿主机(使用 DaemonSet 控制),就意味着可定制性的下降,同时,即使你一个人只报两三条日志,如果同一宿主机中有人在疯狂上报日志,仍会影响到你自己的日志采集。

日志识别

刚刚其实更多的讲的是日志采集 Agent 的部署策略,我们需要从资源消耗、可定制化、可靠性的角度去选择一个适合我们的部署方案,但是部署完了之后,就面临了新的问题:刚刚说了半天,好像只知道日志在什么位置,但是我怎么样让程序去指定的位置读。

众所周知,读文件也是一门艺术,上面我们提到的无论是 stdout、写文件、甚至是直接推(需要落盘保证可恢复),最终在一波部署后,都转换成了——读文件,只是读的文件的位置不同,我们现在只有文件夹,不知道文件夹里那些文件是可用的。

如何识别日志文件,通常的方式就是:

  1. 用户可配
  2. 正则匹配
  3. 规则匹配

用户可配听上去最简单,但大部分情况下我们的日志是存在轮转的,也就是会根据时间进行 yyyymmdd 的拆分,因此不太可靠。

正则匹配看上去灵活度够了,但是如果用户写的正则表达式特别复杂,遇到正则表达式回溯,那对于整个系统来说将是灾难性的(这里好像欠了一篇讲正则表达式的稿子一直没写……)。

规则匹配形如:runtime_log-yyyymmdd.log,相对来说开销会比较小,也可以解决日志轮转的问题,但是同时也需要约束用户的使用,但大部分情况下,应该会使用这种方式来进行灵活度和性能的平衡。

解决了识别文件名的问题,接下来的问题就是,我的文件是不停更新的,怎么样知道现在多了新文件需要我去读取?

最简单的方式是使用轮询每次去记录,看看有没有变更出来的新文件,但众所周知,轮询的效果很差,第一,轮询的时间怎么定,虽然我们对日志要求不高,但当然希望越快能看到越好,如果轮询定的时间长了,日志整个消费流程就慢,如果轮询时间定的短了,那么资源开销就变大了。

另一种高级但朴实无华的方案是系统调用,比如 Linux 中我们就可以使用 inotify 监听文件系统的变化,这样就把轮询变成了消费事件,又一次实现了降本提效。(在其他系统中也有类似的系统调用方法)。

现在我们可以完美的感知到了日志文件的变化,但日志文件的变化,到我怎么用这个日志文件去消费,推到队列里,还差了一步:我怎么知道文件收集到哪里,消费完没。

一个简单而质朴的想法是利用一个文件去记录我现在处理了哪些文件,分别消费到什么位置。类似于一种 kv 结构(filekey-{offset})。这里为什么我们不直接用 filename,而单独命名了 filekey,是因为文件名是可变的,举个简单的例子,我把现在的文件从 runtime_log.log 归档到 runtime_log_20240512.log,那么是否意味着我需要重新上报?——我的预期肯定是不上报的,但如果我们拿文件名作为 key,就会存在这样的问题。

在 Linux 中,同一磁盘(device)的一个 inode 会指向唯一一个文件,因此其实我们可以通过 device+inode 作为 key。

但是问题是,inode 是可以回收再利用的,这样带来的问题是,我已经标记这个文件扫描到第 500 行了,但实际上这是三十天前的文件,在 30 天后新的文件分配到了同一个 inode,那么它的前 500 行就无法正常消费了。

要解决这个问题,我们可以使用算 md5 的方式,比如带上首行 MD5 来做 key,这样如果首行日志不一样,那就不是同一个文件——但问题是:第一,倒也不是 100% 可能不一样,第二,首行多长不知道,那么 MD5 带来的额外开销也是不可预期的。

因此这里我们利用日志轮转的特性,既然你在一段时间会归档、再过一段时间会删除,我在你的归档-删除的周期一起删除你的点位信息,那大概率不会有什么问题。

在监听文件更新上,理论上我们也可以消费 inotify 的监听值,当然我也有看到比如「按照文件修改时间排序,轮询修改时间和点位记录时间,来决定是否打开文件」。

但是还有一个问题:比如 panic 的场景下,日志一定会是多行的,那么我们只能通过引入多行标识符来标识多行的情况,否则默认会是一行一条日志,诸如 panic 日志这种情况可能会被打散。

总结

读到这了,可能有的同学想说:接着往下说呀,但是我们今天的话题聊到采集为止,到了我们今天结尾的步骤,我们总算可以把日志幸福的推送进消息队列了。但接下来我们同样会面临一些挑战:

  1. 生产速度与消费速度
  2. 日志带来的消耗监控
  3. 日志的标准化、格式化

之后的话题,我们下次再说。

(由于欠了太多更新实在学不动了.jpg)

参考资料