使用OpenTelemetry和Jaeger进行追踪

跟踪是跟踪请求通过复杂系统的一个关键组成部分。这种可见性揭示了瓶颈和错误,使更快地解决问题成为可能。在我们Go Web服务系列的上一篇文章中,我们探讨了可观测性的重要性。今天,我们将重点关注跟踪。Jaeger收集、存储和可视化来自分布式系统的跟踪信息。它为跨服务的请求流提供了关键的洞察力。通过将Jaeger与OpenTelemetry集成,开发者可以统一他们的跟踪方法,确保一致和全面的可见性。这种集成简化了性能问题的诊断并增强了系统的可靠性。在这篇文章中,我们将设置Jaeger,将其与应用程序中的OpenTelemetry集成,并探索可视化跟踪以获得更深入的见解。

动机

我们所朝着的方向是一个看起来像这样的Jaeger仪表板:

当我们浏览应用程序的不同部分(在Onehub前端)时,各种请求的跟踪信息(从接触grpc-gateway的那一刻起)被收集,其中包含了每个摘要。我们甚至可以深入到一个跟踪中以获得更详细的视图。看看第一个POST请求(用于在主题中创建/发送消息):

在这里我们看到Create请求接触的所有组件以及它们的进入/退出时间和在方法内外花费的时间。确实非常强大。

开始

TL;DR:为了亲眼看到这个效果并验证博客的其他内容:

  1. 源代码在PART11_TRACING分支中。
  2. 构建所有所需的事物(一旦你检出该分支):
make build

  1. 我们将docker-compose分成了两部分(这将在下面进一步解释),所以确保你有两个窗口在运行。
  • 终端1:make updb dblogs
  • 终端2:make up logs
  1. 导航到localhost:7080,然后开始操作。

高级概述

我们当前的系统:

使用OpenTelemetry进行 instrumentation,我们的系统将演变为:

如我们之前所提到的,每个服务都使用单独的客户端向特定供应商发送数据是相当繁重的。相反,通过单独运行OTel收集器,我们可以确保所有(感兴趣的)服务可以向此收集器发送指标/日志/跟踪,然后此收集器可以根据需要将数据导出到它们的各种后端——在这个案例中,是Jaeger用于跟踪。

让我们开始吧。

设置OTel收集器

第一步是在Docker环境中添加我们的OTel收集器以及Jaeger,以便它们可以被访问。

注意:我们将原本包含一切的docker-compose.yml配置分成了两部分:

  1. db-docker-compose.yml:包含所有数据库和基础设施(非应用程序)相关组件,如数据库(Postgres,Typesense)和监控服务(OTel收集器,Jaeger,Prometheus等)。
  2. docker-compose.yml: 包含所有与应用程序相关的服务(Nginx、gRPC Gateway、dbsync、前端等)

这两个docker-compose环境通过一个共享网络(onehubnetwork)连接,通过该网络这些环境中的服务可以相互通信。通过这种分离,我们只需要在更改时重新启动一部分服务,加快了我们的开发速度。

回到我们的设置:在我们的db-docker-compose.yml中,添加以下服务:

YAML

 

很简单,这在我们的Docker环境中设置了两个服务:

  1. otel-collector:所有被监视的各种服务发送的信号(指标/日志/跟踪)的接收端(我们将随着时间的推移不断添加到此列表中),它使用标准的OTel镜像以及我们的自定义OTel配置(下面)描述了各种可观测性管道(即信号应如何接收、处理和导出以各种方式)。
  2. jaeger:我们的Jaeger实例将接收和存储跟踪(由otel-collector导出),它同时托管存储和仪表板(UI),我们将在/jaeger HTTP路径前缀上导出以通过nginx访问。
  3. prometheus:虽然本文不需要,但我们也会导出指标以供Prometheus抓取。本文不会详细讨论此问题。

需要注意的几点:

  • 虽然本文不需要,但我们将POSTGRES连接详细信息(作为环境变量)传递给otel-collector,以便它可以抓取Postgres健康指标。
  • Jaeger (从v1.35版本开始) 支持OTLP 原生。
  • OTLP的美妙之处在于,可以链接多个OTel收集器,形成一个由收集器/处理器/转发器/路由器等组成的OTel网络。
  • OTLP可以通过GRPC或HTTP端点(分别位于端口4317和4318)提供服务。
  • 默认情况下,OTLP服务会在localhost:4317/4318上启动。如果Jaeger和被监控的服务都在同一个主机/容器上运行,这样是没问题的。然而,由于Jaeger运行在单独的容器上,它们必须绑定到外部地址(0.0.0.0)。文档中这一点并不清晰,导致了很多人的纠结。
  • COLLECTOR_OTLP_ENABLED: true现在是默认设置,不需要明确指定。

