返回
Featured image of post Envoy 集成 Jaeger 实现分布式链路跟踪

Envoy 集成 Jaeger 实现分布式链路跟踪

当我们的应用架构,从单体系统演变为微服务时,一个永远不可能回避的现实是,业务逻辑会被拆分到不同的服务中。因此,微服务实际就是不同服务间的互相请求和调用。更重要的是,随着容器/虚拟化技术的发展,传统的物理服务器开始淡出我们的视野,软件被大量地部署在云服务器或者虚拟资源上。在这种情况下,分布式环境中的运维和诊断变得越来越复杂。如果按照功能来划分,目前主要有 Logging、Metrics 和 Tracing 三个方向,如下图所示,可以注意到,这三个方向上彼此都有交叉、重叠的部分。在我过去的博客里,我分享过关于 ELKPrometheus 的内容,可以粗略地认为,这是对 Logging 和 Metrics 这两个方向的涉猎。所以,这篇文章我想和大家分享是 Tracing,即分布式跟踪,本文会结合 Envoy、Jaeger 以及 .NET Core 来实现一个分布式链路跟踪的案例,希望能带给大家一点 Amazing 的东西。

可观测性:Metrics、Tracing & Logging
可观测性:Metrics、Tracing & Logging

分布式跟踪

如果要追溯分布式跟踪的起源,我想,Google 的这篇名为 《Dapper, a Large-Scale Distributed Systems Tracing Infrastructure》 的论文功不可没,因为后来主流的分布式跟踪系统,譬如 ZipkinJeagerSkywalkingLightStep……等等,均以这篇论文作为理论基础,它们在功能上或许存在差异,原理上则是一脉相承,一个典型的分布式跟踪系统,大体上可以分为代码埋点、数据存储和查询展示三个步骤,如下图所示,Tracing 系统可以展示出服务在时序上的调用层级,这对于我们分析微服务系统中的调用关系会非常有用。

分布式跟踪系统基本原理
分布式跟踪系统基本原理

一个非常容易想到的思路是,我们在前端发出的请求的时候,动态生成一个唯一的 x-request-id,并保证它可以传递到与之交互的所有服务中去,那么,此时系统产生的日志中就会携带这一信息,只要以此作为关键字,就可以检索到当前请求的所有日志。这的确是个不错的方案,但它无法告诉你每个调用完成的先后顺序,以及每个调用花费了多少时间。基于这样的想法,人们在这上面传递了更多的信息(Tag),使得它可以表达层级关系、调用时长等等的特征。如图所示,这是一个由 Jaeger 产生的跟踪信息,我们从中甚至可以知道请求由哪台服务器处理,以及上/下游集群信息等等:

通过 Jaeger 收集 gRPC 请求信息
通过 Jaeger 收集 gRPC 请求信息

目前,为了统一不同 Tracing 系统在 API、数据格式等方面上的差异,社区主导并产生了 OpenTracing 规范,在这个 规范 中,一个 Trace,即调用链,是由多个 Span 组成的有向无环图,而每个 Span 则可以含有多个键值对组成的 Tag。如图所示,下面是 OpenTracing 规范的一个简单示意图,此时,图中一共有 8 个 Span,其中 Span A 是根节点,Span CSpan A 的子节点, Span GSpan F 之间没有通过任何一个子节点连接,称为 FollowsFrom

        [Span A]  ←←←(the root span)
            |
     +------+------+
     |             |
 [Span B]      [Span C] ←←←(Span C is a `ChildOf` Span A)
     |             |
 [Span D]      +---+-------+
               |           |
           [Span E]    [Span F] >>> [Span G] >>> [Span H]
                                       ↑
                                       ↑
                                       ↑
                         (Span G `FollowsFrom` Span F)

事实上,我们上面提到的 ZipkinJeager 都兼容这一规范,这使得我们可以更加灵活和自由地更换 Tracing 系统。除了 OpenTracing 规范,目前,OpenTelemetry 在考虑统一 Logging、Metrics 和 Tracing,即我们通常所说的 APM,如果大家对这个感兴趣,可以做更进一步的了解。

Envoy & Jaeger

目前,主流的服务网格平台如 Istio,选择 Envoy 作为其数据平面的核心组件。通俗地来讲,Envoy 主要是作为代理层来调节服务网格中所有服务的进/出站流量,它可以实现诸如负载均衡、服务发现、流量转移、速率限制、可观测性等等的功能。考虑到不同的服务都可以通过 Gateway 或者 Sidecar 来互相访问,我们更希望通过 Envoy 这个代理层来实现分布式跟踪,而不是在每个应用内都去集成 SDK,这正是服务网格区别于传统微服务的地方,即微服务治理需要的各种能力,逐步下沉到基础设施层。如果你接触过微软的 Dapr,大概就能体会到我这里描述的这种变化。

