微服务可观察性之链路追踪
设计目标
- 无处不在的部署
- 持续的监控
- 低消耗
- 应用级的透明
- 延展性
- 低延迟
Dapper
Dapper,大规模分布式系统的跟踪系统
参考 Google Dapper
论文实现,为每个请求都生成一个全局唯一的 traceid
(cityhash + uuid
算法生成),端到端(跨进程通过 grpc
中的 metadata
传递,同进程使用 ctx
传递)传到上下游所有节点,每一层生成一个 spanid
,通过 traceid
将不同系统孤立的调用日志和异常信息串联一起,通过 spanid
和 level
表达节点的父子关系。
进程内传递和进程间传递:
进程内:使用 context
传递
进程间:例如 gRPC
,可以放在 metadata
里面;如果是 http
,可以放在 header
里面(发起 HTTP
调用需要手动设置好头部。接下来,如果要将请求染色 A 的服务上,可以用七层负载均衡,也可以自己手动筛选服务器,比如说 a.yourcompany.com
,或者用路径来区分。),一些基于 tcp
的 socket
通信,这种自定义实现方式,也需要自己实现。(例如 google
的rpc
协议约定放在 attachment
里面)
初始染色可以是前端起步,也可以是 BFF
层,也可以是更加靠后的任何一个服务,取决于业务需要。
跨端传递核心就是在报文里面带上染色信息。普遍来说,要么是在 HTTP Header
,要么是自定义协议的某个部分(那么自定义协议的头部,要么自定义协议的扩展部分)。重建 ctx
会在解析完报文之后第一时间建好,而后开始层层往下传。
这种元数据的使用方式,可以扩展到很多场景,例如 A/B
测试,多租户,压力测试,mock
测试。
核心概念:
Tree
:整个调用过程Span
:调用单元Annotation
:调用单元中的挂载信息,例如ip
、user
之类的标签
调用链
在跟踪树结构中,树节点是整个架构的基本单元,而每一个节点又是对 span
的引用。虽然 span
在日志文件中知识简单的代表 span
的开始和结束时间,他们在整个树形结构中却是相对独立的。
核心概念:
TraceID
:一个完整的请求的id
SpanID
:其中一个节点的id
ParentID
:上游服务的id
Family
&Title
:服务名 +rpc
名称
追踪信息
- 追踪信息包含时间戳、事件、方法名(
Family
+Title
)、注释(TAG/Comment
) - 客户端和服务器上的时间戳来自不同的主机,因此必须考虑到时间偏差,
RPC
客户端发送一个请求之后,服务端才能接收到,对于响应也是一样的(服务器先响应,然后客户端才能接收到这个响应)。这样一来,服务器端的RPC
就有一个时间戳的上限和下限。
植入点
Dapper
可以对应用开发者近乎零侵入的成本对分布式控制路径进行跟踪,几乎完全依赖于少量通用组件库的改造。如下:
当一个线程在处理跟踪控制路径的过程中,Dapper
把这次跟踪的上下文在 ThreadLocal
中进行存储,在 Go
语言中,约定每个方法首参数为 context
(上下文)
例如 gin
的 middware
其实很多的中间件都有实现,在请求开始时创建一个 trace
或者 span
覆盖通用的中间件 & 通讯框架、不限于:redis
、memcache
、rpc
、http
、database
、queue
。
架构图
- 容器服务的日志经过
log agent
通过unix sock
将日志采集到消息队列(或者写入磁盘之后监控文件变化) - 消费者存储到
es
和hbase
,提供多种存储和检索手段,es
中通过维度检索,hbase
中rawkey
作为traceid
- 通过存储的数据,做
ui
工具(推荐使用成熟的工具jaeger
或者zipkin
)
跟踪消耗
处理跟踪消耗:
- 正在被监控的系统在生成追踪和收集追踪数据的消耗导致系统性能下降
- 需要使用一部分资源来存储和分析跟踪数据,是
Dapper
性能影响中最关键的部分:- 因为收集和分析可以更容易在紧急情况下被关闭(1、在紧急情况下可以自动降级,避免影响主要业务;2、生成
trace
以及写入数据库的逻辑会引发gc
),ID
生成耗时、创建Span
等 - 修改
agent nice
值,以防在一台高负载的服务器上发生cpu
竞争
- 因为收集和分析可以更容易在紧急情况下被关闭(1、在紧急情况下可以自动降级,避免影响主要业务;2、生成
采样:
如果一个显著的操作在系统中出现一次,他就会出现上千次。基于这个准则我们不全量收集数据。(不用担心出现问题的时候信息没有捕捉到。)
有意思的论文:Uncertainty in Aggregate Estimates from Sampled Distributed Traces
跟踪采样
固定采样,
1/1024
:这个简单的方案是对高吞吐量的线上服务来说非常有用,因为那些感兴趣的事件(在大吞吐量的情况下)仍然很有可能经常出现,并且通常足以被捕捉到。
然而,在较低的采样率和较低的传输负载下可能会导致错过重要事件,而想用较高的采样率就需要能接受的性能损耗。
对于这样的系统的解决方案就是覆盖默认的采样率,这样需要手动干预的,这种情况是我们试图避免在
Dapper
中出现的。应对积极采样:
我们理解为单位时间期望采集样本的条目,在高
QPS
下,采样率自然下降,在低QPS
下,采样率自然增加;比如1s
内某个接口采集1条。二级采样:
容器节点数量多,即使使用积极采样仍然会导致采样样本非常多,所以需要控制写入中央仓库的数据的总规模,利用所有
span
都来自一个特定的跟踪并分享同一个traceid
这个事实,虽然这些span
有可能横跨了数千个主机。对于在收集系统中的每一个
span
,我们用hash
算法把traceid
转成一个标量Z
,这里0<=Z<=1
(然后将每一个请求生成一个浮点,大于Z
的时候记录,不满足则不记录,按照请求来进行记录),我们选择了运行期采样率,这样就可以优雅的去掉我们无法写入到仓库中的多余数据,我们还可以通过调节收集系统中的二级采样率系数来调整这个运行期采样率,最终我们通过后端存储压力把策略下发给agent
采集系统,实现精准的二级采样。下游采样:
越被依赖多的服务,网关层使用积极采样以后,对于
downstream
的服务采样率仍然很高。
API
搜索
按照 Family
(服务名)、Title
(接口)、时间、调用者等维度进行搜索(例如直接查询耗时比较长的请求)
详情
根据单个traceid
,查看整个链路信息,包含 span
、level
统计,span
详情,依赖的服务、组件信息等;
全局依赖图
由于服务之间的依赖是动态改变的,所以不可能仅从配置信息上推断出所有这些服务之间的依赖关系,能够推算出服务各自之间的依赖,以及服务和其他软件组件之间的依赖。
依赖搜索
搜索单个服务的依赖情况,方便我们做异地多活时候来全局考虑资源的部署情况,以及区分服务是否属于多活范畴,也可以方便我们经常性的梳理依赖服务和层级来优化我们的整体架构可用性。(还可以将服务之间的链路宽度通过请求量等比放大,作为服务扩展的依据)
推断环依赖
一个复杂的业务架构,很难避免全部是层级关系的调用,但是我们要尽可能保证一点:调用栈永远向下,即:不产生环依赖。(产生环调用的时候,不好处理服务升级的顺序。)
经验&优化
性能优化
- 不必要的串行调用:分析能否改成并行调用,优化性能
- 缓存读放大;例如
for
循环取cache
- 数据库写放大:例如
for
循环取 数据库 - 服务接口聚合调用:将高频调用一个接口改为批量低频调用
异常日志系统集成:
如果这些异常发生在 Dapper
跟踪采样的上下文中,那么相应的 traceid
和 spanid
也会作为元数据记录在异常日志中。异常监测服务的前端会提供一个链接,从特定的异常信息的报告直接导向到他们各自的分布式跟踪;
用户日志集成:
在请求的头中返回 traceid
,当用户遇到故障或者上报客服可以根据 traceid
作为整个请求链路的关键字,再根据接口级的服务依赖接口所涉及的服务并行搜索 ES Index
,聚合排序数据(将 es
中的日志数据还原成一个原始请求),就比较直观的诊断问题了;
容量预估
根据入口网关服务,推断整体下游服务的调用扇出来精确预估流量在各个系统的占比;
网络热点&易故障点:
我们内部 RPC
框架还不够统一,以及基础库的组件部分还没解决拿到应用层协议大小,如果收集起来,可以很简单的实现流量热点、机房热点、异常流量等情况。同理容易失败的 span
,很容易统计出来,方便我们辨识服务的易故障点;
opentracing
标准化的推广,上面几个特性,都依赖 span TAG
来进行计算,因此我们会逐步完成标准化协议,也更方便我们开源,而不是一个内部特殊系统;
监控
Monitoring
:
- 延迟、流量、错误、饱和度:监控需要关注的四个指标
- 长尾问题:少数服务占用大量资源或者少数服务、APP 接收大量请求
- 依赖资源(
Client/Server's view
)
opentracing
(Google Dapper
)
jaeger
zipkin
Logger
:
traceid
关联
Metric
:
Prometheus
+Granfana
日志级别
涉及到 net
、cache
、db
、rpc
等资源类型的基础库,首先监控维度 4 个黄金指标:
- 延迟(耗时,需要区分正常还是异常)
- 流量(需要覆盖来源,即:
caller
) - 错误(覆盖错误码或者
HTTP Status Code
) - 饱和度(服务容量有多满)
系统层面:
CPU
,Memory
,IO
,Network
,TCP/IP
状态等,FD
(等其他),Kernel
:Context Switch
Runtime
:各类GC
、Mem
内部状态等
监控方式
- 线上打开
Profiling
的端口; - 使用服务发现找到节点信息,以及提供快捷的方式快速可以
WEB
化查看进程的Profiling
信息(火焰图等); watchdog
,使用内存、CPU
等信号量触发自动采集;
opentracing
基本用法
opentracing
定义了一套API
,很多tracing
框架都支持这套API
go get github.com/opentracing/opentracing-go@latest
引入依赖
1 | func main() { |
1 | http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { |
- 在程序入口处创建一个根
span
。一般来说,是在HTTP
服务器上接收用户请求的地方创建 span
创建之后要记得Finish()
1 | func xyz(parentSpan opentracing.Span, ...) { |
- 在需要的打点创建子
span
,进一步记录数据Log Field
:发生了什么事情,有唯一时间戳Tag
:贯穿整个span
生命周期Baggage Item
:贯穿tracing
整个剩下的周期。会不断向下传递。
- 将
span
和context.Context
关联起来,向下传递
几个关键 API
StartSpan
:创建根span
ContextWithSpan
:将span
和context.Context
结合在一起,以在进程内传递tracing
信息StartSpanFromContext
:视图从参数ctx
里面拿到一个span
作为父亲span
。如果拿不到,则创建一个根span
ContextWithSpan
和StartSpanFromContext
基本上是成对出现- 除非你明确知道自己是入口,否则使用
StartSpanFromContext
跨端传递
1 | func makeSomeRequest(ctx context.Context) ... { |
Span
跨端传递核心是要把tracing
的信息在端到端之间进行传递。也就是两个过程:在客户端把tracing
信息写入到请求里面,也就是Inject
(注入)过程:在服务端里面提取出来tracing
信息,也就是extract
过程- 对于
HTTP
协议来说,一般是放在HTTP Header
里面 - 对于
RPC
协议来说,如果其本身依赖于HTTP
,那么也是放在Header
里面。但是如果本身是直接TCP
通信,那么就会在自己协议的某个位置里面放下
配合 zipkin
或者 jeager
可以自己搭建一个 zipkin
或者 jeager
本地服务器作为 tracing
的采集服务器,收集采集信息。
1 | docker run -d -p 9411:9411 openzipkin/zipkin |
prometheus
promtheus
是拖的模式,由服务端主动从客户端拖数据。避免客户端上的请求非常多,碎片信息多,导致请求大爆服务端。
指标
prometheus
自身分为客户端和服务端。服务端也就是采集到的数据存储的地方,客户端就是使用prometheus
的地方。prometheus metrics
类型:Counter
:计数器,统计次数,比如说某件事的发生次数Gauge
:度量,可以增加也可以减少,比如说当前正在处理的请求数Histogram
:柱状图,对观察对象进行采样,然后分到一个个桶里面Summary
:采样点按照百分位进行统计,比如说 99 线,999 线等
配合 Grafana
使用
1 | func NewPrometheusService() (*Service, error) { |
需要引入 github.com/prometheus/client_golang/prometheus
namespace
可以是应用名subsystem
:可以是一大类的东西,例如http
name
:则是给指标的命名,根据需求来
向量用法
- 创建一个向量,可以使用
ConstLabels
和Labels
- 使用
WithLabelValues
来获取具体的收集器
这种用法更加普遍
Histogram
和 Summary
区别
Histogram
和Summary
两者都会额外统计计数和总和- 区别:
summary
是在客户端上(也就是我们的应用上)做计算的,所以性能损耗比较大histogram
是在服务端上做计算的summary
不支持聚合操作(因为上报的结果都是已经计算好了的)summary
指标在客户端上硬编码,不灵活histogram
严重依赖于设置合理的bucket
客户端和服务端启动
一般是采用客户端开启一个端口,由服务端来拉取数据。
1 | package main |
- 在客户端暴露采集数据端口:
prometheus
会访问这个端口来拉数据 - 启动
prometheus
服务器:本地实验可以使用docker
来启动 - 推荐配置 CONFIGURATION
1 | docker run \ |
查询语言 PromQL
QUERYING PROMETHEUS
观测
HTTP 服务端观测
HTTP
方法、状态、协议:GET
,POST
、200
、HTTPS
HTTP
路由:不是记录路径,路径可能带参数,所以应该记录命中的路由。注意一些非法请求可能完全不能命中任何路由error
:如果能够利用middleware
之类的东西获得请求执行过程中的error
,则可以记录下来。如果有错误码的设计,那么这一步应该记录下错误码- 请求和响应:在处于开发环境或者
DEBUG
下,请求和响应整体都应该记录下来,用作DEBUG
信息 - 业务ID:如果可以从
HTTP Header
里面解析出来用户ID,或者订单ID,也可以记录下来,后面查找、分析都很有用 - 响应时间
HTTP 客户端观测
HTTP
方法、状态、协议:GET
,POST
、200
、HTTPS
HTTP
路由:如果要做聚合分析,那么应该记录可能命中的路由,否则直接记录整个路径(不含参数)error
:如果能够利用middleware
之类的东西获得请求执行过程中的error
,则可以记录下来。如果有错误码的设计,那么这一步应该记录下错误码- 请求和响应:在处于开发环境或者
DEBUG
下,请求和响应整体都应该记录下来,用作DEBUG
信息 - 业务ID:如果可以从
HTTP Header
里面解析出来用户ID,或者订单ID,也可以记录下来,后面查找、分析都很有用 - 响应时间
RPC 观测
- 服务标志符:用于标记唯一服务的
- 目标主机名、IP 和端口:如果能够拿到目标主机的主机名、IP和端口都可以记录下来
RPC
响应状态:取决于RPC
框架设计和公司规范RPC
请求和响应:开发环境或者DEBUG
下可以完整记录error
:如果能够获得error
,则可以记录下来。如果有错误码的设计,那么这一步应该记录下错误码- 响应时间
- RPC 一般来说比较难获得业务ID,除非是约定了在整个链路中传递,例如放在
metadata
里面
References
OpenTracing(已经弃用,合并到 OpenTelemetry
中)
分布式链路追踪
Zipkin