OTel 配置

OTel 也需要配置具体的接收器、处理器和导出器。我们将在configs/otel-collector.yaml中进行配置。

添加接收器

我们需要告诉OTel收集器要激活哪些接收器。这在外部receivers 部分中指定:

YAML

 

这将在端口4317和4318上分别激活OTLP接收器(分别为grpchttp)。可以启动多种接收器。作为一个例子,我们还添加了一个“postgresql”接收器,它将积极抓取Postgres的指标(尽管这对于本文并不相关)。接收器可以是基于拉取或推送。基于拉取的接收器定期抓取特定目标(例如postgres),而基于推送的接收器监听并“接收”使用OTel客户端SDK的应用程序发送的指标/日志/追踪。就这样。现在我们的收集器已准备好接收(或抓取)适当的指标。

就是这么简单。

添加处理器

在OTel中,处理器是一种在导出之前转换、映射、批量处理、筛选和/或丰富接收信号的方法。例如,处理器可以对指标进行采样,筛选日志,甚至为了效率而批量处理信号。默认情况下不添加任何处理器(使收集器成为一个透明传输)。我们将忽略这一点。

添加导出器

现在是我们确定希望信号导出到何处的时候了:最适合各自信号的后端。就像接收器一样,导出器也可以基于拉取或推送。基于推送的导出器用于将信号推送到另一个以推送模式工作的接收器。这些是出站。基于拉取的导出器暴露可以被其他基于拉取的接收器(例如,prometheus)抓取的端点。我们将添加每种类型的导出器:一个用于追踪和一个用于Prometheus抓取(尽管Prometheus不是本文的主题):

YAML

 

在这里,我们有一个导出器到Jaeger,它运行着OTLP收集器,正如otlp/jaeger所指示的那样。这个导出器将定期将追踪推送到Jaeger。我们还在端口9090上添加了一个“抓取器”端点,Prometheus将定期从中抓取。

“调试”导出器简单地用于将信号转储到标准输出/错误流。

定义管道

接收器、处理器和导出器部分 simply define 仅仅定义了将由收集器启用的模块。它们仍然没有被调用。为了实际调用/激活它们,它们必须被引用为“管道”。管道定义了信号如何通过以及被收集器处理。我们的管道定义(在services部分)将阐明此点:

YAML

 

在这里,我们定义了两个管道。注意管道多么相似但又允许两种不同的导出模式(Jaeger和Prometheus)。现在我们看到了OTel的力量以及在其中创建管道的力量。

  1. traces:

  • 从客户端SDK接收信号
  • 无需处理
  • 将跟踪数据导出到控制台和Jaeger
  1. 指标
  • 接收客户端SDK发送的信号
  • 不进行处理
  • 将指标导出到控制台和Prometheus(通过暴露一个端点供其抓取)。

通过Nginx暴露仪表板

Jaeger提供了一个仪表板,用于可视化有关我们所有请求的跟踪数据。这可以在浏览器中启用以下功能在我们的Nginx配置中进行可视化。同样,虽然这不是本文的主题,但我们还通过nginx在HTTP路径前缀/prometheus上暴露了Prometheus UI。

YAML

 

在Jaeger中可视化跟踪

Jaeger UI相当全面,您可以探索许多功能。在浏览器中导航到Jaeger UI。您将看到一个用于搜索和分析跟踪的全面界面。熟悉主要部分,包括搜索栏和跟踪列表。您可以根据搜索条件搜索各种跟踪,并按服务、时间持续时间、组件等过滤。

分析不同请求中的跟踪时间线以了解操作顺序。每个跨度代表一项工作单元,显示开始和结束时间、持续时间以及相关元数据。这种详细视图在识别跟踪中的性能瓶颈和错误方面非常有帮助。

集成客户端SDK

到目前为止,我们已经设置了我们的系统来可视化、消费信号等。然而,我们的服务仍然还没有更新以向OTel发送信号。在这里,我们将整合各种代码部分的(Golang)客户端SDK。SDK文档是熟悉一些概念的绝佳地方。

我们将处理的关键概念描述如下。

资源

资源是产生信号的实体。在我们这个案例中,资源的作用范围是托管服务的二进制文件。目前,我们整个Onehub服务的范围只有一个资源,但稍后可以进一步细分。