Envoy 在 Istio 中扮演着重要角色
Envoy 在 Istio 中扮演着重要角色

事实上,Envoy 提供了入口来接入不同的 Tracing 系统,以 Zipkin 或者 Jeager 为例,除了前面提到的 x-request-id,它可以帮我们生成类似 x-b3-traceidx-b3-spanid 等等的请求头。参照 官方文档,它大体上提供了下面 3 种策略来支撑系统范围内的跟踪:

  • 生成 UUID :Envoy 会在需要的时候生成 UUID,并操作名为 x-request-id 的 HTTP 头部,应用可以转发这个 HTTP 头部用于统一的记录和跟踪。
  • 集成外部跟踪服务:Envoy 支持可插拔的外部跟踪可视化服务,例如 LightStep、Zipkin 或者 Zipkin 兼容的后端(比如说 Jaeger)等等。
  • 客户端跟踪 ID 连接:x-client-trace-id 这个 HTTP 头部可以用来把不信任的请求 ID 连接到受信的 x-request-id HTTP 头部上。

这意味着,我们可以从客户端或者由 Envoy 来产生一个 x-request-id,只要应用转发这个 x-request-id 或者 外部跟踪系统需要的 HTTP 头部,Envoy 就可以帮我们完成把这些跟踪信息告诉这些外部跟踪系统,甚至在 Sidecar 模式下这一切都是自动完成的。我在写这篇博客时发现,官方还是比较推崇 Sidecar 模式,即一个服务就是一个 Pod,每个 Pod 里自带一个 Envoy 作为代理,对于 Sidecar 模式而言,它的分布式跟踪呈现出下面这样的结构,如果你认真阅读过官方的文档和示例,就会发现其 示例 基本都是这种结构:

Sidecar 模式下的分布式跟踪示意图
Sidecar 模式下的分布式跟踪示意图

考虑到,2022 年还有没有用上 K8S 的人,以及 Catcher Wong 大佬反映 Sidecar 模式比较浪费资源,这里我们还是用 Gateway 模式来实现,譬如我们有两个服务,订单服务(OrderSevice) 和 支付服务(PaymentService),它们都由同一个 Envoy 来代理,当我们在订单服务中调用支付服务时,就会产生一条调用链。对于大多数的微服务而言,从它被拆分地那一刻起,就不可避免地走向了像蜘蛛网一般错综复杂的结局,此时,它的分布式跟踪呈现出下面的结构:

Gateway 模式下的分布式跟踪示意图
Gateway 模式下的分布式跟踪示意图

如果从代码侵入角度来审视这个问题,Sidecar 模式,每个服务都由 Envoy 去生成或者是设置一系列相关的请求头;而如果采取 Gateway 模式,当你在订单服务里调用支付服务时,无论你使用 HttpClient 还是 gRPC,你都需要确保这一系列的请求头能传递下去,这意味着我们要写一点无关紧要的代码,这样看起来前者更好一点,不是吗?可惜,合适和正确,就像鱼和熊掌一样,永远不可兼得。

Span 模型示意图
Span 模型示意图

关于 Jeager,这是一个由 Uber 开发的、受 Dapper 和 Zipkin 启发的分布式跟踪系统,它主要适用于:分布式跟踪信息传递、分布式事务监控、问题分析、服务依赖性分析、性能优化这些场景,因为它兼容 OpenTracing 标准,所以 Span 这个术语对它来说依然使用,什么是 Span 呢?它是一个跟踪的最小逻辑单位,可以记录操作名,操作开始时间 和 操作耗时,下面是 Jaeger 的架构示意图,大家可以混个眼熟:

Jaeger 的架构示意图
Jaeger 的架构示意图

第一个实例

