0x00
本文旨在对如何设计监控方面提供一些意见性的指导和建议。
本文章翻译自Prometheus官方文档:Best Pratices-INSTRUMENTATION
0x01 要怎么监控?
简单地来说,就是监控所有东西:对于任何一个库(Library),子系统(SubSystem)或者服务(Service)来说,都至少应该有几个指标来告诉你他们现在的运行状况。数据采集应该是代码里不可或缺的一部分。这能够在你调查错误的时候,能更容易的从告警着手,进而联系到控制台和代码里去。
服务可以被分为三类
根据监控的目地,服务通常可以分为以下三类:在线服务、离线服务以及批处理任务。虽然这三种服务的定义互相之间略有重叠,但是基本上所有的服务都可以很好的被纳入其中之一里去。
在线服务
如果一个人或者其他的系统期望在这个系统里能够立即得到响应,那么这个系统就是一个在线系统。打一个比方,大部分的数据库、HTTP服务都归于此类。
对于这种系统而言,关键监控指标就是发生的“查询(query)数量”、“错误(error)数量”以及“延迟(latency)情况”。同样而言,如果把这些指标项套用在当前正在处理中的请求上的话,也是十分具有意义的。
而对错误的监控方面的建议,请直接参考下文的错误章节。
对于是属于“在线服务”类的系统而言,应该同时监控它的服务端和客户端。因为如果看到两边的指标数据不一样了,那么这个事情对于Debug是非常有帮助的。不过,如果一个服务里存在有非常多的客户端,以至于无法一一地追踪客户端的状态,那么也只能依靠它们自己的统计数据了。
另外,在对查询(query)进行计数的时候,要么全部在操作开始前,要么都在结束后。 在结束后进行计数是比较推荐的,因为这能够对错误和延迟状况进行统计,而且写起代码来也更简单。
离线服务
对于离线服务而言,不会有人会干等着响应回来,而任务基本上是成批的,并且基本上处理过程都得分为好几个阶段。
针对每一个阶段,追踪每一个扇入的任务,有多少个任务正在处理,上次处理任务的时间,扇出了多少个任务。如果是批量任务的话,那对进出的批次也应该追踪起来。
知道“上次处理任务的时间”这个信息对判断系统有没有挂掉是一个很有用的信息,但是这个信息具有很大的局限性。更好的做法是将心跳信息贯穿整个系统:加入一些带有时间戳的虚拟任务,并将这些任务在系统内一直传递下去。在每个处理阶段内,都把他们观察到的最近一次的心跳时间戳暴露出来。因为对于在不在处理任务时也不存在静默期的系统而言,是不需要其他明确的心跳信息的。
批处理任务
对于离线任务和批处理任务而言,其中的界限是比较模糊的,这是因为离线处理很有可能就是一个批处理任务。因此,在这是用他们是否需要持续运行来区分是不是批处理任务,因为就是这点导致了收集他们的信息十分的困难。
对于批处理任务而言,关键的指标就是上次任务成功的时间。另外,追踪任务在主要处理阶段所花费的时间,总体的运行时间以及上次任务完成时间(无论是成功还是失败)都是很有帮助的。这部分数据应该全都是Gauge类型的,而且都应该直接推到PushGateway里进而完成收集。另外在通常情况下,一些总体性的与任务相关的数字也是很有意义的,例如已处理的任务总数之类的。
针对那些要跑好几分钟的批量任务而言,基于拉的监控模式也是挺好的。这可以让你观察到同一个监控指标在执行不同类型的任务的时候的区别,例如资源使用率,与其他系统交互时候的延迟等等。这些信息对于调查诸如任务处理开始变慢的问题的时候,是非常具有参考意义的。
针对于那些跑得非常频繁(一般来说,小于15分种跑一次就算频繁)的任务而言,你就应该考虑将这些任务转换成一个常驻服务,并将其当作离线任务来处理。
子系统(SubSystem)
除了按三种基本服务类型来监控以外,系统中也有一些部分需要被监控起来。
依赖库(Libraries)
依赖库应该提供一些无需用户额外配置的监控方式。
如果一个库是用来从外部去拿一些资源信息的(比如网络,磁盘,IPC等),理应对他们总体的每分钟调用数量,错误数量(如果有可能发生)以及延迟情况进行统计。
根据被依赖的库的复杂程度,来追踪依赖库内部产生的内部错误以及延迟情况,或是任何你认为有用的通用统计数据。
一个依赖库也很有可能被一个应用内的完全不同的部分独立的调用了,所以注意使用合适的标签(Label)来区分彼此。 打一个比方:一个数据库链接池应该以它连接到的数据库来区分,但是对于DNS客户端库而言就没必要进行区分了。
日志
有一个通用的规则:对于每一条日志而言你都应该对于有一个自增的计数器来对其计数。这是因为,如果你在对某条日志比较感兴趣的时候,你很有可能会想知道它多久发生一次,以及每次发生持续多久。
如果在一个函数里有多个很接近的日志的话(例如在If/Switch的不同分支里),那么这时候也可以用一个计数器来代表他们所有。
通常情况下,对应用日志的INFO/ERROR/WARNING日志总条目进行计数是很有用的,这能够用于在发布过程中检查其有没有较大的差异。
错误处理
错误处理应该和日志比较相近。每次有错误发生,一个计数器就应该自增。但是和日志不同的是,错误通常会向上传递到一个更宽泛的错误计数器之上,这通常取决于你的代码结构。
当汇报错误的时候,你通常应该有其它一些代表总共尝试次数的指标,进而能够用这些信息来计算出错误率。
线程池
对于任何类型的线程池而言,最重要的指标就是请求队列的深度,当前的线程数量,线程池线程的总数,处理的任务数量,以及处理任务所消耗的时间。当然,追踪任务在队列中等待的时间也很有用的。
缓存
对于缓存来说,主要的监控指标是总查询次数,缓存命中次数,总体延迟时间。而对于任意在线系统前的缓存而言,则是查询次数,错误次数以及延迟。
收集器(Collector)
对于实现一个重要的自定义指标收集器而言,有一条建议是将收集器每秒消耗的时间以及其发生的错误次数进行记录,通过Guage的方式进行导出。
只有两种情况下可以将一个事情发生的持续时间用Guage形式导出,上述场景就是其中的一种,另外一种情况则是批处理任务的持续时间。这是因为这两种场景分别代表了搜刮和推送特定信息的场景,并非是要随着时间的推移追踪多个事件的持续时间。
0x10 注意事项
这部分里总结了一些注意事项是做监控时通用的,但其中也有一些与Prometheus特定相关的部分。
使用标签
很少有监控系统用标签和计算表达式的概念作为卖点,因此在这点上需要花点时间来习惯一下。
当你希望有多个指标需要对其求增量/平均值/求和的时候,这些指标通常能够被整合到一个指标里去,而不是利用多个指标来进行收集。
举一个例子:比起用’http_responses_500_total’和’http_responses_403_total’两个指标而言,更好的做法是直接创建一个指标叫做’http_responses_total’,其中用’code’作为标签名来代表返回码 。通过这种方式,一整套指标就能够用同一套规则和视图来进行处理了。
按照经验规则,监控指标的名称中任何一部分都不应该是程序生成的,需要生成的部分就应该使用标签。但是如果指标是来自于其他的监控系统,那么需要处理这样的代理指标可以的场景可以作为一种例外情况来特殊对待。
此外,更多关于命名相关的建议可以参考命名相关的章节。
不要滥用标签
每个标签组合都是一个额外的时间序列,都会会给内存/计算/存储以及网络带来一定的资源消耗。虽然通常来说这个消耗是可以忽略不计的,但放到几百个机器上有一堆有上百个标签的指标的场景来看的话,资源的消耗量肯定是会急剧增加的。
通常情况下,尽可能的保持你的监控指标的标签数量小于10。对于超出这个数量级的指标,尽可能确保它在系统中是一个少数情况。对于大多数的监控项都是不应该带有标签的。
如果有一个监控指标的标签数量大于100,而且还有变得更大的可能的话,就需要想办法给它找一个替代方案了。诸如减少指标的维度,或者将其从实时监控系统移到别的通用数据处理系统里去了。
为了让你有一个对底层数字更好的认识,这里以node exporter为例。node exporter将会暴露每个已挂载的文件系统的监控指标,因此每台机器都会有十几个叫node_filesystem_avail的时间序列。 如果你有10,000台节点的话,最终单node_filesystem_avail就会大概有超过100,000个时间序列了。如果只在这种情况下,Prometheus还是没啥问题的。
但是糟糕的是如果现在你又为每个用户添加了磁盘配额的设定,即在10,000个节点上有10,000个用户,那么这很快就会给原本百万级别的数量级再带来两位数的提升。这个数字对于Prometheus当前的实现来说就太多了! 但是即便对于数量小一些,那也存在一定的机会成本。因为在这个机器上,你已经无法去收集一些别的更有潜力有价值的指标了。
综上,如果你不确定是否应该加上标签,那么最好一开始的时候先别加,在之后有具体的使用场景的时候再加也不迟。
Counter vs. gauge, summary vs. histogram
对于监控来说,知道什么时候该用四种指标类型中的某一个是至关重要的。
在Counter和Gauge类型的选择中, 只有一条简单的经验规则:如果指标的值会下降,那就用Guage。
因为Counter类型只能够单调递增(虽然可能重置,不过这只发生在被监控的进程重启的时候)。也正因为这个特点,Counter在累计一些事件发生的次数或是每个事件里事物的数量的场景下特别有用。举一个例子:HTTP请求的发生的总次数,或是HTTP请求已发送的总字节数。Counter的值如果直接用一般没啥意义,一般都会搭配rate函数来计算它每秒的自增量。
Gauges类型的值能够被直接设置一个大小,或者变大/变小。这点在需要对当前状态收集一个快照的时候尤为有用。例如当前处理中的请求数量,空闲/总内存数量,或者温度。对于Gauge类型的指标,你永远不应该对其使用rate函数进行求值。
Summaries和Histograms类型就比上面两种复杂太多,这部分会在他们自己的章节中单独讨论。
具体的时间戳,而非相对时间
如果你需要追踪一个事情自发生以来的持续时间,在导出指标数据的时候,导出事件发生时间的Unix时间戳而非相对于当前时间的持续时间。
而对于已导出的时间戳数据,可以用过time函数减去事件发生的时间戳来计算出事件的持续时间。通过这种方式能够避免对数据进行更新操作,进而保护在更新操作的时候发生卡住的现象。
循环内
通常情况下,监控带来的资源消耗相比于它对运维和开发时候带来的好处,利是远大于弊的。
但是对于那些进程里性能至关重要的部分,或是每秒都会被调用大于100k次的部分来说,你就应该需要考虑每次需要更新的指标有多少了。
对于一个Java的Counter来说,根据实际情况大概需要12-17纳秒来完成计数操作,这点在别的语言里的性能也差不多。如果在循环里有一些第哦时间消耗敏感的部分,那就应该对需要计数的指标数量进行一些限制,并且尽可能避免在循环内去使用标签查找操作(可以在循环外把根据标签查找到的结果进行一个缓存,例如把With方法(Go)/labels方法(Java)返回的缓存起来。)
另外需要注意的是,更新涉及时间/持续时间的监控指标有可能会调用系统调用。因此,对于那些有性能敏感的代码来说,跑一个Benchmark才是判断性能影响方面最好的办法。
避免缺失指标
在事件还没发生前不进行上报的那种时间序列是很难被处理的,通常的处理方式都无法正确处理他们。因此用将所有已知在之后会出现的时间序列都用 0 导出(其中如果0会导致被误解的话,那么就导出一个NaN)的方式来避免这种情况的发生。
大多数Prometheus的SDK(包括Go/Java/Python)都会自动为你将没有标签的指标用 0 进行导出。