本文概述 分布式监控 的一些概念,并进行分布式追踪实战。
分布式监控概述
分布式监控是一个市场庞大的领域,尤其在现在微服务越来越被广泛采用的的现代,监控和追踪系统可以说百花齐放,诞生了很多开源框架和商业公司。
本质上,无论监控还是日志,关注的其实是同一个东西:打点和收集分析。这里的点可以是一段无结构或者有结构的日志,也可以是一个数字,或者是带 id 上下文的结构化数据。既然要 打点,那么就存在以下几个问题:如何打点,如何收集、展示、分析。细分一下可以分成以下几个领域。
- 收集 agent : agent 一般是节点 deamon 形式,负责监控数据、日志的收集上报 比如 collectd, es apm agent, elastic beats, prometheus exporter 等
- 框架 library: 监控、日志数据也可以在应用客户端直接上报,不经过 agent,如 jaeger client, prometheus client 等
- tranport:可选转发层 比如一些常见的消息队列 kafka 等
- 收集 collector:接受 agent 数据
- 数据处理:监控数据的分析处理
- 存储:监控日志数据存储,比如 prometheus 自带 tsdb,open tsdb, influx db,es..
- 可视化/Dashboarding:kibana, prometheus ui, grafana
- 告警 alterting: grafana, prometheus alter manager,
这里强烈推荐一个网站 https://openapm.io/landscape 在这个网站上你可以选择一些组件,构建出你自己的监控系统。比如下图就是笔者拖出来的一个可以被真实使用的监控系统。这个监控系统中,节点上使用 collectd promethues exporter 来收集节点数据,应用端使用 promethues 收集监控数据,监控数据在 promethues server 汇总,并在 influxdb 持久化存储,日志数据使用 elk。 grafana 进行集中展示。
监控、追踪和日志
Logging,Metrics 和 Tracing 有各自专注的部分。
Logging - 用于记录离散的事件。例如,应用程序的调试信息或错误信息。它是我们诊断问题的依据。
Metrics - 用于记录可聚合的数据。例如,队列的当前深度可被定义为一个度量值,在元素入队或出队时被更新;HTTP 请求个数可被定义为一个计数器,新请求到来时进行累加。
Tracing - 用于记录请求范围内的信息。例如,一次远程方法调用的执行过程和耗时。它是我们排查系统性能问题的利器。
以上的分类办法 参考自 https://zhuanlan.zhihu.com/p/34318538,下图来自 http://peter.bourgon.org/blog/2017/02/21/metrics-tracing-and-logging.html
但是这张图除了好看之外,对监控的理解其实很不对, 以 request scoped 或者可否 aggregatable 进行划分并不是一个准确的划分方式,比如说,用日志也能打点绘制监控数据;基于 logid 的日志方式也能追踪调用链。
那么如何正确的理解 监控、追踪和日志 之间的关系呢。考虑 这样的一个原始服务端应用:
- 起初开发者打了一些本地日志,用于分析和做 debug
- 后来服务端单节点不能满足要求,副本加到了 3个,此时只利用本地日志变得不太方便,开发者就接入了 elk,将日志进行统一的收集和展示
- 开发者要求变得更高了,希望看到各种请求的 code 分布和 latency,此时日志变得很麻烦,给分析带来很大的成本,因此开发者又接入了 metric系统,对请求相关指标进行统计
- 开发者发现,某种请求耗时过长,考虑优化,在日志中写入耗时数据是一个办法,使用 logid(request id)的方式 分析是一个办法,但是不够直观。同时也不方便跨进程的追踪(开发者还调用了其他服务),所以开发者觉得接入 opentrace 直观分析各部调用耗时。
从上面的各步可以看出,开发者对于监控的要求是逐渐增加的,日志和监控 trace 直接的要求越来越高,但是从本质上看,三者并无区别,在日志中 写入耗时数据和使用 专用的监控系统,只是在分析和展示步骤有所不同。因此可以看出 其中一切都来自开发者对于应用的监控需求,而工作的原理都是打点。监控和追踪是日志的高级形式,本质并无不同,理解了这一点,你就能不变应万变了。换个说法,监控和追踪是将日志格式化和专用化的一种方式。当日志专注于 metric类数据,使用监控系统更为方便,当日志系统带有 context 属性(在 opentrace 里面就是 span,你也可以理解成 logid),那么使用专用的 trace 系统更为方便。但是本质都是日志,所以当 es 说他同时能支持 日志、监控、追踪的时候,你就不会觉得奇怪了吧。
追踪
由于笔者在监控方面已经写过一些文章(未来可能会重新整理),不再赘述,本文重点会介绍一下追踪(trace)以及 opentrace 规范。
opentrace
经过上文的分类,大家应该理解,trace 其实也是一种特殊的日志,opentrace 则是用来定义这种特殊的日志规范,而这种日志规范,最特殊的地方在于 span 的定义,即单个日志不是孤岛,通过 span 的串联,他是能组成一组调用链的。
代码语言:txt复制一个tracer过程中,各span的关系
[Span A] ←←←(the root span)
|
------ ------
| |
[Span B] [Span C] ←←←(Span C 是 Span A 的孩子节点, ChildOf)
| |
[Span D] --- -------
| |
[Span E] [Span F] >>> [Span G] >>> [Span H]
↑
↑
↑
(Span G 在 Span F 后被调用, FollowsFrom)
tracer与span的时间轴关系
––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–> time
[Span A···················································]
[Span B··············································]
[Span D··········································]
[Span C········································]
[Span E·······] [Span F··] [Span G··] [Span H··]
当然,除了 span 之外,opentrace 还有其他关注这种特殊日志的定义,比如:
- trace: 一组 span 组成的 dag, 可以理解为调用链
- span: 核心,代表系统中具有开始时间和执行时长的逻辑运行单元
- Operation Name:Span 的操作名
- Inter-Span References:Span 间关系,即 这个 dag 的组织方式
- Logs,Tags: 以 Span 为载体记录的 日志和 属性消息
- Baggage:也是 Span 为载体记录的消息,和 logs,tags 不同的是,baggage 也是会跨越进程传递的。这个怎么理解呢,理论上大部分内容只需要 span id 传递,其他内容本地记录收集就可以,而 有些内容同时也希望作为上下文被传递,这时候就是 baggage的使用场景
opentrace client 分析
opentrace 定义的是一个规范,具体的实现了这个规范的又 Zipkin,Jaeger 等,opentrace 的规范保证,只要使用 opentrace client 的代码,底层实现的切换,内部的代码无需修改。
opentrace client 具体定义了哪些东西呢。和上面讲的概念对应,其实 opentrace 的定义很简洁,主要就 三个 interface,Tracer
, Span
, SpanContext
type Tracer interface {
// 用于新建,启动,返回一个新的 Span
// 比如:
//
// var tracer opentracing.Tracer = ...
//
// // The root-span case:
// sp := tracer.StartSpan("GetFeed")
//
// // The vanilla child span case:
// sp := tracer.StartSpan(
// "GetFeed",
// opentracing.ChildOf(parentSpan.Context()))
//
// // All the bells and whistles:
// sp := tracer.StartSpan(
// "GetFeed",
// opentracing.ChildOf(parentSpan.Context()),
// opentracing.Tag{"user_agent", loggedReq.UserAgent},
// opentracing.StartTime(loggedReq.Timestamp),
// )
//
StartSpan(operationName string, opts ...StartSpanOption) Span
// 注入 Inject 和 Extract 是 Span 传递的关键,Inject 将 Span 信息以某种格式注入载体,
// Extract 则是做提取,以最常见的 Http 为例,Inject 将 Span 信息以 Http Header 的方式
// 注入,提取的时候则 从 Http Header 提取,这里只要 Inject 和 Extract 对应就可以,你也可以
// 定义自己的 Http 注入方式
//
// 比如:
//
// carrier := opentracing.HTTPHeadersCarrier(httpReq.Header)
// err := tracer.Inject(
// span.Context(),
// opentracing.HTTPHeaders,
// carrier)
//
Inject(sm SpanContext, format interface{}, carrier interface{}) error
// 提取,这里给了一个最常见的例子,理解这个例子:StartSpan 有两种情况:一是过来的请求里面有 Span
// 信息了,那么要 Start with clientContext;二是 过来的请求没有 Span,那么 新建一个新的无关的 Span
//
// 例子:
//
//
// carrier := opentracing.HTTPHeadersCarrier(httpReq.Header)
// clientContext, err := tracer.Extract(opentracing.HTTPHeaders, carrier)
//
// // ... assuming the ultimate goal here is to resume the trace with a
// // server-side Span:
// var serverSpan opentracing.Span
// if err == nil {
// span = tracer.StartSpan(
// rpcMethodName, ext.RPCServerOption(clientContext))
// } else {
// span = tracer.StartSpan(rpcMethodName)
// }
Extract(format interface{}, carrier interface{}) (SpanContext, error)
}
// SpanContext represents Span state that must propagate to descendant Spans and across process
// boundaries (e.g., a <trace_id, span_id, sampled> tuple).
type SpanContext interface {
// ForeachBaggageItem grants access to all baggage items stored in the
// SpanContext.
ForeachBaggageItem(handler func(k, v string) bool)
}
// Span represents an active, un-finished span in the OpenTracing system.
//
// Spans are created by the Tracer interface.
// 这里注释进行了删减,比较重要的是 SetOperationName/SetTag/LogFields => Finish
type Span interface {
// Sets the end timestamp and finalizes Span state.
Finish()
// FinishWithOptions is like Finish() but with explicit control over timestamps and log data.
FinishWithOptions(opts FinishOptions)
// Context() yields the SpanContext for this Span. Note that the return
// value of Context() is still valid after a call to Span.Finish(), as is
// a call to Span.Context() after a call to Span.Finish().
Context() SpanContext
// Sets or changes the operation name.
SetOperationName(operationName string) Span
// Adds a tag to the span.
SetTag(key string, value interface{}) Span
// LogFields is an efficient and type-checked way to record key:value
// logging data about a Span, though the programming interface is a little
// more verbose than LogKV(). Here's an example:
//
// span.LogFields(
// log.String("event", "soft error"),
// log.String("type", "cache timeout"),
// log.Int("waited.millis", 1500))
//
// Also see Span.FinishWithOptions() and FinishOptions.BulkLogData.
LogFields(fields ...log.Field)
// LogKV is a concise, readable way to record key:value logging data about
// a Span, though unfortunately this also makes it less efficient and less
// type-safe than LogFields(). Here's an example:
//
// span.LogKV(
// "event", "soft error",
// "type", "cache timeout",
// "waited.millis", 1500)
//
LogKV(alternatingKeyValues ...interface{})
// SetBaggageItem sets a key:value pair on this Span and its SpanContext
// that also propagates to descendants of this Span.
SetBaggageItem(restrictedKey, value string) Span
// Gets the value for a baggage item given its key. Returns the empty string
// if the value isn't found in this Span.
BaggageItem(restrictedKey string) string
// Provides access to the Tracer that created this Span.
Tracer() Tracer
}
span 如何传递
span 如何传递是 opentrace client 中比较重要的内容,毕竟如何 span 不能被跨进程传递,那么和本地日志的区别不是很大了(当然也有这样的使用场景,比如对本进程内的一组函数进行耗时分析)。opentrace client 内置了两种传递、存储方式,分别是 TextMap 和 HTTPHeaders:
- TextMap 将 Span 信息写入一个 map
- 而 HTTPHeaders 类似,只是把 Span 信息写入一个 Http Header,这样 Span 信息就实现了跨 Http 调用
理解了 Span 的原理,自己实现类似的传递方式并不困难,比如在 Grpc-go 里面使用 Meta 字段(类似 HttpHeader)传递Span 信息.
一个具体实现:Jaeger
一个 OpenTrace 的实现系统通常出来实现了 Opentrace 协议的 客户端之外,还包括
- Agent (本地收集)/ Collector (汇总,Server 端)
- 一个 DB (通常使用 cassandra 或者 Elasticsearch,也可用 memory 存在内存里面 用于测试) 做持久化存储
- 一个 UI 用于展示
Jagger Client 的 Inject 使用的 Http Header 如下
代码语言:txt复制func (p Propagator) Inject(
sc jaeger.SpanContext,
abstractCarrier interface{},
) error {
textMapWriter, ok := abstractCarrier.(opentracing.TextMapWriter)
if !ok {
return opentracing.ErrInvalidCarrier
}
textMapWriter.Set("x-b3-traceid", sc.TraceID().String())
if sc.ParentID() != 0 {
textMapWriter.Set("x-b3-parentspanid", strconv.FormatUint(uint64(sc.ParentID()), 16))
}
textMapWriter.Set("x-b3-spanid", strconv.FormatUint(uint64(sc.SpanID()), 16))
if sc.IsSampled() {
textMapWriter.Set("x-b3-sampled", "1")
} else {
textMapWriter.Set("x-b3-sampled", "0")
}
sc.ForeachBaggageItem(func(k, v string) bool {
textMapWriter.Set(p.baggagePrefix k, v)
return true
})
return nil
}
实战
实战例子改编自 https://github.com/yurishkuro/opentracing-tutorial
这个例子中我们使用 一个 client, 两个 server(publish,formatstring),其中 publish server 收到请求后,同时会异步的发一个 回调消息到 mq,而 client 端则等待这个异步消息并推出。
这个例子中除了最基础的 trace 使用,意在解释当常见的 carrier 不能满足要求,如何通过封装消息的方式来包装 span 信息
代码语言:txt复制type Message struct {
Body string
Extra map[string]string
}
我们的做法为封装 mq 消息为 Message, 其中 Body 是实际 mq消息内容,而 Extra 为 Span 消息。通过定制 mq sdk,我们可以做到 Message 格式对用户不暴露。
本地启动 mq 和 jaegertracing
代码语言:txt复制// jaegertracing
docker run --rm -p 6831:6831/udp -p 6832:6832/udp -p 16686:16686 jaegertracing/all-in-one:1.7 --log-level=debug
// rabbitmq, 启动后进入容器新建 root 用户,新建一个 test queue
docker run --name rabbitmq -p 15672:15672 -p 5672:5672 ccr.ccs.tencentyun.com/wajika/rabbitmq-management:3.7.8
启动 server publish,formatstring后,运行一次 client,打开 http://localhost:16686/ 查看 trace 效果
实战代码在 https://github.com/u2takey/trace-example
参考
- opentracing文档中文版 ( 翻译 ) 吴晟
- 开放分布式追踪(OpenTracing)入门与 Jaeger 实现