分布式追踪系统 -- Opentracing

MicroServices提供了一个强大的体系结构,但也有其自身的挑战,特别是在调试和观察跨复杂网络的分布式事务方面,因为没有内存调用或堆栈跟踪,这也是分布式追踪的由来。分布式追踪为描述和分析跨进程事务提供了一种解决方案。Google Dapper(Dapper: 大规模分布式系统链路追踪基础设施)论文(各tracer的基础)中描述了分布式追踪的一些使用案例包括异常检测、诊断稳态问题、分布式分析、资源属性和微服务的工作负载建模。OpenTracing通过提供平台无关、厂商无关的API,使得开发人员能够方便的添加(或更换)追踪系统的实现。OpenTracing提供了用于运营支撑系统的和针对特定平台的辅助程序库。程序库的具体信息请参考详细的规范

关于Metrics、Tracing和Logging

监控链路追踪日志作为实时监测系统运行监控状况,在这三个领域都有对应的工具和解决方案,它们除负责各自的职责(prometheus、jaeger、ELK)在某些情况下可能会重叠。我们可以用维恩图的形式,描绘出各个领域之间的可观测性

Metrics 监控指标的定义特征是它们是可聚合的:它们是在一段时间内组成单个逻辑指标、计数器或直方图的原子。例如:队列的当前深度可以建模为一个规范,其更新聚合为最后一个writer win语义;传入的http请求的数量可以建模为一个计数器,其更新聚合为简单的加法;观察到的请求持续时间可以建模为一种直方图,其更新聚合为时间段并产生统计摘要

Logging 日志的定义特征是它处理离散事件。例如:应用程序调试或错误消息通过一个轮转的文件描述符通过syslog发送到elasticsearch(OK log, nudge nudge); 审计跟踪事件通过kafka推送到数据湖bigtable;或者从服务调用中提取特定于请求的元数据并发送到错误追踪服务(如NewRelic)

Tracing 它定义特征是它处理请求范围内的信息--任何可以绑定到系统中单个事务对象的生命周期的数据或元数据。例如:远程服务的出站rpc的持续时间;发送到数据库的实际sql查询的文本;或入站http请求的相关ID

通过上述定义,我们可以标记重叠部分为下图所示

在云原生应用程序所特有的许多工具最终都是请求范围(request-scoped)内的,因此,讨论进行追踪上下文是有意义的。我们可以看到,并非所有的工具都绑定到请求生命周期:例如,逻辑组件诊断信息或流程生命周期详细信息将与任何离散请求正交。因此,举例来说,并非所有的监控项或日志都可以被强制放入跟踪系统,或者,我们可能会意识到,直接在应用程序中检测监控值会给我们带来更多好处,比如一种灵活的表达式语言,进行实时评估;相反,将监控值强制放入日志管道可能会迫使我们丢弃其中的一些好处

在这三个领域中,监控指标往往需要最少的资源来管理,因为从本质上来说,它们“压缩”得相当好。相反,日志记录往往是压倒性的超过它所报告的生产流量。我们可以画出一个volume开销梯度,从监控(低)到日志(高)-我们观察到tracing可能位于中间的某个地方

我们可以将现有系统分类。例如,prometheus最初只是作为一个监控系统启动的,随着时间的推移,它可能会向跟踪方向发展,从而进入请求范围(request-scope)内的监控,但很可能不会深入到日志空间。ELK提供日志记录和汇总功能,使其成为可聚合的事件空间,但似乎在metrics和tracing领域不断积累更多的功能,意图将它推向中心

以上韦恩图根据Metrics、Tracing和Logging定义的划分,可能不是那么准确,但有助于理解这三个领域之间的关系

OpenTracing API

分布式追踪,也称为分布式请求追踪,是一种用于分析和监视应用程序的方法,特别是那些使用微服务体系结构构建的应用程序,IT和DevOps团队可以使用分布式追踪来监视应用程序; 分布式追踪有助于查明故障发生的位置以及导致性能低下的原因,开发人员可以使用分布式跟踪来帮助调试和优化他们的代码

大多数分布式追踪系统的思想模型都来自Google's Dapper论文,OpenTracing也使用相似的术语

  1. Trace 事物在分布式系统中移动时的描述
  2. Span 一种命名的、定时的操作,表示工作流的一部分。Spans接受key:value标签以及附加到特定Span实例的细粒度、带时间戳的结构化日志
  3. Span Contenxt 携带分布式事务的跟踪信息,包括当它通过网络或消息总线将服务传递给服务时。SPAN上下文包含Trace标识符、SPAN标识符和跟踪系统需要传播到下游服务的任何其他数据