这定义在cmd/backend/obs.go中。请注意,客户端SDK并没有让我们明确进入资源定义的细节。标准助手(sdktrace.WithResource)让我们通过推断最有用的部分(如进程名称、容器名称等)来实时创建资源定义。

我们只需要覆盖一件事:在docker-compose.yml中的onehub服务中设置OTEL_RESOURCE_ATTRIBUTES: service.name=onehub.backend,service.version=0.0.1环境变量。

上下文传播

上下文传播是可观测性中的一个非常重要的主题。当我们能够将各个支柱的信号相关联以识别系统问题时,这些支柱的力量会呈指数级增长。将上下文视为可以与信号关联的额外数据:即,可以以某种独特方式“连接”以将各种信号关联到特定组(例如一个请求)。

提供者/导出者

对于每个信号,OTel 提供了提供一个接口(例如,TracerProvider用于导出跨度/跟踪,MeterProvider用于导出指标,LoggerProvider用于导出日志等等)。对于这些接口,可以有多个实现,例如,用于发送到标准输出/错误流的调试提供者,导出到另一个OTel端点的OTel提供者(在链中),甚至直接通过多种导出器。然而,在我们的情况下,我们想要推迟选择任何供应商,而是将所有信号发送到我们环境中运行的OTel收集器。

为了抽象这一点,我们将创建一个“OTELSetup”类型,以跟踪我们可能想要使用或更换的各种提供者。在cmd/backend/obs.go中,我们有:

Go

 

这是一个简单的包装器,用于跟踪OTel SDK所需的一些常见方面。在这里,我们有了提供者(Logger、Tracer和Metric)以及提供上下文(用于追踪)的方法。所有提供者使用的顶级资源也在这里指定。关闭函数很有趣。当底层导出程序终止(无论是优雅地还是由于退出)时,由提供者调用这些函数。包装器本身采用泛型,因此特定的实例化器可以使用自己的自定义数据。

该仓库包含这种实现的两个示例:

我们将在应用程序中实例化第二个。我们不会深入了解具体实现的细节,因为它们是从SDK中的示例中提取的,并进行了一些小的修复和重构。具体来说,查看otel-collector示例以获取灵感。

初始化OTel提供者。

启用的核心在于,在我们服务中,所有“入口”点都会启动一种与OTel相关的“上下文”。如果在此处创建上下文,则它会发送给这里调用的所有目标,然后后续传播(只要我们做对了事情)。

以简单的ListTopics API调用(api/vi/topics)为例,我们的请求沿着以下路径往返:

[ Browser ] ---> [ Nginx ] ---> [ gRPC Gateway ] ---> [ gRPC Service ] ---> [ Database ]

在我们这个案例中,入口点位于gRPC Gateway接收到来自Nginx的API请求的开始时(我们本可以从HTTP请求到达Nginx的那一刻开始追踪,以突出Nginx的延迟,但我们稍后再推迟)。

所需的是:

  • gRPC Gateway接收到请求。
  • 它创建了一个“自定义”OTel特定的context.Context实例。
  • 创建一个到相应gRPC服务的自定义连接(例如,TopicService),传递这个上下文而不是默认的上下文。
  • 相应的服务在发出跟踪时使用此上下文。

让我们一步一步地来看这个过程。

初始化并准备OTel SDK使用

main.go中,首先初始化收集器连接:

Go

 

  • 第8-16行:我们创建了一个与运行在Docker环境中的otel-collector的连接。
  • 第17-21行:随后,我们设置了OTel跟踪和指标提供程序,以便我们的收集器可以现在推送所有跟踪和指标(记得我们之前在OTel配置中定义了接收器)。
  • 第23-25行:设置最终清理器,在关闭时清理OTel连接/提供程序等。
  • 第27行:像以前一样设置我们的数据库和连接。
  • 第29行及以后:以前我们只是将GRPC和网关服务放在后台运行,就这样。我们并没有太在意它们的返回或退出状态。对于一个更有弹性的系统,深入了解我们启动的服务生命周期非常重要。所以现在我们要为每个启动的服务传递“回调”通道。当服务器退出时,各自的方法将在这些通道上回调,表示它们已优雅地退出。我们的整个二进制文件将在这些服务中的任何一个退出时退出。

例如,让我们看看我们的网关服务如何利用这个通道。

而不是像这样启动HTTP服务器(对于grpc-gateway):

1    http.ListenAndServe(gw_addr, mux)

现在我们这样做:

