理论派 | Theory
调用链追踪系统在伴鱼:OpenTelemetry最佳实践案例分享
作者 郑鹤
在理论篇中,我们介绍了伴鱼在调用链追踪领域的调研工作,本篇继续介绍伴鱼的调用链追踪实践。在正式介绍前,简单交代一下背景:2015年,在伴鱼服务端起步之时,技术团队就做出统一使用Go语言的决定。这个决定的影响主要体现在:
1. 早期实践
1.1 对接Jaeger
2019年,公司内部的微服务数量逐步增加,调用关系日趋复杂,工程师做性能分析、问题排查的难度变大。这时亟需一套调用链追踪系统帮助我们增强对服务端全貌的了解。经过调研后,我们决定采用同样基于Go语言搭建的、由CNCF孵化的项目Jaeger。当时,服务的开发和治理都尚未引入context,不论进程内部调用还是跨进程调用,都没有上下文传递。因此早期引入调用链追踪的工作重心就落在了服务及服务治理框架的改造,包括:
部署方面:测试环境采用all-in-one,线上环境采用direct-to-storage方案。整个过程前后大约耗时一个月,我们在2019年Q3上线了第一版调用链追踪系统。配合广泛被采用的prometheus + grafana以及ELK,我们在微服务群的可观测性上终于凑齐了调用链(traces)、日志(logs)和监控指标(metrics)三个要素。
下图是第一版调用链追踪系统的数据上报通路示意图。服务运行在容器中,通过opentracing的sdk埋点,Jaeger的go-sdk上报到宿主机上的Jaeger-agent,后者再将数据进一步上报到Jaeger-collector,最终将调用链数据写入ES,建立索引,即图中的Jaeger backends。
1.2 遇到的问题
在理论篇中,我们介绍过Jaeger支持三种采样方式:
这些采样方式都属于头部连贯采样(head-based coherent sampling),我们在理论篇中曾讨论过其优劣势。伴鱼的生产环境中使用的是限流采样策略:每个进程每秒最多采1条trace。这种策略虽然很节省资源,但其缺点在一次次线上问题排查中逐渐暴露:
- 一个进程中包含多个接口:不论按固定概率采样还是限流采样,都会导致小流量接口一直采集不到调用链数据而饿死(starving)
- 线上服务出错是小概率事件,导致出错的请求被采中的概率更小,就导致采到的调用链信息量不大,引发问题的调用链却丢失的问题
2. 调用链通路改造
2.1 使用场景
2020年,我们不断收到业务研发的反馈:能不能全量采集trace?
这促使我们开始重新思考如何改进调用链追踪系统。我们做了一个简单的容量预估:目前Jaeger每天写入ES的数据量接近100GB/天,如果要全量采集trace数据,保守假设平均每个HTTP API服务的总QPS为100,那么完整存下全量数据需要10TB/天;乐观假设100名服务器研发每人每天查看1条trace,每条trace的平均大小为1KB,则整体信噪比千万分之一。可以看出,这件事情本身的ROI很低,考虑到未来业务会持续增长,存储这些数据的价值也会继续降低,因此全量采集的方案被放弃。退一步想:全量采集真的是本质需求吗?实际上并非如此,我们想要的其实是「有意思」的trace全采,「没意思」的trace不采。
根据理论篇中介绍的应用场景,实际上第一版调用链追踪系统只支持了稳态分析,而业务研发亟需的是异常检测。要同时支持这两种场景,我们必须借助尾部连贯采样(tail-based coherent sampling)。相对于头部连贯采样在第一个span处就做出是否采样的决定,尾部连贯采样可以让我们在获取(接近)完整的trace信息后再做出判断。理想情况下,只要能合理地制定采样的判断逻辑,我们的调用链追踪系统就能做到「有意思」的trace全采,「没意思」的trace不采。
2.2 架构设计
Jaeger团队从2017年就开始讨论引入tail-based sampling的可能性,但至今未有定论。在一次与Jaeger工程师jpkrohling的一对一沟通中,对方也证实了目前Jaeger尚没有相关的支持计划。因此,我们不得不另辟蹊径。经过一番调研,我们找到了刚刚进驻CNCF SandBox的OpenTelemetry,它的opentelemetry-collector子项目恰好能支持我们在现有架构上引入尾部连贯采样。
2.2.1 OpenTelemetry Collector
整个OpenTelemetry项目目的在于统一市面上可观测性数据(telemetry data)的标准,同时提供推动这些标准实施的组件和工具。opentelemetry-collector就是这个生态中的一个重要组件,它的架构图如下:
collector内部有4个核心组件:
利用不同的Receivers、Processors、Exporters,我们可以组装出一个或多个Pipelines。
opentelemetry-collector项目被拆成两部分:主项目opentelemetry-collector和社区贡献项目opentelemetry-collector-contrib,前者负责管理核心逻辑、数据结构以及通用的Receivers、Processors、Exporters、Extensions实现,后者则负责接收一些社区贡献的组件,当然贡献者主要都来自于可观测性SaaS解决方案供应商,如DataDog、Splunk、LightStep以及一些公有云厂商。opentelemtry-collector项目的插件化管理方式,使得定制化开发Receiver、Processor、Exporter的成本很低,我们在做概念验证时,基本可以在一两个小时内开发并测试完毕一个组件。除此之外,opentelemetry-collector-contrib还提供了开箱即用的tailsamplingprocessor。
由于opentelemetry-collector是OpenTelemetry团队推动标准实施的重要组件,且OpenTelemetry本身并没有提供独立的数据存储、索引实现的计划,因此它在与市面上流行的调用链追踪框架,如Zipkin、Jaeger、OpenCensus,的兼容性上下了很大功夫。通过Receivers和Exporters的使用,我们可以用它替换jaeger-agent,也可以放在jaeger-agent与jaeger-collector之间,必要时还可以在jaeger-agent和jaeger-collector之间部署多层collectors。除此之外,如果有一天想换掉jaeger-backend,比如新发布的Grafana Tempo,我们也能很轻松的完成,并且利用多个Pipelines或一个Pipeline多个Exporters灰度上线,逐步替换。
2.2.2 上报通路
基于以上的调研和现有的架构,我们设计了第二版调用链追踪的架构,如下图所示:
用一组opentelemetry-collector替换jaeger-agent,即图中的otel-agent;同时在otel-agent与jaeger-collector之间增加另一组opentelemetry-collector,即图中的otel-collector。otel-agent收集宿主机上不同服务上报的trace数据,打包批量发送给otel-collector,后者负责做尾部连贯采样,将「有意思」的trace继续输出到原始架构中的jaeger-collector,后者负责将数据投入ElasticSearch中,建立索引。
这里还有一个问题需要解决:整个架构要做到高可用,势必要部署多个otel-collector实例,如果使用简单的负载均衡策略,不同otel-agents、以及单个otel-agent不同时刻上报的数据,可能被随机上报到某个otel-collector实例,这就意味着,同一个trace的不同spans无法集中到同一个otel-collector实例上,既会导致同一个trace的不同spans的决定不一致,即不是连贯采样;也会导致尾部采样时提供判断的数据不全。解决方案很简单:让otel-agent按照traceID做负载均衡。
在调研阶段我们正好看到opentelemetry-collector社区也有此支持计划,并且前面提到的工程师jpkrohling正在通过增加loadbalancingexporter解决它,虽然直接使用bleeding edge的版本有一定的风险,我们还是决定尝试。在概念验证阶段,我们也的确发现了新功能的若干问题,但通过反馈(#1621)和贡献(#1677、#1690)的方式一一解决,最终获得了可以按预期执行尾部连贯采样的调用链追踪系统。
2.3 采样规则
尾部连贯采样的数据通路已经准备就绪,下一步就是确定和实施采样规则。
2.3.1 「有意思」的调用链
什么是「有意思」的调用链?研发在分析、排障过程中想查询的任何调用链就是「有意思」的调用链。但落实到代码的必须是确定性的规则,根据日常排障经验,我们先确定了以下三种情形:
满足任意条件,就认为这个调用链「有意思」。在伴鱼,只要服务打印了ERROR级别的日志就会触发报警,研发人员就会收到im消息或电话报警,如果能保证触发报警的调用链数据必采,研发人员的排障体验就会有很大的提升;我们的DBA团队认为超过200ms的查询请求都被判定为慢查询,如果能保证这些请求的调用链必采,就能大大方便研发排查导致慢查询的请求;对于在线服务来说,时延过高会令用户体验下降,但具体高到什么程度会引发明显的体验下降我们暂时没有数据支撑,因此先配置为1s,支持随时修改阈值。
当然,以上条件并不绝对,我们完全可以在之后的实践中根据反馈调整、新增规则,如单个请求引起的数据库、缓存查询次数超过某阈值等。
2.3.2 采样流水线
在第二版系统中,我们期望同时支持稳态分析与异常检测,因此采样过程既要按概率或限流方式采集一部分trace,也需要按上文拟定的「有意思」规则采集令一部分trace。截止到本文撰写前,tailsamplingprocessor支持4种策略:
其中可以为我们所用的有numeric_attribute、string_attribute以及rate_limiting。
「按概率或限流采集一部分trace」可以利用rate_limiting实现吗?rate_limiting只能按照每秒通过的spans个数限流,但spans数量在高峰期、低峰期、业务所处阶段都不一样,每个trace的平均spans数量也会随着微服务群规模以及依赖关系发生变化,因此设置spans_per_second将让我们很难对这个参数的最终效果合理评估,因此直接使用rate_limiting的方案被否决。
「按规则采集另一部分trace」可以直接使用numeric_attribute和string_attribute实现吗?span中还存在布尔(bool)类型的tag,以「在调用链上如果打印了ERROR级别日志」为例,按照规范我们会记录span.SetTag("error" , true),但tailsamplingprocessor并未支持bool_attribute;此外,未来我们可能会有更复杂的组合条件,这时仅靠numeric_attribute和string_attribute也无法实现。
经过再三分析,我们最终决定利用Processors的链式结构,组合多个Processor完成采样,流水线如下图所示:
其中probattr负责在trace级别按概率抽样,anomaly负责分析每个trace是否符合「有意思」的规则,如果命中二者之一,trace就会被打上标记,即sampling.priority。最后在tailsamplingprocessor上配置一条规则即可,如下所示:
1 tail_sampling:
2 policies:
3 [
4{
5 name: sample_with_high_priority,
6 type: numeric_attribute,
7 numeric_attribute:{ key: "sampling.priority", min_value: 1, max_value: 1 }
8 }
9 ]
这里sampling.priority是整数类型,当前取值只有0和1。按上面的配置,所以sampling.priority = 1的trace都会被采集。后期可以增加更多的采集优先级,在必要的时候可以多采样(upsampling)或降采样(downsampling)。
3. 部署实施
采样规则确立后,整个解决方案就已跑通,下一步就是进入部署实施阶段。
3.1 上线准备
3.1.1 基础库改造
动态更新Tracer
在第一版系统中,每个进程启动时会从apollo上获取采样配置,传递给Jaeger sdk,后者利用配置初始化GlobalTracer。GlobalTracer会在trace的第一个span出现时,决定是否采集,并把这个决定传递下去,即头部连贯采样。在实施新架构时,我们需要Jaeger sdk将所有trace数据尽数上报。为了让这个过程更加平滑,我们对Jaeger sdk配置做出两方面改造:
日志库改造
为了保证打印过ERROR级别日志的调用链必采,我们也在通用日志库的相应位置上给span打上error标签。
3.1.2 监控看板配置
opentelemetry-collector内部利用OpenCensus sdk埋了很多有用的监控指标,并按照Open Metrics规范暴露数据。因为指标数量并不多,我们将大多数指标都配置了到Grafana看板中,包括:
下面介绍几个在实践中我们认为比较重要的指标:
xxx_receiver_accepted/refused_spans
这里的xxx指代任意一个pipeline中使用的receiver。实际上这里有两个具体指标:receiver接收的spans数量和receiver拒绝的spans数量。二者可以与其它指标结合,分析系统在当前状况下的入口流量瓶颈。
xxx_exporter_send(failed)_spans
这里的xxx指代任意一个pipeline中使用的exporter。实际上这里有两个具体指标:exporter发送成功的spans数量和exporter发送失败的spans数量。二者可以与其它指标结合,分析系统在当前状况下的出口流量瓶颈。
otelcol_processor_tail_sampling_sampling_trace_dropped_too_early
要介绍上面这个指标,需要简单了解tailsamplingprocessor的工作原理。在分布式环境中,tailsamplingprocessor永远无法确定一个trace的所有spans在当前时刻是否收集完毕,因此需要设置一个超时时间,即这里的decision_wait,下面假设decision_wait = 5s。Trace Data进入processor后,会被分别放入两个数据结构:
一个固定大小的队列和一个哈希表,二者合起来实现trace data的LRU cache。同时processor会将所有进入到其中的traces按照每秒一个batch组织起来,内部一共维持5个batch(decision_wait)。每隔一秒钟,将最老的batch取出来,对其中的traces分别判断是否符合采样规则,符合则将其传递给后续的processors:
如果在做采样决策时,发现相应的trace已经被LRU cache清出,则认为「trace dropped too early」,后者意味着tailsamplingprocessor已经超负荷。理论上这个指标如果不等于0,尾部连贯采样功能就处于异常状态。
3.2 灰度方案
上文提到过,实施改造需要让Jaeger sdk全量上报trace。由于「是否上报」这个决定在请求入口服务(即HTTP服务)做出,并会随着跨进程调用传播到下游服务,同时伴鱼服务端内部的入口服务已经按照业务拆分,因此灰度的过程就可以按入口服务进行,从流量小的、级别低入口服务开始上线观察,再逐渐加入流量大的、级别高的入口服务,最终默认打开全量采样,并在这个过程中发现、解决潜在问题。
3.3 资源消耗优化
新版架构所需资源与旧版差别不大:
在逐步上线到所有入口服务之前,我们做了比较充分的风险评估。开启全量采集后,主要增加了宿主机的网络i/o,在千兆网卡(约300MB/s)支持下,增加后的i/o量远远未达到瓶颈。实施过程中,业务团队也确实没有感知。不过在灰度上线过程中,我们也发现并解决了若干问题。
3.3.1 热点服务问题
不同服务的请求量不同。个别服务的上报量过大会导致不同otel-agent的流量不均衡,在高峰期造成otel-agent的CPU经常超过预警线。我们通过增加热点服务实例,减小单个实例的请求量的方式,间接地均衡了每个otel-agent承载的流量。
3.3.2 过滤下推
在生产环境中,我们默认维持最近7天的trace数据。在分析ES中索引jaeger-span-*的过程中,意料之中地,我们看到了power law的存在:
仔细分析可以发现,50%以上的span都属于apolloConfigCenter.*。熟悉apollo的研发应该知道,通常apollo sdk会通过长轮询的方式从元数据中心拉取配置信息,并缓存到本地,而服务在应用层获取配置都是通过本地缓存获取。因此实际上这里的所有apolloConfigCenter.*只是本地访问,而不是跨进程调用,span数据价值比较低,可以忽略。于是我们开发了通过正则匹配spanName过滤span的processor,并部署到otel-agent上,我们称之为过滤下推。部署上线后,ES索引体积下降超过50%,目前每天索引体积为40-50 GB;otel-collector和otel-agent的CPU消耗也降低了接近50%。
3.4 制定SLO
在伴鱼服务端内,线上问题排查的第一入口通常是im消息,报警平台会将导致报警的traceID以及日志注入到消息中,并提供一个链接页面,方便研发快速查看报警相关的调用链信息及其在整个调用链上每个服务打印的日志。基于此,我们制定了新版调用链追踪系统的SLO:
目前我们刚刚在报警平台中支持此SLI的埋点。目前还有个别服务尚未升级相关依赖,因此该指标尚不能反映所有服务的情况,我们会继续推动各服务全面升级,按照以上SLO来要求新版系统。
4. 小结
借助开源项目,我们得以通过花费极少的人力,解决当前伴鱼内部调用链追踪应用的稳态分析及异常检测需求,同时也为开源项目和社区做出微小的贡献。调用链追踪是可观测性平台的重要组件,未来我们将继续把一些精力放在telemetry data的整合上,为研发提供更全面、一致的服务观测分析体验。
5. 参考
作者介绍
郑鹤,伴鱼技术团队架构师。