四大件 从应用层分布式跟踪系统的角度来看,现代软件系统如下图所示

对现代软件系统中的组件可以分为三类

  • 应用程序和业务逻辑 您的代码
  • 广泛的共享库 其他人的代码
  • 广泛共享的服务 其他人的基础设施

这三类组件有着不同的需求,驱动着监控应用程序的分布式追踪系统的设计。最终的设计产生了四个重要的部分

  • 追踪监测API 修饰应用程序代码
  • 有线协议 在RPC请求中与应用程序数据一起发送内容
  • 数据协议 异步(带外)发送到分析系统的内容
  • 分析系统 用于处理追踪数据的数据库和交互式用户界面

OpenTracing API提供了一个标准的、与供应商无关的框架,这意味着如果开发者想要尝试一种不同的分布式追踪系统,开发者只需要简单地修改Tracer配置即可,而不需要替换整个分布式追踪系统

OpenTracing由API规范(描述了语言无关的数据模型和Opentracing API指南)、实现该规范的框架和库以及项目文档组成,OpenTracing不是一个标准,OpenTracing API项目正致力于为分布式跟踪创建更加标准化的API和工具

OpenTracing 数据模型

OpenTracing中的Trace是有Span隐式定义的,Trace可以认为是Span的有向无环图,其中Span之间的边成为引用。下图是由8个Spans构成的一个Trace

有时,使用时间轴来可视化Trace会更容易,如下图

每一个Span封装以下状态

  • 操作名称
  • 起始时间戳
  • 完成时间戳
  • 一组零个或多个key:value的Span Tags,keys必须是字符串,values可以是strings,bools,numeric类型
  • 一组零个或多个Span Logs,日志自身是与时间戳匹配的key:value对。键必须是字符串,尽管值可以是任何类型。并非所有的opentracing实现都必须支持每种值类型
  • 一个SpanContext
  • 通过SpanContext引用零个或多个因果相关的Spans

每一个SpanContext封装以下状态

  • 任何OpenTracing-Implementation-Dependent状态(TraceID,SpanID)需要指定引用的Span以区分不同Span的进程边界
  • Baggage Items 跨进程边界的键值对

Spans之间的引用 一个Span可以引用零个或多个因果关系的其他Spans,OpenTracing目前定义了两种类型的引用 ChildOf 和 FollowsFrom,两种引用类型都特别模拟了父Span和子Span之间的直接因果关系

ChildOf引用 一个Span可以是父Span的子Span,满足以下条件可构成亲子关系

  • 表示RPC服务端的Span可以是表示该RPC客户端Span的Child
  • 表示SQL插入的Span可以是表示ORM保存方法的Child
  • 许多并发(或分布式)工作的Span可能都是独立的,`单亲`Span合并在截止日期内返回的所有子Span的结果
具有ChildOf关系的时序图

FollowsFrom引用 某些父Span在任何方面都不依赖子Span的结果,如下图

OpenTracing规范中三个关键且互连的类型Tracer、Span和SpanContext,以下将详细介绍每种类型的行为;每种行为在编程语言中都成为一个“方法”,尽管它实际上可能是一组由于类型重载等原因而相关的同级方法

Tracer 用于创建Span,并理解如何跨进程边界注入(序列化)和提取(反序列化)Span。从形式上将,它具有以下功能

开始新Span 返回已启动的新的Span实例(但是没有Finished)

参数列表

  • operation name(必须) 人类可读的字符串,简洁地表示Span完成的工作(例如,RPC方法名或函数名)
  • references 零个或多个引用相关的SpanContext(可选)
  • start timestamp 如果省略,默认情况下使用当前walltime(可选)
  • tags 零个或多个(可选)

将SpanContext注入载体

参数列表

  • 一个SpanContext实例
  • format 格式描述符,告诉Tracer实现如何在carrier参数中对spancontext进行编码
  • carrier 类型有format决定

从carrier中抽取SpanContext 通过Tracer启动一个新的Span时返回适合作为引用的SpanContext

必要的参数

  • format
  • carrier