Go

 

注意第9-14行中,服务器关闭是在一个单独的goroutine中监视的,第15行中,如果服务器退出时出现错误,它将通过作为参数传递给这个方法的“通知”通道发送回去。

现在我们服务的各个部分都可以访问一个“活动”的OTLP连接,以便在需要发送信号时使用。

OTel gRPC网关中间件

在上面的示例中,用于启动gRPC网关的http.Server实例使用了一个自定义处理器:OTel HTTP包中的http.Handler。这个处理器接收一个现有的http.Handler实例,用OTel上下文对其进行装饰,并确保将其传播给任何调用的下游服务。

Go

 

我们的HTTP处理器很简单:

  • 第4行:我们创建一个新的OTel特定包装器来处理HTTP请求。
  • 第5行:我们设置了SpanFormatter选项,以便通过方法和HTTP请求路径唯一标识追踪。如果没有这个SpanNameFormatter,网关中的追踪默认“名称”仅仅是"gateway",导致所有追踪看起来都像这样:

使用OTel包装网关以调用gRPC

默认情况下,gRPC网关库在创建/管理到底层GRPC服务的连接时会创建一个“普通”的上下文。毕竟,网关不知道OTel。在这种模式下,用户/浏览器到gRPC网关的连接和网关到gRPC服务的连接将被视为两条不同的追踪。

因此,将创建gRPC连接的责任从网关中移除,并提供一个已经意识到OTel的连接是非常重要的。我们现在就来做这件事。

在OTel集成之前,我们使用以下方式为gRPC注册网关处理程序:

Go

 

现在传递不同的连接变得很简单:

Go

 

我们所做的是首先创建了一个client(第6行),它充当我们gRPC服务器的连接工厂。这个客户端相当简单。只使用了gRPC的ClientHandlerotelgrpc.NewClientHandler)来创建连接。这确保了当前跟踪中在新HTTP请求上开始的上下文现在通过这个处理程序传递到gRPC服务器。

就是这样。现在我们应该开始看到网关的新请求以及网关到gRPC请求在单个合并的跟踪中,而不是作为两个不同的跟踪。

开始和结束跨度

我们快做到了。到目前为止:

  • 我们已启用OTel收集器和Jaeger以接收和存储跟踪(跨度)数据(在docker-compose中)。
  • 我们设置了基本的OTel收集器(作为单独的pods运行)作为我们的跟踪器、指标和日志的“提供者”(即,我们应用程序的OTel集成将使用此端点来投放所有信号)。
  • 我们将网关的HTTP处理程序包装成OTel启用,以便创建和传播跟踪及其上下文。
  • 我们重写了网关中的(gRPC)客户端,使其现在使用我们的OTel设置传递OTel上下文,而不是使用默认上下文。
  • 我们创建了全局跟踪器/计量器/日志实例,这样我们就可以使用它们发送实际信号。

现在我们需要为代码中的所有“有趣”的地方生成跨度。以ListTopics方法为例(在services/topics.go中):

Go

 

我们调用数据库来获取主题并返回它们。与数据库访问方法(在datastore/topicds.go中)类似:

Go

 

这里我们主要关心的是在这些方法中每种方法花费了多少时间。我们只需在这些方法中创建跨度,就大功告成了。我们对服务和数据存储方法所做的更改(分别)如下:

  • services/topics.go:
Go

 

  • datastore/topicds.go:
Go

 

通用模式是:

1. 创建跨度

ctx, span := Tracer.Start(ctx, "<span name>")

在这里,给定的上下文(ctx)被“包裹”,并返回一个新的上下文。我们可以(并且应该)将这个新的包裹上下文传递给进一步的方法。当我们调用数据存储ListTopics方法时,就是这么做的。

2. 结束跨度

defer span.End()

结束跨度(无论方法何时返回)确保记录了正确的完成时间/代码等。如果需要,我们还可以为此添加其他事情,如添加标签和状态,以提供更多信息以帮助调试。

就是这样。您可以在Jaeger中看到您美丽的跟踪,并越来越多地了解请求的端到端性能!

结论

我们在本文中涵盖了OTel和跟踪背后的许多内容,但仍然只是略微触及了表面。为了不过载这篇文章(它已经很详细了),我们将在未来的文章中介绍新的概念和复杂的细节。现在,您可以在自己的服务中尝试一下,并尝试在otel-contrib存储库中使用其他导出器和接收器进行实验。

Source:
https://dzone.com/articles/tracing-with-opentelemetry-and-jaeger