OK,现在来分享本文的第一个示例,如前文所述,我们要实现的是一个 Gateway 模式下的请求跟踪。为此,我们准备了两个 ASP.NET Core 项目,分别来模拟订单服务(OrderService) 和 支付服务(PaymentService),当我们通过 Envoy 访问 OrderService 的时候,会在其内部访问 PaymentService,以此来验证 Envoy 能否帮我们找到这条调用链。首先,我们来编写 OrderService,代码非常简单,从 HTTP 请求头中拿到 Jeager 需要的字段,并在调用 OrderService 的时候传递这些字段:

 1[HttpPost]
 2public async Task<IActionResult> Post([FromBody] OrderInfo orderInfo)
 3{
 4  var paymentInfo = new PaymentInfo()
 5  {
 6    OrderId = orderInfo.OrderId,
 7    PaymentId = Guid.NewGuid().ToString("N"),
 8    Remark = orderInfo.Remark,
 9  };
10
11  // 设置请求头
12  _httpClient.DefaultRequestHeaders.Add("x-request-id", Request.Headers["x-request-id"].ToString());
13  _httpClient.DefaultRequestHeaders.Add("x-b3-traceid", Request.Headers["x-b3-traceid"].ToString());
14  _httpClient.DefaultRequestHeaders.Add("x-b3-spanid", Request.Headers["x-b3-spanid"].ToString());
15  _httpClient.DefaultRequestHeaders.Add("x-b3-parentspanid", Request.Headers["x-b3-parentspanid"].ToString());
16  _httpClient.DefaultRequestHeaders.Add("x-b3-sampled", Request.Headers["x-b3-sampled"].ToString());
17  _httpClient.DefaultRequestHeaders.Add("x-b3-flags", Request.Headers["x-b3-flags"].ToString());
18  _httpClient.DefaultRequestHeaders.Add("x-ot-span-context", Request.Headers["x-ot-span-context"].ToString());
19
20  // 调用/Payment接口
21  var content = new StringContent(JsonConvert.SerializeObject(paymentInfo), Encoding.UTF8, "application/json");
22  var response = await _httpClient.PostAsync("/Payment", content);
23
24  var result = response.IsSuccessStatusCode ? "成功" : "失败";
25  return new JsonResult(new { Msg = $"订单创建{result}" });
26}

接下来,PaymentService 就会变得非常简单,因为我们不会真的去对接一个支付系统,所以,就简单意思一下好啦!

1 [HttpPost]
2public IActionResult Post([FromBody] PaymentInfo paymentInfo)
3{
4  var requestId = Request.Headers["x-request-id"].ToString();
5  return new JsonResult(new { Msg = $"支付成功, 流水号:{requestId}" });
6}

服务编写好以后,按照惯例,我们使用 docker-compose.yaml 文件来进行编排,除了 OrderServicePaymentService,我们还需要 EnvoyJeager,即至少需要四个服务:

 1version: '3'
 2services:
 3  envoy_gateway:
 4    build: Envoy/
 5    ports:
 6      - "9090:9090"
 7      - "9091:9091"
 8    volumes:
 9      - "./Envoy/envoy.yaml:/etc/envoy/envoy.yaml"
10      - "./Envoy/logs/:/etc/envoy/logs/"
11  order_service:
12    build: OrderService/
13    ports:
14      - "8081:80"
15    environment:
16      ASPNETCORE_URLS: "http://+"
17  payment_service:
18    build: PaymentService/
19    ports:
20      - "8082:80"
21    environment:
22      ASPNETCORE_URLS: "http://+"
23  jaeger:
24    image: jaegertracing/all-in-one
25    environment:
26    - COLLECTOR_ZIPKIN_HOST_PORT=9411
27    ports:
28    - "9411:9411"
29    - "16686:16686"

此时,重头戏终于来了,Envoy 是如何连接外部跟踪系统的呢?我们可以设置 HttpConnectionManager 这个过滤器下的 tracing 字段,这里我们选择 ZipkinConfig 这个类型,因为 Jaeger 完全兼容 Zipkin,所以,我们可以直接使用这个 Provider。

 1    filter_chains:
 2    - filters:
 3      - name: envoy.filters.network.http_connection_manager
 4        typed_config:
 5          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
 6          generate_request_id: true
 7          tracing:
 8            provider:
 9              name: envoy.tracers.zipkin
10              typed_config:
11                "@type": type.googleapis.com/envoy.config.trace.v3.ZipkinConfig
12                collector_cluster: jaeger
13                collector_endpoint: "/api/v2/spans"
14                collector_endpoint_version: HTTP_JSO

基本上,只要是官方支持的 Provider,我们都可以照猫画虎接入进来,当然每一种 Provider 的配置项可能会不一样,这里我们唯一要注意的是 collector_cluster, 它表示的是指向 Jeager 服务器的一个 Cluster,这意味着我们要为它单独定义一个 Cluster :

 1  clusters:
 2  - name: jaeger
 3    type: STRICT_DNS
 4    connect_timeout: 0.25s
 5    lb_policy: ROUND_ROBIN
 6    load_assignment:
 7      cluster_name: jaeger
 8      endpoints:
 9      - lb_endpoints:
10        - endpoint:
11            address:
12              socket_address:
13                address: jaeger
14                port_value: 9411