NOTE 注入和提取所需要的格式 注入和提取都依赖于一个可扩展的格式参数,该参数指示关联的“carrier”的类型以及如何在该载体中对SpanContext进行编码。所有OpenTracing的实现都必须支持以下格式

    • Text Map 任意字符串到字符串的映射,不限制key,value字符集
    • HTTP Headers 字符串到字符串的映射,包含适合在HTTP Header中使用的key,value
    • Binary 表示SpanContext的任意二进制blob

Span 完成Span之后,只能调用检索Span的方法,其他都不可用

  • 检索Span(s) SpanContext 不需要参数,返回指定span的SpanContext,返回值可以在Span完成之后使用
  • 覆盖操作名称 指定新的Operation Name,当Span Start时会覆盖原来的Operation Name
  • 完成Span 显示指定finish timestamp参数,如不指定默认使用当前walltime
  • 设置Span Tags 指定key(必须是字符串),value(支持字符串、布尔及数字类型)
  • 日志结构数据 指定日志的键值,或者精准时间戳,根据不同的实现传参可能不一样
  • 设置baggage item 需要字符串类型的参数baggage key和baggage value

SpanContext 它更多的是一个“概念”,而不是通用OpenTracing层的一个有用的功能。大多数OpenTracing用户只在启动新的Span时,或者在向某个传输协议注入/提取跟踪时,通过引用与SpanContext交互。在OpenTracing中,我们强制SpanContext实例是不可变的,以避免span finish和引用的复杂生存期问题

下表列出当前已知的OpenTracing的实现

追踪示例

端到端注入和提取追踪数据流程

  1. 一个客户端进程有一个SpanContext实例,它将在一个HTTP协议之上生成一个RPC
  2. 客户端进程调用Tracer.Inject(...),传递活动的SpanContext实例、Text Map格式的标识符和Text Map Carrier作为参数
  3. Inject已经在Carrier中填充了Text Map,客户端应用程序在其自制的http协议(例如,作为头)中对该映射进行编码
  4. 发起HTTP请求,数据跨越进程边界
  5. 现在在服务器进程中,应用程序代码从自制的http协议中解码Text Map并使用它初始化Text Map Carrier
  6. 服务进程调用Tracer.Extract(...),抽取从上面传入所需的操作名、Text Map的格式标识符和Carrier
  7. 在没有数据损坏或其他错误的情况下,服务器现在有一个SpanContext实例,它与客户机中的实例属于同一个Trace

最佳实践

OpenTracing是一个很薄的标准化层,位于应用程序/代码库和使用追踪及因果关系数据的各种系统之间,如下图

OpenTracing的一些使用案例

  • 应用代码 编写应用程序代码的开发人员可以使用opentracing来描述因果关系,划分控制流,并在此过程中添加细粒度日志信息
  • 库代码 采取中间控制请求的库可以与OpenTracing集成,例如,Web中间件库可以使用OpenTracing为请求创建Spans,或者ORM库可以使用OpenTracing描述高级别的ORM语义,并对特定SQL查询执行测量
  • RPC/IPC框架 任何负责跨越进程边界的子系统都可以使用OpenTracing来标准化跟踪状态的格式,因为它可以注入和提取各种有线格式和协议

以上所有内容都应该能够使用OpenTracing来描述和传播分布式追踪,而不必知道OpenTracing的底层实现

追踪一个函数

def top_level_function():
    span1 = tracer.start_span('top_level_function')
    try:
        . . . # business logic
    finally:
        span1.finish()

// 假设在业务逻辑处我们想追踪函数function2,为了将其附加到正在进行的Trace
// 我们需要一种访问Span1的方法,稍后讨论如何实现,现在我们假设有一个HEALPER函数 get_current_span
// 如果调用方尚未启动一个Trace,开发人员都不希望在该函数中启动新的跟踪,因此我们假设get_current_SPAN可能返回none
def function2():
    span2 = get_current_span().start_child('function2') \
        if get_current_span() else None
    try:
        . . . # business logic
    finally:
        if span2:
            span2.finish()

追踪服务的Endpoints 当服务器想要跟踪请求的执行时,通常需要执行以下步骤

  1. 尝试提取与传入请求一起传播的SpanContext(如果客户端已经启动追踪),如果找不到传播的SpanContext则启动一个新的Trace
  2. 将新创建的Span存储在某个请求上下文,该上下文在整个应用、应用程序代码及RPC框架中传播
  3. 当服务器处理完请求时,最终通过span.finish()关闭Span

