微服务可观察性之链路追踪

设计目标

  • 无处不在的部署
  • 持续的监控
  • 低消耗
  • 应用级的透明
  • 延展性
  • 低延迟

Dapper

Dapper,大规模分布式系统的跟踪系统

参考 Google Dapper 论文实现,为每个请求都生成一个全局唯一的 traceidcityhash + uuid 算法生成),端到端(跨进程通过 grpc 中的 metadata 传递,同进程使用 ctx 传递)传到上下游所有节点,每一层生成一个 spanid,通过 traceid 将不同系统孤立的调用日志和异常信息串联一起,通过 spanidlevel 表达节点的父子关系。

image-20231205113116946

进程内传递和进程间传递:

Image 6 of 7

进程内:使用 context 传递

进程间:例如 gRPC,可以放在 metadata里面;如果是 http,可以放在 header 里面(发起 HTTP

调用需要手动设置好头部。接下来,如果要将请求染色 A 的服务上,可以用七层负载均衡,也可以自己手动筛选服务器,比如说 a.yourcompany.com,或者用路径来区分。),一些基于 tcpsocket 通信,这种自定义实现方式,也需要自己实现。(例如 googlerpc协议约定放在 attachment里面)

初始染色可以是前端起步,也可以是 BFF 层,也可以是更加靠后的任何一个服务,取决于业务需要。

跨端传递核心就是在报文里面带上染色信息。普遍来说,要么是在 HTTP Header,要么是自定义协议的某个部分(那么自定义协议的头部,要么自定义协议的扩展部分)。重建 ctx 会在解析完报文之后第一时间建好,而后开始层层往下传。

这种元数据的使用方式,可以扩展到很多场景,例如 A/B 测试,多租户,压力测试,mock 测试。

核心概念:

  • Tree:整个调用过程
  • Span:调用单元
  • Annotation:调用单元中的挂载信息,例如 ipuser 之类的标签

调用链

在跟踪树结构中,树节点是整个架构的基本单元,而每一个节点又是对 span 的引用。虽然 span 在日志文件中知识简单的代表 span 的开始和结束时间,他们在整个树形结构中却是相对独立的。

image-20231205113257114

核心概念:

  • TraceID:一个完整的请求的id
  • SpanID:其中一个节点的 id
  • ParentID:上游服务的 id
  • Family & Title:服务名 + rpc 名称