还记得 Envoy 支撑系统内的分布式跟踪的三个支撑策略是什么吗?显然,我们可以通过 generate_request_id 字段来控制 Envoy 生成作用于 x-request-idUUID,我们希望用户从 前端 或者 cURL 中发送的请求,都能自动地带上 x-request-id 请求头,所以,我们这里将其设为 true,这意味着,从现在开始,我们的请求有了这样一个 x-request-id, 其实,如果不考虑 Jeager 的话,我们请求已经可以实现跟踪了,只要后续的请求都像我这里一样传递 x-request-id 即可。原因我们已经在前面说过,此时,这些请求没有一个上下文的概念,更不要说要理清楚其中的调用层级,所以,接下来,我们还要做一点微不足道的工作:

 1            virtual_hosts:
 2            - name: backend
 3              domains:
 4              - "*"
 5              routes:
 6              - match:
 7                  prefix: "/Payment"
 8                route:
 9                  auto_host_rewrite: true
10                  prefix_rewrite: /api/Payment
11                  cluster: payment_service
12                decorator:
13                  operation: PaymentService
14              - match:
15                  prefix: "/Order"
16                route:
17                  auto_host_rewrite: true
18                  prefix_rewrite: /api/Order
19                  cluster: order_service
20                decorator:
21                  operation: OrderService

如上所示,如果我们希望 Envoy 能记录我们的请求,那么,我们的请求必须要从它这里经过。这听起来像一句废话,可是在我调用 PaymentService已经确保我的请求是从 /Payment 这个路由上发起。默认情况下,在生成 Span 的时候,Envoy 会使用 --service-cluster 这个参数来作为 Span 的名称,这个参数通常写在 Envoy 的启动命令里,在这个示例中,它的取值是 reverse-proxy。仔细一想,会觉得哪里不太对,这样一来,所以的 Span 不就是同一个名字了吗?事实上,一开始我做实验的时候,确实是这个结果。解决方是设置一个 operation。此时,如果我们通过 Postman 访问订单接口 /Order,不出意外的话,我们会收到订单创建成功的结果,在浏览器里输入http://localhost:16686,我们来看看 Jeager 都收集到了哪些信息:

JeagerUI 数据查询
JeagerUI 数据查询

从图中我们可以非常容易地识别出 Service 和 Operation 在 Envoy 中分别对应着什么,我们注意到这里检索到了三个 Span,因为博主后来又加了一个 EchoService,从这里我们能看到它整个过程从何时开始,经过多长时间以后结束。如果我们点击它,会看到更加详细的说明,如下图所示:

JeagerUI 数据展示
JeagerUI 数据展示

显然,这个调用关系是符合我们预期的,即客户端调用了OrderServiceOrderService调用了PaymentService,对于每一次调用,我们均可以从 Span 的 Tag 中获得更多信息,文章中的第三张图,实际上就是出自这里,有了这些信息以后,我们排查或者分析微服务中的问题,是不是感觉容易了很多呢?结合 ELK,你可以知道要去找哪里的日志,而这些正是分布式跟踪的意义所在!

通过 Jaeger 收集 gRPC 请求信息
通过 Jaeger 收集 gRPC 请求信息

好了,到这里为止,关于 Envoy 在分布式跟踪上的探索,终于可以告一段落,完整的项目文件我已经放在 Github 上供大家参考,谢谢大家!

本文小结

可观测性(Logging、Metrics & Tracing) 是当下微服务中重要的一个组成部分,从 ELk 收集日志,到 Prometheus 监控指标, 再到 Jeager 跟踪调用链,我们看到了一种完全不同于单体系统中打断点、单步调试的诊断思路,这是否说明,微服务的治理永远是一个绕不过去的话题。在这篇文章里,我们简单介绍了分布式跟踪系统,比如最常见的 ZipkinJeagerSkywalkingLightStep…等等,其基本思想是生成一个 x-request-id,并在不同的服务或者应用中传递这个信息。在此基础上,我们介绍了 OpenTracing 规范,即 一个调用链(Trace),是由多个 Span 组成的有向无环图,而每个 Span 则可以含有多个键值对组成的 Tag。目前,Envoy 官方主推的是 Sidecar 模式,即每个服务分配一个 Envoy 作为代理,考虑到博主目前使用 Gateway 模式更多一点,故结合 ASP.NET Core 和 Jeager 实现了一个简单的示例,这个示例唯一的不足在于,服务或者应用必须显式地传递这些请求头,如果直接集成 SDK,效果应该会比现在好很多,可这样的话,就显得不那么云原生了,如果大家有更好的做法,欢迎在评论区留言和交流。大家可以稍微注意一下 OpenTelemetry 这个项目,如果你需要更完备的可观测性信息收集。好了,以上就是这篇博客的全部内容,晚安,世界。

Built with Hugo
Theme Stack designed by Jimmy