从传入请求中提取SpanContext 假设我们有一个http服务,并且SpanContext通过HTTP头从客户端传入,通过访问request.headers提取carrier

extracted_context = tracer.extract(
    format=opentracing.HTTP_HEADER_FORMAT,
    carrier=request.headers  // 这里我们使用headers映射carrier,tracer对象知道它需要读取哪个报头,以便重建tracer状态和Baggage
)

持续或开始追踪一个传入请求 如果追踪程序没有在传入请求找到相关headers值,则extracted_context将会为None,可能是客户端没有发送它们,这种情况下服务器需要启动一个全新的Trace

extracted_context = tracer.extract(
    format=opentracing.HTTP_HEADER_FORMAT,
    carrier=request.headers
)
// operation是指服务器要为Span命名的名称。例如,如果http请求是针对/save_user/123的post,则可以将operation名称设置为post:/save_user/。opentracing API不指定应用程序如何命名Span
if extracted_context is None:
    span = tracer.start_span(operation_name=operation)
else:
    span = tracer.start_span(operation_name=operation, child_of=extracted_context)
span.set_tag('http.method', request.method) // set_tag调用是在SPAN中记录有关请求的附加信息的示例
span.set_tag('http.url', request.full_url)

进程内请求上下文传播 请求上下文传播指的是应用程序将某个上下文与传入请求关联起来的能力,以便在同一进程中应用程序的所有其他层都可以访问该上下文。它可以用来为应用层提供对请求特定值的访问,例如最终用户的身份、授权令牌和请求的截止期限。它也可以用于传输当前Trace Span。有两种常用的上下文传播技术

  • 隐式传播 在隐式传播技术中,上下文存储在特定于平台的存储中,允许从应用程序中的任何位置检索它。rpc框架通常通过使用诸如线程本地或连续本地存储,甚至全局变量(对于单线程进程)等机制来使用。这种方法的缺点是几乎总是有性能损失,而且在像go这样不支持线程本地存储的平台上,几乎不可能实现隐式传播
  • 显示传播 在显式传播技术中,应用程序代码的结构是传递特定的上下文对象,而显式上下文传播的缺点是它会将可能被视为基础结构问题的内容泄漏到应用程序代码中
func HandleHttp(w http.ResponseWriter, req *http.Request) {
    ctx := context.Background()
    ...
    BusinessFunction1(ctx, arg1, ...)
}

func BusinessFunction1(ctx context.Context, arg1...) {
    ...
    BusinessFunction2(ctx, arg1, ...)
}

func BusinessFunction2(ctx context.Context, arg1...) {
    parentSpan := opentracing.SpanFromContext(ctx)
    childSpan := opentracing.StartSpan(
        "...", opentracing.ChildOf(parentSpan.Context()), ...)
    ...
}

追踪客户端调用 当应用程序充当RPC客户端时,它应该在发出请求之前启动一个新的Trace Span,并将新的Span与该请求一起传播。下面的示例演示了如何为HTTP请求进行操作

def traced_request(request, operation, http_client):
    # get_current_span不属于OpenTracing API的一部分,检索当前Span传播的请求上下文
    parent_span = get_current_span()

    # start a new span to represent the RPC
    span = tracer.start_span(
        operation_name=operation,
        child_of=parent_span.context,
        tags={'http.url': request.full_url}
    )

    # propagate the Span via HTTP request headers
    tracer.inject(
        span.context,
        format=opentracing.HTTP_HEADER_FORMAT,
        carrier=request.headers)

    # 假设http客户端是异步的,它返回一个future,我们需要添加一个on-completion回调来完成当前的子Span
    def on_done(future):
        if future.exception():
            span.log(event='rpc exception', payload=exception)
        span.set_tag('http.status_code', future.result().status_code)
        span.finish()

    try:
        future = http_client.execute(request)
        future.add_done_callback(on_done)
        return future
    except Exception e:
        # 如果 http 返回带有异常的future,使用span.log记录该异常
        span.log(event='general exception', payload=e)
        # span.finish 避免资源泄漏
        span.finish()
        raise

使用Baggage/分布式上下文传播 上面的示例展示了客户端和服务器通过有线传播了Span/Trace,包括任何Baggage。客户端可以使用Baggage将附加数据传递给服务端及它可能调用的任何下游服务

# client side
span.context.set_baggage_item('auth-token', '.....')