追踪信息

  • 追踪信息包含时间戳、事件、方法名(Family + Title)、注释(TAG/Comment
  • 客户端和服务器上的时间戳来自不同的主机,因此必须考虑到时间偏差,RPC 客户端发送一个请求之后,服务端才能接收到,对于响应也是一样的(服务器先响应,然后客户端才能接收到这个响应)。这样一来,服务器端的 RPC 就有一个时间戳的上限和下限。

image-20231205174815073

植入点

Dapper 可以对应用开发者近乎零侵入的成本对分布式控制路径进行跟踪,几乎完全依赖于少量通用组件库的改造。如下:

当一个线程在处理跟踪控制路径的过程中,Dapper 把这次跟踪的上下文在 ThreadLocal 中进行存储,在 Go 语言中,约定每个方法首参数为 context(上下文)

例如 ginmiddware

image-20231205113838814

其实很多的中间件都有实现,在请求开始时创建一个 trace 或者 span

覆盖通用的中间件 & 通讯框架、不限于:redismemcacherpchttpdatabasequeue

架构图

image-20231205113851775

  1. 容器服务的日志经过 log agent通过 unix sock 将日志采集到消息队列(或者写入磁盘之后监控文件变化)
  2. 消费者存储到 eshbase,提供多种存储和检索手段,es 中通过维度检索,hbaserawkey 作为 traceid
  3. 通过存储的数据,做 ui 工具(推荐使用成熟的工具 jaeger或者 zipkin

跟踪消耗

处理跟踪消耗:

  • 正在被监控的系统在生成追踪和收集追踪数据的消耗导致系统性能下降
  • 需要使用一部分资源来存储和分析跟踪数据,是 Dapper 性能影响中最关键的部分:
    • 因为收集和分析可以更容易在紧急情况下被关闭(1、在紧急情况下可以自动降级,避免影响主要业务;2、生成 trace以及写入数据库的逻辑会引发gc),ID 生成耗时、创建 Span
    • 修改 agent nice 值,以防在一台高负载的服务器上发生 cpu 竞争

采样:

如果一个显著的操作在系统中出现一次,他就会出现上千次。基于这个准则我们不全量收集数据。(不用担心出现问题的时候信息没有捕捉到。)

有意思的论文: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(接口)、时间、调用者等维度进行搜索(例如直接查询耗时比较长的请求)

image-20231205115007139

详情

根据单个traceid,查看整个链路信息,包含 spanlevel统计,span详情,依赖的服务、组件信息等;

image-20231205115109399

全局依赖图

由于服务之间的依赖是动态改变的,所以不可能仅从配置信息上推断出所有这些服务之间的依赖关系,能够推算出服务各自之间的依赖,以及服务和其他软件组件之间的依赖。

image-20231205115207728

依赖搜索

搜索单个服务的依赖情况,方便我们做异地多活时候来全局考虑资源的部署情况,以及区分服务是否属于多活范畴,也可以方便我们经常性的梳理依赖服务和层级来优化我们的整体架构可用性。(还可以将服务之间的链路宽度通过请求量等比放大,作为服务扩展的依据)

image-20231205115322370

推断环依赖

一个复杂的业务架构,很难避免全部是层级关系的调用,但是我们要尽可能保证一点:调用栈永远向下,即:不产生环依赖。(产生环调用的时候,不好处理服务升级的顺序。)

image-20231205115413025

经验&优化

性能优化

  1. 不必要的串行调用:分析能否改成并行调用,优化性能
  2. 缓存读放大;例如 for 循环取 cache
  3. 数据库写放大:例如 for 循环取 数据库
  4. 服务接口聚合调用:将高频调用一个接口改为批量低频调用

异常日志系统集成:

如果这些异常发生在 Dapper 跟踪采样的上下文中,那么相应的 traceidspanid 也会作为元数据记录在异常日志中。异常监测服务的前端会提供一个链接,从特定的异常信息的报告直接导向到他们各自的分布式跟踪;

用户日志集成:

在请求的头中返回 traceid,当用户遇到故障或者上报客服可以根据 traceid 作为整个请求链路的关键字,再根据接口级的服务依赖接口所涉及的服务并行搜索 ES Index,聚合排序数据(将 es 中的日志数据还原成一个原始请求),就比较直观的诊断问题了;

容量预估

根据入口网关服务,推断整体下游服务的调用扇出来精确预估流量在各个系统的占比;

网络热点&易故障点:

我们内部 RPC 框架还不够统一,以及基础库的组件部分还没解决拿到应用层协议大小,如果收集起来,可以很简单的实现流量热点、机房热点、异常流量等情况。同理容易失败的 span,很容易统计出来,方便我们辨识服务的易故障点;

opentracing

标准化的推广,上面几个特性,都依赖 span TAG 来进行计算,因此我们会逐步完成标准化协议,也更方便我们开源,而不是一个内部特殊系统

监控

Monitoring

  • 延迟、流量、错误、饱和度:监控需要关注的四个指标
  • 长尾问题:少数服务占用大量资源或者少数服务、APP 接收大量请求
  • 依赖资源(Client/Server's view

opentracingGoogle Dapper

  • jaeger
  • zipkin

Logger

  • traceid 关联

Metric:

  • Prometheus + Granfana

image-20231205172015791

日志级别

涉及到 netcachedbrpc 等资源类型的基础库,首先监控维度 4 个黄金指标:

  • 延迟(耗时,需要区分正常还是异常)
  • 流量(需要覆盖来源,即:caller
  • 错误(覆盖错误码或者 HTTP Status Code
  • 饱和度(服务容量有多

image-20231205172251344

系统层面:

  • CPUMemoryIONetworkTCP/IP 状态等,FD(等其他),KernelContext Switch
  • Runtime:各类 GCMem 内部状态等

监控方式

  • 线上打开 Profiling 的端口;
  • 使用服务发现找到节点信息,以及提供快捷的方式快速可以 WEB 化查看进程的 Profiling 信息(火焰图等);
  • watchdog,使用内存、CPU 等信号量触发自动采集;

image-20231205172423414

image-20231205172427832

opentracing 基本用法

  • opentracing 定义了一套 API,很多 tracing 框架都支持这套 API
  • go get github.com/opentracing/opentracing-go@latest 引入依赖
1
2
3
4
5
6
7
func main() {
opentracing.SetGlobalTracer(
// tracing impl specific:
some_tracing_impl.New(...),
)
...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
var serverSpan opentracing.Span
appSpecificOperationName := ...
wireContext, err := opentracing.GlobalTracer().Extract(
opentracing.HTTPHeaders,
opentracing.HTTPHeadersCarrier(req.Header))
if err != nil {
// Optionally record something about err here
}

// Create the span referring to the RPC client if available.
// If wireContext == nil, a root span will be created.
serverSpan = opentracing.StartSpan(
appSpecificOperationName,
ext.RPCServerOption(wireContext))

defer serverSpan.Finish()

ctx := opentracing.ContextWithSpan(context.Background(), serverSpan)
...
}
  • 在程序入口处创建一个根 span。一般来说,是在 HTTP 服务器上接收用户请求的地方创建
  • span 创建之后要记得 Finish()
1
2
3
4
5
6
7
8
func xyz(parentSpan opentracing.Span, ...) {
...
sp := opentracing.StartSpan(
"operation_name",
opentracing.ChildOf(parentSpan.Context()))
defer sp.Finish()
...
}
  • 在需要的打点创建子 span,进一步记录数据
    • Log Field:发生了什么事情,有唯一时间戳
    • Tag:贯穿整个 span 生命周期
    • Baggage Item:贯穿 tracing 整个剩下的周期。会不断向下传递。
  • spancontext.Context 关联起来,向下传递

几个关键 API

  • StartSpan:创建根 span
  • ContextWithSpan:将 spancontext.Context 结合在一起,以在进程内传递 tracing 信息
  • StartSpanFromContext:视图从参数 ctx 里面拿到一个 span 作为父亲 span。如果拿不到,则创建一个根 span
  • ContextWithSpanStartSpanFromContext 基本上是成对出现
  • 除非你明确知道自己是入口,否则使用 StartSpanFromContext

跨端传递

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func makeSomeRequest(ctx context.Context) ... {
if span := opentracing.SpanFromContext(ctx); span != nil {
httpClient := &http.Client{}
httpReq, _ := http.NewRequest("GET", "http://myservice/", nil)

// Transmit the span's TraceContext as HTTP headers on our
// outbound request.
opentracing.GlobalTracer().Inject(
span.Context(),
opentracing.HTTPHeaders,
opentracing.HTTPHeadersCarrier(httpReq.Header))

resp, err := httpClient.Do(httpReq)
...
}
...
}
  • 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

Dependency graph screenshot

prometheus

Prometheus architecture

promtheus 是拖的模式,由服务端主动从客户端拖数据。避免客户端上的请求非常多,碎片信息多,导致请求大爆服务端。

指标

  • prometheus 自身分为客户端和服务端。服务端也就是采集到的数据存储的地方,客户端就是使用 prometheus 的地方。
  • prometheus metrics 类型:
    • Counter:计数器,统计次数,比如说某件事的发生次数
    • Gauge:度量,可以增加也可以减少,比如说当前正在处理的请求数
    • Histogram:柱状图,对观察对象进行采样,然后分到一个个桶里面
    • Summary:采样点按照百分位进行统计,比如说 99 线,999 线等

配合 Grafana

Grafana screenshot

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func NewPrometheusService() (*Service, error) {
cli := prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "pushgateway",
Subsystem: "network",
Name: "cmd_duration_seconds",
Help: "CLI application execution in seconds",
Buckets: prometheus.DefBuckets,
}, []string{"name"})
http := prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "http",
Subsystem: "network",
Name: "request_duration_seconds",
Help: "The latency of the HTTP requests.",
Buckets: prometheus.DefBuckets,
}, []string{"handler", "method", "code"})

s := &Service{
pHistogram: cli,
httpRequestHistogram: http,
}
err := prometheus.Register(s.pHistogram)
if err != nil && err.Error() != "duplicate metrics collector registration attempted" {
return nil, err
}
err = prometheus.Register(s.httpRequestHistogram)
if err != nil && err.Error() != "duplicate metrics collector registration attempted" {
return nil, err
}
return s, nil
}

需要引入 github.com/prometheus/client_golang/prometheus

  • namespace 可以是应用名
  • subsystem:可以是一大类的东西,例如 http
  • name:则是给指标的命名,根据需求来

向量用法

  • 创建一个向量,可以使用 ConstLabelsLabels
  • 使用 WithLabelValues 来获取具体的收集器

这种用法更加普遍

HistogramSummary 区别

  • HistogramSummary 两者都会额外统计计数和总和
  • 区别:
    • summary 是在客户端上(也就是我们的应用上)做计算的,所以性能损耗比较大
    • histogram 是在服务端上做计算的
    • summary 不支持聚合操作(因为上报的结果都是已经计算好了的)
    • summary 指标在客户端上硬编码,不灵活
    • histogram 严重依赖于设置合理的 bucket

客户端和服务端启动

一般是采用客户端开启一个端口,由服务端来拉取数据。

1
2
3
4
5
6
7
8
9
10
11
12
package main

import (
"net/http"

"github.com/prometheus/client_golang/prometheus/promhttp"
)

func main() {
http.Handle("/metrics", promhttp.Handler())
http.ListenAndServe(":2112", nil)
}
  • 在客户端暴露采集数据端口:prometheus 会访问这个端口来拉数据
  • 启动 prometheus 服务器:本地实验可以使用 docker 来启动
  • 推荐配置 CONFIGURATION
1
2
3
4
docker run \
-p 9090:9090 \
-v /path/to/prometheus.yml:/etc/prometheus/prometheus.yml \
prom/prometheus

查询语言 PromQL

QUERYING PROMETHEUS

观测

HTTP 服务端观测

image-20231206172436479

  • HTTP 方法、状态、协议:GETPOST200HTTPS
  • HTTP 路由:不是记录路径,路径可能带参数,所以应该记录命中的路由。注意一些非法请求可能完全不能命中任何路由
  • error:如果能够利用 middleware 之类的东西获得请求执行过程中的 error,则可以记录下来。如果有错误码的设计,那么这一步应该记录下错误码
  • 请求和响应:在处于开发环境或者 DEBUG 下,请求和响应整体都应该记录下来,用作 DEBUG 信息
  • 业务ID:如果可以从 HTTP Header 里面解析出来用户ID,或者订单ID,也可以记录下来,后面查找、分析都很有用
  • 响应时间

HTTP 客户端观测

image-20231206173700490

  • HTTP 方法、状态、协议:GETPOST200HTTPS
  • HTTP 路由:如果要做聚合分析,那么应该记录可能命中的路由,否则直接记录整个路径(不含参数)
  • error:如果能够利用 middleware 之类的东西获得请求执行过程中的 error,则可以记录下来。如果有错误码的设计,那么这一步应该记录下错误码
  • 请求和响应:在处于开发环境或者 DEBUG 下,请求和响应整体都应该记录下来,用作 DEBUG 信息
  • 业务ID:如果可以从 HTTP Header 里面解析出来用户ID,或者订单ID,也可以记录下来,后面查找、分析都很有用
  • 响应时间

RPC 观测

image-20231206173831463

  • 服务标志符:用于标记唯一服务的
  • 目标主机名、IP 和端口:如果能够拿到目标主机的主机名、IP和端口都可以记录下来
  • RPC 响应状态:取决于 RPC 框架设计和公司规范
  • RPC 请求和响应:开发环境或者 DEBUG 下可以完整记录
  • error:如果能够获得 error,则可以记录下来。如果有错误码的设计,那么这一步应该记录下错误码
  • 响应时间
  • RPC 一般来说比较难获得业务ID,除非是约定了在整个链路中传递,例如放在 metadata 里面

References

OpenTracing(已经弃用,合并到 OpenTelemetry 中)

分布式链路追踪

Zipkin