# server side (one or more levels down from the client)
token = span.context.get_baggage_item('auth-token')

记录事件 我们已经在客户端Span的例子中使用了日志,事件可以在没有有效载荷的情况下被记录(日志),而不仅仅是在创建/完成Span的地方。例如,应用程序可以在中间件执行的任意位置记录高速缓存未命中事件,只要它能够从请求上下文访问当前Span。Tracer自动记录事件的时间戳,而不是应用与整个Span的Tags,也可以将外部提供的时间戳与事件关联起来

span = get_current_span()
span.log(event='cache-miss')

用外部时间戳记录Span 在某些情况下,由于各种原因,将与OpenTracing兼容的Tracer合并到服务中是不切实际的。例如,用户可能有一个日志文件,其中包含来自黑盒进程(例如haproxy)的基本SPAN数据。为了将数据输入到OpenTracing兼容的系统中,API需要一种用外部定义的时间戳记录Span的方法

explicit_span = tracer.start_span(
    operation_name=external_format.operation,
    start_time=external_format.start,
    tags=external_format.tags
)
explicit_span.finish(
    finish_time=external_format.finish,
    bulk_logs=map(..., external_format.logs)
)

在Trace开始之前设置采样优先级 大多数分布式追踪系统应用采样来减少需要记录和处理的跟踪数据量。有时开发人员希望有一种方法来确保追踪系统将记录(采样)特定Trace,例如在http请求中包含一个特殊参数,如debug=true。OpenTracing API围绕一些有用的标记进行标准化,其中之一就是所谓的“采样优先级”:确切的语义是特定于实现的,但是任何大于零(默认值)的值都表示Trace的重要性优先级提高。为了将此属性传递给依赖pre-trace采样的跟踪系统,可以使用以下方法

if request.get('debug'):
    span = tracer.start_span(
        operation_name=operation,
        tags={tags.SAMPLING_PRIORITY: 1}
    )

跟踪消息总线方案 应该处理两种消息总线类型消息队列发布/订阅(主题)。从Trace的角度来看,消息总线类型并不重要,只与生产者关联的Span上下文被传播到消息的零个或多个使用者。然后,使用者有责任创建一个Span来封装对已使用消息的处理,并建立对传播的Span上下文的FollowsFrom引用。与RPC客户端示例一样,消息传递生产者在发送消息之前需要启动一个新的Trace Span,并将新Span的SpanContext与该消息一起传播。消息在消息总线上排队/发布后,Span将完成。下面的例子说明了它是如何实现的

def traced_send(message, operation):
    # retrieve current span from propagated message context
    parent_span = get_current_span()

    # start a new span to represent the message producer
    span = tracer.start_span(
        operation_name=operation,
        child_of=parent_span.context,
        tags={'message.destination': message.destination}
    )

    # propagate the Span via message headers
    tracer.inject(
        span.context,
        format=opentracing.TEXT_MAP_FORMAT,
        carrier=message.headers)

    with span:
        messaging_client.send(message)
    except Exception e:
        ...
        raise

下面是消息消费者检查传入消息是否包含Span上下文的示例。如果包含,它将与消息生产者的Span建立关系

extracted_context = tracer.extract(
    format=opentracing.TEXT_MAP_FORMAT,
    carrier=message.headers
)
span = tracer.start_span(operation_name=operation, references=follows_from(extracted_context))
span.set_tag('message.destination', message.destination)

队列上的同步请求-响应 一些消息传递平台/标准(e.g. JMS)支持在消息头中提供ReplyTo目的地的能力。当使用者收到消息时,它会将结果消息返回到指定的目的地。此模式可用于模拟同步请求/响应,在这种情况下,消费者和生产者Span之间的关系类型ChildOf。这种模式也可用于授权,以指示应将结果通知的第三方,在这种情况下,它将被视为两个独立的消息交换,并以Follow From类型的连接每个阶段

小结

OpenTraing不是标准,OpenTracing API项目正致力于为分布式跟踪创建更加标准化的API和工具。OpenTraing API作为位于应用程序/代码库和使用追踪及因果关系数据的各种系统之间轻薄的标准化层,为上层(应用程序)定义标准接口,其底层具体实现对应用透明。目前已知著名分布式追踪系统CNCF Jaeger、DataLog等都是OpenTracing的实现,因此,OpenTracing是学习分布式追踪系统最好的切入点

发布于 2019-09-23

文章被以下专栏收录