-NET-现代分布式跟踪指南-全-

.NET 现代分布式跟踪指南(全)

原文:zh.annas-archive.org/md5/1f9298b049d28b75ab8a4ac0f34f1921

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

如果你曾参与过分布式应用、基础设施或客户端库的开发,你很可能遇到过许多分布式系统可能崩溃的方式。

例如,你服务中的默认重试策略可能会使其及其所有依赖项崩溃。在特定负载下,竞争条件可能导致死锁,或者在用户账户之间导致数据泄露。通常只需毫秒的用户操作可能会显著减慢,而服务仪表板却没有任何其他问题的迹象。功能性问题可能会在用户端产生模糊和难以解释的影响。

当与分布式应用一起工作时,我们依赖遥测来评估其性能和功能。我们需要更多的遥测来识别和缓解问题。

在过去,我们依赖于使用供应商特定 SDK 收集的自定义日志和指标。我们构建了自定义解析器、处理管道和报告工具,以便使遥测数据可用。

然而,随着应用的日益复杂,我们需要更好的、更用户友好的方法来理解我们系统中正在发生的事情。我个人认为,阅读数兆字节的日志或视觉检测指标中的异常是不切实际的。

分布式跟踪是一种允许我们在整个系统中跟踪操作的技术。它为我们提供的遥测数据提供了关联性和因果关系,使我们能够检索描述特定操作的 所有相关数据,或者根据上下文(如请求的资源或用户标识符)找到所有操作。

仅分布式跟踪是不够的;我们需要其他遥测信号,如指标、事件、日志和配置文件,以及收集和将它们导出到可观察性后端库。幸运的是,我们有 OpenTelemetry 来完成这项任务。OpenTelemetry 是一个多云原生、供应商中立的遥测平台,支持多种编程语言。它提供了收集自定义数据所需的核心组件,以及针对常见技术的仪器库。OpenTelemetry 为不同的信号标准化遥测格式,确保收集数据的关联性、一致性和结构。

通过利用一致和结构化的遥测数据,不同的可观察性供应商可以提供诸如服务图、跟踪可视化、错误分类以及检测导致故障的常见属性等工具。这本质上使我们能够自动化性能分析中人类难以处理的易出错和繁琐的部分。监控和调试技术现在可以成为整个行业的标准化实践,不再依赖于部落知识、运行手册或过时的文档。

.NET 中的现代分布式跟踪探讨了.NET 应用程序中遥测收集的所有方面,重点关注分布式跟踪和性能分析。它从可观测性挑战和解决方案的概述开始,然后深入探讨现代.NET 应用程序提供的内置监控功能。当与 OpenTelemetry 一起使用时,这些功能变得更加引人注目。虽然共享的 OpenTelemetry 配置库可以带我们走很长的路,但有时我们仍然需要编写自定义配置。本书展示了如何在考虑性能影响和冗余的情况下收集自定义跟踪、指标和日志。它还涵盖了常见云模式(如网络调用、消息传递和数据库交互)的配置。最后,它讨论了在现有系统中实施和演进可观测性的组织和技术方面。

可观测性领域仍然相对较新且发展迅速,这意味着几乎任何问题都有多种解决方案。本书旨在解释基本可观测性概念,并提供几种可能的解决方案来解决常见问题,同时突出相关的权衡。它还帮助您获得实际技能,以实现和利用跟踪和可观测性。

我希望您觉得提供的示例有用,并将它们用作实验的游乐场。我鼓励您探索新的和创造性的方法来使分布式系统更具可观测性,并将您的发现与社区分享!

本书面向对象

本书面向使用现代可观测性工具和标准的.NET 服务的软件开发人员、架构师和系统操作员。它提供了一种全面的方法来分析性能和端到端调试。软件测试人员和支持工程师也会发现这本书很有用。假设读者对 C#编程语言和.NET 平台有基本了解,以便理解手动配置的示例,但这不是必需的。

本书涵盖内容

第一章现代应用程序的可观测性需求,概述了常见的监控技术并介绍了分布式跟踪。它涵盖了 OpenTelemetry——一个供应商无关的遥测平台,并展示了它是如何通过关联的遥测信号解决分布式应用程序的可观测性挑战的。

第二章.NET 中的本地监控,概述了.NET 提供的开箱即用的诊断功能。这些功能包括结构化和关联的日志和计数器,以及使用 dotnet-monitor 工具的临时监控。我们还将使用 OpenTelemetry 对第一个应用程序进行配置,并亲身体验分布式跟踪。

第三章, .NET 可观察性生态系统,探讨了更广泛的跟踪仪器化和环境。我们将学习如何查找和评估仪器库,从 Dapr 等基础设施获取跟踪,并最终以 AWS Lambda 和 Azure Functions 为例,展示如何对无服务器应用程序进行仪器化。

第四章, 使用诊断工具进行低级性能分析,介绍了更低级别的.NET 诊断和性能分析。我们将了解如何收集和分析运行时计数器和性能跟踪,以便在分布式跟踪提供不足的情况下,在进程内获得更多的可观察性。

第五章, 配置和控制平面,概述了 OpenTelemetry 的配置和定制。我们将探索不同的采样策略,并学习如何丰富和过滤跨度或自定义指标收集。最后,我们将介绍 OpenTelemetry Collector——一个可以处理许多遥测后处理任务的代理。

第六章, 跟踪您的代码,深入探讨了使用.NET 跟踪 API 或 OpenTelemetry shim 的跟踪仪器化。在这里,我们将了解用于收集跨度、展示如何在进程内利用环境上下文传播以及记录事件和异常的ActivityActivitySource类。我们还将涵盖对仪器化代码的集成测试。

第七章, 添加自定义指标,深入探讨了现代.NET 指标 API。您将了解可用的仪器——计数器、仪表和直方图,它们用于以不同的方式聚合测量值,并获得实际操作经验,以实现和使用指标来监控系统健康或调查性能问题。

第八章, 编写结构和关联日志,概述了.NET 中的日志记录,重点关注Microsoft.Extension.Logging。我们将学习如何高效地编写结构和可查询的日志,并使用 OpenTelemetry 收集它们。我们还将探讨如何使用 OpenTelemetry Collector 来管理日志成本。

第九章, 最佳实践,提供了根据应用需求和场景选择最合适的遥测信号的建议,并展示了如何以最小的可观察性影响来控制遥测成本。它还介绍了 OpenTelemetry 语义约定——针对常见模式和技术的遥测收集配方。

第十章, 跟踪网络调用,以 gRPC 为例探讨了网络调用仪器。我们将学习如何根据 RPC 语义约定对简单的请求-响应调用进行仪器化,并传播上下文。我们还将涵盖在仪器化流式调用时的挑战和可能的解决方案。

第十一章仪表化消息场景,探讨了异步处理场景的仪表化。我们将学习如何端到端跟踪消息,仪表化批处理场景,并引入特定于消息的指标,以便检测扩展和性能问题。

第十二章仪表化数据库调用,探讨了使用跟踪和指标进行数据库和缓存仪表化。我们还将介绍将外部指标从 Redis 实例转发到我们的可观测性后端,并使用收集到的遥测数据进行性能分析和缓存策略优化。

第十三章推动变革,涵盖了与可观测性改进相关的组织和规划方面。我们将讨论低可观测性的成本,并提出几种衡量方法。我们将制定一个入职计划,讨论常见陷阱,并了解如何从更好的可观测性中受益于日常开发任务。

第十四章创建您的约定,提供了从统一的 OpenTelemetry 配置开始,在整个系统中一致收集遥测数据的建议。我们还将学习如何定义自定义语义约定并在共享代码中实现它们,使其易于遵循。

第十五章对遗留应用程序进行仪表化,讨论了在存在遗留服务的情况下对系统新部分进行仪表化的挑战。我们将提出可以最小化对遗留组件更改的解决方案,并学习利用遗留相关性传播格式,实现最小化传递上下文传播,并将遥测数据从遗留服务转发到新的后端。

为了充分利用这本书

本书中的示例是用.NET 7.0 开发的。大多数示例都是跨平台的,可以在 Docker Linux 容器中或使用dotnet CLI 工具运行。示例在 Windows 操作系统上使用 OpenTelemetry 版本 1.4.0 进行了测试。它们应该与.NET 和 OpenTelemetry 库的未来版本兼容。我们在docker-compose文件中使用 OpenTelemetry Collector、Prometheus、Jaeger 和其他外部图像的固定版本。

第三章《.NET 可观测性生态系统》中,我们将尝试使用 AWS 和 Azure 客户端库和无服务器环境。建议拥有 AWS 和/或 Azure 订阅,但并非必需。我们将在 AWS 的免费层内和 Azure 的促销信用额度内进行操作。

本书中涵盖的软件/硬件 操作系统要求
.NET SDK 7.0 Windows、macOS 或 Linux
OpenTelemetry for .NET 版本 1.4.0 Windows、macOS 或 Linux
Docker 和docker-compose工具 Windows、macOS 或 Linux
.NET Framework 4.6.2(在第十五章中用于一个遗留系统的示例) Windows
PerfView 工具(在第四章中) Windows,有可用的跨平台替代方案

虽然 OpenTelemetry 保证未来版本中的 API 兼容性,但本书中提到的语义约定并不稳定。因此,跨度、指标、事件和属性可能会被重命名或以不同的方式更改。请参考 OpenTelemetry 规范仓库(github.com/open-telemetry/opentelemetry-specification)以了解语义约定领域的最新动态。

如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。 ****

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET。如果代码有更新,它将在 GitHub 仓库中更新。

我们还提供了来自我们丰富的书籍和视频目录中的其他代码包,可在github.com/PacktPublishing/找到。查看它们吧!

代码在行动

本书“代码在行动”视频可在packt.link/O10rj查看。

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/BBBNm

使用的约定

本书中使用了多种文本约定。

Code in text:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。以下是一个示例:“另一种选择是将 W3C Trace Context 格式的traceparent值作为字符串传递给StartActivity方法。”

代码块设置如下:

using var activity = Source.StartActivity("DoWork");
try
{
  await DoWorkImpl(workItemId);
}
catch
{
  activity?.SetStatus(ActivityStatusCode.Error);
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

using var provider = Sdk.CreateTracerProviderBuilder()
  .ConfigureResource(b => b.AddService("sample"))
  .AddSource("Worker")
  .AddJaegerExporter()

任何命令行输入或输出都按以下方式编写:

$ docker-compose up --build
$ dotnet run

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“让我们用 PerfView 打开跟踪文件,然后单击线程 时间选项。”

小贴士或重要提示

看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packt.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

读完Modern Distributed Tracing in .NET后,我们非常乐意听到您的想法!扫描下面的二维码直接进入这本书的亚马逊评论页面并分享您的反馈。

分享您的想法

https://packt.link/r/1-837-63613-3

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载本书的免费 PDF 副本

感谢您购买此书!

您喜欢随时随地阅读,但无法携带您的印刷书籍吗?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的邮箱访问权限。

按照以下简单步骤获取福利:

  1. 扫描下面的二维码或访问以下链接。

下载本书的免费 PDF 副本

packt.link/free-ebook/9781837636136

  1. 提交您的购买证明

  2. 就这些!我们将直接将免费 PDF 和其他福利发送到您的邮箱。

第一部分:介绍分布式追踪

在这部分,我们将介绍分布式追踪的核心概念,并展示它是如何使运行云应用变得更容易的。我们将自动为我们的第一个服务进行配置,并探索围绕 OpenTelemetry 构建的.NET 可观察性方法。

本部分包含以下章节:

  • 第一章现代应用的可观察性需求

  • 第二章.NET 中的原生监控

  • 第三章.NET 可观察性生态系统

  • 第四章使用诊断工具进行低级性能分析

第一章:现代应用程序的可观察性需求

随着分布式系统复杂性的增加,我们需要更好的工具来构建和运行我们的应用程序。分布式跟踪就是这样一种技术,它允许您以最小的努力收集结构化和关联的遥测数据,并使可观察性供应商能够构建强大的分析和自动化工具。

在本章中,我们将探讨常见的可观察性挑战,并了解分布式跟踪如何将可观察性带到我们的系统中,而日志和计数器无法做到这一点。我们将看到关联和因果关系,以及结构化和一致的遥测数据如何帮助回答关于系统的任意问题,并更快地缓解问题。

这是你将学到的内容:

  • 使用计数器、日志和事件概述监控技术

  • 分布式跟踪的核心概念——跨度及其结构

  • 上下文传播标准

  • 如何生成有意义且一致的遥测数据

  • 如何使用分布式跟踪、指标和日志进行性能分析和调试

到本章结束时,你将熟悉分布式跟踪的核心概念和构建块,你将能够将其与其他遥测信号一起使用,以调试功能问题和调查分布式应用程序中的性能问题。

理解为什么日志和计数器不足以满足需求

监控和可观察性文化在行业中各不相同;一些团队使用printf进行临时调试,而其他团队则采用复杂的可观察性解决方案和自动化。然而,几乎每个系统都使用常见的遥测信号组合:日志、事件、指标或计数器,以及配置文件。仅遥测收集是不够的。如果我们能够检测和调查问题,则系统是可观察的,为了实现这一点,我们需要工具来存储、索引、可视化和查询遥测数据,跨不同信号导航,并自动化重复分析。

在我们开始探索跟踪并了解它如何帮助之前,让我们谈谈其他遥测信号及其局限性。

日志

日志是某些事件的记录。日志通常包含时间戳、级别、类名和格式化消息,并且可能还包含带有额外上下文的属性包。

日志是一种低仪式的工具,任何生态系统都有大量的日志库和工具。

日志中常见的问题包括以下内容:

  • 冗余: 初始阶段,我们不会有足够的日志,但最终,随着我们填补空白,我们将拥有过多的日志。它们变得难以阅读且存储成本高昂。

  • 性能: 即使使用得当,日志记录也是一个常见性能问题。在日志级别被禁用时,序列化对象或为日志分配字符串也是非常常见的。

一条新的日志语句可能会让你的生产环境崩溃;我有过一次这样的经历。我添加的日志每毫秒都会写一次。乘以服务实例的数量,它创建了一个足够大的 I/O 瓶颈,足以显著增加延迟和用户的错误率

  • 不可查询:来自应用程序的日志是供人类使用的。我们可以在应用程序内部添加上下文并统一格式,但仍只能通过上下文属性来过滤日志。日志会随着每次重构而改变,消失或过时。新加入团队的人需要学习特定于系统的日志语义,学习曲线可能很陡峭。

  • 无关联:不同操作的日志是交织在一起的。寻找描述某些操作的日志的过程称为关联。一般来说,日志关联,尤其是在服务之间,必须手动实现(剧透:不是在 ASP.NET Core 中)。

注意

日志易于生成,但很冗长,可能会显著影响性能。它们也难以过滤、查询或可视化。

为了便于访问和有用,日志被发送到某个中心位置,即日志管理系统,它存储、解析和索引它们,以便可以查询。这意味着你的日志至少需要有一些结构。

.NET 中的ILogger`支持结构化日志记录,正如我们将在第八章,“编写结构化和关联日志”中看到的,因此你得到的是可读性强的消息,以及上下文。结构化日志记录,结合结构化存储和索引,将你的日志转换为丰富的事件,你可以用于几乎任何事情。

事件

事件是某事的记录。它有一个时间戳和属性包。它可能有一个名称,或者这可能是属性之一。

日志和事件之间的区别是语义上的——事件是有结构的,通常遵循特定的模式。

例如,描述向购物袋添加商品的日志应该有一个众所周知的名字,例如shopping_bag_add_item,并具有user-iditem-id属性。然后,你可以通过名称、项目和用户来查询它们。例如,你可以找到所有用户中最受欢迎的前 10 个商品。

如果你将其作为日志消息编写,你可能会写一些像这样的事情:

logger.LogInformation("Added '{item-id}' to shopping bag
  for '{user-id}'", itemId, userId)

如果你的日志提供程序捕获单个属性,你将获得与事件相同的环境。因此,现在我们可以找到关于这个用户和商品的每一条日志,这很可能包括与添加商品无关的其他日志。

注意

具有一致模式的日志可以有效地查询,但与日志一样存在冗长和性能问题。

指标和计数器

日志和事件存在相同的问题——冗长和性能开销。解决这些问题的方法之一是聚合。

指标是某物通过维度和时间聚合的值。例如,请求延迟指标可以包含 HTTP 路由、状态码、方法、服务名称和实例维度。

指标常见的难题包括以下:

  • 基数:每个维度的组合都是一个时间序列,聚合发生在单个时间序列内。添加一个新维度会导致组合爆炸,因此指标必须具有低基数——也就是说,它们不能有太多的维度,并且每个维度必须具有少量不同的值。因此,你不能使用指标来衡量像每个用户的体验这样的细粒度事物。

  • 无因果关系:指标仅显示相关性,没有因果关系,因此它们不是调查问题的优秀工具。

作为你系统的专家,你可能使用你的直觉来找出某些类型行为可能的原因,然后使用指标来证实你的假设。

  • queue_is_fullqueue_is_empty。像 queue_utilization 这样的东西会更通用。随着时间的推移,指标的数量会随着警报、仪表板和依赖它们的团队流程数量的增加而增长。

注意

指标对性能的影响很小,体积不大,不会随着规模的增长而大幅增长,存储成本低,查询时间短。它们非常适合仪表板和警报,但不适合问题调查或细粒度分析。

计数器是一个单一的时间序列——它是一个没有维度的指标,通常用于收集资源利用率,如 CPU 负载或内存使用。计数器不适合应用程序性能或使用,因为你需要为每个属性组合(如 HTTP 路由、状态码和方法)设置一个专门的计数器。收集它们很困难,使用起来更难。幸运的是,.NET 支持具有维度的指标,我们将在第七章,“添加自定义指标”中讨论它们。

缺少什么?

现在你已经知道你需要监控单体或小型分布式系统所需的一切——使用指标进行系统健康分析和警报,事件用于使用情况,日志用于调试。这种方法已经将科技行业推进很远,而且其中并没有本质上的错误。

通过最新的文档、几个关键的性能和用量指标、简洁、结构化、相关和一致的事件、跨所有服务的通用约定和工具,任何操作你的系统的人都可以进行性能分析和调试问题。

注意

因此,最终目标是高效地运营系统,问题不在于特定的遥测信号或其局限性,而在于缺乏标准解决方案、实践、相关性以及现有信号的结构。

在我们深入分布式跟踪并了解其生态系统如何解决这些差距之前,让我们总结一下我们对于完美可观察性解决方案的新要求,我们打算通过跟踪来解决这些问题,以及它带来的新功能。同时,我们也应该记住旧的功能——低性能开销和可管理的成本。

系统性调试

我们需要能够以通用方式调查问题。从错误报告到指标上的警报,我们应该能够深入问题,从头到尾跟踪特定的请求,或者从堆栈深处的错误向上冒泡,以了解其对用户的影响。

所有这些都应该在你被叫醒并在凌晨 2 点被叫去解决生产中的事件时相对容易完成。

回答即兴问题

我可能想知道来自华盛顿州雷德蒙德的用户是否在购买我的网站上的产品时经历了比平时更长的配送时间,以及原因——是因为运输公司、雨水、该地区的云服务提供商问题,或其他任何原因。

不需要添加更多遥测来回答大多数使用或性能问题。偶尔,你可能需要添加一个新的上下文属性或事件,但在稳定的代码路径上这应该是罕见的。

自文档化系统

现代系统是动态的——随着持续部署、运行时功能标志的变化以及数十个具有自己不稳定性的外部依赖项,没有人能知道一切。

遥测成为你唯一的真相来源。假设它有足够的上下文和通用语义,一个可观测性供应商应该能够合理地可视化它。

自动仪器化

在你的系统中为所有事物进行仪器化是困难的——它是重复的、易出错的,并且难以保持最新、测试和强制执行通用的模式和语义。我们需要为常见的库共享仪器化,而我们会仅添加特定于应用程序的遥测和上下文。

理解了这些要求后,我们将继续讨论分布式跟踪。

介绍分布式跟踪

分布式跟踪是一种将结构、关联和因果关系引入收集遥测的技术。它定义了一个特殊的事件称为跨度,并指定了跨度之间的因果关系。跨度遵循用于可视化和分析跟踪的通用约定。

跨度

跨度描述了一个操作,如入站或出站 HTTP 请求、数据库调用、昂贵的 I/O 调用或任何其他有趣的调用。它具有足够的结构来表示任何事物,同时仍然有用。以下是跨度最重要的属性:

  • 跨度的名称应以人类可读的格式描述操作类型,具有低基数,并且易于人类阅读。

  • 跨度的开始时间和持续时间。

  • 状态表示成功、失败或无状态。

  • 跨度类型区分客户端、服务器和内部调用,或在异步场景中的生产者和消费者。

  • 属性(也称为标签或注释)描述特定的操作。

  • 跨度上下文标识跨度并在各处传播,以实现关联。父跨度标识符也包含在子跨度中,以表示因果关系。

  • 事件提供了关于跨度内操作的其他信息。

  • 当父子关系不起作用时,链接将连接跟踪和跨度——例如,在批处理场景中。

注意

在 .NET 中,追踪跨度由 System.Diagnostics.Activity 表示。System.Span 类与分布式追踪无关。

跨度之间的关系

跨度是一个追踪的单位,为了追踪更复杂的操作,我们需要多个跨度。

例如,用户可能尝试获取一个图像并向服务发送请求。图像未缓存,服务从冷存储中请求它(如图 图 1.1 所示):

图 1.1 – GET 图像请求流程

图 1.1 – GET 图像请求流程

为了使此操作可调试,我们应该报告多个跨度:

  1. 传入的请求

  2. 从缓存中获取图像的尝试

  3. 从冷存储中检索图像

  4. 缓存图像

这些跨度形成一个 trace-id。在追踪中,每个跨度由 span-id 标识。跨度包括指向父级跨度的指针——它只是父级的 span-id

trace-idspan-idparent-span-id 允许我们不仅关联跨度,而且记录它们之间的关系。例如,在 图 1.2 中,我们可以看到 Redis GETSETEXHTTP GET 跨度是兄弟,而传入的请求是它们的父级:

图 1.2 – 展示跨度之间关系的追踪可视化

图 1.2 – 展示跨度之间关系的追踪可视化

跨度可以具有更复杂的关系,我们将在后面的 第六章 追踪您的代码 中讨论。

trace-idspan-id) 使得跨信号场景更加有趣。例如,您可以在日志上标记父级跨度上下文(剧透:只需配置 ILogger 即可)并将日志与追踪相关联。例如,如果您使用 ConsoleProvider,您将看到如下内容:

图 1.3 – 日志包括跨度上下文,可以与其他信号相关联

图 1.3 – 日志包括跨度上下文,可以与其他信号相关联

您还可以使用示例将度量与追踪相关联——包含对记录测量有贡献的操作的追踪上下文的度量元数据。例如,您可以检查与您延迟分布长尾对应的跨度示例。

属性

跨度属性是一个包含关于操作详细信息的属性包。

跨度属性应该足够详细地描述这个特定的操作,以便理解发生了什么。OpenTelemetry 语义约定指定了用于帮助此目的的属性,我们将在本章后面的 确保一致性和结构 部分讨论。

例如,一个传入的 HTTP 请求至少由以下属性标识:HTTP 方法、路径、查询、API 路由和状态码:

图 1.4 – HTTP 服务器跨度属性

图 1.4 – HTTP 服务器跨度属性

仪器化点

因此,我们已经定义了跨度及其属性,但我们应该在何时创建跨度?我们应该在它们上放置哪些属性?虽然没有严格的遵循标准,但以下是一些经验法则:

为每个传入和传出的网络调用创建一个新的跨度,并在可用时使用协议或技术的标准属性。

这就是我们之前在 memes 示例中做过的事情,它使我们能够看到在服务边界上发生了什么,并检测到常见问题:依赖性问题、状态、延迟以及每个服务的错误。这也使我们能够关联日志、事件以及我们收集的任何其他内容。此外,可观察性后端了解 HTTP 语义,并将知道如何解释和可视化您的跨度。

这条规则有一些例外,例如套接字调用,其中请求可能太小而无法进行仪器化。在其他情况下,您可能仍然会合理地关注冗长性和生成数据的量——我们将在 第五章 配置和控制平面 中看到如何通过采样来控制它。

跟踪 - 构建块

现在您已经熟悉了跟踪的核心概念及其方法论,让我们来谈谈实现。我们需要一组方便的 API 来创建和丰富跨度,并在周围传递上下文。历史上,每个 应用性能监控APM)工具都有自己的 SDK,用于使用它们自己的 API 收集遥测数据。更换 APM 供应商意味着重写所有的仪器代码。

OpenTelemetry 解决了这个问题——它是一个跨语言的遥测平台,用于跟踪、指标、事件和日志,统一了遥测收集。大多数 APM 工具、日志管理和可观察性后端都支持 OpenTelemetry,因此您可以在不重写任何仪器代码的情况下更换供应商。

.NET 跟踪实现符合 OpenTelemetry API 规范,在这本书中,.NET 跟踪 API 和 OpenTelemetry API 是可以互换使用的。我们将在 第六章 跟踪您的代码 中讨论它们之间的区别。

尽管 OpenTelemetry 基本原语已嵌入 .NET 中,且仪器代码不依赖于它们,但为了从应用程序中收集遥测数据,我们仍然需要添加 OpenTelemetry SDK,它包含我们配置收集和导出器所需的一切。您也可以编写自己的与 .NET 跟踪 API 兼容的解决方案。

OpenTelemetry 成为了跟踪及其他领域的行业标准;它支持多种语言,除了提供统一的 API 集合外,它还提供了可配置的 SDK 和遥测的标准线格式——OpenTelemetry 协议OTLP)。您可以通过添加特定的导出器或将后端配置为支持 OTLP,将遥测数据发送到任何兼容的供应商。

图 1**.5 所示,应用程序配置 OpenTelemetry SDK 以将遥测数据导出到可观察性后端。应用程序代码、.NET 库和各种仪器化使用 .NET 跟踪 API 创建跨度,OpenTelemetry SDK 会监听、处理并将它们转发到导出器。

图 1.5 – 跟踪构建块

图 1.5 – 跟踪构建块

因此,OpenTelemetry 将仪器化代码与可观察性供应商解耦,但它做的远不止这些。现在,不同的应用程序可以共享仪器化库,可观察性供应商可以在其之上构建丰富的体验。

仪器化

从历史上看,所有 APM 供应商都必须对流行的库进行仪器化:HTTP 客户端、Web 框架、Entity Framework、SQL 客户端、Redis 客户端库、RabbitMQ、云提供商的 SDK 等。这并不容易扩展。但有了 .NET 跟踪 API 和 OpenTelemetry 语义,仪器化对所有供应商都变得普遍。你可以在 OpenTelemetry Contrib 仓库中找到一个不断增长的共享社区仪器化列表:github.com/open-telemetry/opentelemetry-dotnet-contrib

此外,由于 OpenTelemetry 是一个供应商中立的标准化方案,并且已经集成到 .NET 中,现在库可以实现原生仪器化——HTTP 和 gRPC 客户端、ASP.NET Core 以及其他几个库都支持它。

即使有原生跟踪支持,默认情况下也是关闭的——你需要安装和注册特定的仪器(我们将在 第二章 中介绍,Native Monitoring in .NET)。否则,跟踪代码将不起作用,因此不会增加任何性能开销。

后端

可观察性后端(也称为监控、APM 工具和日志管理系统)是一组负责摄取、存储、索引、可视化、查询以及可能帮助您监控系统、调查问题和分析性能的其他工具。

可观察性供应商构建这些工具,并提供丰富的用户体验,帮助你使用跟踪以及其他信号。

使用 OpenTelemetry 生态系统收集常见库的跟踪变得容易。正如你在 第二章 中将看到的,Native Monitoring in .NET,大部分工作都可以在启动时通过几行代码自动完成。但我们是怎样使用它们的呢?

当你可以将跨度发送到 stdout 并将它们存储在文件系统中时,这并不会充分利用所有跟踪的优势。跟踪数据可能非常大,即使它们很小,使用 grep 来搜索它们也不方便。

跟踪可视化(如甘特图、跟踪查看器或跟踪时间线)是跟踪提供者常见的功能之一。图 1**.6 展示了 Jaeger 中的跟踪时间线——一个开源的分布式跟踪平台:

图 1.6 – 在 Jaeger 中标记感叹号显示错误的跟踪可视化

图 1.6 – 在 Jaeger 中标记感叹号显示错误的跟踪可视化

虽然找到错误日志可能需要一段时间,但可视化显示了重要的信息——失败的位置、延迟和步骤序列。正如我们在图 1.6中可以看到的那样,前端调用失败是因为存储端的问题,我们可以进一步深入调查。

然而,我们也可以看到前端对存储进行了四次连续调用,这可能会并行执行以加快速度。

另一个常见功能是通过 span 的任何属性进行过滤或查询,例如名称、trace-idspan-idparent-id、名称、属性名称、状态、时间戳、持续时间或任何其他内容。图 1.7中显示了此类查询的一个示例:

图 1.7 – 计算 Redis 命中率的自定义 Azure Monitor 查询

图 1.7 – 计算 Redis 命中率的自定义 Azure Monitor 查询

例如,我们不报告缓存命中率的指标,但我们可以从跟踪中估计它。虽然由于采样它们可能不够精确,并且可能比指标查询更昂贵,但我们仍然可以临时这样做,尤其是在我们调查特定故障时。

由于跟踪、指标和日志是相关的,如果你的供应商支持多个信号或与其他工具良好集成,你将充分利用可观察性功能。

检查上下文传播

相关性和因果关系是分布式跟踪的基础。我们刚刚介绍了相关 span 如何共享相同的trace-id,并在parent-span-id中记录指向父 span 的指针,形成一个操作因果链。现在,让我们探索它在实际中的工作方式。

进程内传播

即使在单个服务中,我们通常也有嵌套的 span。例如,如果我们跟踪一个请求到只从数据库读取项目的 REST 服务,我们至少想要看到两个 span——一个用于传入的 HTTP 请求,另一个用于数据库查询。为了正确关联它们,我们需要从 ASP.NET Core 传递 span 上下文到数据库驱动程序。

一种选择是显式地将上下文作为函数参数传递。在 Go 中,这是一个可行的解决方案,因为显式上下文传播是一个标准,但在.NET 中,它会使分布式跟踪的入门变得困难,并破坏自动化的魔法。

.NET Activity(也称为 span)是隐式传播的。当前活动可以通过Activity.Current属性访问,该属性由System.Threading.AsyncLocal<T>支持。

使用我们之前的例子,一个从数据库读取的服务,ASP.NET Core 为入站请求创建一个 Activity,并且它成为此请求范围内发生的任何事情的当前 Activity。数据库驱动程序的仪器创建另一个使用 Activity.Current 作为其父级的 Activity,而不了解 ASP.NET Core,并且用户应用程序没有传递 Activity。如果配置为这样做,日志框架会在 Activity.Current 上盖章 trace-idspan-id

它适用于同步或异步代码,但如果你在后台使用内存队列处理项目或显式地使用线程进行操作,你将不得不帮助运行时并显式传播活动。我们将在 第六章跟踪你的代码 中更多地讨论这个问题。

进程外传播

进程内关联很棒,对于单体应用程序来说,几乎足够。但在微服务世界中,我们需要端到端跟踪请求,因此需要在网络上传播上下文,这就是标准发挥作用的地方。

在这个领域你可以找到多种实践——每个用于支持自定义内容的复杂系统,例如 x-correlation-idx-request-id。在旧版 Google 系统中可以找到 x-cloud-trace-contextgrpc-trace-bin,在 AWS 上可以找到 X-Amzn-Trace-Id,在 Microsoft 生态系统中可以找到 Request-Id 变体和 ms-cv。假设你的系统是异构的,并使用各种云提供商和跟踪工具,关联变得困难。

跟踪上下文(你可以在 www.w3.org/TR/trace-context 上更详细地了解)是一个相对较新的标准,它将 HTTP 上的上下文传播转换为标准,但它被广泛采用,并在 OpenTelemetry 和 .NET 中默认使用。

W3C 跟踪上下文

跟踪上下文标准定义了 traceparenttracestate HTTP 头以及填充它们的格式。

跟踪上下文头

traceparent 是一个 HTTP 请求头,它以以下格式携带协议版本、trace-idparent-idtrace-flags

traceparent: {version}-{trace-id}-{parent-id}-{trace-flags}
  • version:协议版本——目前只定义了 00

  • trace-id:逻辑端到端操作 ID。

  • parent-id:标识客户端 span 并作为相应服务器 span 的父级。

  • trace-flags:表示采样决策(我们将在 第五章配置和控制平面 中讨论)。现在我们可以确定 00 表示父 span 被采样出来,而 01 表示它被采样进来。

所有标识符都必须存在——也就是说,traceparent 有一个固定的长度,易于解析。图 1**.8 展示了使用 traceparent 头的上下文传播示例:

图 1.8 – traceparent 从出站 span 上下文中填充并成为入站 span 的父级

图 1.8 – traceparent 从出站跨度上下文中填充并成为入站跨度的父级

注意

该协议不要求创建跨度,也不指定仪器点。常见的做法是针对每个出站和入站请求创建跨度,并将客户端跨度上下文放入请求头部。

跟踪状态头部

tracestate 是另一个请求头,它为跟踪工具提供了额外的上下文。它是为 OpenTelemetry 或 APM 工具携带额外的控制信息而设计的,而不是为特定应用程序的上下文(在后面的 Baggage 部分中详细说明)。

tracestate 由一系列键值对组成,序列化为以下格式的字符串:"vendor1=value1,vendor2=value2"

tracestate 可以用来传播不兼容的旧版关联 ID,或者供应商需要的某些附加标识符。

例如,OpenTelemetry 使用它来携带采样概率和分数。例如,tracestate: "ot=r:3;p:2" 表示一个键值对,其中键是 ot(OpenTelemetry 标签),值是 r:3;p:2

tracestate 头部有一个软限制在大小(512 个字符)上,并且可以被截断。

traceresponse(草案)头部

traceparenttracestate 不同,traceresponse 是一个响应头。在撰写本文时,它由 W3C Trace-Context Level 2 定义([www.w3.org/TR/trace-context-2/](https://www.w3.org/TR/trace-context-2/)),并达到了 W3C 编辑草案状态。在 .NET 或 OpenTelemetry 中没有对其的支持。

traceresponse 非常类似于 traceparent。它具有相同的格式,但不是客户端标识符,而是返回服务器跨度的 trace-idspan-id 值:

traceresponse: 00-{trace-id}-{child-id}-{trace-flags}

traceresponse 是可选的,从某种意义上说,服务器不需要返回它,即使它支持 W3C Trace-Context Level 2。当客户端没有传递有效的 traceparent 时,返回 traceresponse 是有用的,但可以记录 traceresponse

面向外部服务的可能决定开始一个新的跟踪,因为他们不相信调用者的 trace-id 生成算法。均匀随机分布是一个担忧;另一个原因可能是特殊的 trace-id 格式。如果服务重新启动跟踪,返回 traceresponse 头部给调用者是一个好主意。

B3

B3 规范(https://github.com/openzipkin/b3-propagation)被 Zipkin – 分布式跟踪系统之一所采用。

B3 标识符可以以以下格式的单个 b3 头部进行传播:

b3: {trace-id}-{span-id}-{sampling-state}-{parent-span-id}

另一种方法是使用 X-B3-TraceIdX-B3-SpanIdX-B3-ParentSpanIdX-B3-Sampled 传递单个组件。

样本状态表示服务是否应该跟踪相应的请求。除了 0(不记录)和 1(记录)之外,它还允许我们通过将标志设置为 d 来强制跟踪。这通常用于调试目的。样本状态可以在没有其他标识符的情况下传递,以指定服务所需的采样决策。

注意

与 W3C Trace-Context 的关键区别,除了头部名称之外,还在于同时存在 span-idparent-span-id。B3 系统可以在客户端和服务器端使用相同的 span-id,为两者创建单个跨度。

Zipkin 重用来自传入请求的 span-id,并在其上指定 parent-span-id。Zipkin 的跨度同时表示客户端和服务器,如图 图 1**.9 所示,记录它们的不同持续时间和状态:

图 1.9 – Zipkin 创建一个跨度来表示客户端和服务器

图 1.9 – Zipkin 创建一个跨度来表示客户端和服务器

OpenTelemetry 和 .NET 支持 b3 头部,但忽略 parent-span-id – 它为每个跨度生成一个新的 span-id,因为不可能重用 span-id(见 图 1**.10)。

图 1.10 – OpenTelemetry 不使用 B3 头部中的父跨度 ID,并为客户端和服务器创建不同的跨度

图 1.10 – OpenTelemetry 不使用 B3 头部中的父跨度 ID,并为客户端和服务器创建不同的跨度

行李

到目前为止,我们讨论了跨度上下文和关联。但在许多情况下,分布式系统具有特定于应用程序的上下文。例如,你在前端服务上授权用户,之后,user-id 对于应用程序逻辑不再需要,但你仍然希望将其作为属性添加到所有服务的跨度中,以便按用户查询和汇总。

你可以在前端一次性标记 user-id。然后,后端记录的跨度将不会有 user-id,但它们将与前端共享相同的 trace-id。因此,通过在查询中进行一些连接,你仍然可以进行按用户分析。这在某种程度上是可行的,但可能成本高昂或速度较慢,因此你可能决定传播 user-id 并将其标记在后台跨度上。

行李 (www.w3.org/TR/baggage/) 定义了分布式上下文的通用传播格式,你可以通过添加、读取、删除和修改行李成员来使用它进行业务逻辑或其他任何事情。例如,你可以将请求路由到测试环境,并传递功能标志或额外的遥测上下文。

行李由一系列分号分隔的成员组成。每个成员都有一个键、值以及以下格式的可选属性 – key=value;property1;key2=property2key=value;property1;key2=property2,anotherKey=anotherValue

OpenTelemetry 和.NET 只传播行李,但不会在遥测上盖章。您可以将ILogger配置为盖章行李,并需要显式丰富跟踪。我们将在第五章“配置和控制平面”中看到它是如何工作的。

小贴士

您不应在行李中放置任何敏感信息,因为它几乎不可能保证它会流向何处——您的应用程序或边车基础设施可以将其转发到您的云提供商或任何其他地方。

在您的系统中维护一个已知的行李键列表,并且只使用已知的键,否则您可能会收到来自另一个系统的行李。

行李规范处于工作草案状态,可能还会发生变化。

备注

虽然 W3C 跟踪上下文标准是针对 HTTP 的,而 B3 适用于任何 RPC 调用,但它们通常用于任何上下文传播需求——例如,在消息场景中,它们作为事件有效负载传递。一旦引入了特定协议的标准,这可能会改变。

确保一致性和结构

正如我们已经定义的,跨度是描述有趣操作的有序事件。

跨度的开始时间、持续时间、状态、类型和上下文是强类型的——它们使关联和因果关系成为可能,使我们能够可视化跟踪并检测失败或延迟问题。

跨度的名称和属性描述了一个操作,但不是强类型或严格定义的。如果我们不以有意义的方式填充它们,我们可以检测到问题,但无法了解实际发生了什么。

例如,对于客户端 HTTP 调用,除了通用属性之外,我们还想捕获至少 URL、方法和响应代码(或异常)——如果我们不知道这些中的任何一项,我们就处于盲目的状态。一旦我们填充了它们,我们就可以开始使用查询此类跨度进行一些强大的分析,以回答以下常见问题:

  • 在这个请求的作用域内,哪些依赖项被调用?哪些失败了?它们的延迟是多少?

  • 我的应用程序是并行还是顺序地独立调用依赖项?当可以延迟执行时,它是否发出任何不必要的请求?

  • 依赖项端点是否配置正确?

  • 每个依赖项 API 的成功或错误率和延迟是多少?

备注

这种分析依赖于一个应用程序使用相同的属性来处理所有 HTTP 依赖项。否则,执行查询的操作员将很难编写和维护它们。

将统一和社区驱动的遥测收集从可观察性供应商的盘子上拿掉后,他们现在可以完全专注于(半)自动化分析和提供强大的性能和故障分析工具。

OpenTelemetry 定义了一套关于跨度、跟踪和资源的语义约定,我们将在第九章“最佳实践”中详细讨论。

构建应用程序拓扑

分布式跟踪,结合语义约定,使我们能够构建如图图 1.11所示的应用程序图(也称为服务图),你可以看到整个系统以及关键的健康指标。它是任何调查的入口点。

图 1.11 – Azure Monitor 为 meme 服务创建的应用程序图是一个包含所有基本健康指标的最新系统图

图 1.11 – Azure Monitor 为 meme 服务创建的应用程序图是一个包含所有基本健康指标的最新系统图

可观测性供应商依赖于跟踪和指标语义来构建服务图。例如,客户端跨度上存在 HTTP 属性表示一个出站的 HTTP 调用,我们需要显示指向新依赖节点的出站箭头。我们应该根据跨度的主机属性来命名此节点。

如果我们看到相应的服务器跨度,我们现在可以根据跨度上下文和因果关系将服务器节点与依赖节点合并。还有其他可视化或自动化工具你可能觉得有用——例如,关键路径分析,或找到与更高延迟或错误率相对应的常见属性。这些每一个都依赖于跨度属性和遵循常见语义或至少在服务之间保持一致。

资源属性

资源属性描述了进程、主机、服务和环境,并且对于服务实例报告的所有跨度都是相同的——例如,服务名称、版本、唯一服务实例 ID、云提供商账户 ID、区域、可用区以及 K8s 元数据。

这些属性使我们能够检测特定环境或实例的特定异常——例如,只有具有新版本代码的实例的错误率增加,进入重启循环的实例,或在区域和可用区遇到问题的云服务。

基于标准属性,可观测性供应商可以编写通用查询来执行此分析或构建通用仪表板。它还使社区能够为流行技术创建供应商无关的工具和解决方案。

这些属性描述了一个服务实例,并不需要在每个跨度上出现——例如,OTLP 在每次跨度批次中只传递一次资源属性。

性能分析概述

现在你已经了解了分布式跟踪的核心概念,让我们看看我们如何使用可观测性堆栈来调查常见的分布式系统问题。

基线

在我们讨论问题之前,让我们建立一个代表健康系统行为的基线。我们还需要它来做出数据驱动的决策,以帮助以下常见设计和开发任务,例如:

  • 风险估计:任何对热点路径上的功能工作都是发布前进行额外性能测试的良好候选者,并且使用功能标志保护新代码。

  • 容量规划:了解当前负载对于理解系统是否可以处理计划的增长和新功能是必要的。

  • 理解改进潜力:优化经常执行的代码更有意义,因为即使是小的优化也能带来显著的性能提升或成本降低。同样,提高可靠性为错误率较高且被其他服务使用的组件带来最大的好处。

  • 学习使用模式:根据用户如何与您的系统交互,您可能需要更改您的扩展或缓存策略,将特定功能提取到新的服务中,或者合并服务。

描述每个服务性能的通用指标包括以下内容:

  • 延迟:服务响应的速度

  • 吞吐量:每秒服务处理多少请求、事件或字节数

  • 错误率:服务返回的错误数量

您的系统可能需要其他指标来衡量持久性或数据正确性。

当这些信号包括 API 路由、状态码和其他上下文属性时,每个信号都很有用。例如,错误率可能总体上很低,但对于特定用户或 API 路由却很高。

在可能的情况下,在服务器和客户端上测量信号,可以给您一个更清晰的画面。例如,您可以检测网络故障并避免“在我的机器上它工作正常”的情况,当客户端看到问题时,服务器却没有。

调查性能问题

让我们将性能问题分为两个重叠的类别:

  • 影响整个实例、服务器甚至整个系统,并移动分布中位数的广泛问题。

  • 完成时间过长的单个请求或作业。如果我们可视化延迟分布,如图 图 1**.12 所示,我们将在分布的长尾中看到这些问题——它们很少见,但属于正常行为。

图 1.12 – Azure Monitor 延迟分布可视化,中位数请求(第 50 个百分位数)大约为 80 毫秒,第 95 个百分位数大约为 250 毫秒

图 1.12 – Azure Monitor 延迟分布可视化,中位数请求(第 50 个百分位数)大约为 80 毫秒,第 95 个百分位数大约为 250 毫秒

长尾

单个问题可能是由一系列不幸的事件引起的——瞬态网络问题、乐观并发算法中的高竞争、硬件故障等。

分布式跟踪是调查此类问题的优秀工具。如果您有错误报告,您可能有一个有问题的操作的跟踪上下文。为了实现这一点,请确保您在网页上显示 traceparent 值,并返回用户需要记录的 traceresponse 或文档,或者当向您的服务发送请求时记录 traceresponse

因此,如果您知道跟踪上下文,您可以从检查跟踪视图开始。例如,在 图 1**.13 中,您可以看到由瞬态网络问题引起的长时间请求的示例。

图 1.13 – 由短暂网络问题和重试引起的高延迟请求

图 1.13 – 由短暂网络问题和重试引起的高延迟请求

前端请求大约花费了 2.6 秒,时间花在存储服务下载表情包内容上。我们看到 Azure.Core.Http.Request 的三次尝试,每次都很快,它们之间的时间对应于退避间隔。最后一次尝试是成功的。

如果你没有 trace-id,或者可能是跟踪被采样,你可能可以根据上下文和高延迟过滤类似操作。

例如,在 Jaeger 中,你可以根据服务、跨度名称、属性和持续时间来过滤跨度,这有助于你在“大海捞针”中找到所需信息。

在某些情况下,你可能会遇到神秘的间隙——服务运行正常,但花费了大量时间什么也没做,如图 图 1.14 所示:

图 1.14 – 带有高延迟和跨度间隙的请求

图 1.14 – 带有高延迟和跨度间隙的请求

如果你从跟踪中获取不到足够的数据,请检查此跨度范围内是否有任何日志可用。

你还可以检查资源利用率指标——此时是否有 CPU 峰值,或者可能发生了垃圾回收暂停?你可能会通过时间戳和上下文找到一些相关性,但无法判断这是根本原因还是巧合。

如果你有一个将配置文件与跟踪关联的持续分析器(是的,它们可以使用 Activity.Current 来做到这一点),你可以检查是否有此或类似操作的配置文件可用。

我们将在 第四章 “使用诊断工具进行低级性能分析”中看到如何使用 .NET 诊断工具进一步调查,但如果你对 图 1.14 中发生的事情感到好奇,服务读取了一个未进行度量的网络流。

尽管我们谈论的是个别性能问题,但在许多情况下,我们不知道这些问题有多普遍,尤其是在事件开始时。跨跟踪的指标和丰富查询可以用来找出问题有多普遍。如果你在值班,检查问题是否普遍或变得更加频繁通常比找到根本原因更紧急。

注意

在分布式系统中,长尾延迟请求是不可避免的,但总有优化机会,例如缓存、协同定位、调整超时和重试策略等。监控 P95 延迟和分析跟踪以解决长尾问题有助于你找到这些改进区域。

性能问题

性能问题表现为延迟或吞吐量下降,超出正常变化范围。假设你快速失败或限制传入调用,你也可能会看到 408429503 HTTP 状态码的错误率增加。

这种问题可能始于依赖项可用性的轻微下降,导致服务重试。由于出站请求比平时消耗更多资源,其他操作会变慢,处理客户端请求的时间会增长,同时活跃请求和连接的数量也会增加。

理解发生了什么可能具有挑战性;您可能会看到高 CPU 使用率和相对较高的 GC 率——这些都是您通常在过载系统上看到的症状,但没有突出显示的。假设您测量了依赖项吞吐量和错误率,您可能会在那里看到异常,但可能很难判断它是原因还是结果。

在这种情况下,单个分布式跟踪很少有用——每个操作都会花费更长的时间,并且会有更多的短暂错误,但跟踪可能看起来正常。

这里有一份首先需要检查的简单事项列表,它们是更高级分析的基础:

  • 是否有正在进行的部署或最近的功能发布?您可以使用service.version资源属性来找出问题是否仅限于运行新版本代码的实例。如果您在跟踪或事件中包含功能标志,您可以查询它们以检查降级是否仅限于(或始于)启用新功能的请求。

  • 问题是否特定于某个 API、代码路径或属性组合?一些后端,如 Honeycomb,会自动化这种分析,找到对应于更高延迟或错误率的属性。

  • 是否所有实例都受到影响?有多少实例处于活动状态?基于属性的分析在这里也很有帮助。

  • 您的依赖项是否健康?如果可以,检查它们的服务器端遥测数据,看看它们是否与其他服务(而不仅仅是您的服务)存在问题。

属性分析在这里同样有帮助——假设您的云存储账户或数据库分区中只有一个表现异常,您会看到这一点。

  • 事件发生前负载是否急剧增加?或者,如果您的服务是自动扩展的,自动扩展器是否正常工作,您是否能够跟上负载?

关于基础设施、云提供商和其他方面还有更多问题要问。这个练习的目的是尽可能缩小问题范围并理解问题。如果问题不在您的代码中,调查有助于找到更好的方法来处理这类问题,并给您一个机会填补您遥测中的空白,这样下次类似的事情发生时,您可以更快地识别它。

如果您怀疑代码中存在问题,.NET 提供了一套信号和工具来帮助调查高 CPU、内存泄漏、死锁、线程池饥饿和代码分析,正如我们将在第四章中看到的,使用诊断工具进行低级性能分析

摘要

分布式系统需要一种新的可观察性方法,该方法简化了事故的调查并最小化了解决问题的时间。这种方法应专注于人类体验,如数据可视化、遥测信号的关联以及分析自动化。它需要结构化、关联的遥测信号,以及利用它们构建丰富体验的新工具。

分布式跟踪就是这样一种信号——它跟随请求穿过任何系统,并使用跨度描述服务操作,跨度代表系统中的操作事件。.NET 支持分布式跟踪,并原生集成与 OpenTelemetry,这是一个跨语言平台,以供应商无关的方式收集、处理和导出跟踪、指标和日志。大多数现代供应商都与 OpenTelemetry 兼容,并利用分布式跟踪功能。OpenTelemetry 生态系统包括一系列共享的仪器库,这些库自动化了常见的遥测收集需求。

分布式跟踪通过在进程内和进程间传播上下文来实现关联和因果关系。OpenTelemetry 为常见技术定义了标准语义,以便供应商可以构建依赖于一致和标准属性的跟踪可视化、应用程序映射、共享仪表板、警报或查询。跟踪上下文和一致属性使跨度、日志、指标以及来自你系统的任何其他信号之间的关联成为可能。

可以使用分布式跟踪有效地分析个别问题,对广泛性能问题的调查则依赖于指标和跟踪上的属性以及时间戳关联。可观察性供应商可能会自动化这种分析。

指标、跟踪和事件的组合提供了正确数量的细节。指标使我们能够以成本效益的方式接收无偏见的数据。通过查询具有高基数属性的跟踪和事件,我们可以回答关于系统的即兴问题。

在下一章中,我们将亲身体验分布式跟踪。我们将构建一个演示应用程序并探索.NET 中的原生跟踪功能。

问题

  1. 你会如何定义跨度(span)和跟踪(trace)?跨度包含哪些信息?

  2. 跨度关联是如何工作的?

  3. 假设你正在值班并收到用户关于你的服务响应时间慢的报告,你会如何处理调查?

进一步阅读

  • 《使用 OpenTelemetry 的云原生可观察性》 by Alex Boten

第二章:.NET 的原生监控

在本章中,我们将探索现代 .NET 应用程序的即用型诊断功能,从日志和临时诊断开始,然后继续探讨 OpenTelemetry 在此之上提供的内容。我们将创建一个示例应用程序并对其进行工具化,展示跨进程日志关联,并学习如何使用 dotnet-monitor 捕获详细日志。然后,我们将调查 .NET 运行时计数器并将它们导出到 Prometheus。最后,我们将配置 OpenTelemetry 以从 .NET、ASP.NET Core 和 Entity Framework 收集跟踪和指标,并查看基本的自动工具化如何满足可观察性需求。

以下是我们将要涵盖的主题:

  • ASP.NET Core 应用程序中的原生日志关联

  • 使用 .NET 运行时计数器的简约监控

  • 安装 OpenTelemetry 并启用常用工具化

  • 使用 HTTP 和数据库工具进行跟踪和性能分析

到本章结束时,你将准备好在 .NET 库和框架中使用分布式跟踪工具,启用日志关联和指标,并利用多个信号一起调试和监控你的应用程序。

技术要求

我们将开始构建一个示例应用程序,并为此使用以下工具:

  • .NET SDK 7.0 或更高版本

  • 推荐使用带有 C# 开发设置的 Visual Studio 或 Visual Studio Code,但任何文本编辑器都可以工作

  • Docker 和 docker-compose

应用程序代码可以在 GitHub 上的书籍仓库中找到,地址为 github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/tree/main/chapter2

构建示例应用程序

图 2.1 所示,我们的应用程序由两个 REST 服务和一个 MySQL 数据库组成:

图 2.1 – Meme 服务图

图 2.1 – Meme 服务图

  • 前端:为上传和下载图像服务用户请求的 ASP.NET Core Razor Pages 应用程序

  • 存储:使用 Entity Framework Core 存储图像的 ASP.NET Core WebAPI 应用程序,或用于本地开发的内存中

我们将在本章后面部分看到如何使用 Docker 运行完整的应用程序。现在,请在本地运行它并探索现代 .NET 所带来的基本日志和监控功能。

我们将在整本书中使用 Microsoft.Extensions.Logging.ILogger API。ILogger 提供了方便的 API 来编写结构化日志,包括详细程度控制以及将日志发送到任何地方的能力。

ASP.NET Core 和 Entity Framework 使用 ILogger;我们只需要为特定的类别或事件配置日志级别以记录传入的请求或数据库调用,并使用日志作用域提供额外的上下文。我们将在 第八章 中详细介绍,即 编写结构化和关联日志。现在,让我们看看日志关联的实际应用。

日志关联

ASP.NET Core 默认启用跨多个服务的日志关联。它创建一个日志记录器可以通过 Activity.Current 访问的活动,并配置 Microsoft.Extensions.Logging 以在日志作用域中填充跟踪上下文。ASP.NET Core 和 HttpClient 也默认支持 W3C 跟踪上下文,因此上下文会自动通过 HTTP 传播。

一些日志提供程序,例如 OpenTelemetry,不需要任何配置即可关联日志,但我们的梗图应用程序使用的是控制台提供程序,默认情况下它不会打印任何日志作用域。

因此,让我们配置我们的控制台提供程序以打印作用域,我们将在每个日志记录上看到跟踪上下文。我们还将所有类别的默认级别设置为 Information,以便我们可以看到输出:

appsettings.json

"Logging": {
  "LogLevel" : {"Default": "Information"},
  "Console" : {"IncludeScopes" : true}
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter2/storage/appsettings.json

注意

通常,你只会为应用程序代码使用 Information,并将 WarningError 设置为框架和第三方库。

让我们来看看 – 首先启动存储,然后在不同的终端中启动前端:

storage$ dotnet run
frontend$ dotnet run

确保同时保持两个终端开启,以便我们稍后可以检查日志。现在,让我们从浏览器的前端获取预加载的梗图 – 访问 http://localhost:5051/Meme?name=dotnet 并然后检查日志。

在前端,你可能看到如下内容(为了简洁起见,省略了其他日志和作用域):

info: System.Net.Http.HttpClient.storage.LogicalHandler[101]
      => SpanId:4a8f253515db7fec, TraceId:e61779
516785b4549905032283d93e09, ParentId:00000000000000
      00 => HTTP GET http://localhost:5050/memes/dotnet
      End processing HTTP request after 182.2564ms - 200
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      => SpanId:4a8f253515db7fec, TraceId:e6177951678
      5b4549905032283d93e09, ParentId:0000000000000000 =>
      Request finished HTTP/1.1 GET http://localhost:
        5051/Meme?name=dotnet - 200 256.6804ms

第一条记录描述了对存储服务的发出调用。你可以看到状态、持续时间、HTTP 方法以及 URL,以及跟踪上下文。第二条记录描述了一个进入的 HTTP 调用,并具有类似的信息。

注意

此跟踪上下文在两个日志记录中是相同的,属于进入的 HTTP 请求。

让我们看看存储上发生了什么:

info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      => SpanId:5a496fb9adf85727, TraceId:e61779516785b
      4549905032283d93e09, ParentId:67b3966e163641c4
      Request finished HTTP/1.1 GET http://localhost:
        5050/memes/dotnet - 200 1516 102.8234ms

注意

TraceId 值在前端和存储上是相同的,因此我们默认具有跨进程日志关联。

如果我们配置了 OpenTelemetry,我们会看到类似于 图 2.2 中所示的跟踪。2*:

图 2.2 – 前端和存储之间的通信

图 2.2 – 前端和存储之间的通信

我们已经知道 ASP.NET Core 为每个请求创建一个活动 – 它默认读取跟踪上下文头信息,但我们可以配置不同的传播者。因此,当我们从前端向存储发出请求时,HttpClient 创建另一个活动 – ASP.NET Core 的子活动。HttpClient 将其活动的跟踪上下文注入到发出的请求头中,以便它们流向存储服务,在那里 ASP.NET Core 解析它们并创建一个新的活动,该活动成为发出请求的子活动。

尽管我们没有导出活动,但它们被创建并用于通过跟踪上下文丰富日志,从而实现跨不同服务之间的关联。

在没有导出活动的情况下,我们实现了关联,但没有因果关系。正如你在日志中看到的,存储上的 ParentId 与出站 HTTP 请求上的 SpanId 不相同。

关于因果关系的提示

这里发生的情况是,出站请求活动是在 HttpClient 内部创建的,它不会使用 ILogger 写入日志。我们刚才看到的出站请求的日志记录是由 Microsoft.Extensions.Http 包中的处理器编写的。这个处理器由 ASP.NET Core 配置。当处理器记录请求开始时,HttpClient 活动尚未创建,而当处理器记录请求结束时,HttpClient 活动已经停止。

因此,使用 ASP.NET Core 和 ILogger,我们可以轻松地启用日志关联。然而,日志不能替代分布式跟踪——它们只是提供了额外的细节。日志也不需要重复跟踪。

避免重复非常重要:作者曾经通过删除由 丰富事件 重复的日志,为公司节省了每月 80k 美元。

在本书接下来的内容中,我们将使用日志进行调试和捕获覆盖跟踪空白点的额外信息。

在生产中使用日志

在生产中记录日志,我们通常需要一个日志管理系统——一组工具,它们收集并发送日志到中央位置,可能在这个过程中丰富、过滤或解析它们。OpenTelemetry 可以帮助我们收集日志,但我们还需要一个后端来存储、索引和查询日志,使用任何上下文,包括 TraceId。这样,我们就可以在需要时轻松地从跟踪导航到日志。

使用 dotnet-monitor 进行按需日志记录

在重现问题或当日志导出管道损坏时,动态增加日志详细程度以获取更多详细信息可能是有用的,或者直接从服务中获取日志。

使用 dotnet-monitor 是可能的——这是一个能够连接到特定 .NET 进程并捕获日志、计数器、配置文件和核心转储的诊断工具。我们将在 第三章.NET 可观察性生态系统 中讨论它。

让我们安装并启动 dotnet-monitor 来看看它能做什么:

$ dotnet tool install -g dotnet-monitor
$ dotnet monitor collect

注意

如果你使用的是 macOS 或 Linux,你需要对 dotnet-monitor REST API 的请求进行身份验证。请参阅 https://github.com/dotnet/dotnet-monitor/blob/main/documentation/authentication.md 中的文档,或者仅用于演示目的,使用 dotnet monitor collect –no-auth 命令禁用身份验证。

如果你仍然运行着前端和存储服务,当你通过浏览器打开 https://localhost:52323/processes 时,你应该能在你的机器上的其他 .NET 进程中看到它们:

{"pid": 27020, …, "name": "storage"},
{"pid": 29144, … "name": "frontend"}

现在,让我们通过请求以下内容来通过 dotnet-monitor 从存储中捕获一些调试日志:

https://localhost:52323/logs?pid=27020&level=debug&duration=60

它首先连接到进程,启用请求的日志级别,然后开始将日志流式传输到浏览器 60 秒。它不会更改主日志管道中的日志级别,但会直接将请求的日志返回给您,如图 图 2.3 所示。

图 2.3 – 使用 dotnet-monitor 进行动态级别临时日志记录

图 2.3 – 使用 dotnet-monitor 进行动态级别临时日志记录

您可以使用 POST 日志 API 应用更高级的配置 – 查看 https://github.com/dotnet/dotnet-monitor 了解更多相关信息以及其他 dotnet-monitor 功能。

在具有受限 SSH 访问的多实例服务上在生产环境中使用 dotnet-monitor 可能具有挑战性。让我们通过在 Docker 中以边车形式运行 dotnet-monitor 来看看我们如何做到这一点。它也可以在 Kubernetes 中作为边车运行。

使用运行时计数器进行监控

因此,我们有来自平台和服务的相关日志,我们可以用它们来调试问题。但关于系统健康和性能呢?.NET 和 ASP.NET Core 提供了事件计数器,可以提供一些关于整体系统状态的见解。

我们可以在不运行和管理 dotnet-monitor 的情况下使用 OpenTelemetry 收集计数器。但如果您的指标管道损坏(或者您还没有一个),您可以将 dotnet-monitor附加到您的进程上进行临时分析。

dotnet-monitor 监听 .NET 运行时报告的 EventCounters,并将它们以 Prometheus 暴露格式 返回到 HTTP 端点。

注意

Prometheus 是一个抓取和存储指标的指标平台。它支持多维数据,并允许我们使用 PromQL 对数据进行切片、切块、过滤和计算派生指标。

我们将运行我们的服务作为一组 Docker 容器,其中 dotnet-monitor 作为前端和存储的边车运行,并将 Prometheus 配置为抓取来自 dotnet-monitor 边车的指标,如图 图 2.4 所示。

图 2.4 – 在 Prometheus 中具有运行时计数器的 Meme 服务

图 2.4 – 在 Prometheus 中具有运行时计数器的 Meme 服务

这使得我们的设置更接近现实生活,在那里我们没有在服务实例上运行 dotnet-monitor 的便利。

因此,让我们继续运行我们的应用程序。打开终端,导航到 chapter2 文件夹,并运行以下命令:

$ docker-compose -f ./docker-compose-dotnet-monitor.yml
  up --build

当 MySQL 启动时,您可能会看到一些错误。现在我们先忽略它们。几秒钟后,您应该能够通过之前的相同 URL 访问前端。

让我们探索由 .NET 运行时发布的 CPU 和内存计数器:

  • cpu-usage 事件计数器(报告为 systemruntime_cpu_usage_ratio 指标到 Prometheus):表示 CPU 使用率作为百分比。

  • gc-heap-size(或 systemruntime_gc_heap_size_bytes):表示以兆字节为单位的近似已分配托管内存大小。

  • time-in-gc(或 systemruntime_time_in_gc_ratio):表示自上次垃圾回收以来在垃圾回收上花费的时间。

  • gen-0-gc-countgen-1-gc-countgen-2-gc-count(或 systemruntime_gen_<gen>_gc_count):表示每个间隔对应生成中垃圾回收的计数。默认更新间隔为 5 秒,但您可以调整它。生成大小也作为计数器公开。

  • alloc-rate(或 systemruntime_alloc_rate_bytes):表示每个间隔的字节分配率。

您还可以找到来自 Kestrel、Sockets、TLS 和 DNS 的计数器,这些计数器可以用于调查特定问题,例如 DNS 故障、长请求队列或 HTTP 服务器上的套接字耗尽。请查看 .NET 文档以获取完整列表(https://learn.microsoft.com/dotnet/core/diagnostics/available-counters)。

ASP.NET Core 和 HttpClient 请求计数器没有维度,但如果您没有 OpenTelemetry 跟踪或度量,它们将非常有用,可以大致了解所有 API 的吞吐量和失败率。

Prometheus 从 dotnet-monitor 度量端点抓取度量。我们可以自己访问它来查看原始数据,如图 图 2.5 所示。5:

图 2.5 – Prometheus 中暴露的前端度量

图 2.5 – Prometheus 中暴露的前端度量格式

您还可以使用 Prometheus 查询和绘制基本可视化,如图 图 2.6 所示。只需点击 http://localhost:9090/graph。对于任何高级可视化或仪表板,我们需要与 Prometheus 集成的工具,例如 Grafana

图 2.6 – Prometheus 中前端和存储服务器的 GC 内存堆大小

图 2.6 – Prometheus 中前端和存储服务器的 GC 内存堆大小

如您所见,即使是基本的 ASP.NET Core 应用也自带了最小的监控能力——整体系统健康的计数器和用于调试的相关日志。使用 dotnet-monitor,我们甚至可以在不更改代码或重启应用程序的情况下(当然,前提是我们有权访问应用程序实例)在运行时检索遥测数据。

通过对运行 dotnet-monitor 作为边车和日志管理解决方案的一些额外基础设施更改,我们将能够构建一个非常基本的实时监控解决方案。

我们仍然缺少具有丰富上下文的分布式跟踪和度量。现在让我们看看如何通过 OpenTelemetry 仪表板启用它们,并进一步改善这一体验。

使用 OpenTelemetry 启用自动收集

在本节中,我们将向我们的演示应用程序添加 OpenTelemetry,并启用 ASP.NET Core、HttpClient、Entity Framework 和运行时度量的自动收集。我们将看到它为裸骨监控体验添加了什么。

我们将跟踪导出到 Jaeger,并将指标导出到 Prometheus,如图2.7所示:

图 2.7 – Meme 服务向 Jaeger 和 Prometheus 发送遥测数据

图 2.7 – Meme 服务向 Jaeger 和 Prometheus 发送遥测数据

如果您的可观察性后端有 OTLP 端点,您可以直接向其发送数据,或者您可以在应用程序中配置特定后端的导出器。那么,让我们开始使用 OpenTelemetry 检测我们的应用程序。

安装和配置 OpenTelemetry

OpenTelemetry 作为一系列 NuGet 包提供。以下是我们演示应用程序中使用的几个包:

  • OpenTelemetry:包含我们生成跟踪和指标以及配置通用处理和导出管道所需的一切的 SDK。它本身不收集任何遥测数据。

  • OpenTelemetry.Exporter.Jaeger:此包包含一个跟踪导出器,将跨度发布到 Jaeger。

  • OpenTelemetry.Exporter.Prometheus.AspNetCore:此包包含 Prometheus 导出器。它为 Prometheus 创建一个新的/metrics端点以抓取指标。

  • OpenTelemetry.Extensions.Hosting:此包简化了 ASP.NET Core 应用程序中的 OpenTelemetry 配置。

  • OpenTelemetry.Instrumentation.AspNetCore:此包为 ASP.NET Core 启用跟踪和指标自动检测。

  • OpenTelemetry.Instrumentation.Http:此包为System.Net.HttpClient启用跟踪和指标自动检测。

  • OpenTelemetry.Instrumentation.EntityFrameworkCore:Entity Framework Core 的跟踪检测。我们只需要它用于存储服务。

  • OpenTelemetry.Instrumentation.ProcessOpenTelemetry.Instrumentation.Runtime:这两个包为 CPU 和内存利用率启用进程级指标,并包括我们在dotnet-monitor中之前看到的运行时计数器。

您也可以逐个启用其他计数器源,使用OpenTelemetry.Instrumentation.EventCounters包。

分布式跟踪

要配置跟踪,首先在IServiceCollection上调用AddOpenTelemetry扩展方法,然后调用WithTracing方法,如下例所示:

Program.cs

builder.Services.AddOpenTelemetry().WithTracing(
  tracerProviderBuilder => tracerProviderBuilder
    .AddJaegerExporter()
    .AddHttpClientInstrumentation()
    .AddAspNetCoreInstrumentation()
    .AddEntityFrameworkCoreInstrumentation());

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter2/storage/Program.cs

在这里,我们添加了 Jaeger 导出器,并启用了HttpClient、ASP.NET Core 和 Entity Framework 检测(在存储上)。

我们还通过launchSetting.jsondocker-compose-otel.yml中的OTEL_SERVICE_NAME环境变量配置服务名称,以便在 Docker 运行时。OpenTelemetry SDK 读取它并相应地设置service.name资源属性。

Jaeger 主机通过docker-compose-otel.yml中的OTEL_EXPORTER_JAEGER_AGENT_HOST环境变量进行配置。

我们将在第五章“配置和控制平面”中更多地讨论配置,并学习如何配置采样、丰富遥测数据以及添加自定义源。

指标

指标配置类似——我们首先在IServiceCollection上调用AddOpenTelemetry扩展方法,然后在WithMetrics回调中设置 Prometheus 导出器和HttpClient、ASP.NET Core、进程和运行时的自动检测。Entity Framework 的检测不报告指标。

Program.cs

builder.Services.AddOpenTelemetry()
        ...
        .WithMetrics(meterProviderBuilder => meterProviderBuilder
            .AddPrometheusExporter()
            .AddHttpClientInstrumentation()
            .AddAspNetCoreInstrumentation()
            .AddProcessInstrumentation()
            .AddRuntimeInstrumentation());

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter2/storage/Program.cs

在构建应用程序实例之后,我们还需要暴露 Prometheus 端点:

Program.cs

var app = builder.Build();
app.UseOpenTelemetryPrometheusScrapingEndpoint();

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter2/storage/Program.cs

我们已经准备好运行应用程序了!

$ docker-compose -f docker-compose-otel.yml up –-build

你应该看到所有服务的日志,包括 MySQL 启动时的一些错误。检查前端以确保它正常工作:https://localhost:5051/Meme?name=dotnet

探索自动生成的遥测数据

现在表情包服务已经启动并运行。请随意上传您最喜欢的表情包,如果您看到任何问题,请使用遥测数据进行调试!

调试

如果你尝试在服务启动后立即上传某些内容,你可能会得到如图图 2.8所示的错误。让我们找出原因!

Figure 2.8 – Error from application with traceparent

图 2.8 – 带有 traceparent 的应用程序错误

我们可以从两个角度来接近这个调查。第一个是使用页面上的traceparent;第二个是根据错误状态从前端过滤跟踪。无论如何,让我们去 Jaeger——我们的跟踪后端运行在http://localhost:16686/。我们可以通过Trace ID搜索或根据服务错误过滤,如图图 2.9所示:

Figure 2.9 – Find the trace in Jaeger by Trace ID (1) or with a combination of the service (2) and error (3)

图 2.9 – 通过 Trace ID(1)或服务(2)和错误(3)的组合在 Jaeger 中找到跟踪

如果我们打开跟踪,我们会看到存储服务拒绝了连接——查看图 2.10。这里发生了什么?

Figure 2.10 – Drill down into the trace: the frontend service could not reach the storage

图 2.10 – 深入跟踪:前端服务无法访问存储

由于没有来自存储的跟踪,让我们用 docker logs chapter2-storage-1 检查存储日志。我们将在 第八章编写结构化和关联日志 中以更方便的方式查询日志。现在,让我们只是 grep 存储日志在问题发生的时间附近,并找到相关的记录,如图 图 2**.11 所示:

图 2.11 – 存储标准输出中的连接错误

图 2.11 – 存储标准输出中的连接错误

显然,存储无法连接到 MySQL 服务器,并且它只能在建立连接后才能启动。如果我们进一步挖掘 MySQL 日志,我们会发现它启动花费了一段时间,但之后一切工作正常。

从这次调查中得出的某些行动项是:在前端启用重试并调查 MySQL 的缓慢启动。如果它在生产环境中发生,并且有多个存储实例,我们还应该深入了解负载均衡器和服务发现行为。

追踪带来的便利性在于 – 我们本可以用日志单独完成同样的调查,但这会花费更长的时间,并且会更困难。假设我们处理的是一个更复杂的案例,涉及多个服务中的数十个请求,解析日志将根本不是一个合理的选项。

正如我们在这个例子中所看到的,追踪可以帮助我们缩小问题范围,但有时我们仍然需要日志来了解正在发生的事情,尤其是在启动期间的问题。

性能

让我们查看一些由 HTTP 和运行时仪器收集的指标。

OpenTelemetry 定义了具有低基数属性的 http.server.durationhttp.client.duration 直方图指标,包括方法、API 路由(仅限服务器)和状态码。这些指标允许我们计算延迟百分位数、吞吐量和错误率。

使用 OpenTelemetry 指标,ASP.NET Core 仪器可以填充 API 路由,这样我们就可以最终分析每个路由的延迟、吞吐量和错误率。并且直方图提供了更多的灵活性 – 我们现在可以检查延迟的分布,而不仅仅是中位数、平均值或预定义的百分位数集合。

延迟

HTTP 客户端延迟可以定义为发起请求与收到响应之间的时间。对于服务器来说,是收到请求与服务器响应结束之间的时间。

小贴士

在分析延迟时,过滤掉错误并检查延迟的分布,而不仅仅是平均值或中位数。通常检查第 95 个百分位数(也称为 P95)。

图 2**.12 显示了客户端和服务器端 PUT /meme API 的 P95 延迟:

图 2.12 – 服务器与客户端 PUT /meme 延迟 P95(以毫秒为单位)

图 2.12 – 服务器与客户端 PUT /meme 延迟 P95(以毫秒为单位)

首字节时间与最后字节时间

在 .NET 中,HttpClient 在返回响应之前会缓冲响应,但可以通过 HttpCompletionOptions 配置在接收到头部后立即返回响应。在这种情况下,HttpClient 仪表无法测量时间到最后一字节。

在前端使用不可靠连接或传输大量数据的客户端中,时间到第一个字节时间到最后一个字节之间的区别可能很重要。在这种情况下,对流操作进行仪表化并测量时间到第一个字节和最后一个字节是有用的。您可以通过这些指标之间的差异来了解连接质量并优化最终用户体验。

错误率

错误率只是给定时间段内每项请求的不成功请求的比率。这里的关键问题是什么构成了错误:

  • 1xx2xx3xx 状态码表示成功。

  • 5xx 代码涵盖了诸如无响应、断开客户端、网络或 DNS 问题等错误。

  • 4xx 范围内的状态码难以分类。例如,404 可能代表一个问题 – 可能是客户端期望检索数据但数据不存在 – 但也可能是积极的场景,其中客户端在创建或更新资源之前检查资源是否存在。其他状态也存在类似的问题。

注意

OpenTelemetry 只将 4xx 作为错误标记为客户端跨度。我们将在 第五章配置和控制平面 中看到如何根据您的需求定制它。

通常也会将超过给定阈值的延迟视为错误来衡量可用性,但为了可观察性目的,我们并不严格需要它。

图 2.13 展示了按错误代码分组的单个 API 服务器错误率图表的示例:

图 2.13 – 按错误代码分组的 GET/meme API 每秒错误率

图 2.13 – 按错误代码分组的 GET/meme API 每秒错误率

计算服务器上每个 API 路由和方法的错误率也很重要。由于不同的请求速率,很容易错过调用频率较低的 API 的峰值或变化。

小贴士

对于“已知”错误返回精确的状态码,而只让服务在未处理的异常时返回 500,这使得使用您的服务变得更容易,同时也简化了监控和警报。通过查看错误代码,我们可以辨别可能的原因,并避免在已知情况下浪费时间。任何 500 响应都变得重要,需要调查和修复或妥善处理。

检查资源消耗,我们可以使用运行时和进程指标。例如,图 2.14 展示了 CPU 使用率:

图 2.14 – CPU 使用率查询

图 2.14 – CPU 使用率查询

查询返回了所有实例中每个由作业维度表示的服务平均 CPU 利用率百分比 – 我们在 configs/prometheus-otel.yml 中配置了作业。

状态维度将处理器时间分为用户时间和特权(系统)时间。为了计算每个服务每个实例的总平均 CPU 使用率,我们可以编写另一个 Prometheus 查询:

sum by (job, instance) (rate(process_cpu_time_s[1m]) * 100)

该查询计算每个实例的总 CPU 使用率,然后计算每个服务的平均值。

如您所见,Prometheus 查询语言是一个强大的工具,它允许我们计算派生指标,并对它们进行切片、切块和过滤。

我们将在 第四章使用诊断工具进行低级性能分析 中看到更多关于运行时指标和性能分析的示例。

摘要

在本章中,我们探讨了平台和框架支持的 .NET 诊断和监控功能。ASP.NET Core 上下文传播默认启用,日志提供程序可以使用它来关联日志。我们需要一个日志管理系统来存储来自服务多个实例的日志,并有效地查询它们。

dotnet-monitor 允许按需从您服务的特定实例流式传输日志,并使用 Prometheus 抓取事件计数器,以获得关于服务健康的基本概念。它还可以用于低级性能分析,并可以在生产环境中运行。

然后,我们为 HTTP 栈和 Entity Framework 启用了 OpenTelemetry 自动仪器化。HTTP 和 DB 跟踪启用了基本的调试功能,为每个远程调用提供了通用信息。您可以根据属性搜索跟踪,并使用您的跟踪后端进行查询。有了跟踪,我们可以轻松地找到有问题的服务或组件,如果这还不够,我们可以检索日志以获取更多关于问题的详细信息。通过将日志与跟踪关联起来,我们可以轻松地在它们之间导航。

HTTP 指标启用了常见的性能分析。根据您的后端,您可以查询、过滤和派生指标,并基于它们构建仪表板和警报。

现在我们已经对基本的分布式跟踪和指标有了实践经验,让我们更深入地探索 .NET 生态系统,看看您如何利用仪器化来处理常见的库和基础设施。

问题

  1. 您如何在 Razor 页面上显示跟踪上下文?

  2. 假设可观察性后端停止接收来自服务某些实例的遥测数据。我们该如何了解这些实例的情况?

  3. 在 Prometheus 文档(https://prometheus.io/docs/prometheus/latest/querying/basics/)的帮助下,使用 PromQL 编写一个查询来计算每个服务和 API 的吞吐量(每秒请求数)。

  4. 在我们的 meme 服务中,如果您只知道 meme 的名称,您会如何找出 meme 上传的时间和下载次数?

第三章:.NET 可观察性生态系统

在上一章中,我们探讨了平台和框架中包含的.NET 可观察性功能,但还有更多针对其他库和环境的工具。

在本章中,我们将学习如何查找和评估工具,然后更深入地研究几个特定库的工具:StackExchange.Redis、Azure 和 AWS SDKs。我们还将探索使用Dapr分布式应用程序运行时)作为示例的基础设施跟踪和指标。最后,我们将看到如何在控制较少的无服务器环境中配置跟踪,但可观察性更为重要。

通过本章,你将学习:

  • 如何查找、评估和启用 OpenTelemetry 工具

  • 当涉及到可观察性时,Dapr 和服务网格能够做到什么程度

  • 如何在无服务器环境中启用跟踪

到本章结束时,你将获得不同类型工具的实际操作经验,并能够配置和使用分布式跟踪来支持广泛的后端应用程序。让我们开始吧!

技术要求

在本章中,我们将演进我们的 meme 应用程序,并使用云对象存储,Amazon S3 或 Azure Blob 存储,以及本地 Redis 缓存。本章的代码可在本书的 GitHub 存储库中找到,网址为github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/tree/main/chapter3,其文件夹结构如下:

  • libraries:包含本章第一部分的库工具示例应用程序

  • dapr:包含第二部分的 Dapr 工具示例

  • serverless:包含awsazure文件夹,其中包含 AWS Lambda 和 Azure Functions 工具的示例

要运行这些应用程序,你需要以下工具:

  • .NET SDK 7.0 或更高版本

  • Visual Studio 或 VS Code,但任何文本编辑器都可以工作

  • Docker 和docker-compose

  • Dapr CLI

  • 一个 Azure 订阅(可选):

    • 我们将使用 Blob 存储和应用洞察。

    • 使用 Blob 存储,我们将确保保持在免费层级的限制之内。Application Insights 没有免费层,但你仍然可以使用 Azure 促销积分来试用。

    • 我们将使用 Azure Function Tools v4 和(可选)Azure CLI。

  • 一个 AWS 订阅(可选):

    • 我们将使用 S3、Lambda 和 X-Ray。我们将确保每个服务的使用都保持在免费层级的限制之内。

    • 我们将需要 AWS 工具包用于 VS 或 Lambda .NET CLI 以及(可选)AWS CLI。

配置云存储

如果你不想创建 Azure 或 AWS 订阅,你仍然可以通过将storage/appsettings.json中的CloudStorage.Type设置为Local来本地运行librariesdapr示例。无服务器演示没有本地设置。

否则,将CloudStorage.Type设置为你的选择存储,AwsS3AzureBlob,然后让我们看看如何配置它们。

AWS S3

使用 AWS 控制台或 CLI 创建一个新的桶:

$ aws s3api create-bucket –bucket <name> --region <region>

然后,将桶信息添加到libraries/storage/appsettings.json

我们还需要凭证来访问 blob 存储,我们将使用我们能够使用的凭证文件。您可以使用aws configure命令生成一个。应用程序将在${HOME}/.aws/credentials中搜索 AWS 凭证文件。

替换libraries/serverless/aws文件夹中的docker-compose.yml中的HOME环境变量。

Azure Blob 存储

创建一个新的存储账户。您可以使用 Azure 门户或 Azure CLI,然后获取连接字符串:

$ az storage account create –-resource-group <group> --name
<account>
$ az storage account show-connection-string -–resource-
group <group> --name <account>

将连接字符串添加到libraries/docker-compose.yml旁边的.env文件中,格式如下:

AZURE_BLOB_CONNECTION_STRING="DefaultEndpointsProtocol=
  https;...."

使用流行库的仪表化

在上一章中,我们看到了如何为.NET 平台、ASP.NET Core 和 Entity Framework 启用跟踪以涵盖基础知识,但任何人都可以为流行的库创建仪表化并与社区分享。此外,跟踪和度量原语作为.NET 和 OpenTelemetry 的一部分,可以以供应商无关的方式收集数据,库可以添加原生仪表化。

有多个术语描述不同类型的仪表化:

  • 自动仪表化有时意味着可以在不修改任何应用程序代码的情况下启用仪表化,但有时也用来描述任何易于启用的共享仪表化。

  • 仪表化库意味着您可以通过安装相应的 NuGet 包并在启动时用几行代码进行配置来启用仪表化。

  • 原生仪表化意味着仪表化代码是库的一部分,因此不需要额外的 NuGet 包,但您可能仍然需要启用仪表化。

  • 手动仪表化是您作为应用程序代码的一部分自己编写的。

自动、原生和仪表化库之间的界限模糊。例如,从.NET 7.0 开始,HTTP 客户端包含原生仪表化,但您可能仍然可以通过相应的仪表化以更方便的方式启用它。或者,通过一些配置 OpenTelemetry 的字节码重写,我们可以在不更改任何应用程序代码的情况下启用库仪表化。在这本书中,我们使用自动仪表化的宽松版本(由于缺乏更好的术语)来描述所有非手动仪表化,但在相关的情况下我们会提到具体的类型。

我们可以从几个来源找到可用的仪表化:

  • OpenTelemetry 注册表(opentelemetry.io/registry):😃 您可以根据语言和组件过滤仪表化。尽管如此,许多仪表化并未添加到注册表中。它列出了所有类型的仪表化,而不考虑它们的类型。

  • OpenTelemetry .NET 仓库 (github.com/open-telemetry/opentelemetry-dotnet):😃 包含 .NET 框架和库的库仪表化。我们之前章节中使用的 ASP.NET Core 和 HTTP 客户端仪表化工具,以及 SQL、gRPC 和 OSS 后端导出器都存放在这里。这些都是仪表化库。

  • OpenTelemetry Contrib 仓库 (github.com/open-telemetry/opentelemetry-dotnet-contrib):😃 包含不同的 OpenTelemetry 组件:仪表化库、导出器和其它实用工具。你可以在那里找到 AWS SDK、ElasticSearch、WCF、StackExchange.Redis 等的仪表化工具。我们之前章节中使用的 Entity Framework 仪表化工具也存放在这个仓库中。

  • OpenTelemetry 仪表化仓库 (github.com/open-telemetry/opentelemetry-dotnet-instrumentation):😃 包含通过不同机制(.NET 分析 API)工作的完全无代码自动仪表化。你可以在那里找到 GraphGL 和 MongoDB 仪表化工具。除了为特定库提供的自动仪表化机制外,它还提供了一种无代码配置 OpenTelemetry 的机制,包括一组常见的仪表化库。

  • 其他来源:如果你在注册表或 OpenTelemetry 仓库中没有找到你想要的内容,请在 OpenTelemetry 仓库中搜索问题,并且不要忘记检查你的库仓库。例如,你可以在 github.com/jbogard/MongoDB.Driver.Core.Extensions.DiagnosticSources 找到 MongoDB 仪表化工具,它被用于 instrumentation 仓库,但也可以作为一个独立的仪表化库使用。

在添加仪表化工具时,请注意它们的稳定性和成熟度。opentelemetry-dotnet 仓库中的仪表化工具被广泛使用,但尚未稳定(在你阅读此内容时可能已经发生变化)。

contrib 仓库中的仪表化工具有不同的状态;例如,AWS 是稳定的,而 MySQL 则处于 alpha 阶段,并在编写本文时适用于相对较旧的 MySQL.Data 包版本。

小贴士

如果你决定依赖一个不太常见的预览包,请确保对其进行充分的测试。与客户端库版本的兼容性、稳定性和性能应该是主要关注点。所有这些都应该通过集成和压力测试来覆盖——只需确保启用仪表化功能!

了解监控的工作原理并检查其背后的机制是否满足您的性能要求是很好的。例如,原生监控依赖于ActivitySourceDiagnosticSource,MongoDB 和 AWS 监控依赖于相应库中的钩子。所有这些方法都应该工作得相当好,但MySQL.Data监控依赖于System.Diagnostics.TraceListener,默认情况下不是线程安全的,并且当配置为线程安全时,性能也不佳。

即使是最高效的监控也会带来一些性能损失。您应该预计吞吐量与非监控代码相比会下降几个百分点。具体的数字很大程度上取决于您的场景和 OpenTelemetry 配置,例如采样。

注意

许多开发者认为自动监控是神奇的,因此为了避免这种情况而避免使用它们。通过了解监控背后的机制,您可以识别出需要额外测试的区域,了解限制,并增强使用它(或不用)的信心。

因此,让我们监控 meme 服务的新版本,并深入了解我们将要使用的每个监控项。

应用程序监控

我们的新演示应用程序将 meme 存储在 Azure Blob Storage 或 AWS S3 中,并在 Redis 中缓存,如图图 3.1所示:

图 3.1 – 可配置云存储的 meme 服务

图 3.1 – 可配置云存储的 meme 服务

如果您不想配置云订阅,也可以将其设置为在 Redis 中存储 meme。

与上一章相比,前端没有变化——我们已经在那里启用了 OpenTelemetry 和 HTTP 监控。在存储方面,尽管我们仍然需要为 AWS、Redis 和 Azure SDK 添加更多监控。

首先,我们需要安装OpenTelemetry.Contrib.Instrumentation.AWSOpenTelemetry.Instrumentation.StackExchangeRedis,然后进行配置:

libraries\storage\Program.cs

builder.Services.AddOpenTelemetry()
  .WithTracing(tracerProviderBuilder =>
        tracerProviderBuilder.
      .AddRedisInstrumentation(redisConnection, o =>
            o.SetVerboseDatabaseStatements = true)
      .AddAWSInstrumentation(o =>
            o.SuppressDownstreamInstrumentation = false)
     ...);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter3/libraries/storage/Program.cs

让我们逐一展开并探索监控项。

Redis

Redis 监控通过OpenTelemetry.Instrumentation.StackExchangeRedis包提供,并来自contrib仓库——那里有文档和示例。

让我们看看我们如何评估这种监控。虽然关于它的具体细节可能会改变,但这种方法可以应用于任何其他监控库。

在编写本文时,Redis 仪表化尚不稳定,但在 NuGet 上有相当多的下载量,并且没有报告任何错误。如果我们调查它是如何工作的,我们会看到它利用了 StackExchange.Redis 分析 API——允许开始分析会话并记录执行期间发生的事件的钩子。尽管名称如此,它不需要附加分析器。

这是一个相对复杂的仪表化——分析 API 并非为分布式跟踪而设计,因此仪表化必须通过维护会话的内部缓存并清理它们来填补空白。

要启用仪表化,我们在 TracerProviderBuilder 上调用 AddRedisInstrumentation 扩展方法并传递连接实例。如果你有多个连接,你必须为每个连接启用仪表化。

我们还传递了仪表化选项并启用了详细数据库语句,通过将 SetVerboseDatabaseStatements 标志设置为 true 来收集包括 Redis 键和脚本在内的附加数据:

AddRedisInstrumentation(redisConnection, o =>
  o.SetVerboseDatabaseStatements = true)

在将配置部署到生产环境之前,检查该配置可能对应用程序性能和输出冗余性的影响是个好主意。如果我们查看 Redis 仪表化代码,这个标志保护了基于反射(但高效)的调用,以获取命令键和脚本。

根据我们在 Redis 中存储的内容,我们还应确保它不会记录任何秘密或敏感数据。

你可能已经注意到仪表化遵循一个常见的模式,但与 Redis 仪表化不同,大多数仪表化是全局的,并且不需要每个客户端实例的设置。

还有其他选项可以控制 Redis 上的跟踪:你可以指定用于丰富活动的回调,禁用具有附加计时的事件,并配置间隔以清理完成的会话。

如果我们现在启动应用程序并在 http://localhost:5051/ 上上传和下载几个 Meme,我们会看到类似于 图 3.2 中所示的跟踪,用于 Meme 下载流程:

图 3.2 – 使用 Redis 跨度下载 Meme

图 3.2 – 使用 Redis 跨度下载 Meme

你可以看到描述通用网络端点的标准 net.peer.* 属性和描述与 db.statement 匹配 Redis 命令和键的数据库调用的 db.* 属性。我们只看到了键(this_is_fine),因为我们已将 SetVerboseDatabaseStatements 设置为 true,否则 db.statement 将匹配命令 HMGET

你还可以看到三个日志(Jaeger 中的跨度事件)描述了 Redis 命令的附加计时信息。由于 Redis 非常快,你可能会发现这些事件不太有用,可以通过将 EnrichActivityWithTimingEvents 设置为 false 来禁用它们,这应该会减少你的可观察性账单并略微提高性能。

AWS SDK

AWS SDK 仪表化可在 OpenTelemetry.Contrib.Instrumentation.AWS NuGet 包中找到,代码位于 contrib 仓库中。让我们尝试使用相同的方法来评估它。

它是稳定的,并依赖于适用于所有 AWS 客户端和实例的全局跟踪处理器,而不仅仅是 S3。此处理器反过来利用 .NET 跟踪原语:ActivityActivitySource

要启用 AWS 仪表化,只需在 TracerProviderBuilder 上调用 AddAWSInstrumentation 扩展方法。此时,只有一个可配置的选项控制是否跟踪嵌套 HTTP 调用:

AddAWSInstrumentation(o => o
  .SuppressDownstreamInstrumentation = false)

图 3.3 展示了 Meme 上传跟踪:PutObject,它反过来向 S3 发起 HTTP PUT 请求。Meme 上传后,它被缓存在 Redis 上:

图 3.3 – 将 Meme 上传到 S3

图 3.3 – 将 Meme 上传到 S3

嵌套的 HTTP 跨度来自 HTTP 客户端仪表化,我们之所以能看到它,是因为 SuppressDownstreamInstrumentation 设置为 false

如果我们展开 S3.PutObject,我们将看到描述此操作的属性,如图 3.4 所示:

图 3.4 – AWS S3 跨度属性

图 3.4 – AWS S3 跨度属性

Azure SDK

Azure SDK 仪表化是原生的——它已嵌入到现代库中——您不需要安装任何额外的包。所有客户端库的跟踪代码都可在 github.com/Azure/azure-sdk-for-net/ 仓库中找到。然而,由于跟踪语义约定是实验性的,它仍然不稳定。例如,属性名称、类型和活动之间的关系可能会在未来发生变化。

您可以通过 AppContext 切换在 csproj 中启用它,或者通过在 Azure 客户端初始化之前添加以下代码:

AppContext.SetSwitch(
  "Azure.Experimental.EnableActivitySource",
  true)

仪表化直接使用 ActivitySourceActivity,因此我们只需要在 TracerProviderBuilder 上调用 AddSource("Azure.*") 方法来启用它。它启用了所有以 Azure 开头的源,但您也可以启用单个源。

图 3.5 展示了 Azure SDK 块上传跟踪——逻辑上传操作和嵌套 HTTP 请求。我们在这里看到一个,但对于分块下载、复杂调用或重试的情况,我们会看到多个嵌套 HTTP 调用:

图 3.5 – Azure Blob 上传

图 3.5 – Azure Blob 上传

我们探讨了几个库的跟踪,并学习了如何发现和评估仪表化。现在,让我们发现我们可以从基础设施中获得什么。

利用基础设施

在本节中,我们将探讨 Dapr 在微服务中的应用。Dapr 提供服务发现、组件绑定、密钥管理、锁定、状态管理、可观察性以及更多构建块,帮助开发者专注于应用逻辑。我们将重点关注分布式跟踪。

在我们的演示应用程序中,我们将使用 Dapr 处理所有网络调用,并在此上启用跟踪和度量。我们还将保持微服务上的遥测开启。图 3.6 展示了新的应用程序布局:

图 3.6 – 使用 Dapr 运行的 Meme 应用

图 3.6 – 使用 Dapr 运行的 Meme 应用

Dapr 作为边车运行——一个围绕每个应用程序实例的独立进程。在我们的设置中,前端通过 Dapr 调用存储,Dapr 处理服务发现、错误处理、加密、负载均衡等。存储反过来使用 Dapr 输出绑定与 Azure、AWS 或本地存储表情包进行通信。

Dapr 与 Kubernetes 集成良好,但我们将使用自托管模式并使用docker-compose来保持简单。

Dapr 支持通过 Dapr 进行的应用程序入站和出站调用的分布式跟踪和度量。让我们看看它在实践中意味着什么。

配置密钥

Dapr 密钥配置需要不同于我们在库演示中使用的不同方法。我们需要按以下方式更新darp/configs/dapr/storage-components/secrets.json

  • 对于 AWS,将您的访问密钥放在{"awsKey": <key>, "awsSecret": <secret>}中。

  • 对于 Azure,设置{"azStorageAccount": <account>, "azStorageKey": <key>}. 如果你没有 Azure 凭证,请从dapr/configs/dapr/storage-components文件夹中删除binding-azure.yaml文件,否则示例将无法工作。

  • 对于本地运行,请在storage/appsettings.json中将CloudStorage.Type设置为Local

在 Dapr 上配置可观察性

要启用跟踪和度量,让我们在配置规范中添加相应的部分:

./dapr/configs/dapr/config.yml

spec:
  metric:
    enabled: true
  tracing:
    samplingRate: "1"
    zipkin:
      endpointAddress: "http://otelcollector:9412/
        api/v2/spans"

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter3/dapr/configs/dapr/config.yml

我们还在docker-compose.yml中添加了 Dapr 边车,在 OpenTelemetry 收集器上启用了 Zipkin 跟踪接收器,并将 Dapr 度量端点添加到 Prometheus 目标以进行抓取。因此,我们同时从应用程序和 Dapr 接收跟踪和度量。让我们来看看。

跟踪

现在让我们使用docker-compose up --build运行应用程序,点击http://localhost:16686并找到一些上传请求,你应该会看到类似于图 3.7中所示的跟踪:

图 3.7 – 应用程序和 Dapr 的跟踪

图 3.7 – 应用程序和 Dapr 的跟踪

来自frontend /memes/d8…CallLocal/storage/memes/d8…的前两个跨度——它们是新的,并且来自 Dapr。

如果我们像图 3.8中所示那样展开它们,我们还会看到它设置的属性:

图 3.8 – Dapr 跨度属性

图 3.8 – Dapr 跨度属性

你可能想知道我们是否还需要在服务上进行分布式跟踪——让我们检查一下。

停止容器并在docker-compose.yml中的前端存储上注释掉OTEL_EXPORTER_OTLP_ENDPOINT环境变量;如果没有提供端点,我们不启用 OpenTelemetry。

然后,重新启动应用程序并再次上传一些表情包,结果如图 3.9所示:

图 3.9 – 应用程序未启用 OpenTelemetry 的 Dapr 跟踪

图 3.9 – 应用程序未启用 OpenTelemetry 的 Dapr 跟踪

因此,我们看到来自 Dapr 的跨度,但跟踪看起来并不正确——上传到 Azure Blob 不是用 CallLocal/storage 跨度表示的入站请求的子项。那里发生了什么?

第二章 .NET 的原生监控 中,我们展示了 ASP.NET Core 和 .NET 中的 HttpClient 创建活动,无论是否存在 OpenTelemetry。这就是这里发生的事情——CallLocal/v1.0/bindings/azureblob 的曾祖父母,但它们之间的跨度没有被记录,因果关系丢失。

类似地,如果你在一个默认不启用分布式跟踪的应用程序上使用 Dapr,上下文将不会在 CallLocal/v1.0/bindings/azureblob 中传播。

注意

Dapr 或服务网格,如 Istio,可以跟踪网络调用,但它们无法在应用程序进程内传播跟踪上下文,并依赖于应用程序来完成。如果你的应用程序没有这样做,它们也不能在日志上标记上下文。

如果你无法对你的应用程序进行仪表化,来自 Dapr 或服务网格的跟踪仍然很有用,尽管它们是半相关的。

如果你使用 Dapr 的原因超出了可观察性,并且你的应用程序已经进行了仪表化,那么 Dapr 跟踪可以让你对 Dapr 本身有可观察性,以查看它是如何处理请求的,这样你可以比较延迟、调试配置问题等等。

指标

Dapr 报告了关于应用程序通信和绑定(如 HTTP 和 gRPC 请求计数、持续时间以及请求和响应大小直方图)的详细指标。你也可以找到 Dapr 自身的 Go 运行时统计信息。

这些指标看起来很有前景,但默认情况下,它们使用 HTTP 请求路径作为指标上的属性,这具有高基数。虽然它们允许使用正则表达式来减少基数并将路径转换为 API 路由,但在高规模的生产应用程序中可能会出现问题。一旦它们准备好投入生产,它们可以成为许多进程内指标的绝佳替代品,这些指标涵盖了网络通信。

服务器端无服务器环境

服务器端无服务器环境比其他系统更需要可观察性——它们经常用于以少量或没有用户代码的方式集成不同的服务,这使得调试和本地测试变得困难。尽管负载均衡、扩展和其他常见的基础设施组件由我们处理,但我们仍然需要了解当事情没有按预期进行时发生了什么。

此外,作为用户,我们在遥测收集选项上非常有限——我们无法安装代理、配置运行时或在特权模式下运行某些内容——我们只能使用云提供商提供的。同时,云提供商有为我们仪表化代码的巨大机会。让我们看看 AWS Lambda 和 Azure Functions 提供了什么,以及我们可以在其上做什么。

AWS Lambda

AWS Lambda 支持与 X-Ray 集成的调用跟踪;您只需通过控制台或 CLI 启用活动跟踪即可跟踪对函数的传入调用并查看基本的调用指标:

图 3.10 – AWS X-Ray 服务映射显示默认 Lambda 仪表化

图 3.10 – AWS X-Ray 服务映射显示默认 Lambda 仪表化

要进一步跟踪代码中的操作,您需要使用 X-Ray SDK 作为稳定的解决方案或 OpenTelemetry,目前它处于测试阶段。在这个演示中,我们将使用 OpenTelemetry。

OpenTelemetry 的配置可能会发生变化。因此,我们将礼貌地请您查看最新的 ADOT 收集器AWS Distro for OpenTelemetry Collector)说明,可在 aws-otel.github.io/docs/getting-started/lambda/lambda-dotnet 找到。

ADOT 收集器基于 OpenTelemetry 收集器;它也兼容 AWS 环境,并附带一组预选的社区组件。我们将向 X-Ray 发送跟踪信息,这是 ADOT 收集器的默认配置,但您可以配置它以将数据发送到您的可观察性后端。

现在,我们已经准备好探索 Lambda 中的跟踪体验。

启用额外的跟踪

Lambda 中的跟踪配置与其他服务类似。首先,我们需要安装 OpenTelemetry.Instrumentation.AWSLambda NuGet 包,然后与导出器和其他仪表化配置一起进行配置:

Function.cs

static Function()
{
  Sdk.SetDefaultTextMapPropagator(new
      AWSXRayPropagator());
    TracerProvider = Sdk.CreateTracerProviderBuilder()
      .AddAWSLambdaConfigurations()...;
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter3/serverless/aws/memefunc/Function.cs

让我们分析一下这里发生的事情。首先,我们将 AWSXRayPropagator 设置为默认上下文传播器——它通过 X-Amzn-Trace-Id 标头启用上下文传播。

然后,我们使用 AddAWSLambdaConfigurations 启用了 Lambda 仪表化。如果我们深入了解,这个方法做了几件事情:

  • 检测和配置资源属性,如云提供商、区域、函数名称和版本

  • 启用 ActivitySource,它报告 Lambda 调用并缝合上下文

注意,我们在静态构造函数中这样做以优化性能并降低成本。尽管 Lambda 是无服务器的,但它使用一个进程来处理多个调用。

作为最后一步,我们需要实现包装我们的 Lambda 逻辑的跟踪处理程序:

Function.cs

async Task<APIGatewayProxyResponse> TracingHandler(
  APIGatewayHttpApiV2ProxyRequest req, ILambdaContext ctx)
    =>
    await AWSLambdaWrapper.TraceAsync(TracerProvider,
      MemeHandler, req, ctx);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter3/serverless/aws/memefunc/Function.cs

注意,我们配置 Lambda 调用 TracingHandler 而不是内部的 MemeHandler

如果我们回到配置,其余部分启用 AWS SDK 和 HTTP 客户端工具。我们还配置了不带参数的 OTLP 导出器 – 它使用默认端点 (localhost:4317) 和默认协议 (gRPC)。

我们还配置了 前端 使用 X-Ray 导出器将数据发送到 ADOT,因此我们可以在同一位置获取所有跟踪。

如果您尚未部署 Lambda 函数,现在就部署它,例如,使用 Visual Studio 的 AWS Toolkit 或 .NET CLI 的 Lambda 工具。

确保在 Storage__Endpoint 环境变量上配置函数 URL – 您可以在 ./frontend/docker-compose.yml 中设置它。在演示中我们不使用授权,但请确保保护您现实生活中的应用程序。

现在,让我们开始 docker-compose up --build,然后在 http://localhost:5051 上上传和下载一些表情包。

让我们切换到 AWS X-Ray 并检查跟踪。您应该会看到类似于 图 3.11 的内容:

图 3.11 – 使用 OpenTelemetry 的 Lambda 跟踪

图 3.11 – 使用 OpenTelemetry 的 Lambda 跟踪

如果您检查服务图,它现在除了 Lambda 节点外还显示了 S3。

现在您已经知道了如何为 AWS Lambda 启用跟踪,让我们看看 Azure Functions 有哪些能力。

Azure Functions

Azure Functions 支持与 Azure Monitor (Application Insights) 集成的分布式跟踪。它包括触发器和大多数绑定。如果您使用进程内函数,跟踪也会覆盖用户代码,对于隔离的工作进程,您需要自行在工作进程中启用和配置跟踪。

Azure Functions 依赖于用于触发器和绑定的客户端 SDK 中的工具。例如,它们在 HTTP 触发器中重用 ASP.NET Core 活动,并在 Azure Blob Storage 输入和输出中使用 Azure SDK 工具。

Azure Functions 运行时目前尚不支持 OpenTelemetry 用于进程内函数,但您的可观察性供应商可能提供一种扩展来填补这一空白。

在我们的示例中,Azure Functions 主机自动向 Application Insights 报告触发器和绑定调用 – 这种自动收集在存在 APPLICATIONINSIGHTS_CONNECTION_STRING 环境变量时启动,我们可以在 local.settings.json 文件中设置它,如本例所示:

./serverless/azure/memefunc/local.settings.json

"Values": {
  ...
  "APPLICATIONINSIGHTS_CONNECTION_STRING":
       "InstrumentationKey=<key>;IngestionEndpoint=
           <endpoint>"
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter3/serverless/azure/memefunc/local.settings.json

我们还需要使用以下代码为工作进程启用 OpenTelemetry:

./serverless/azure/memefunc/Program.cs

var host = new HostBuilder()
  .ConfigureFunctionsWorkerDefaults
  .ConfigureServices(services => services
    .AddOpenTelemetry()
    .WithTracing(builder => builder
      .AddSource("Microsoft.Azure.Functions.Worker")
      ...)
    )
  .Build();

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter3/serverless/azure/memefunc/Program.cs

这里我们使用了一种熟悉的方式来启用 OpenTelemetry,但 the Microsoft.Azure.Functions.Worker 活动源是新的。该源是 Azure Functions Worker 的一部分,它从主机传播跟踪上下文到隔离的工作进程。它创建了一个代表工作者调用的活动。

Azure.Monitor.OpenTelemetry.Exporter 上发送数据到 Application Insights 端点。

运行示例需要创建一个 Application Insights 资源。你可以使用以下命令创建:

$ az monitor app-insights component create --app <resource-
  name> --location <region> -g <resource-group>

它将返回包含 connectionString 的 JSON 输出,这是我们配置 Functions 所需要的。现在让我们在 memefunc/local.setting.json 中设置 Azure Blob Storage 和 Application Insights 连接字符串,然后我们就可以运行应用程序了:

serverless/azure/frontend$ dotnet run
serverless/azure/memefunc$ func start --port 5050

打开 http://localhost:5051 来上传和下载一些表情包,然后转到你的 Application Insights 资源并搜索最近请求。图 3.12 展示了捕获的跟踪示例:

图 3.12 – Azure Functions 跟踪

图 3.12 – Azure Functions 跟踪

我们追踪了这个从 storage-download 函数发出的调用,该函数随后下载了一个 blob。我们使用了 Azure Blob Storage 绑定,因此与 blob 存储的所有通信都由 Azure Functions 主机处理,且在工作进程之外。因此,Azure Functions 调用跨度(storage-download)以及所有与 blob 相关的跨度都是由 Functions 主机报告的。

Invoke 调用跨度由 Microsoft.Azure.Functions.Worker 活动源记录;它代表在工作进程上的函数调用。如果我们有任何在工作者内部完成的嵌套操作,我们会看到它们作为 Invoke 调用跨度的子项被报告。

尽管大部分应用程序逻辑发生在应用程序代码之外,但由于跟踪,我们仍然可以看到底层的操作。

摘要

在本章中,我们探讨了 .NET 生态系统中的仪器化。你学习了如何评估和配置不同类型的仪器库,如何启用和使用 Dapr 上的跟踪,以及不同配置级别下无服务器环境可以提供什么。

客户端库自动跟踪可以在 OpenTelemetry 仓库或注册表中找到,而一些库不需要跟踪工具,提供原生的跟踪功能。跟踪工具的成熟度和稳定性水平各不相同,因此作为你正常集成和压力测试的一部分,审查和测试它们很重要。跟踪工具通常提供配置选项来控制它们捕获的细节量,这允许你为你的系统找到合适的成本效益比。客户端库和框架并不是跟踪的唯一来源——你的基础设施,如服务网格、Web 服务器、负载均衡器和代理也可以产生跟踪。我们检查了 Dapr 中的跟踪故事,确认它提供了对 Dapr 本身的洞察,但不能传播上下文并在应用程序的日志和其他信号上打上标记。因此,基础设施跟踪是补充但无法替代进程内跟踪。

无服务器环境提供了与跟踪和监控工具的集成;这对于它们来说至关重要,因为用户在无服务器运行时的配置上有限。

我们探索了支持 OpenTelemetry 的 AWS Lambda,使用 ADOT 收集器和代码内配置,以及支持供应商特定代码无跟踪模式的 Azure Functions,而开箱即用的 OpenTelemetry 支持尚未到来。

现在你已经知道了如何在不同的环境中发现和使用第三方跟踪工具,你应该能够对广泛范围的分布式应用程序获得可观察性。然而,要调试进程内问题,如死锁、内存泄漏或低效的代码,我们需要更底层的遥测——这就是我们将在下一章中探讨的内容。

问题

  1. 你会如何找到一个你使用的流行库的跟踪工具?当你找到一个时,你会检查什么?

  2. OpenTelemetry 跟踪工具背后的典型机制是什么?

  3. 服务网格在跟踪方面能做什么,不能做什么?

第四章:使用诊断工具进行低级性能分析

虽然分布式跟踪对于微服务来说效果很好,但在进程内部进行深度性能分析时则不太有用。在本章中,我们将探讨 .NET 诊断工具,这些工具允许我们检测和调试性能问题,并分析低效代码。我们还将学习如何在生产中执行即席性能分析并自动捕获必要的信息。

在本章中,您将学习以下内容:

  • 使用 .NET 运行时计数器来识别常见的性能问题

  • 使用性能跟踪优化低效代码

  • 在生产中收集诊断信息

到本章结束时,您将能够使用 .NET 诊断工具调试内存泄漏、识别线程池饥饿,并收集和分析详细性能跟踪。

技术要求

本章的代码可在本书的 GitHub 仓库中找到,网址为 github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/tree/main/chapter4。它包括以下组件:

  • issues 应用程序,其中包含性能问题的示例

  • loadgenerator,这是一个生成负载以重现问题的工具

要运行示例并执行分析,我们需要以下工具:

  • .NET SDK 7.0 或更高版本。

  • .NET 的 dotnet-tracedotnet-stackdotnet-dump 诊断工具。请使用 dotnet tool install –global dotnet-<tool> 安装每个工具。

  • Docker 和 docker-compose.

要运行本章中的示例,启动可观察性堆栈,该堆栈由 Jaeger、Prometheus 和 OpenTelemetry 收集器组成,使用 docker-compose up

确保您从 issues 文件夹中启动 dotnet run -c Release。我们不使用 Docker 运行它,这样更容易使用诊断工具。

OpenTelemetry.Instrumentation.ProcessOpenTelemetry.Instrumentation.Runtime NuGet 包中配置了 HTTP 客户端和 ASP.NET Core 的指标。以下是我们的指标配置:

Program.cs

builder.Services.AddOpenTelemetry()
  ...
  .WithMetrics(meterProviderBuilder =>
      meterProviderBuilder
      .AddOtlpExporter()
          .AddProcessInstrumentation()
          .AddRuntimeInstrumentation()
          .AddHttpClientInstrumentation()
          .AddAspNetCoreInstrumentation());

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/tree/main/chapter4/issues/Program.cs

调查常见的性能问题

性能下降是某些其他问题(如竞争条件、依赖项减速、高负载或任何导致您的 服务级别指标SLIs)超出健康限制并错过 服务级别目标SLOs)的问题)的症状。这些问题可能影响多个,如果不是所有代码路径和 API,即使它们最初仅限于特定场景。

例如,当下游服务出现问题时,它会导致所有 API 的吞吐量显著下降,包括那些不依赖于该下游服务的 API。重试、额外的连接或处理下游调用的线程消耗比平常更多的资源,并将它们从其他请求中夺走。

注意

仅凭资源消耗,无论是高是低,都不能表明存在性能问题(或缺乏性能)。如果用户不受影响,高 CPU 或内存利用率可能是有效的。当它们异常高时,调查它们仍然很重要,因为这可能是未来问题的早期信号。

我们可以通过监控 SLIs 并对违规情况进行警报来检测性能问题。如果你看到问题普遍存在,而不是特定于某些场景,那么检查整个进程的资源消耗,例如 CPU 使用率、内存和线程计数,以找到瓶颈是有意义的。然后,根据受限的资源,我们可能需要捕获更多信息,例如转储、线程堆栈、详细的运行时或库事件。让我们通过几个常见问题的例子,并讨论它们的症状。

内存泄漏

随着时间的推移,应用程序消耗越来越多的内存时会发生内存泄漏。例如,如果我们缓存对象而不进行适当的过期和溢出逻辑,应用程序随着时间的推移将消耗越来越多的内存。增长的内存消耗会触发垃圾回收,但缓存会保留对所有对象的引用,GC 无法释放它们。

让我们重现一个内存泄漏,并探讨帮助我们识别它并找到根本原因的信号。首先,我们需要运行 loadgenerator 工具:

loadgenerator$ dotnet run -c Release memory-leak –-parallel 100 –-count 20000000

它发出了 2000 万次请求然后停止,但如果让它长时间运行,我们会看到吞吐量下降,如图 图 4.1 所示:

图 4.1 – 服务吞吐量(每秒成功请求次数)

图 4.1 – 服务吞吐量(每秒成功请求次数)

我们可以看到吞吐量下降的时期,服务停止处理请求——让我们调查一下原因。

.NET 报告事件计数器,帮助我们监控每个 GC 生成的大小。新分配的对象出现在 0 代;如果它们在垃圾回收中存活,它们会被提升到 1 代,然后到 2 代,在那里它们会停留直到被收集或进程终止。大对象(85 KB 或更大)出现在 大对象 LOH)中。

OpenTelemetry 运行时仪器在 process_runtime_dotnet_gc_heap_size_bytes 指标下报告生成大小。监控 process_memory_usage_bytes 也很有用。我们可以在 图 4.2 中看到 2 代和物理内存消耗:

图 4.2 – 应用程序内存消耗显示内存泄漏

图 4.2 – 应用程序内存消耗显示内存泄漏

我们可以看到,随着时间推移,2 代增长,虚拟内存也随之增长。进程使用的物理内存上下波动,这意味着操作系统开始使用磁盘,除了 RAM。这个过程被称为分页交换,这在操作系统级别是启用(或禁用)的。当启用时,它可能会显著影响性能,因为 RAM 通常比磁盘快得多。

最终,系统将耗尽物理内存,页面文件将达到其大小限制;然后,进程将因OutOfMemoryException错误而崩溃。这可能会根据环境和堆大小配置而提前发生。对于 32 位进程,当虚拟内存大小达到 4 GB 且地址空间耗尽时,OOM 发生。内存限制可以由应用程序服务器(IIS)、托管提供商或容器运行时配置或强制实施。

Kubernetes 或 Docker 允许您限制容器的虚拟内存。不同环境的行为各不相同,但通常情况下,当达到限制后,应用程序会以OutOfMemory退出代码终止。内存泄漏可能需要几天、几周甚至几个月才能导致进程因OutOfMemoryException而崩溃,因此一些内存泄漏可能处于休眠状态,可能引起罕见的重启,并仅影响长尾延迟分布。

热路径上的内存泄漏可能导致整个服务快速崩溃。当内存消耗迅速增长时,垃圾回收会密集地尝试释放一些内存,这会使用 CPU 并可能导致托管线程暂停。

我们可以使用.NET 事件计数器和 OpenTelemetry 仪表板来监控单个代的垃圾回收,如图图 4**.3所示:

图 4.3 – 单一代的垃圾回收速率每秒

图 4.3 – 单一代的垃圾回收速率每秒

如您所见,0 代和 1 代的收集发生频率较高。观察一致的内存增长和垃圾回收的频率,我们现在可以相当肯定我们正在处理内存泄漏。我们还可以从Microsoft-Windows-DotNETRuntime事件提供程序收集 GC 事件(我们将在下一节中学习如何做),得出相同的结论。

让我们也检查 OpenTelemetry 进程仪表板报告的 CPU 利用率(如图图 4**.4所示),作为process_cpu_time_seconds_total度量,从中我们可以推导出利用率:

图 4.4 – 内存泄漏期间的 CPU 利用率

图 4.4 – 内存泄漏期间的 CPU 利用率

我们可以看到,当用户 CPU 利用率和特权(系统)CPU 利用率同时上升时,这些时期与图 4.1中吞吐量下降的时期相同。用户 CPU 利用率来源于System.Diagnostics.Process.UserProcessorTime属性,而系统利用率(基于 OpenTelemetry 术语)来源于System.Diagnostics.Process.PriviledgedProcessorTime属性。这些时期与图 4.1中吞吐量下降的时期相同。

我们的调查可能从高延迟、高错误率、高进程重启次数、高 CPU 或高内存利用率开始,所有这些都是同一问题的症状——内存泄漏。因此,现在我们需要进一步调查它——让我们收集内存转储以查看其中有什么。假设你可以在本地机器上重现该问题,Visual Studio 或 JetBrains dotMemory 可以捕获和分析内存转储。我们将使用dotnet-dump,我们可以在遇到问题的实例上运行它。查看learn.microsoft.com/dotnet/core/diagnostics/dotnet-dump以了解更多关于该工具的信息。

因此,让我们使用以下命令来捕获转储:

$ dotnet-dump collect -–name issues

一旦收集了转储,我们就可以使用 Visual Studio、JetBrains dotMemory 或其他自动化并简化分析的工具来分析它。我们将使用dotnet-dump CLI 工具以困难的方式完成这项工作:

$ dotnet-dump analyze <dump file name>

这将打开一个提示符,我们可以在其中运行SOS命令。SOS 是一个调试器扩展,它允许我们检查正在运行的过程和转储。它可以帮助我们找出堆上的内容。

我们可以使用dumpheap -stat命令来完成这项工作,该命令按类型打印对象的计数、总计数和大小,如图 4.5所示:

图 4.5 – 显示约 2000 万个 MemoryLeakController 实例的托管堆统计信息

图 4.5 – 显示约 2000 万个 MemoryLeakController 实例的托管堆统计信息

统计数据按升序打印,因此总大小最大的对象出现在末尾。在这里,我们可以看到我们几乎有 2000 万个MemoryLeakController实例,它们消耗了大约 1.5GB 的内存。控制器实例的范围是请求,看起来在请求结束后并没有被回收。让我们找到GC 根——保持控制器实例存活的对象。

我们需要找到任何控制器实例的地址。我们可以使用它的方法表——每个表格行中的第一个十六进制数字。方法表存储每个对象类型信息,是内部 CLR 实现细节。

我们可以使用另一个 SOS 命令来找到它的对象地址:

$ dumpheap -mt 00007ffe53f5d488

这将打印一个包含所有MemoryLeakController实例地址的表格。让我们复制其中一个,以便我们可以用它与 GC 根一起找到:

$ gcroot -all <controller-instance-address>

图 4.6显示了gcroot命令打印的从 GC 根到控制器实例的路径:

图 4.6 – ProcessingQueue 正在保持控制器实例存活

图 4.6 – ProcessingQueue 正在保持控制器实例存活

我们可以看到issues.ProcessingQueue正在持有这个和其他控制器实例。它内部使用ConcurrentQueue<Action>。如果我们检查控制器代码,我们会看到我们添加了一个使用_logger的动作——这是一个控制器实例变量,它隐式地保持控制器实例存活:

MemoryLeakController.cs

_queue.Enqueue(() => _logger.LogInformation(
    "notification for {user}",
    new User("Foo", "leak@memory.net")));

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/tree/main/chapter4/issues/Controllers/MemoryLeakController.cs

要修复这个问题,我们需要停止在动作中捕获控制器的记录器,并给队列添加大小限制和背压。

线程池饥饿

当 CLR 没有足够的线程在池中处理工作负载时,就会发生线程池饥饿,这可能在启动时发生,或者在负载显著增加时发生。让我们重现它并看看它是如何表现的。

issues应用程序运行时,使用以下命令添加一些负载,向应用程序发送 300 个并发请求:

$ dotnet run -c Release starve ––parallel 300

现在,让我们检查吞吐量和延迟的情况。你可能看不到来自应用程序的任何指标或跟踪,或者看到在负载开始之前报告的过时指标。如果你尝试访问问题应用程序上的任何 API,例如 http://localhost:5051/ok,它将超时。

如果你检查issues进程的 CPU 或内存,你会看到非常低的利用率——进程卡在什么也不做。这会持续几分钟,然后解决——服务开始正常响应并报告指标和跟踪。

要理解当进程不报告指标和跟踪时发生了什么,可以使用dotnet-counters工具。查看.NET 文档learn.microsoft.com/dotnet/core/diagnostics/dotnet-counters以了解更多关于这个工具的信息。现在,让我们运行它来查看运行时计数器:

$ dotnet-counters monitor --name issues

它应该打印一个表格,其中包含随时间变化的运行时计数器,如图4.7所示:

图 4.7 – dotnet-counters 输出动态显示运行时计数器

图 4.7 – dotnet-counters 输出动态显示运行时计数器

在这里,我们关注线程池计数器。我们可以看到有 1,212 个工作项在线程池队列长度中等待,并且随着线程数量的增加而持续增长。每秒只有少数(如果有的话)工作项被完成。

这种行为的根本原因是控制器中的以下代码,它阻止了线程池线程:

_httpClient.GetAsync("/dummy/?delay=100", token).Wait();

因此,线程不会切换到另一个工作项,而是坐着等待空调用完成。这影响所有任务,包括那些将遥测数据导出到收集器的任务 – 它们在同一个队列中等待。

运行时逐渐增加线程池大小,最终足够大以清理工作项队列。查看图 4.8以了解线程池计数器的动态变化:

图 4.8 – 饥饿前后线程池线程数和队列的变化

图 4.8 – 饥饿前后线程池线程数和队列的变化

如您所见,我们没有关于饥饿发生时的数据。但是,在线程池队列清理后,我们开始获取数据,并看到运行时将线程数调整到更低的值。

我们刚刚看到某个代码路径上的问题如何影响整个过程的性能,以及我们如何使用运行时指标和诊断工具来缩小范围。现在,让我们学习如何调查特定于某些 API 或单个请求的性能问题。

性能分析

如果我们分析对应于线程池饥饿或内存泄漏的个别跟踪,我们不会看到任何特别之处。在轻负载下它们运行得很快,但在负载增加时速度会变慢或失败。

然而,一些性能问题仅影响某些场景,至少在典型负载下是这样。锁和效率低下的代码是这类操作的例子。

我们很少在分布式跟踪下对本地操作进行仪器化,假设本地调用很快,异常有足够的信息供我们调查失败。

但是,当我们有计算密集型或效率低下的代码在服务中时会发生什么?如果我们查看分布式跟踪,我们会看到高延迟和跨度之间的间隙,但我们不知道为什么会发生。

我们提前知道某些操作,例如复杂算法或 I/O,可能需要很长时间才能完成或失败,因此我们可以故意用跟踪或只是写一个日志记录来跟踪它们。但我们很少故意将低效代码引入热点路径;因此,我们使用分布式跟踪、指标或日志进行调试的能力有限。

在这种情况下,我们需要更精确的信号,例如性能分析。性能分析涉及收集调用栈、内存分配、计时和调用频率。这可以通过使用.NET 性能分析 API 在进程内完成,这些 API 需要应用程序以某种方式配置。低级性能分析通常在开发者的本地机器上完成,但曾经是应用程序性能监控APM)工具中收集性能数据和跟踪的一种流行机制。

在本章中,我们将使用一种不同类型的分析,也称为性能追踪,它依赖于 System.Diagnostics.Tracing.EventSource,并且可以随时进行。EventSource 实质上是一个平台日志记录器 – CLR、库和框架将它们的诊断信息写入事件源,这些事件源默认是禁用的,但可以动态地启用和控制。

.NET 运行时和库事件涵盖了 GC、任务、线程池、DNS、套接字和 HTTP 等其他内容。ASP.NET Core、Kestrel、依赖注入、日志记录和其他库也有它们自己的事件提供者。

您可以使用 EventListener 监听进程内的任何提供者,并访问事件及其负载,但 EventSource 的真正威力在于您可以通过 dotnet-monitor 工具从进程外部控制提供者,如 第二章 中所述,在 .NET 中的 原生监控

让我们看看使用 EventSource 进行性能追踪和剖析如何帮助我们调查特定问题。

低效的代码

让我们运行我们的演示应用程序,看看低效的代码如何表现出来。确保可观察性堆栈正在运行,然后启动 问题 应用程序,然后应用一些负载:

$ dotnet run -c Release spin –-parallel 100

负载生成器向 http://localhost:5051/spin?fib= 端点发送 100 个并发请求。spin 端点计算第 n 个斐波那契数;正如您将看到的,我们的斐波那契实现相当低效。

假设我们不知道这个斐波那契实现有多糟糕,让我们尝试调查为什么这个请求花费了这么长时间。让我们通过访问 http://localhost:16686,点击 查找追踪,并查看延迟分布来打开 Jaeger,如图 图 4.9 所示:

图 4.9 – Jaeger 中的延迟分布

图 4.9 – Jaeger 中的延迟分布

我们可以看到几乎所有请求都需要超过 2 秒才能完成。如果您点击任何一个点,Jaeger 将显示相应的追踪。它应该看起来与 图 4.10 中显示的类似:

图 4.10 – Jaeger 中的长追踪

图 4.10 – Jaeger 中的长追踪

负载应用程序被配置为我们可以测量客户端延迟。我们可以看到客户端请求花费了 4.5 秒,而服务器端请求大约花费了 1.5 秒。在 spin 请求中,我们调用相同应用程序的虚拟控制器,并可以看到相应的客户端和服务器跨度。这里唯一突出的是有很多空隙,我们不知道那里发生了什么。

如果我们检查指标,我们将看到高 CPU 和高服务器延迟,但没有可疑之处可以帮助我们找到根本原因。因此,是时候捕获一些性能追踪了。

多种工具可以捕获经历此问题的进程的性能追踪,例如 Windows 上的 PerfView 或 Linux 上的 PerfCollect。

我们将使用跨平台的dotnet-trace CLI 工具,您可以在任何地方安装和使用它。请运行以下命令 10-20 秒:

$ dotnet-trace collect --name issues

使用此命令,我们已启用Microsoft-DotNETCore-SampleProfiler事件源(以及其他默认提供者)来通过读取.NET 文档learn.microsoft.com/dotnet/core/diagnostics/dotnet-trace捕获dotnet-trace工具的托管线程调用堆栈。我们还可以将其配置为收集来自任何其他事件源的事件。

工具将跟踪保存到issues.exe_*.nettrace文件中,我们也可以用它来分析:

$ dotnet-trace report issues.exe_*.nettrace topN

它输出在堆栈上花费时间最多的前(默认为 5 个)方法。图 4.11显示了部分输出示例:

图 4.11 – 堆栈上的前五种方法

图 4.11 – 堆栈上的前五种方法

关于最上面的行没有详细信息——这是由于非托管或动态生成的代码造成的。但第二行是我们的——MostInefficientFibonacci方法看起来可疑,值得检查。它在调用堆栈上出现了 29.3%的时间(排他性百分比)。在嵌套调用中,它在调用堆栈上出现了 31.74%的时间(包含性百分比)。这很简单,但在更复杂的情况下,这种分析将不足以满足需求,我们可能需要进一步深入到流行的调用堆栈中。

您可以使用我之前提到的任何性能分析工具打开跟踪文件。我们将使用 SpeedScope(www.speedscope.app/),这是一个基于 Web 的工具。

首先,让我们将跟踪文件转换为speedscope格式:

dotnet-trace convert --format speedscope
  issues.exe_*.nettrace

然后,我们必须通过浏览器将生成的 JSON 文件拖放到 SpeedScope 中。它将显示每个线程捕获的调用堆栈。

您可以点击不同的线程。您会看到其中许多都在等待工作,如图图 4.12所示:

图 4.12 – 线程正在等待工作

图 4.12 – 线程正在等待工作

这解释了报告中的最上面一行——大多数时候,线程都在非托管代码中等待。

如您所见,还有另一组线程正在努力计算斐波那契数,如图图 4.13所示:

图 4.13 – 显示使用斐波那契数计算的控制器调用的调用堆栈

图 4.13 – 显示使用斐波那契数计算的控制器调用的调用堆栈

如您所见,我们使用了一个没有记忆的递归斐波那契算法,这解释了糟糕的性能。

我们还可以使用dotnet-stack工具,该工具可以打印托管线程堆栈跟踪快照。

调试锁

使用性能跟踪,我们可以检测到积极消耗 CPU 的代码,但如果没有发生任何事情——例如,如果我们代码中有一个锁?让我们来看看。

让我们启动问题应用并生成一些负载:

$dotnet run -c Release lock ––parallel 1000

如果我们检查 CPU 和内存消耗,我们可以看到它们很低,增长不多,线程计数变化不大,线程队列是空的,竞争率很低。同时,吞吐量很低(大约每秒 60 个请求)和延迟很大(P95 大约是 3 秒)。所以,应用程序什么都没做,但它不能更快。如果我们检查跟踪信息,我们会看到一个很大的空白,没有进一步的数据。

这个问题特定于锁 API;如果我们击中另一个 API,例如http://localhost:5051/ok,它会立即响应。这缩小了我们搜索锁 API 的范围。

假设我们不知道那里有一个锁,让我们再次使用$ dotnet-trace collect --name issues收集一些性能跟踪信息。如果我们得到topN堆栈,就像之前的例子一样,我们不会看到任何有趣的东西 – 只是有线程在等待工作 – 锁定很快;等待被锁定资源变得可用需要更长的时间。

我们可以更深入地挖掘生成的跟踪文件,以找到锁控制器中实际发生的事情的堆栈跟踪。我们将在 Windows 上使用 PerfView,但你也可以在 Linux 上使用 PerfCollect,或其他工具,如 JetBrains dotTrace,来打开跟踪文件并找到堆栈跟踪。

让我们使用 PerfView 打开跟踪文件,然后单击LockController.Lock,如图图 4.14 所示:

图 4.14 – 在所有线程中查找 LockController 堆栈

图 4.14 – 在所有线程中查找 LockController 堆栈

我们可以看到LockController很少出现在调用堆栈上,以及它的嵌套调用 – 我们可以从包含和排除的百分比都接近 0 来判断。从这个角度来看,我们可以得出结论,我们正在等待的东西是异步的;否则,我们会在调用堆栈上看到它。

现在,让我们右键单击LockController行,然后单击LockController堆栈。切换到调用树选项卡,如图图 4.15 所示:

图 4.15 – 带有 LockController.Lock 的调用堆栈

图 4.15 – 带有 LockController.Lock 的调用堆栈

我们可以看到控制器调用了SemaphoreSlim.WaitAsync – 这应该是我们的首要嫌疑人。它解释了低 CPU、低内存使用和线程计数没有异常。它仍然让客户端等待并保持客户端连接打开。

注意

图 4.15 中,我们只能看到调用堆栈的同步部分 – 它不包括WaitAsync或之后发生的事情。

我们在这里所做的分析依赖于运气。在现实世界的场景中,这个问题会隐藏在其他调用中。我们会有多名嫌疑人,需要收集更多数据来进一步调查。由于我们正在寻找异步嫌疑人,使用dotnet-traceSystem.Threading.Tasks.TplEventSource提供者收集与任务相关的事件将很有用。

如果我们查看代码,问题很明显,但在现实世界的代码中,它可能被很好地隐藏在功能标志或第三方库后面:

LockController.cs

await semaphoreSlim.WaitAsync(token);
try
{
    ThreadUnsafeOperation();
    await _httpClient.GetAsync("/dummy/?delay=10", token);
}
finally
{
    semaphoreSlim.Release();
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/tree/main/chapter4/issues/Controllers/LockController.cs

这里的问题在于我们在对下游服务的 HTTP 调用周围设置了一个锁。如果我们只将ThreadUnsafeOperation包装在一个同步锁中,我们将看到大约每秒 20K 的请求吞吐量和大约 20 毫秒的 P95 低延迟。

性能跟踪是一个强大的工具,它允许我们捕获.NET 运行时、标准和第三方库报告的低级别数据。在本章中我们讨论的示例中,我们在与服务的同一主机上运行诊断工具。当你本地重现问题或优化开发环境中的服务时,这是合理的。让我们看看在具有多个服务实例运行和受限 SSH 访问的更真实情况下我们能做什么。

在生产中使用诊断工具

在生产中,我们需要能够以合理的性能和遥测预算主动收集一些数据,以便我们可以在之后分析数据。

在一个运行进程的特定实例上重现问题并从它那里收集性能跟踪或转储,在安全且分布式的应用程序中是困难的。如果像缓慢的内存泄漏或罕见的死锁这样的问题只影响少数实例,甚至可能很难检测到它,并且在检测到时,实例已经被回收,问题不再可见。

连续剖析

我们在寻找的是一个连续剖析器——一个收集样本性能跟踪的工具。它可以运行很短的时间,以最小化收集对每个实例的性能影响,并将配置文件发送到中央存储,在那里它们可以被存储、与分布式跟踪相关联、查询和查看。分布式跟踪支持采样,剖析器可以使用它来一致地捕获跟踪和配置文件。

许多可观察性供应商,如 Azure Monitor、New Relic、Dynatrace 等,为.NET 提供连续的剖析器。例如,Azure Monitor 允许我们从跟踪中导航到配置文件,正如你在图 4.16中看到的那样:

图 4.16 – 在 Azure Monitor 中从跟踪导航到配置文件

图 4.16 – 在 Azure Monitor 中从跟踪导航到配置文件

我们将看到本章前面讨论的不高效代码示例的长时间跟踪,但连续剖析器已启用并捕获了一些这些调用。如果我们点击剖析器图标,我们将看到调用堆栈,类似于我们使用dotnet-collect捕获的,如图图 4.17所示:

图 4.17 – 显示包含 MostInefficientFibonacci 方法的递归调用堆栈的配置文件

图 4.17 – 显示包含 MostInefficientFibonacci 方法的递归调用堆栈的配置文件

使用持续分析器,我们可以在几秒钟内调试低效的代码,前提是问题足够频繁地重现,以便我们可以捕获分布式跟踪和对其进行分析。

dotnet-monitor 工具

除了分析单个调用之外,我们还需要能够主动和按需捕获转储。可以配置 .NET 在进程崩溃时捕获转储,但在容器中这并不总是有效,而且访问和传输转储并不简单。

使用 dotnet-monitor,我们可以以与 dotnet 诊断工具相同的方式捕获日志、内存和 GC 转储,并收集性能跟踪:

  • 可以使用 dotnet-monitor /trace API 或 dotnet-trace CLI 工具收集来自事件源的性能跟踪。

  • 可以使用 /dump API 或 dotnet-dump 工具收集转储

  • 可以使用 /metrics API 或 dotnet-counters 工具收集事件计数器

查阅 dotnet-monitor 文档以了解这些以及其他它提供的 HTTP API:github.com/dotnet/dotnet-monitor/tree/main/documentation

我们还可以配置触发器和规则,根据 CPU 或内存利用率、GC 频率和其他运行时计数器值主动收集跟踪或转储。结果会被上传到可配置的外部存储。

我们在 第二章Native Monitoring in .NET 中查看了一些 dotnet-monitor 的功能,其中我们在 Docker 中将其作为边车容器运行。同样,你可以在 Kubernetes 中将其作为边车运行。

摘要

性能问题通过降低服务可用性来影响用户体验。分布式跟踪和常用指标允许你将问题缩小到特定的服务、实例、API 或其他因素的组合。当这还不够时,你可以通过添加更多的跨度来提高分辨率,但最终,解决方案的性能影响和成本可能会变得不合理。

.NET 运行时指标提供了对 CLR、ASP.NET Core、Kestrel 以及其他组件的洞察。这些指标可以通过 OpenTelemetry、dotnet-countersdotnet-monitor 收集。这些指标可能足以找到问题的根本原因,或者只是提供如何继续调查的输入。下一步可能是捕获进程转储和分析内存或线程的调用栈,这可以通过 dotnet-dump 实现。

对于特定场景的问题,性能跟踪提供了详细信息,使我们能够看到应用程序中发生的情况或在第三方库代码底层的操作。性能跟踪可以通过 dotnet-tracedotnet-monitor 收集。通过捕获性能跟踪,我们可以看到详细的调用栈,获取关于消耗 CPU 的统计信息,并更精确地监控竞争和垃圾回收。这不仅是一个调查底层问题的优秀工具,也是优化代码的好工具。

在安全的多实例环境中收集低级数据具有挑战性。连续分析器可以按需、按某些计划或通过响应某些触发器收集性能跟踪和其他诊断信息。它们还可以负责将数据存储在中央位置,并将其与其他遥测信号可视化并关联起来。

dotnet-monitor工具可以作为边车运行,然后主动或按需提供诊断数据的基本功能,并将其发送到外部存储。

在本章中,您学习了如何使用.NET 诊断工具收集诊断数据,以及如何使用它来解决几类常见的性能问题。结合我们之前学到的关于指标、分布式跟踪和日志的知识,应该能够让您调试应用程序中的大多数分布式和本地问题。

因此,现在您已经知道如何利用自动仪表化和使用他人创建的遥测数据。在下一章中,我们将学习如何丰富自动生成的遥测数据,并使其符合我们的需求。

问题

  1. 您首先会检查什么来了解应用程序是否健康?

  2. 如果您看到影响多个不同场景的主要性能问题,您会如何调查它?

  3. 性能跟踪是什么?您如何利用它?

第二部分:仪表化.NET 应用程序

本部分提供了对.NET 跟踪、指标、日志以及更多内容的深入概述和实践指南。我们将从了解 OpenTelemetry 配置开始,然后深入探讨手动仪表化,使用不同的信号。

本部分包含以下章节:

  • 第五章配置和控制平面

  • 第六章跟踪您的代码

  • 第七章添加自定义指标

  • 第八章编写结构化和关联日志

第五章:配置和控制平面

在前面的章节中,我们学习了如何通过几行代码启用自动检测,并利用收集到的遥测数据来调试问题和监控性能。自动收集的跟踪信息和指标是您可观察性解决方案的基础,但它们通常不足以在没有应用程序上下文的情况下使用。在本章中,我们将学习如何自定义遥测收集——丰富、调整或控制其数量。我们将深入研究以下主题:

  • 通过采样控制成本

  • 丰富和过滤遥测

  • 自定义上下文传播

  • 使用 OpenTelemetry Collector 构建处理管道

到本章结束时,你将能够选择采样策略并在你的系统中配置它,有效地使用自定义属性丰富自动生成的跟踪信息,并在服务之间传播你的上下文。我们还将了解如何抑制嘈杂的跨度(span)和指标。

技术要求

本章的代码可在 GitHub 上的书籍仓库中找到,网址为 github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/tree/main/chapter5,其结构如下:

  • sampling 应用程序包含采样代码片段

  • memes 应用程序是来自 第二章在 .NET 中进行原生监控 的 meme 服务的改进版本,其中包含丰富和上下文传播的示例

为了运行示例和执行分析,我们需要以下工具:

  • .NET SDK 7.0 或更高版本

  • Docker 和 docker-compose

通过采样控制成本

跟踪所有操作使我们能够调试系统中的单个问题,即使是很少见的问题,但从性能和遥测存储的角度来看,这可能是不切实际的。

优化和简洁的检测对性能的影响通常很低,但遥测摄入、处理、存储、查询和其他可观察性体验可能会非常昂贵。

可观察性供应商的定价模式各不相同——一些按摄入的跟踪信息量收费,另一些按事件、跟踪信息或报告数据的宿主机数量收费。摄入成本通常包括保留遥测数据 1 到 3 个月。一些供应商还按数据检索和扫描收费。本质上,与发送和检索遥测相关的成本会随着遥测数据量的增加而增长。

实际上,我们可能只对一小部分跟踪信息感兴趣——记录失败、长时间请求和其他罕见情况的信息。我们也可能为了分析目的查询跟踪信息的一个子集。

因此,收集所有跟踪信息带来的性能影响相对较小,可能是合理的,但将所有这些信息存储在可观察性后端通常是不合理的。

注意

你可能会考虑将跟踪用作审计日志,并需要记录每个操作。然而,我们需要跟踪来调试和解决生产中的事件,这意味着需要快速查询时间,可能短的保留时间,并且跟踪对每个值班人员都可用。审计日志通常需要不同的隐私和保留策略。它们也不一定需要快速和随机的访问。

采样是一种允许记录跟踪子集的技术,从而降低存储成本。主要有两种采样方法:基于头的基于尾的

注意

基于头的采样和基于尾的采样都依赖于跟踪上下文传播,无论采样决策如何都需要发生。

让我们更仔细地看看不同的采样方法,并了解何时以及如何应用它们。

基于头的采样

使用基于头的采样时,记录或不记录跟踪的决定是在应用程序进程启动跟踪时做出的,通常是随机的(或基于事先可用的信息)。这里的假设是,在需要关注的高规模问题发生频繁到足以记录至少一些发生的情况。换句话说,从未被记录的问题太罕见,可能不太重要。“太罕见”和“频繁到足以”在这里完全取决于应用程序需求。

基于头的采样算法试图保持一致性,以便我们可以在任何跟踪中捕获所有跨度或一个都不捕获。这是通过遵循上游采样决策或在每个服务上做出独立但一致的决策来实现的。让我们更深入地了解这些方法,并检查我们如何实现自定义采样解决方案。

父级采样

使用traceparent。例如,正如我们在第一章中看到的,“现代应用程序的可观察性需求”,traceparent中的00-trace1-span1-01表示上游服务记录了这个跨度,而00-trace2-span2-00表示这个跨度没有被记录。要在 OpenTelemetry 中启用此行为,你可以使用ParentBasedSampler。当所有服务遵循父决策时,在第一个组件上配置的采样概率(或百分比或记录的跟踪)适用于所有下游服务。

注意

使用基于父级的采样时,做出决策的组件需要完全信任——如果它开始记录所有跟踪,可能会超载你的遥测收集管道,并可能导致你的可观察性后端成本激增。你通常会在你的 API 网关或前端做出采样决策,并且永远不会信任来自外部客户端的采样决策。

根本组件仍然需要做出独立的采样决策。即使这个决策可以是随机的,其他服务会跟随,但坚持使用 OpenTelemetry(或你的可观察性供应商)的采样算法并在整个系统中保持一致性是个好主意。

概率采样

另一种方法是在每个服务上做出采样决策,但保持一致性,因此在一个服务上记录的 trace 如果它们配置了相同的采样率,也会在另一个服务上记录。

为了实现这一点,使用 trace-id。如果分数小于概率,则跨度将被记录,否则将被丢弃。

注意

概率采样通过仅记录部分跟踪来降低成本。意外的负载或流量激增会导致记录的跟踪量按比例增长。OpenTelemetry for .NET 默认不支持固定速率采样器,但你可以配置收集器来执行此操作,或者你的可观察性供应商可能提供一种方法。

概率采样在 OpenTelemetry 中通过 TraceIdRatioBasedSampler 实现,并且可以通过在 TracerProviderBuilder 上的 SetSampler 方法进行配置:

Program.cs

builder.Services.AddOpenTelemetry()
  .WithTracing(tp => tp
    .SetSampler(new TraceIdRatioBasedSampler(0.1))
    .AddOtlpExporter());

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter5/sampling/Program.cs

在这个例子中,我们将概率设置为 0.1,这意味着 10% 的所有跟踪将被记录。

如果我们想在下游组件上配置基于父级的采样,我们应该设置 ParentBasedSampler 的一个实例,如下面的示例所示:

Program.cs

tp.SetSampler(
   new ParentBasedSampler(new AlwaysOffSampler()))

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter5/sampling/Program.cs

我们需要提供一个采样器,用于在没有父级跟踪上下文的情况下使用 - 在这个例子中,我们正在采样所有不带 traceparent 的请求。我们可以进一步自定义基于父级的采样器 - 设置采样器来处理不同的情况:当父级是远程或本地时,以及父级是否被记录。

一致采样

假设所有服务都配置了相同的采样概率,一个跟踪中的所有跨度将一致地被记录或丢弃 - 将不会有部分跟踪。然而,对所有服务使用相同的比率并不总是实用的。你可能希望为新服务或负载较小的服务配置更高的采样概率。我们可以通过在服务上配置不同的比率来实现这一点。

因此,我们应该预期一些跟踪器将被部分记录。图 5**.1 展示了一个部分跟踪的示例:

图 5.1 - 具有不同采样概率的服务

图 5.1 – 具有不同采样概率的服务

服务 A 开始跟踪 - 它生成跟踪 ID 并通过计算分数并与服务上配置的采样概率进行比较来做出采样决策。

假设分数是 0.1 – 它小于概率 (0.2),因此请求被采样,我们应该记录相应的跨度及其本地子节点。然后是 0.01服务 B 计算分数 – 它是相同的,所以决定不记录跨度或其本地子节点。但随后 服务 B 调用 服务 C,它记录相应的跨度。

因此,对于这个跟踪,我们将有来自 trace-id 的跨度,具有不同的分数,例如,0.005,所有服务都会记录相应的跨度,我们将有一个完整的跟踪。

概率采样依赖于 trace-id 是随机的,并且使用相同的哈希函数计算每个跨度的分数。如果你在所有服务上使用相同语言的纯 OpenTelemetry,并且没有自定义 ID 生成或配置供应商特定的采样器,则这种情况成立。如果你的 trace-id 不是随机的,或者你必须使用不同的采样算法,我们需要一种稍微不同的方法,称为 一致性采样。这是一个实验性的方法,目前在 .NET 中尚未实现。

该方法依赖于采样分数传播:启动跟踪的组件使用任何算法计算采样分数,并通过 tracestate 将其传播到下游服务。下游服务不需要再次计算分数 – 它们从 tracestate 中读取它,并通过将此分数与其配置的概率进行比较来做出采样决策。

自定义采样器

你可以实现自己的采样器。例如,DebugSampler 记录所有在 tracestate 标头中具有 debug 标志的活动,并为所有其他活动使用概率采样器。使用此采样器,你可以通过发送带有有效 traceparenttracestate: myapp=debug:1 标头的请求来强制记录跟踪,这在测试或重现问题时可能很有用:

DebugSampler.cs

class DebugSampler : Sampler
{
    private readonly static Sampler On
      = new AlwaysOnSampler();
    private readonly static Regex DebugFlag
      = new Regex("(^|,)myapp=debug:1($|,)",
                  RegexOptions.Compiled);
    private readonly Sampler _default;
    public DebugSampler(double probability)
    {
        _default =
           new TraceIdRatioBasedSampler(probability);
    }
    public override SamplingResult ShouldSample(
      in SamplingParameters parameters)
    {
        var tracestate =
           parameters.ParentContext.TraceState;
        if (tracestate != null &&
             DebugFlag.IsMatch(tracestate))
           return On.ShouldSample(parameters);
        return _default.ShouldSample(parameters);
    }
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter5/sampling/DebugSampler.cs

采样器实现了一个 ShouldSample 方法,它接受 SamplingParameters 并返回 SamplingResult

采样参数包括父跟踪上下文和创建时间属性、Activity 名称、类型、tracestate 和链接。

SamplingResult 是一个包含 SamplingDecision 枚举的结构,该枚举可以取三个可能值之一:

  • Drop:创建 Activity,但不记录它。

  • RecordAndSample:创建 Activity,记录它,并在跟踪上下文中设置采样标志。

  • RecordOnly:创建 Activity 并记录它,但不在跟踪上下文中设置采样标志。内置采样器永远不会返回 RecordOnly 决策,但你可以实现自定义采样器,并在本地返回此类决策给跟踪请求,而不强迫下游服务遵循它(如果它们尊重父决策)。

SamplingResult还包含更新的tracestate值和属性——采样器可以设置它们,它们将在即将创建的活动上使用。我们可以通过在TracerProviderBuilder实例上调用SetSampler方法以与之前相同的方式配置此采样器。

注意

使用 OpenTelemetry,每个采样决策都会创建一个活动。但在.NET 中,可以防止创建被采样的Activity

我们将在第六章“跟踪您的代码”中了解更多关于采样在 vanilla .NET 中的信息,并将在本章后面看到一些关于如何抑制活动创建的示例。

基于尾部的采样

基于尾部的采样,决策是在跟踪结束后做出的,并可能考虑跟踪持续时间、错误的存在或任何其他在跨度上可用的信息。正如您所想象的,我们首先需要在跟踪中缓冲所有跨度,然后将它们全部发送到可观察性提供者或丢弃它们。基于尾部的采样必须在不同的服务之间发生,并且只能由外部组件,如 OpenTelemetry Collector 来完成。Collector 中的尾部采样处理器高度可配置,并支持多种采样策略,包括基于速率限制、延迟和状态码的策略。您可以组合多个策略。实际上,很难知道跟踪何时结束,因此 Collector 在收到该跟踪的第一个跨度后,会根据可配置的时间段开始缓冲跨度,然后根据可用数据做出决策。

Collector 中的基于尾部的采样处理器允许创建复合采样策略。例如,您可以在.NET 服务上配置概率采样以最小化性能影响,然后对 Collector 应用速率限制以控制遥测量级和可观察性后端成本。

您也可以配置更高的采样概率,以收集更多带有更大延迟、错误或特定属性的跟踪。目前缓冲仅限于 Collector 的单个实例,因此如果来自同一跟踪的跨度最终落在不同的 Collector 上,基于尾部的采样可能会产生部分跟踪,但仍然会捕获对应于失败或增加延迟的部分。由于基于尾部的采样需要缓冲跨度,它消耗内存并需要额外的计算资源,因此适用于短跟踪。通常,比较管理 Collector 设置的代价与其带来的节省是有意义的。

注意

在非概率采样中,基于跟踪的使用分析是有偏的,可能是误导性的。独立于跟踪收集的应用程序内的度量可能仍然是您的真实来源。

根据您的需求,您可以将不同的方法结合起来——例如,收集所有数据但将其保存在冷(较冷)存储中,仅使用更昂贵的后端存储一小部分跟踪。

现在你已经准备好选择一个采样策略并在你的系统中实施它了!让我们继续探索如何通过额外的上下文来丰富跨度。

丰富和过滤遥测数据

来自自动化的跟踪和指标描述了操作的技术方面。虽然我们总是可以添加更多带有自定义上下文的跨度(我们将在第六章 跟踪你的代码中学习如何做),但将自定义上下文添加到自动收集的遥测数据中可能更加实用。

应用程序特定的上下文对于跟踪使用情况是必要的,它包含有助于检测和调查问题的关键信息。

例如,如果我们以我们的梗图服务为例,在跨度上拥有梗图名称和大小将非常有帮助。有了这个,我们就能找到最受欢迎的梗图,关联梗图的上传和下载请求,规划容量,进行缓存优化,或者进行分区推理。

添加梗图名称最简单的方法是通过Activity.SetTag方法。例如,我们可以在Meme页面上添加以下代码:

Meme.cshtml.cs

public async Task<IActionResult> OnGet([FromQuery] string
  name)
{
  Activity.Current?.SetTag("meme_name", name);
    ...
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter5/memes/frontend/Pages/Meme.cshtml.cs

Activity.Current在这里是由 ASP.NET Core 创建的。它通过 OpenTelemetry ASP.NET Core 的仪器启用——如果跟踪被禁用,那么这里的Activity.Current将是null,因此我们应该始终使用null检查或使用空合并来保护Activity.Current

我们还检查活动是否被记录——如果IsAllDataRequested标志为true。在采样出的活动上记录属性是没有意义的,所以这是一个优化。虽然在这种情况下它非常小,但使用它是避免任何不必要的字符串分配或防止执行更重的操作以检索属性值的好习惯。

最后,我们调用SetTag方法——这是一个接受字符串标签名称和可空对象值的函数。我们将在第六章 跟踪你的代码中更多地讨论 Activity API 和标签。

注意

.NET Activity API 中的标签与 OpenTelemetry 的span属性相同。标签来自 OpenTracing,并且没有重命名以保持向后兼容性。本书中标签属性可以互换使用。

使用这种方法,我们可以将梗图名称添加到所有 ASP.NET Core 活动中。但是,对于 HTTP 客户端和 MySQL 活动呢?在这些活动上拥有梗图名称将非常方便。

在一般情况下,可以使用跨度处理器来完成,但一些仪器提供了可扩展的挂钩,允许它们丰富它们的活动。

让我们逐一查看这些方法。

Span 处理器

跨度处理器是 OpenTelemetry 导出管道的一个组件。当 OpenTelemetry 被通知有关活动开始或停止事件时,它会同步调用处理器上的相应方法。通过实现我们自己的处理器并将其添加到跟踪提供程序中,我们可以拦截所有活动并添加来自AsyncLocalThreadLocal或全局可用的另一个上下文中的属性。我们还可以覆盖或删除属性或过滤活动。

Enriching

在我们对梗名称执行此操作之前,我们需要决定如何将其传递给处理器。由于我们希望梗名称出现在所有服务的所有跨度中,这将是行李的一个很好的用例。行李,正如我们在第一章,“现代应用程序的可观察性需求”中看到的,代表在服务之间传播的应用特定上下文。

因此,让我们继续在前端MemeUpload页面上添加梗名称到行李中:

Meme.cshtml.cs

Activity.Current.SetTag("meme_name", name);
Baggage.SetBaggage("meme_name", name);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter5/memes/frontend/Pages/Meme.cshtml.cs

设置Baggage并不总是影响之前开始并当前正在进行的活动——它是AsyncLocal的副作用,我们将在第六章,“跟踪您的代码”中深入探讨这一点。因此,我们将在前端页面上继续在活动上设置梗名称标签。Baggage在底层使用AsyncLocal,因此我们现在可以在处理器中可靠地使用它:

MemeNameEnrichingProcessor.cs

class MemeNameEnrichingProcessor : BaseProcessor<Activity>
{
    public override void OnEnd(Activity activity)
    {
        var name = GetName(activity);
        if (name != null)
            activity.SetTag("meme_name", name);
    }
    private string? GetName(Activity activity)
    {
        if (Baggage.Current.GetBaggage()
             .TryGetValue("meme_name", out var name))
return name;
        return activity.GetBaggageItem("meme_name");
    }
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter5/memes/frontend/MemeNameEnrichingProcessor.cs

我们在这里重写了OnEnd方法——我们首先从行李中获取梗名称并将其添加为活动的一个标签。我们不需要检查活动是否为null,因为在这种情况下处理器不会被调用,但我们可能仍然需要检查它是否被采样,因为,正如我们很快就会看到的,采样出的活动有时仍然可以到达您的处理器。

注意

我们从Baggage.Current获取一个名称,但如果它不在那里,我们也会检查Activity.Baggage。原因是Baggage类型位于OpenTelemetry命名空间中,并且可以在跟踪之外使用。但 ASP.NET Core 并不知道它,并在Activity上填充Baggage。作为一个经验法则,始终使用Baggage.SetBaggage设置Baggage,但从BaggageActivity中读取它。

最后一步是在前端和存储服务上的TracerProvider上注册此处理器:

Program.cs

Builder.Services.AddOpenTelemetry()
  .WithTracing(builder => builder
    .AddProcessor<MemeNameEnrichingProcessor>()
    …);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter5/memes/frontend/Program.cs

就这样 - 前端将 meme 名称添加到Baggage中,然后Baggage会自动传播到存储。每当任何活动结束时, enriching 处理器都会在它上面打印 meme 名称。

顺便说一下,我们还可以通过使用SetTag方法并传入null值来调用处理器以删除不需要的标签。

过滤

有时候您想丢弃一些活动 - 例如,那些代表前端上检索静态文件或来自网络爬虫的请求的活动。

在跟踪中间开始的活动后丢弃活动会破坏因果关系。这应该只针对没有子活动的事件进行。

一些仪表化提供了抑制活动的钩子,这样它们甚至不会被创建 - 我们将在本节稍后看到一些示例。

但是,仪表化并不总是支持抑制,过滤掉已经启动的活动可能只是一种选择。让我们看看如何使用处理器来完成它:

StaticFilesFilteringProcessor.cs

public class StaticFilesFilteringProcessor :
    BaseProcessor<Activity>
{
    public override void OnEnd(Activity activity)
    {
        if (activity.Kind == ActivityKind.Server &&
            activity.GetTagItem("http.method") as string
                                              == "GET" &&
            activity.GetTagItem("http.route") == null)
            activity.ActivityTraceFlags &=
              ~ActivityTraceFlags.Recorded;
     }
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter5/memes/frontend/StaticFilesFilteringProcessor.cs

在此处理器中,我们检查活动是否具有Server类型(描述一个传入请求),使用GET方法,并且没有路由。我们只能在OnEnd回调中检查路由的存在,因为路由是在活动开始后计算的。

因此,我们在Activity上取消设置记录标志,以便它将在导出管道中稍后丢弃。

您可能会想出一个更好的启发式方法来识别静态文件,如果它不需要路由,您可以使用 ASP.NET Core 仪表选项来抑制此类活动,正如我们稍后将看到的。

要注册此处理器,请使用AddProcessor方法将其添加到TracerProviderBuilder中。确保按照您希望它们运行的顺序添加处理器:

Program.cs

Builder.Services.AddOpenTelemetry()
  .WithTracing(builder => builder
    .AddProcessor<StaticFilesFilteringProcessor>()
    .AddProcessor<MemeNameEnrichingProcessor>()
    …
);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter5/memes/frontend/StaticFilesFilteringProcessor.cs

我们刚刚学习了如何使用处理器过滤和丰富活动;现在让我们看看我们可以使用仪表化选项做什么。

自定义仪表化

仪表化可能提供配置选项,允许自定义遥测收集。例如,您可以通过相应配置选项上的RecordException标志来配置记录 HTTP 客户端和 ASP.NET Core 仪表化的异常事件:

AddHttpClientInstrumentation(o => o.RecordException = true)

仪器也可以提供回调,允许从特定的仪器上下文中填充属性,例如requestresponse对象。

让我们用它来设置存储中传入 HTTP 请求的大小,这样我们就可以使用 ASP.NET Core 仪器丰富钩子来分析表情包的大小:

Program.cs

AddAspNetCoreInstrumentation(o =>
{
    o.EnrichWithHttpRequest = (activity, request) =>
        activity.SetTag("http.request_content_length",
                       request.ContentLength);
    o.EnrichWithHttpResponse = (activity, response) =>
        activity.SetTag("http.response_content_length",
                        response.ContentLength);
    o.RecordException = true;
})

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter5/memes/storage/Program.cs

除了 ASP.NET Core,你还可以在opentelemetry-dotnet仓库中找到类似的 HTTP、gRPC 和 SQL 客户端仪器钩子。

同样的仪器还提供了防止创建Activity的钩子。例如,如果我们想抑制为静态文件创建的活动而不是在处理器中丢弃它们,我们可以编写如下内容:

Program.cs

AddAspNetCoreInstrumentation(o => o.Filter =
    ctx => !IsStaticFile(ctx.Request.Path))
...
static bool IsStaticFile(PathString requestPath)
{
    return requestPath.HasValue &&
        (requestPath.Value.EndsWith(".js") ||
         requestPath.Value.EndsWith(".css"));
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter5/memes/frontend/Program.cs

如果你考虑根据动态上下文抑制或丰富单个活动,当可用时,仪器钩子是最佳选择。如果你想用环境上下文丰富所有活动,处理器将是正确的选择。现在让我们看看如何使用资源在所有活动中填充静态上下文。

资源

OpenTelemetry 资源描述了一个服务实例——它是一组静态属性,描述了服务名称、版本、命名空间、实例或任何其他静态属性。OpenTelemetry 为 Kubernetes、通用容器、云、进程、操作系统、设备和其他常见资源类型定义了语义约定。

你可以显式配置资源或通过环境变量。例如,我们已经使用了OTEL_SERVICE_NAME环境变量来配置服务名称。我们可以将OTEL_RESOURCE_ATTRIBUTES设置为逗号分隔的键值对列表(例如,region=westus,tag=foo)来指定任何自定义资源。

可以使用ResourceBuilder进行显式配置,我们应该在TraceProviderBuilder上注册它:

Program.cs

var env = new KeyValuePair<string, object>("env",
  builder.Environment.EnvironmentName);
var resourceBuilder = ResourceBuilder.CreateDefault()
    .AddService("frontend", "memes", "1.0.0")
    .AddAttributes(new[] { env });
...
Builder.Services.AddOpenTelemetry()
  .WithTracing(builder => builder
    .SetResourceBuilder(resourceBuilder)
  ...
);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter5/memes/frontend/Program.cs

注意,环境变量检测是默认进行的,但你可以通过使用ResourceBuilder.CreateEmpty而不是CreateDefault工厂方法来关闭它。资源在每个信号上都会被填充,如果需要,我们可以为跟踪、指标和日志配置不同的资源。

我们已经准备好运行 meme 服务并查看结果。使用 compose up --build 运行它。应用程序启动后,访问 http://locahost:5051 的前端并上传和下载一些 meme。现在,您应该能够看到新的属性——所有跨度上的 meme 名称和在存储的传入请求上的内容大小。图 5**.2 展示了一个 GET 请求的示例:

图 5.2 – 带自定义属性的自动收集的 ASP.NET Core 活动截图

图 5.2 – 带自定义属性的自动收集的 ASP.NET Core 活动截图

我们还在每个导出的跨度上获得了新的资源属性,以及在传入和传出的 HTTP 跨度上的异常事件,如图 5**.3 所示:

图 5.3 – 资源属性和异常事件

图 5.3 – 资源属性和异常事件

在这里,我们可以看到来自我们应用程序的 service.versionservice.namespaceservice.instance.idenvregiontag 属性,而 host.nameos.type 是由 OpenTelemetry Collector 资源检测器稍后添加的。

通过资源属性、处理器和行李、仪器钩子和标志,您可以自定义遥测自动收集,使用自定义属性丰富活动,添加事件,并记录异常。您还可以更改或删除属性,以及抑制或过滤活动。但关于指标,我们能自定义它们吗?

指标

自动收集的指标不如跟踪可定制。使用 OpenTelemetry SDK,我们只能使用静态资源属性来丰富它们,但 OpenTelemetry Collector 提供了可以添加、删除或重命名属性名称和值、跨属性聚合、更改数据类型或以其他方式处理指标的处理器。

尽管如此,您可以使用 MeterProviderBuilder.AddView 方法过滤出特定的仪器或其属性。例如,您可以使用以下代码删除具有特定名称的仪器:

Program.cs

WithMetrics(builder => builder.AddView(
  "process.runtime.dotnet.jit.il_compiled.size",
  MetricStreamConfiguration.Drop));

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter5/memes/frontend/Program.cs

Prometheus 将点替换为下划线,相应的仪器在那里显示为 process_runtime_dotnet_jit_il_compiled_size。检查相应的 OpenTelemetry 仪器文档以找到仪器的原始名称。例如,.NET 运行时仪器文档可以在以下位置找到:github.com/open-telemetry/opentelemetry-dotnet-contrib/blob/main/src/OpenTelemetry.Instrumentation.Runtime/README.md

你还可以指定你希望在仪表化中使用的属性 – OpenTelemetry 将仅使用指定的属性,并将丢弃其他属性。这样做可以节省未使用属性的成本或删除错误添加的高基数属性。

例如,此代码从 ASP.NET Core 请求持续时间指标中删除了 http.schemehttp.flavor

Program.cs

AddView("http.server.duration",
    new MetricStreamConfiguration(){
    TagKeys = new [" {"http.host", "http.method",
       "http.scheme", "http.target", "http.status_code" }
})

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter5/memes/frontend/Program.cs

我们刚刚看到了如何使用应用程序特定的上下文丰富跟踪和指标,并使用 OpenTelemetry 中可用的不同机制更新或删除属性。让我们继续探索 OpenTelemetry 配置,并学习如何配置上下文传播。

自定义上下文传播

当对新的系统进行仪表化时,使用 W3C 跟踪上下文传播是默认且最简单的选项 – 由于 .NET 和 OpenTelemetry 默认使用它,因此不需要任何显式配置。然而,现有的系统可能采用遗留的上下文传播约定。

为了支持它们,我们可以在 OpenTelemetry 上配置自定义的全局传播器,使用 Sdk.SetDefaultTextMapPropagator。例如,如果你的某个旧客户端应用程序仍然使用一些自定义关联 ID 的变体,你仍然可以从请求头中读取它并将其转换为 trace-id 兼容的格式(或将其移动到行李中)。

你可以使用复合传播器同时支持多个标准,如下例所示:

XCorrelationIdPropagator.cs

Sdk.SetDefaultTextMapPropagator(
  new CompositeTextMapPropagator(new TextMapPropagator[] {
    new B3Propagator(true),
    new XCorrelationIdPropagator(),
    new BaggagePropagator()}));
DistributedContextPropagator.Current =
  DistributedContextPropagator.CreateNoOutputPropagator();

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter5/memes/frontend/XCorrelationIdPropagator.cs

在这里,我们配置了来自 OpenTelemetry.Extensions.Propagators 包的 B3 传播器,一个用于 x-correlation-id 支持的自定义传播器,以及一个用于行李的传播器。请注意,我们还通过将 DistributedContextPropagator.Current 设置为无输出传播器来禁用了本机 ASP.NET Core 和 HTTP 客户端传播。如果我们不这样做,它们将继续提取和注入 Trace-Context 头部。

当使用复合上下文传播器时,确保解决冲突并在同一请求中获取多个冲突的跟踪上下文组合时定义优先级 – 我们将在 第十六章仪表化 现有应用程序 中更多地讨论这个问题。

使用 OpenTelemetry Collector 处理管道

正如我们之前看到的,OpenTelemetry Collector 是另一个能够控制、丰富、过滤、转换、路由、聚合、采样和以任何其他可能的方式处理遥测数据的组件。图 5**.4 展示了主要的收集器组件:

图 5.4 – OpenTelemetry Collector 管道

图 5.4 - OpenTelemetry 收集器管道

接收器从不同的来源获取遥测数据,处理器对数据进行处理并将其传递给导出器。

由于收集器是一个单独的过程,可能运行在不同的机器上,它没有动态上下文,例如您可能想要在跨度上盖章的特定 HTTP 请求头。这种上下文只能添加到您的应用程序内部。

但收集器可能对应用程序运行的环境有更多上下文信息——例如,它可以丰富遥测数据,包括 Kubernetes 或云提供商上下文。它还可以接收任何格式的遥测数据并将其转换为 OpenTelemetry 信号。

收集器支持多种接收器,包括用于指标的 Docker 统计信息、statsd 或 containerd,以及用于日志的 Kubernetes 事件、syslog 或 journald,还有许多其他接收器。收集器处理器可以在任何信号上丰富、过滤或更改属性名称和值。其中一个常见的用例是敏感数据编辑。

您可以在 OpenTelemetry 注册表中找到可用的收集器组件,opentelemetry.io/registry;只需确保检查您考虑使用的每个组件的稳定性级别。

在许多情况下,问题不在于是否在过程中或使用收集器配置和自定义遥测管道——两者都是。收集器可以帮助您从一个可观察性堆栈迁移到另一个堆栈,提供遥测收集的安全带,并从您的应用程序中卸载一些工作。

摘要

在前面的章节中,我们探讨了遥测自动收集,现在我们已经学会了如何自定义这种遥测。我们了解到了不同的采样方法——基于头部和基于尾部。基于头部的采样会在以一定概率开始时决定记录跟踪(或跨度)。子跟踪可以跟随父跟踪的决定,并且跟踪总是完整的,但无法控制单个服务上的跟踪量。为了克服这一点,下游服务可以配置不同的速率并使用一致的采样来最大化完整跟踪的数量。一些跟踪可能是不完整的,但仍然可用于监控单个服务或服务组。

概率采样捕获所有跟踪的一部分,并且对于减轻性能开销非常有用。如果您需要可预测的成本,您应该考虑基于速率的采样。它在 OpenTelemetry 收集器或可观察性供应商中实现。OpenTelemetry 收集器还可以执行基于尾部的采样,并以更高的概率记录失败和长跟踪。

自动检测收集通用信息,因此我们需要添加应用程序特定的上下文以获得更深入的可见性。增强可以在进程内部使用资源完成 - 描述你的服务实例的静态属性,或者使用跨度处理器、检测钩子或配置选项来捕获动态上下文。我们可以添加、删除或更改属性,并且我们可以使用行李来在我们的系统中传播应用程序特定的上下文。

我们有时可以使用检测钩子、标志或使用处理器过滤掉活动。对于已经启动的活动进行过滤应该谨慎,因为这会破坏已丢弃活动的前辈和后继之间的关联。这仅适用于没有子活动的情况。

指标也允许一些自定义 - 我们可以用资源属性丰富它们,删除特定的检测,或限制由检测填充的属性。

我们还研究了上下文传播的自定义,这可以提供与你可能拥有的自定义和旧版相关解决方案的互操作性。

最后,我们讨论了除了进程内配置之外可以使用的收集器功能 - 环境资源检测或过滤和按摩遥测。它还可以提供速率限制采样,并保护你的遥测管道免受过载。

在本章中,你学习了如何选择采样策略并实现它,用应用程序上下文丰富跟踪,并自定义上下文传播。这标志着我们的自动检测之旅结束;从现在开始,我们将探索检测内部机制,并学习如何编写我们自己的。在下一章中,我们将专注于创建活动。

问题

  1. 你会如何构建一个通用的采样解决方案,它还能捕获分布式应用程序中的失败和长时间运行的跟踪?

  2. 你会如何记录 HTTP 客户端跨度中的重试?

  3. 在 OpenTelemetry 收集器上配置速率限制采样。

第六章:跟踪您的代码

在前面的章节中,我们讨论了仪器库,并学习了如何使用自动收集的遥测来监控和调试分布式系统。自动仪器化,当可用时,为网络调用提供必要的合理覆盖,但您可能希望跟踪额外的逻辑操作、I/O、套接字或其他没有共享仪器可用的调用。

本章提供了使用 System.Diagnostics 原语或 OpenTelemetry API 进行手动跟踪的深入指南,并解释了自动仪器化背后的机制。我们将涵盖 Activity 属性以及如何填充它们,并展示如何记录事件。然后,我们将学习如何使用链接来表示跨度之间的复杂关系。最后,我们将涵盖仪器的测试方面。

您将学习如何执行以下操作:

  • 使用 .NET API 或 OpenTelemetry API 适配器创建活动

  • 使用 Activity.Current 进行环境上下文传播并了解其限制

  • 使用 ActivityEvent 并了解何时使用日志

  • 使用链接表示跟踪之间的复杂关系

  • 验证您的仪器

到本章结束时,您应该能够使用手动跟踪来满足您的应用程序需求。

技术要求

本章的代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/tree/main/chapter6

我们需要以下工具来完成这项工作:

  • NET SDK 7.0 或更高版本

  • 推荐使用带有 C# 开发设置的 Visual Studio 或 Visual Studio Code,但任何文本编辑器都可以工作

  • Docker

如果您想探索 Jaeger 中的示例应用程序的跟踪,可以使用以下命令运行它:

$ docker run -d --name jaeger -p 6831:6831/udp -p
  16686:16686 jaegertracing/all-in-one:latest

使用 System.Diagnostics 或 OpenTelemetry API 适配器进行跟踪

正如我们在前面的章节中看到的,.NET 中的分布式跟踪依赖于 System.Diagnostics 命名空间中的原语:ActivityActivitySource。它们被 HTTP 客户端和 ASP.NET Core 仪器化所使用。

.NET 和 OpenTelemetry 使用的术语不同:Activity 代表 OpenTelemetry 跨度,ActivitySource 映射到跟踪器,而标签映射到属性。

OpenTelemetry.Api NuGet 包还提供了 TelemetrySpanTracer 以及 OpenTelemetry.Trace 命名空间中的几个辅助类。这些 API 是 .NET 跟踪 API 的 适配器 – 一个不提供任何额外功能的薄层包装,它不会在 .NET 跟踪 API 上提供任何额外功能。

您可能想知道应该使用哪一个。一般来说,除非您想坚持使用 OpenTelemetry 术语,否则应使用 .NET API。适配器只是一个额外的层,它带来了一点点性能开销。

使用 System.Diagnostics 进行跟踪

假设我们想要仪器化一个操作——例如,一个处理工作项的方法。它可能分解为更小的、可能自动仪器化的操作,如 HTTP 请求。在这种情况下,描述单个请求的跨度不会显示处理的整体持续时间和结果,因此我们需要创建一个新的逻辑 Activity 来描述它。

活动应该从 ActivitySource 创建,它会通知 OpenTelemetry 和其他潜在监听器有关它们的信息。使用这种方法,我们可以使用以下代码对我们的工作处理操作进行仪器化:

Worker.cs

private static readonly ActivitySource Source =
  new ("Worker");
...
using var activity = Source.StartActivity("DoWork");
activity?.SetTag("work_item.id", workItemId);
try
{
  await DoWorkImpl(workItemId);
}
catch (Exception ex)
{
  activity?.SetStatus(ActivityStatusCode.Error,
    ex.Message);
  throw;
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter6/tracing-with-net/Worker.cs

在这个例子中,实际的处理发生在 DoWorkImpl 方法中。我们在调用此方法之前创建了一个新的活动,并隐式地结束了它。如果该方法抛出异常,我们将活动的状态设置为 Error,并在描述中提供异常消息。当控制离开作用域时,活动将被处置(并结束),但我们也可以调用 Activity.Stop 方法来显式停止它。

我们在这里创建 ActivitySource 作为静态单例,因为我们假设我们需要它在应用程序的生命周期内使用。如果你决定将其作为实例变量,并将其生命周期绑定到你的应用程序中的某个长期存在的客户端或服务,请确保将其处置掉。

在这个例子中,我们唯一配置的是 Activity 名称——我们将 DoWork 传递给 ActivitySource.StartActivity 方法。

注意

ActivitySource.StartActivity 返回的活动是可空的。如果没有为此源设置监听器,或者监听器以返回 ActivitySamplingResult.None 的方式以特定方式采样此活动,则它可以是 null。

我们将在稍后了解更多关于 API 的内容,但首先,让我们学习如何导出生成的活动。

使用 OpenTelemetry 导出活动

到目前为止,我们已经使用 OpenTelemetry.Extensions.Hosting NuGet 包在 ASP.NET Core 应用程序中配置了 OpenTelemetry。在这里,我们将使用纯 OpenTelemetry SDK 配置,它看起来与我们之前在 第五章 中看到的相当相似,配置和 控制平面

Program.cs

using var provider = Sdk.CreateTracerProviderBuilder()
  .ConfigureResource(b => b.AddService("activity-sample"))
  .AddSource("Worker")
  .AddJaegerExporter()
  .AddConsoleExporter()
  .Build()!;

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter6/tracing-with-net/Program.cs

在这个例子中,我们通过将服务名称设置为 activity-sample 并启用 ActivitySource(我们在上一个示例中用它来创建活动)来构建 TracerProvider 实例。我们还在使用控制台导出器的同时使用 Jaeger – 跨度将被导出到两者中。

OpenTelemetry 需要显式配置以监听 ActivitySource,但您可以使用通配符启用一组它们,正如我们在 第三章 中所看到的,The .NET Observability Ecosystem

让我们使用以下命令运行这些示例:

tracing-with-net$ dotnet run open-telemetry –scenario basic

我们应该看到 Activity 被导出到控制台:

Activity.TraceId:         9c45e1b454e28bf1edbba296c3315c51
Activity.SpanId:          bcd47a4fc7d92063
Activity.TraceFlags:         Recorded
Activity.ActivitySourceName: Worker
Activity.DisplayName:        DoWork
Activity.Kind:               Internal
Activity.StartTime:          2022-12-07T23:10:49.1998081Z
Activity.Duration:           00:00:00.1163745
Resource associated with Activity:
    service.name: activity-sample

当我们配置 OpenTelemetry 以监听 Worker 源时,它利用了 System.Diagnostics.ActivityListener 原语。如果您使用 OpenTelemetry,您可能不需要直接使用监听器,但您可能仍然会发现它在测试目的或调试仪表问题时很有用。让我们看看它是如何工作的。

使用 ActivityListener 监听活动

ActivityListener 允许我们通过其名称订阅任何 ActivitySource 实例,并在由启用源创建的活动开始或结束时接收通知。以下示例展示了如何编写一个监听器:

Program.cs

ActivitySource.AddActivityListener(new ActivityListener()
{
  ActivityStopped = PrintActivity
  ShouldListenTo = source => source.Name == "Worker",
  Sample = (ref ActivityCreationOptions<ActivityContext> _)
    => ActivitySamplingResult.AllDataAndRecorded
});

Modern Distributed Tracing in .NET

在这里,我们通过 Worker 订阅 ActivitySource 并指定我们在所有活动中进行采样。当 Activity 结束时,我们调用我们的 PrintActivity 方法。我们也可以在需要时提供 ActivityStarted 回调。

因此,让我们使用以下命令运行此示例:

tracing-with-net$ dotnet run activity-listener

您应该看到类似以下内容:

DoWork: Id = 00-7720a4aca8472f92c36079b0bee3afd9-
0d0c62b5cfb15876-01, Duration=110.3466, Status = Unset

现在您已经了解了 OpenTelemetry 和 ActivitySource 如何协同工作,是时候探索其他跟踪 API 了。

开始活动

ActivitySource 类定义了多个 CreateActivityStartActivity 方法重载。

调用 StartActivity 等同于调用 CreateActivity 并稍后使用 Activity.Start 方法启动它:

Source.CreateActivity("foo", ActivityKind.Client)?.Start()

Start 方法生成一个新的 span ID,捕获开始时间,并通过 Activity.Current 属性填充环境上下文。Activity 在启动之前不能使用。因此,在大多数情况下,StartActivity 方法是最简单选择,而 CreateActivity 可能仅在你想要构造一个活动实例但稍后启动它时有用。

注意

采样回调发生在活动创建期间,因此您必须将影响采样决策的所有属性传递给 StartActivityCreateActivity 方法。

这里是开始时间属性:

  • Internal 表示本地或逻辑操作。ClientServer span 描述了同步远程调用(如 HTTP 请求)的客户端和服务器端。同样,ProducerConsumer span 描述了异步操作(如异步消息)的相应端。

可观察性后端依赖于 span 类型进行可视化,例如服务图和半自动化的性能分析。

  • ActivityContext 结构体。这通常基于 W3C Trace Context 标准(适用于 HTTP)并可能对其他协议有所不同。ActivityContext 包含跟踪 ID、跨度 ID、跟踪标志和跟踪状态。

另一个选项是将 W3C Trace Context 格式的 traceparent 值作为字符串传递给 StartActivity 方法。活动启动后,您可以稍后设置 tracestate,但当然,您将无法使用它来做出采样决策。

如果未提供父上下文,则使用 Activity.Current

  • Activity 启动并应影响采样决策。如果您不使用属性来做出基于头的采样决策,最好不填充它们,并最小化采样活动性能开销。

  • 链接:链接可以关联不同的跟踪,并代表跨度之间的关系,而不仅仅是父子关系。我们将在本章后面了解更多关于它们的内容。

  • Activity 启动。

Activity 也支持自定义跟踪上下文格式——例如,传统的分层格式。

Activity 启动后,我们可以随时添加更多属性,更改开始和结束时间,更新采样决策,设置 tracestate,并记录事件。

在添加新事件或属性之前,请确保检查 IsAllDataRequested 标志,该标志指定活动是否已被采样。我们可以使用它通过保护任何昂贵的操作来最小化仪表化的性能影响:

StartSamples.cs

if (activity?.IsAllDataRequested == true)
    activity?.SetTag("foo", GetValue());

https://github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter6/tracing-with-net/StartSamples.cs

ActivityActivitySource API 是任何仪表化的基础。我们将在本章后面介绍允许填充事件和链接的附加 API,并在本书的其余部分提供更多示例。现在,让我们快速看一下如何使用 OpenTelemetry API shim。

使用 OpenTelemetry API shim 进行跟踪

OpenTelemetry API shim 在 .NET API 之上不提供任何附加功能;它仅将术语与 OpenTelemetry 对齐。如果您在其他语言中使用 OpenTelemetry,那么它可能对您更有吸引力。如果您决定走这条路,请记住,TracerSpan 的行为与 ActivitySourceActivity 相匹配。例如,这意味着您仍然需要在配置 OpenTelemetry 时启用每个追踪器。

让我们重复使用 TracerTelemetrySpan 类来对我们的处理仪表化进行操作:

Worker.cs

private static readonly Tracer Tracer = TracerProvider
  .Default.GetTracer("Worker");
...
using var workSpan = Tracer.StartActiveSpan("DoWork"));
  workSpan.SetAttribute("work_item.id", workItemId);
  try
  {
    await DoWorkImpl(workItemId);
  }
  catch (Exception ex)
  {
    workSpan.SetStatus(Status.Error.WithDescription(
      ex.Message));
    throw;
  }
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter6/tracing-with-shim/Worker.cs

整体流程是相同的:我们创建一个 Tracer 实例而不是 ActivitySource,然后使用它来创建一个跨度。添加属性和设置状态的方式与 ActivitySource 示例类似。

如果我们要查看TracerTelemetrySpan的内部,我们会看到它们完全依赖于ActivitySourceActivity。因此,启用此仪表化和丰富以及自定义它与启用基于ActivitySource的仪表化相同——这是通过在TracerProviderBuilder上使用AddSource方法来完成的(源名称与跟踪器名称匹配)。

尽管 API 看起来很相似,但有一些重要的区别:

  • Span 不是可空的。你总是得到一个 Span 的实例,即使没有监听底层的ActivitySource(但那时它是一个优化后的、不可操作的实例)。

  • 所有对 Span 的操作都由TelemetrySpan.IsRecording标志内部保护,该标志等同于activity?.IsAllDataRequested == true检查。然而,在IsRecording标志后面保护计算属性值和其他 Span 属性等昂贵的操作可能仍然很有用。

  • Span 默认不是活动的(即,当前的)。虽然你不能在没有使其成为当前的情况下启动一个活动,但对于TelemetrySpan来说并非如此。你可能已经注意到,我们在DoWork Span 中使用了Tracer.StartActiveSpan方法,它填充了Activity.Current

如果我们使用Tracer.StartSpan方法,我们会得到一个已启动的活动,但Activity.Current不会指向它。为了使其成为当前,我们可以调用Tracer.WithSpan方法。

如果我们使用tracing-with-otel-api$ dotnet run命令运行之前的 OpenTelemetry API 示例,我们会看到与之前使用纯.NET 跟踪 API 相同的跟踪。

现在,让我们看看我们如何创建活动的层次结构并使用环境上下文来丰富它们。

使用环境上下文

在复杂的应用程序中,我们通常在每个跟踪中都有多层的 Span。这些 Span 是由不同的库发出的,这些库彼此之间并不知情。然而,由于在Activity.Current属性中传播的环境上下文,它们仍然相关联。

让我们创建两层活动——我们将通过重试失败的操作并对尝试和逻辑DoWork操作进行仪表化来使处理更加健壮:

Worker.cs

public static async Task DoWork(int workItemId) {
  using var workActivity = Source.StartActivity();
  workActivity?.AddTag("work_item.id",  workItemId);
  await DoWithRetry(async tryCount => {
    using var tryActivity = Source.StartActivity("Try");
    try
    {
      await DoWorkImpl(work.Id, tryCount);
      tryActivity?.SetTag("try_count", tryCount);
    }
    catch (Exception ex)
    {
      tryActivity?.RecordException(ex);
      tryActivity?.SetStatus(ActivityStatusCode.Error);
      throw;
    }
  }
}

https://github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter6/tracing-with-net/Worker.cs

在这个例子中,我们有一个workActivity,它描述了逻辑操作,还有一个tryActivity,它描述了一个尝试。让我们用以下命令来运行它:

tracing-with-net$ dotnet run open-telemetry –scenario with-
retries

在 Jaeger 中查看跟踪,网址为http://localhost:16686。你应该看到与图 6.1中显示的跟踪类似的内容:

图 6.1 – 工作项处理

图 6.1 – 工作项处理

在这里,我们可以看到有两个尝试来处理一个工作项——第一个尝试失败并抛出异常,然后在第二次尝试后操作成功。通过查看这个跟踪,可以清楚地了解为什么DoWork操作花费了这么多时间——它是在尝试之间花费的。

注意,我们没有对workActivitytryActivity进行任何特殊的关联操作。这是因为当tryActivity开始时workActivity是当前的——因为我们没有提供任何父活动,它默认为Activity.Current实例。

要解决工具问题,我们可以通过查看其属性来检查启动活动上的Activity的父级。Activity.Parent代表一个隐式父级。当我们启动一个活动时,我们也可以显式提供字符串形式的traceparent或父ActivityContext——在这些情况下,Parent属性将为 null。你可以在github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter6/tracing-with-net/StartSamples.cs找到一些示例,我们将在第十章中看到更多关于上下文传播的示例,即跟踪网络调用Activity.ParentId等同于traceparent头,而Activity.ParentSpanId代表它的span-id部分。如果Activity有任何父级,这些属性将被填充。

回到我们的例子,如果所有尝试都失败了会发生什么?我们是否应该在DoWork活动上设置一个错误?嗯,我们可以在DoWithRetry方法内部这样做,使用Activity.Current?.SetStatus(ActivityStatusCode.Error)。我们可以在这里使用当前活动,因为我们控制着何时以及如何调用DoWithRetry方法。

作为一条经验法则,除非你确信它是正确的,否则请避免添加属性、事件或在Activity.Current上设置状态。由于抑制、过滤或中间创建的新活动,Current可能指向其他活动。所以,请确保在你的工具中显式传递活动实例。

如果你想要丰富一个自动收集的Activity,在启用工具时提供的丰富回调中访问Current属性应该是安全的。

一些工具可能也提供了对创建的Activity的访问权限。例如,ASP.NET Core 通过IHttpActivityFeature接口这样做。你也可以使用Parent属性向上遍历活动树,以找到你想要丰富的那一个。

Activity.Current是在AsyncLocal之上工作的,因此.NET 运行时会通过异步调用传播它。这在与后台处理或手动线程操作中不起作用,但你可以始终显式传递活动并手动设置所需的Activity.Current值。

现在,我们知道如何创建活动层次结构并使用属性来描述我们的场景。但有时,我们需要更轻量级的东西,比如事件——让我们更仔细地看看它们。

记录事件

Span 描述了具有持续时间和结果的操作,但有时创建 span 可能过于冗长和昂贵 - 例如,对于繁忙的套接字级通信。事件常见的用例包括记录 gRPC 流调用中的异常或单个消息。

要表示在某个时间点发生的事情,我们应该使用事件或日志。在 OpenTelemetry 中,日志和事件之间的区别是语义上的 - 它们是相同的数据结构,具有相同的网络格式,但属性不同。例如,日志有强制性的严重性,这并不适用于事件。另一方面,事件有强制性的名称。

它们在 API 和实现方面也有所不同(至少在.NET 7.0 和之前的版本中)。在本节中,我们将探索 Activity 的事件 API;我们将在第八章中查看日志,编写结构化和 关联日志

何时使用事件

要创建一个活动事件,我们需要一个活动实例,但这在例如启动时并不适用。

活动事件依赖于采样 - 我们可以将它们添加到一个已采样的Activity中,但通常情况下,它们会随着它一起被丢弃。

事件的生命周期与Activity实例紧密耦合,因此它将保留在内存中,直到被垃圾回收。在.NET 侧,你可以有的事件数量没有限制,但 OpenTelemetry 导出器限制了导出事件的数量。默认设置为 128,可以通过OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT环境变量来控制。

注意

活动事件应该用来表达那些不值得创建 span、没有持续时间或太短,并且有可预测结果的操作。事件必须在某个Activity的作用域内发生,并且只有在Activity被采样时才应该导出。在单个Activity实例下,也应该有合理数量的它们。考虑到这些限制,只要你的可观察性后端支持,日志通常是一个更好的选择。

既然我们已经知道了这些限制,我们终于可以开始玩转事件了。

ActivityEvent API

事件用ActivityEvent类表示。要创建一个事件,我们必须提供一个事件名称,并且可以可选地指定一个时间戳(默认为事件构造时的时间),以及一组属性。

事件名称是一个低基数字符串,它暗示了事件的结构:具有相同名称的事件预期描述的是同一件事物的发生,并且应该使用相同的属性集。

让我们用事件丰富 HTTP 客户端的检测。想象一下,我们已经通过 HTTP 读取了一个长流,并想要控制内容缓冲。

要实现这一点,我们可以将 HttpCompletionOption.ResponseHeadersRead 标志传递给 HttpClient.SendAsync 方法。然后 HTTP 客户端将在读取响应体之前返回响应。了解我们收到响应的时间点很有用,这样我们就可以知道读取响应花费了多长时间。

以下示例演示了这一点:

Worker.cs

public static async Task DoWork(int workItemId) {
  using var work = Source.StartActivity();
  try
  {
    work?.AddTag("work_item.id", workItemId);
    var res = await Client.GetAsync(
      "https://www.bing.com/search?q=tracing",
      HttpCompletionOption.ResponseHeadersRead);
    res.EnsureSuccessStatusCode();
    work?.AddEvent(
      new ActivityEvent("received_response_headers"));
    ...
  }
  catch (Exception ex)
  {
    work?.SetStatus(ActivityStatusCode.Error,
      ex.Message);
  }
}

https://github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter6/events/Worker.cs

在此示例中,我们通过 HTTP 客户端管道启动 Activity 以跟踪整体逻辑请求处理,然后记录 response_headers 事件。此事件没有任何属性 – 它的唯一目的是记录我们从服务器收到响应的时间戳。

让我们添加更多事件!假设我们在 HTTP 管道中使用节流或断路器,我们将不会有任何物理 HTTP 请求,也不会有自动仪表化报告的跨度。事件可以提供对其的可观察性。

我们将使用 .NET 7 中可用的 RateLimiter 类实现客户端节流,该类包含在 System.Threading.RateLimiting NuGet 包中。我们将在 DelegatingHandler 类中这样做,如下例所示:

RateLimitingHandler.cs

private readonly TokenBucketRateLimiter _rateLimiter =
  new (Options);
protected override async Task<HttpResponseMessage>
  SendAsync(HttpRequestMessage req, CancellationToken ct)
{
  using var lease = _rateLimiter.AttemptAcquire();
  if (lease.IsAcquired)
    return await base.SendAsync(req, ct);
  return Throttle(lease);
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter6/events/RateLimitingHandler.cs

在这里,我们正在尝试从速率限制器获取租约。如果成功获取,我们将调用 base.SendAsync 方法,让此请求进一步处理。否则,我们必须像以下代码片段中所示那样节流请求:

RateLimitingHandler.cs

private HttpResponseMessage Throttle(RateLimitLease lease)
{
  var res = new HttpResponseMessage(
    HttpStatusCode.TooManyRequests);
  if (lease.TryGetMetadata(MetadataName.RetryAfter,
    out var retryAfter))
  {
    var work = Activity.Current;
    if (work?.IsAllDataRequested == true)
    {
      var tags = new ActivityTagsCollection();
tags.Add("exception.type", "rate_is_limited");
      tags.Add("retry_after_ms",
        retryAfter.TotalMilliseconds);
      work?.AddEvent(new ActivityEvent("exception",
        tags: tags));
    }
    res.Headers.Add("Retry-After",
      ((int)retryAfter.TotalSeconds).ToString());
  }
  return res;
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter6/events/RateLimitingHandler.cs

Throttle 方法中,我们发出 exception 事件,并提供与 retry_after 属性相关的消息。我们从速率限制器获取了此属性的值;它提供了有关何时重试此请求将是有意义的提示。

events 文件夹中的示例演示了一个完整的速率限制解决方案 – 它配置速率限制器每 5 秒允许一个请求,但并行发送两个请求,以便第一个请求通过,而第二个请求被节流。

运行示例 events$ dotnet run,然后切换到 Jaeger,以查看 events-sample 服务发出的两个跟踪。

一个跟踪有两个跨度,代表一个成功的操作,如图 6**.2 所示:

图 6.2 – 带有逻辑和物理 HTTP 跨度和 response_headers 事件的跟踪

图 6.2 – 带有逻辑和物理 HTTP 跨度和 response_headers 事件的跟踪

这里,我们可以看到第一个字节的传输时间大约为 110 毫秒。然后,我们得到了response_headers事件;逻辑DoWork操作的其余部分都花在了读取流内容上。

另一个跟踪只有一个跨度,表示一个失败的操作;它在图 6.3中显示:

图 6.3 – 带有逻辑调用和 rate_is_limited 异常事件的跟踪

图 6.3 – 带有逻辑调用和 rate_is_limited 异常事件的跟踪

这里,我们可以看到一个逻辑DoWork跨度,它以错误结束。如果我们展开属性,我们会看到一个状态描述,表明Response status code does not indicate success: 429 (Too Many Requests)。这可能会给我们一些关于发生了什么的线索,即使我们没有事件。这里没有物理 HTTP 跨度,这可能会令人困惑且不清楚响应来自何处。

使用rate_is_limited事件,我们可以填充额外的属性,如retry_after_ms,但最重要的是,我们可以轻松理解问题的根本原因,并找到事件发送的代码位置。

记录异常

在前面的例子中,我们创建了一个表示错误的事件,这是一个在 OpenTelemetry 中定义的特殊事件。它的名称是exception,并且具有exception.typeexception.messageexception.stacktrace属性。需要typemessage之一。

如果我们有一个异常对象,我们就可以使用在OpenTelemetry.Trace.ActivityExtensions类中声明的RecordException扩展方法。我们可以使用activity?.RecordException(ex)记录异常,然后传递自定义标签集合以添加到事件中。

此方法在底层调用Activity.AddEvent方法,填写所有异常属性,包括堆栈跟踪。由于堆栈跟踪可能非常大,记录未处理的异常并只记录一次是一个好主意。

将跨度与链接关联

到目前为止,我们讨论了跨度之间的父子关系。它们很好地覆盖了请求-响应场景,并允许我们将分布式调用堆栈描述为一棵树,其中每个跨度最多有一个父级,所需的孩子数量。

但如果我们的场景更复杂呢?例如,如何表达从多个传感器接收温度数据并在后端聚合,如图图 6.4所示?

图 6.4 – 批处理

图 6.4 – 批处理

在这个例子中,传感器在不同的跟踪范围内将数据发送到聚合器。聚合器必须启动第三个跟踪 – 它不应该继续传感器的任何跟踪。

我们可以使用链接将trace3连接到trace1trace2,这样我们就可以关联所有这些跟踪。链接不指定跨度之间的确切关系,但在本例的范围内,我们可以将它们视为单个跨度的多个父级。

链接主要用于消息场景,在这些场景中,消息以批量的形式发送和接收以优化网络使用,或者也可以一起处理。

链接有两个属性:一个链接跟踪上下文和一组属性。目前,它们只能提供给 StartActivity 方法,并且可以用于做出采样决策。这是 OpenTelemetry 规范的限制,未来可能会被移除。

使用链接

让我们看看如何使用链接来使用内存队列对批量处理场景进行仪器化。在后台处理中,我们不能依赖于 Activity.Current 从入队操作流向处理。因此,我们将 ActivityContext 与工作项一起通过队列传递。

但首先,我们需要为入队操作创建一个 Activity,以便我们有上下文来捕获和传递:

Producer.cs

public void Enqueue(int id)
{
  using var enqueue = Source
    .StartActivity(ActivityKind.Producer)?
    .SetTag("work_item.id", id);
  _queue.Enqueue(new WorkItem(id, enqueue?.Context));
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter6/links/Producer.cs

虽然对远程队列的发布调用进行仪器化很重要,但在本例中并非必需。我们在这里这样做只是为了捕获一些有效的 ActivityContext。如果我们有任何其他活动,我们可以使用其上下文。

现在,我们已经准备好对工作项处理器进行仪器化:

BatchProcessor.cs

async Task ProcessBatch(List<WorkItem> items)
{
  using var activity = Source.StartActivity(
      ActivityKind.Consumer,
      links: items
        .Select(i => new ActivityLink(i.Context)));
  activity?.SetTag("work_items.id",
       items.Select(i => i.Id).ToArray());
  ...
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter6/links/BatchProcessor.cs

在这里,我们遍历工作项,并为每个工作项使用随 WorkItem 实例传递的跟踪上下文创建了一个 ActivityLink

然后,我们将包含所有接收到的 ID 的数组添加到 BatchProcessing 活动中的 work_item.id 属性。理想情况下,我们会在 ActivityLink 构造函数上为链接本身添加属性,但我不了解现在有任何可观察性后端支持它。作为替代方案,我们也可以为每个工作项创建一个事件,并在它们上填充属性。

让我们运行带有 links$ dotnet run 的示例。它将入队三个工作项,然后一次性处理它们。在 Jaeger 中,我们应该看到四个独立的跟踪 – 一个用于每个入队操作,一个用于批量处理。后者如图 6**.5 所示:

图 6.5 – 使用链接处理跨度

图 6.5 – 使用链接处理跨度

我们可以看到它有三个引用(在 Jaeger 术语中称为链接),我们可以点击它们并到达相应的 Enqueue 操作,如图 6**.6 所示:

图 6.6 – 入队跨度

图 6.6 – 入队跨度

在 Jaeger 中,无法从Enqueue跨度导航到ProcessBatch跨度。但一些可观察性后端支持双向导航。例如,*图 6**.7 显示了与 Azure Monitor 中处理相关的Enqueue操作:

图 6.7 – 在 Azure Monitor 中可视化的两个链接跟踪

图 6.7 – 在 Azure Monitor 中可视化的两个链接跟踪

注意,已经使用链接关联了两个不同的操作 ID(跟踪 ID)。我们将在第十一章中看到更多关于消息场景中链接的示例,消息场景的仪器化。现在,让我们学习如何测试我们的仪器化。

测试你的仪器

测试日志的想法可能看起来很疯狂 – 日志不是为了停留在某个地方或保留特定的结构。跟踪就不是这样。

仪器化直接影响你评估生产健康和使用的功能。测试自动仪器化可能仅限于基本快乐案例验证 – 我们只需要检查它是否启用并且以正确的格式发出一些数据。这将帮助我们检测依赖项更新的潜在问题。手动仪器化需要更多的关注。

让我们看看我们如何在 ASP.NET Core 应用程序中测试任何仪器。我们将依赖Microsoft.AspNetCore.Mvc.Testing NuGet 包提供的集成测试功能。您可以在 https://learn.microsoft.com/aspnet/core/test/integration-tests 中找到更多详细信息。它允许我们为了测试目的修改 ASP.NET Core 应用程序的配置。在本节中,我们将使用它来更改 OpenTelemetry 管道并拦截活动。

拦截活动

有几种不同的方法可以拦截活动;让我们列出它们:

  • 我们可以添加一个跨度处理器,类似于我们在第五章中丰富活动的方式,配置和控制平面。由于处理器是同步运行的,我们可以验证Activity属性与环境上下文 – 例如,Baggage.Current – 的对应关系。我们还可以检查(当需要时)属性是否在OnStart回调的开始时间提供。

  • 我们可以实现一个测试导出器。这种方法的不利之处在于,我们只能看到已完成的活动。此外,导出器是异步运行的,并且将没有环境上下文来验证。

  • 我们可以编写一个自定义的ActivityListener实现。这种方法将不允许我们测试我们使用 OpenTelemetry 所做的自定义和配置。我们甚至无法验证 OpenTelemetry 是否配置为监听特定的ActivitySource实例或检查采样是否按预期工作。

因此,ActivityListener 可以是一个很好的单元测试选择,处理器在集成测试方面提供了最大的灵活性,这是我们在这里要关注的重点。让我们看看如何在测试中向 OpenTelemetry 管道中注入处理器。

OpenTelemetry.Extensions.Hosting NuGet 包允许我们使用 ConfigureOpenTelemetryTracerProvider 扩展方法自定义管道。它在 OpenTelemetry 管道配置之后、TracerProvider 实例构建之前被调用。如果你使用的是纯 OpenTelemetry,你将不得不为测试实现一个回调来更改管道。

下面是添加测试处理器的示例:

TestFactory.cs

public class TestFactory : WebApplicationFactory<Program>
{
  public readonly TestActivityProcessor Processor = new ();
  protected override void ConfigureWebHost(
    IWebHostBuilder b)
  {
    b.ConfigureServices(s => {
      s.ConfigureOpenTelemetryTracerProvider(
        (_, traceProviderBuilder) =>
          traceProviderBuilder.AddProcessor(Processor));
      ...
    });
  }
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter6/testing/app.tests/TestFactory.cs

TestFactory 类允许我们为测试设置 ASP.NET Core 应用程序,我们在 ConfigureWebHost 方法中这样做。在那里,我们调用 ConfigureOpenTelemetryTracerProvider 方法,在那里我们更改 OpenTelemetry 管道并注入我们的测试处理器。以下是简约的处理器实现:

TestActivityProcessor.cs

public class TestActivityProcessor :BaseProcessor<Activity>
{
  ConcurrentQueue<Activity> _processed = new ();
  public override void OnEnd(Activity activity) =>
    _processed.Enqueue(activity);
  ...
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter6/testing/app.tests/TestActivityProcessor.cs

我们几乎准备好编写一些测试了,但还有一个挑战——我们如何过滤与特定测试相关的活动?

过滤相关活动

当我们并行运行测试时,我们实际上注册了多个监听相同 ActivitySource 实例的 OpenTelemetry 管道。通过覆盖我们的工具化的单元测试,我们可以更好地控制这一点,但在集成测试的情况下,ActivitySource 及其监听器实际上是静态的和全局的——如果我们并行运行测试,我们将在处理器中看到所有这些活动。我们需要过滤与我们的测试相关的相关活动,我们可以使用分布式跟踪来完成这一点。

我们将为每个测试启动一个新的活动并将上下文传播到被测试的服务。然后,我们可以根据它们的跟踪 ID 过滤处理过的活动。这种方法在 TracingTests 中实现(github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter6/testing/app.tests/TracingTests.cs)。

验证

一旦我们可以过滤与这次测试执行相关的所有活动,我们就可以对它们进行检查。检查你在监控和调试任务中依赖的所有属性是有用的。例如,以下代码验证了 ASP.NET Core 的 Activity 的几个属性:

TracingTests.cs

Assert.Equal("/document/foo",
  httpIn.GetTagItem("http.target"));
Assert.Equal(404, httpIn.GetTagItem("http.status_code"));
Assert.Equal(ActivityStatusCode.Unset, httpIn.Status);
Assert.Empty(httpIn.Events);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter6/testing/app.tests/TracingTests.cs

现在,你已经准备好编写和测试你的仪器化了!你可能会发现使用分布式跟踪来满足你的通用集成测试需求也很有用——依靠它来验证预期的测试行为,并调查不可靠的测试或不稳定的服务行为。你还可以使用跟踪作为验证服务行为的一个输入。

摘要

在这一章中,我们探讨了使用 .NET 诊断原语的手动分布式跟踪仪器化。ActivityActivitySource 是默认的代码仪器化方式——创建、开始、结束和丰富活动属性和事件。你可以使用 OpenTelemetry API 包中的 TracerTelemetrySpan 实现相同的功能。它们在 .NET 诊断 API 上提供了一个薄薄的包装,同时使用 OpenTelemetry 术语。

我们还研究了使用 Activity.Current 的环境上下文传播以及它是如何使多个仪器层协同工作的。然后,我们了解了事件及其限制,并使用链接关联不同的跟踪。

最后,我们涵盖了测试——因为仪器化对于监控可能是关键的,所以我们应该像验证其他功能一样验证它。我们学习了如何在 ASP.NET Core 应用程序中可靠地做到这一点。

通过这种方式,你应该能够编写丰富的跟踪仪器,并调试和验证自定义跟踪代码。为了实现更好的可观察性,我们可以将多个信号结合在一起,尽量减少重复,因此,在下一章中,我们将探讨手动指标仪器化,并看看它如何与跟踪一起工作。

问题

  1. 假设你使用 ActivitySource 开始了 Activity,你该如何配置 OpenTelemetry 来监听它?它是如何工作的?

  2. 你应该在什么时候使用 Activity 事件?有哪些替代方案?

  3. 我们需要链接做什么?我们如何使用它们?

第七章:添加自定义度量值

在上一章中,我们探讨了手动分布式跟踪仪表化,这应该有助于我们通过临时查询调试单个操作或分析服务使用情况。在这里,我们将讨论度量值。首先,我们将学习何时使用它们,了解基数要求,然后了解跟踪和度量值如何相互补充。我们将探讨.NET 中度量值 API 的演变,然后在本章的大部分内容中讨论 OpenTelemetry 度量值。我们将涵盖仪表,如计数器、仪表和直方图,并深入了解每个仪表。

本章将涵盖以下主题:

  • .NET 中度量值的优点、局限性和演变

  • 如何以及何时使用不同的计数器

  • 如何记录和使用仪表

  • 如何使用直方图记录值分布

到本章结束时,你应该能够为每个场景选择合适的仪表,并在你的应用程序中实现和使用它来分析性能、健康和用法。

技术要求

本章的代码可在 GitHub 上本书的存储库中找到:github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/tree/main/chapter7

运行示例和执行分析,我们需要以下工具:

  • .NET SDK 7.0 或更高版本

  • Docker 和docker-compose

.NET 中的度量值——过去和现在

尽管我们在这本书中专注于分布式跟踪,但了解度量值对于理解何时以及如何使用它们来提高可观察性非常重要。

度量值使我们能够报告在一定时间段和一组属性(即维度或标签)上聚合的数据。度量值可以表示为一系列时间序列,其中每个序列测量一个指标随时间变化的唯一属性值组合。例如,包括特定服务实例的 CPU 利用率或特定路由、HTTP 方法、响应代码和实例的 HTTP 请求延迟。

跟踪和度量值之间的主要区别在于聚合——跟踪捕获具有详细属性的单独操作。跟踪回答诸如“这个特定请求发生了什么?”和“为什么会发生?”等问题。另一方面,度量值告诉我们系统或其特定部分发生了什么,失败的常见程度如何,性能问题有多普遍,等等。

在深入研究度量值的使用案例、优点和 API 之前,我们首先需要了解度量值的主要限制——低基数

基数

基数表示唯一属性组合的数量或时间序列的数量。添加一个新属性会导致时间序列数量的组合爆炸,这会导致度量值体积的组合增长。

注意

指标应该具有低基数,但“低”和“高”是相对的——它们的定义取决于预算、后端限制和本地内存消耗。

例如,一个相对较大的 Prometheus 实例可以支持数百万个活跃的时间序列。如果我们有 1,000 个服务实例正在运行,并且该服务公开了四个 HTTP 路由,每个路由有三个方法,并返回五个不同的状态码,我们将报告 1,000(实例)* 4(路由)* 3(方法)* 5(状态码)= 60K 个 HTTP 服务器请求持续时间指标的时序(在最坏的情况下)。如果我们尝试包含诸如 customer_id 属性之类的信息,并且有 1,000 个活跃客户,我们仅对 HTTP 服务器请求持续时间指标就开始报告 60M 个时序。

我们仍然可以通过水平扩展 Prometheus 来做到这一点,因此当有正当理由时,报告一些高基数属性仍然是可行的。

注意

指标在导出之前在内存中聚合,因此具有高基数的指标可能会影响应用程序性能。

在 .NET 中,属性基数没有限制,但 OpenTelemetry SDK 对每个指标的最大指标数和每个指标的属性组合数有可配置的限制。

何时使用指标

资源消耗、低级通信细节或打开连接的数量最好用指标表示。在其他情况下,我们有选择,可以将遥测报告为指标、跨度或事件。例如,如果我们想按路由测量传入 HTTP 请求的数量,我们可以查询按服务、路由和时间戳过滤的跨度。我们也应该报告关于它的指标吗?让我们看看。

指标是在假设低基数的情况下实现和优化的,这带来了几个重要的好处:

  • 可预测的成本和有限的资源消耗:随着负载的增加,指标的量增长不多——只有当服务扩展并添加新实例时,我们才会得到一组新的时序。

  • 低性能影响:报告单个测量值无需分配内存。

  • 无偏的使用和性能数据:指标不受采样决策的影响而记录。指标并不总是报告精确数据,但我们可以通过配置收集间隔和直方图边界来控制它们的精度。

  • 快速且便宜(更便宜)的查询:虽然可观察性后端以不同的方式存储指标,并且它们的定价选项各不相同,但指标通常更紧凑,这通常会导致更快的摄取和更便宜的查询。

当我们定期使用指标来监控服务健康和利用率时,指标效果最佳。

当你想对某些操作进行仪表化并且对使用哪个信号有疑问时,以下策略可以有所帮助:如果你需要无偏数据或想在仪表板上创建警报或图表,请使用指标。否则,从跟踪和临时查询开始。如果你发现自己正在大量运行类似的跟踪查询,那么添加一个指标以优化此类查询。

假设你的跟踪后端不支持丰富的查询,你可能希望更加主动地添加指标。如果你的后端针对高基数数据和即兴分析进行了优化,你可能不需要很多指标。

既然我们已经对何时需要指标有了大致的了解,让我们深入了解仪表化。

报告指标

.NET 中有几个不同的指标(和计数器)API - 让我们来看看它们,并了解何时使用它们。

性能计数器

System.Diagnostics.PerformanceCounter类及其相关类实现了 Windows 性能计数器。它们不支持维度。这些限制使得性能计数器不太可能成为现代分布式系统监控故事的理想选择。

事件计数器

System.Diagnostics.Tracing.EventCounter是一个跨平台的计数器实现,它代表一个单一的时间序列 - 我们在第二章Native Monitoring in .NET,和第四章Low-Level Performance Analysis with Diagnostic Tools中看到了它的实际应用,在那里我们使用dotnet-countersdotnet-monitor收集了来自.NET 的计数器。OpenTelemetry 也可以监听它们,将它们转换为 OpenTelemetry 指标,并用资源属性丰富它们。

如果你想要报告一个不需要任何维度(除了静态上下文)的指标,并且希望能够使用诊断工具动态地打开和关闭此指标,事件计数器将是一个不错的选择。

我们不会深入探讨EventCounter API,所以请参考.NET 文档(learn.microsoft.com/dotnet/core/diagnostics/event-counters)以获取更多信息。

OpenTelemetry 指标

我们将要关注的 API 在System.Diagnostics.Metrics命名空间中,位于System.Diagnostics.DiagnosticSource NuGet 包中。这些 API 遵循 OpenTelemetry 的指标规范和术语,除了使用“tags”代替“attributes”这个术语。没有为指标提供适配器。

指标 API 支持使用以下仪表记录多维数据:

  • Counter: 表示随时间增加的值 - 例如,打开的连接数

  • UpDownCounter: 表示随时间增加或减少的加法值 - 例如,活动连接数

  • Gauge: 表示当前值 - 例如,从消息队列接收到的最后一条消息的序列号

  • Histogram: 表示值的分布 - 例如,HTTP 请求延迟

可以使用Meter类创建仪表。因此,首先我们需要一个Meter实例,我们可以使用一个名称和可选的仪表版本来创建它:Meter meter = new("sample")

Meter 的名称可以与应用程序名称、命名空间、类或其他在您的情况下有意义的任何内容匹配。它用于启用指标,如下面的示例所示:

Program.cs

using var meterProvider = Sdk.CreateMeterProviderBuilder()
  .AddMeter("queue.*")
  .AddOtlpExporter()
  .Build()!;

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter7/metrics/Program.cs

在这里,我们启用了所有来自以 queue. 开头的 Meter 的指标(我们可以使用精确匹配或通配符)。

Meter 是可丢弃的。在某些情况下,当您在整个应用程序的生命周期中使用相同的 Meter 实例时,可以将 Meter 实例设置为静态;否则,请确保将其丢弃以禁用所有嵌套仪器。

注意

我们可以直接监听指标,无需 OpenTelemetry,使用 System.Diagnostics.Metrics.MeterListener 类。它可以订阅特定的仪器并记录它们的测量值。MeterListener 被 OpenTelemetry 使用,因此您可能会发现它在调试仪器时很有用。

现在我们有了 Meter 实例并配置了 OpenTelemetry 以导出指标,我们可以使用 Meter 类的工厂方法创建仪器;例如,meter.CreateCounter<long>("connections.open")

我们将在本章后面看到如何创建仪器,但到目前为止,这里有一个常见仪器属性的列表:

  • (byte, short, int, long, float, double, 或 decimal)。

  • 仪器名称代表一个唯一的导出指标名称;这是一个必需的属性。OpenTelemetry 将仪器名称限制为 63 个字符,并具有其他限制。我们将在第九章最佳实践中进一步讨论。

  • 单位代表统一单位与度量代码(unitsofmeasure.org/)之后的可选值单位。

  • 描述是一个可选的自由格式文本,简要描述仪器。

我们可以在进程中创建具有相同名称的多个仪器实例 – OpenTelemetry SDK 将来自它们的聚合数据合并为一个值。仪器实际上是通过其名称、单位和资源属性组合来识别的。因此,来自具有相同身份的多个仪器实例的测量值将一起聚合。让我们逐一探索仪器,并学习如何使用它们,从计数器开始。

使用计数器

CounterUpDownCounter 代表可加值 – 有意义求和的值。例如,具有不同 HTTP 方法的传入请求数的求和是有意义的,但不同核心的 CPU 利用率求和则没有意义。

在仪器方面,CounterUpDownCounter 之间的唯一区别是前者单调增加(或保持不变),而后者可以减少。例如,打开和关闭的连接数量应该用 Counter 表示,而活动连接的数量应该用 UpDownCounter 表示。

这两种计数器都可以是同步的或异步的:

  • 同步计数器在值发生变化时报告值的变化量。例如,一旦我们成功启动了一个新的连接,我们可以增加打开和活动连接的计数器。一旦我们完成了连接的终止,我们只减少活动连接的数量。

  • UpDownCounter 仪器在项目入队时增加,在出队或创建 ObservableUpDownCounter 并在回调中返回队列长度时减少。

让我们在内存队列处理中进行仪器化,并在过程中了解每个仪器。

Counter 类

同步计数器在 System.Diagnostics.Metrics.Counter 类中实现。我们将使用它来跟踪入队项的数量:

Producer.cs

private static Meter Meter = new("queue.producer");
private static Counter<long> EnqueuedCounter =
  Meter.CreateCounter<long>("queue.enqueued.count",
    "{count}",
    "Number of enqueued work items");

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter7/metrics/Producer.cs

在这里,我们创建了一个名为 queue.producerMeter 类实例。在这里它是静态的,因为我们永远不会需要禁用相应的仪器。然后,我们创建了一个名为 queue.enqueue.count 的静态计数器,其参数类型为 long,单位设置为 {count}

我们还需要在每次入队时增加它。Counter 提供了 Add 方法来记录正的变化量;它有几个重载可以传递零个或多个属性。在我们的示例中,我们有多个队列,并传递队列名称:

Producer.cs

EnqueuedCounter.Add(1,
  new KeyValuePair<string, object?>("queue", _queueName))

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter7/metrics/Producer.cs

让我们使用 metrics$ docker-compose up --build 运行示例应用程序,并在 OpenTelemetry Collector 的 http://localhost:8889/metrics 上打开指标端点。我们应该在 Prometheus 展示格式中看到 queue_enqueued_count_total 等其他指标:

# HELP queue_enqueued_count_total Number of enqueued work items
# TYPE queue_enqueued_count_total counter
queue_enqueued_count_total{job="metrics ",queue="add"} 323
queue_enqueued_count_total{job="metrics ",queue="remove"} 323

在这里,我们可以看到描述,以及仪器的类型,随后是所有属性组合的列表和最新的计数器值。

我们还可以在 Prometheus(在 http://localhost:9090)中可视化这个计数器。通常,我们对速率或趋势感兴趣,而不是计数器的绝对值。例如,项目入队的速率可以很好地表明生产者的负载和性能。

我们可以通过使用 sum by (queue) (rate(queue_enqueued_count_total[1m])) 查询来获取这个值 – Prometheus 计算每秒的速率(并在 1 分钟内平均),然后通过按队列名称对应用程序实例进行分组来汇总值。相应的图表显示在 图 7**.1 中:

图 7.1 – 按队列名称分组的每秒入队速率

图 7.1 – 按队列名称分组的每秒入队速率

在这里,我们可以看到我们以大约每秒 16 个项目的速度向每个队列中入队。除了 sum,我们还可以使用 minmax 运算符来查看是否有突出显示的应用程序实例。

计数器以及其他仪器公开了一个 Enabled 标志,该标志指示是否有任何监听器为此仪器。仪表默认情况下是禁用的,并且特定的仪器可以被禁用,因此应该使用 Enabled 标志来保护任何必要的指标报告的额外工作。这对于本地仪器非常重要,因为此类库的最终用户可能已启用或未启用指标,目标是当指标被禁用时没有性能影响。

在仪器上公开的其他属性包括 NameUnitDescriptionMeter,我们使用这些属性来创建此仪器。

UpDownCounter 类

System.Diagnostics.Metrics.UpDownCounter 类在 API 方面与 Counter 类非常相似。您可以通过在 Meter 实例上使用 CreateUpDownCounter 方法来创建一个,提供仪器名称,以及可选的单位描述。UpDownCounter 类公开了一个 Add 方法,该方法接受测量值的增量以及零个或多个标签。它还公开了 Enabled 标志以及仪器创建时使用的属性,例如其名称、单位和描述。

然而,在消费端,UpDownCounter 是不同的。它不是单调的,映射到 Prometheus 中的 gauge 类型。我们将在 The ObservableUpDownCounter 部分了解更多关于它的信息。

ObservableCounter 类

System.Diagnostics.Metrics.ObservableCounter 实现了 Counter 的异步版本。在消费端,同步和异步计数器之间没有区别。ObservableCounter 只提供了在定期执行的回调中记录计数器的更方便的方式。

例如,在 OpenTelemetryInstrumentation.Runtime NuGet 包中,可用的完成(由线程池完成)任务的数量(process.runtime.dotnet.thread_pool.completed_items.count)被实现为 ObservableCounter。在每次调用时,它返回 ThreadPool.CompletedWorkItems 属性。

我们可以使用 CreateObservableCounter 方法创建一个可观察的计数器:Meter.CreateObservableCounter<long>("my.counter", GetValue)。在这里,除了名称之外,我们还传递了一个 lambda 函数 – GetValue – 该函数返回计数器的当前值。

它在指标即将导出时执行。在我们的应用程序中,这每 5 秒发生一次,而 OTLP 导出器的默认周期是 60 秒。我们使用OTEL_METRIC_EXPORT_INTERVAL环境变量进行了配置,但也可以通过PeriodicExportingMetricReaderOptions.ExportIntervalMilliseconds属性显式设置:

ExplicitConfiguration.cs

AddOtlpExporter((exporterOptions, readerOptions) =>
  readerOptions.PeriodicExportingMetricReaderOptions
    .ExportIntervalMilliseconds = 5000)

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter7/metrics/ExplicitConfiguration.cs

ExportIntervalMilliseconds属性控制计数器值收集的频率,因此它控制了单个时间序列的精度和体积。

此配置不会影响基于拉取的导出器,如 Prometheus,其中它由外部控制(例如,通过 Prometheus 实例上的scrape_interval参数)。在我们的示例应用程序中,我们有 OTLP 导出器,它是基于推送的,并将指标发送到 OpenTelemetry Collector。然后收集器在http://localhost:8889/metrics端点公开指标,Prometheus 从那里抓取它们。

使用ObservableCounter,我们只能使用启动时提供的回调记录数据,并且CreateObservableCounter方法有几个重载,允许我们报告指标值,以及其属性(通过Measurement结构体)或作为Measurement实例的列表。

有几件重要的事情需要了解回调:

  • Counter.Add方法不同,它报告计数器的绝对值。

  • 它应该在合理的时间内完成。我们可以使用OTEL_METRIC_EXPORT_TIMEOUT环境变量或PeriodicExportingMetricReaderOptions.ExportTimeoutMilliseconds属性以类似的方式配置超时。

  • 回调不应为同一组属性返回多个测量值。OpenTelemetry SDK 对此情况的行为未定义。

注意

这些要求来自 OpenTelemetry 规范。MeterListener不会强制执行任何这些要求。

要取消订阅可观察计数器,我们必须销毁相应的Meter实例。因此,如果计数器依赖于任何实例数据,并且属于具有有限生命周期的对象,我们必须将Meter作为实例变量创建,并与其所属的对象一起销毁。让我们通过ObservableUpDownCounter的例子来看一下这一点。

ObservableUpDownCounter 类

System.Diagnostics.Metrics.ObservableUpDownCounter表示UpDownCounter的异步版本。它的创建方式类似于ObservableCounter,但其消费端与UpDownCounter相匹配。

我们将使用它来报告队列长度——它应该能给我们一个很好的指示,关于处理器的吞吐量和它是否足够快地处理项目。

队列长度不是单调的——它可以上升和下降,因此常规计数器将不起作用。我们可以将其跟踪为 UpDownCounter:在入队时增加它,在出队时减少它。但如果我们使用 ObservableUpDownCounter,我们只需每几秒返回一次队列长度,就能更节省地达到相同的效果。

在更复杂的分布式队列案例中,我们可能无法对生产者和消费者都进行仪表化,并需要定期通过向代理的网络调用获取当前分布式队列长度(如果你决定在计数器回调中这样做,请小心)。

让我们实现队列长度计数器。首先,Processor 类是可丢弃的,因此我们应该假设它在应用程序结束之前可能会死亡。在这种情况下,禁用所有仪器很重要——我们需要创建一个 Meter 作为实例变量并创建计数器:

Processor.cs

_queueNameTag = new KeyValuePair<string, object?>("queue",
   queueName);
_meter = new Meter("queue.processor");
_queueLengthCounter = _meter
  .CreateObservableUpDownCounter(
    "queue.length",
    () => new Measurement<int>(queue.Count, _queueNameTag),
    "{items}",
    "Queue length");

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter7/metrics/Processor.cs

在这里,我们创建了一个名为 queue.lengthObservableUpDownCounter 实例,并将其配置为在回调中报告长度,同时包含队列名称属性。我们最后需要做的是使用 _meter.Dispose() 方法来销毁 Meter 实例以及处理器。就这样了!

使用 metrics$ docker-compose up --build 启动示例应用程序(除非它仍在运行),并检查 queue_length 度量(在 http://localhost:8889/metrics)。你应该会看到它,以及其他度量:

# HELP queue_length Queue length
# TYPE queue_length gauge
queue_length{job="metrics",queue="add"} 2
queue_length{job="metrics",queue="remove"} 0
queue_length{job="metrics",queue="update"} 157

如您所见,UpDownCounterObservableUpDownCounter 都映射到仪表 Prometheus - 我们将在下一节中了解更多关于仪表的信息。

我们可以使用 Prometheus UI(在 http://localhost:9090)中的 avg by (queue) (queue_length) 查询来可视化此度量,如图 图 7.2 所示:

图 7.2 – 每个队列的平均队列长度

图 7.2 – 每个队列的平均队列长度

通过查看此图表,我们可以得出结论,更新队列线性增长,而其他队列几乎为空。我们在这里不需要复杂的查询,因为我们对绝对值感兴趣,因为我们预计队列长度始终很小。

让我们了解其他仪器——仪表和直方图——并调查更新队列中会发生什么。

使用异步仪表

System.Diagnostics.Metrics.ObservableGauge 表示非累加度量的当前值。它只有异步版本。

ObservableUpdownCounter 的关键区别在于计数器是可累加的。例如,对于计数器,如果我们有多个具有相同计数器名称、相同时间戳和相同属性的度量点,我们只需将它们相加即可。对于仪表,聚合没有意义,OpenTelemetry 使用最后报告的值。

当导出到 Prometheus 时,ObservableGaugeObservableUpdownCounter是相同的,但它们的 OTLP 定义(网络格式)是不同的。

小贴士

您可以通过启用ConsoleExporter输出或查看 OpenTelemetry 文档在opentelemetry.io/docs/reference/specification/overview/#metrics-data-model-and-sdk来了解 OpenTelemetry 侧指标点的内部表示。

我们使用ObservableGauge来报告最后处理项的序列号。这对于分布式队列很有用,其中序列号(或偏移量)表示项在队列中的唯一和有序位置。

通过查看序列号趋势,我们可以估计处理了多少项以及它们的处理速度有多快。例如,如果处理器卡在尝试处理无效的工作项,我们会看到序列号没有增加。

将来自不同队列的序列号相加没有意义,因此它应该是一个ObservableGauge,我们可以使用熟悉的 API 来创建它:

Processor.cs

_sequenceNumberGauge = _meter
  .CreateObservableGauge(
    "processor.last_sequence_number",
    () => new Measurement<long>(_seqNo, _queueNameTag),
    null,
    "Sequence number of the last dequeued item");

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter7/metrics/Processor.cs

在回调中,我们返回_seqNo实例变量,我们在出队工作项后更新它。我们在这里唯一需要的是线程安全;我们不需要精度,因为数据是定期收集的。

只要它们具有不同的属性,我们可以报告带有零个或多个属性或一次多个测量的值。

如果我们使用metrics$ docker-compose up --build运行示例应用程序,我们可以使用如delta(processor_last_sequence_number[1m])之类的查询在 Prometheus 中检查序列号。它返回每分钟的增量,并在*图 7**.3 中显示:

图 7.3 – 每分钟的序列号增量

图 7.3 – 每分钟的序列号增量

如我们所见,在应用程序启动后,queue_length计数器的增量稳定在每分钟约 3,000 项——更新队列处理不够快。通过查看指标,我们无法说为什么,但有一个可以提供一些线索——处理持续时间。让我们看看它。

使用直方图

System.Diagnostics.Metrics.Histogram 表示值的分布——例如,操作持续时间或有效载荷大小。直方图只能同步报告,因为每个测量都很重要。正如我们在第二章中看到的,在.NET 中的原生监控,它们允许我们在查询时计算百分位数。

在我们的示例中,我们将使用直方图来记录处理持续时间:

Processor.cs

_processingDurationHistogram = _meter
  .CreateHistogram<double>(
    "processor.processing.duration",
    "ms",
    "Item processing duration");

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter7/metrics/Processor.cs

每次我们从队列中处理一个项目时,我们都应该测量并记录它所花费的时间:

Processor.cs

Stopwatch? duration = _processingDurationHistogram
  .Enabled ? Stopwatch.StartNew() : null;
var status = await Process(item);
if (duration != null)
  _processingDurationHistogram.Record(
    duration.Elapsed.TotalMilliseconds,
    _queueNameTag,
    new KeyValuePair<string, object?>("status",
      StatusToString(status)));

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter7/metrics/Processor.cs

在这里,我们使用Enabled标志 – 当指标未启用时,它阻止我们在堆上分配Stopwatch对象。

记录方法有多个重载,可以报告与该值关联的零个或多个属性。在这里,我们报告队列名称和处理状态。状态具有低基数 – 它是一个只有几个值的enum

我们还希望尽可能高效,因此我们实现了最优且非分配的StatusToString方法。

让我们使用metrics$ docker-compose up --build运行应用程序,并检查 Prometheus 展示格式(在http://localhost:8889/metrics)中的直方图看起来如何。你应该会看到每个队列、状态和桶的processor_processing_duration_milliseconds_bucket点集。

例如,这是我看到的Ok(为了简洁起见,省略了一些属性和桶):

processor_processing_duration_milliseconds_bucket{le="0"} 0
...
processor_processing_duration_milliseconds_bucket{le="50"} 27
processor_processing_duration_milliseconds_bucket{le="75"} 52
processor_processing_duration_milliseconds_bucket{le="100"} 67
processor_processing_duration_milliseconds_bucket{le="250"} 72
...
processor_processing_duration_milliseconds_bucket{le="+Inf"} 72
processor_processing_duration_milliseconds_sum
  4145.833300000001
processor_processing_duration_milliseconds_count 72

每个桶由一个le属性标识 – 包含的上限边界。有 27 个测量值小于或等于 50 毫秒,52 个测量值小于 75 毫秒,等等。总的来说,有 72 个测量值,所有持续时间的总和约为 4,146 毫秒。

OTLP 格式定义了一些我们在这里看不到的更多有趣属性:

  • 每个桶的minmax值 – Prometheus 不支持它们,但在 OTLP 数据中显示出来。

  • 示例,代表桶中跟踪的示例。我们可以使用它们轻松地从指标导航到跟踪,并调查高直方图桶中的长时间处理操作。它们在.NET 的 OpenTelemetry 中尚未实现。

我们在这里可以看到的桶边界是默认的。它们是静态的,如果测量的值在[0, 10000]范围内,则效果最佳。如果我们开始测量[10,000, 20,000]范围内的值,每个测量值都会在最后两个桶中,这将使百分位计算无效。在这种情况下,我们应该使用MeterProviderBuilder.AddView方法为相应的直方图设置显式边界。

未来,OpenTelemetry 将允许我们使用具有动态边界调整的数据的指数直方图。

注意,我们还有processor_processing_duration_milliseconds_sumprocessor_processing_duration_milliseconds_count指标,因此通过仅报告直方图,我们就可以得到百分位数、平均值和测量计数器。

我们可以使用以下查询获取中值处理时间:

histogram_quantile(0.5, sum(rate(processor_processing_duration_milliseconds_bucket{status="Ok"}[1m])) by (le, queue))

这应该生成图 7.4中所示的图表:

图 7.4 – 每个队列的中值处理时间

图 7.4 – 每个队列的中值处理时间

在这里,我们可以看到添加队列的中值处理时间约为 61 毫秒,移除队列约为 48 毫秒,更新队列约为 75 毫秒。

让我们也检查处理速率,使用sum by (queue) (rate(processor_processing_duration_milliseconds_count[1m]))查询,如图 7.5所示:

图 7.5 – 每个队列的处理速率

图 7.5 – 每个队列的处理速率

更新队列中的项目以每秒约 14 个项目的速率进行处理;入队速率约为每秒 16 个项目,正如我们在图 7.1中看到的。这应该解释了为什么更新队列在增长:

  • 处理时间过长 – 我们应该尝试优化它,使其目标至少为 60 毫秒,以便能够以每秒 16 个项目的速率进行处理。

  • 如果优化不可行(或不足),我们知道我们需要每秒处理额外的 2-3 个项目,因此我们需要大约 20%更多的处理器实例。

  • 我们还可以在生产者端实现背压,并限制更新请求,以减少处理器的入队速率。

仅使用一小套指标,我们就能够将问题缩小到特定区域。如果这是一个生产事故,我们能够通过增加处理器数量来快速缓解,然后调查其他选项。

摘要

在本章中,我们探讨了.NET 和 OpenTelemetry 中的指标。

指标使我们能够收集聚合的多维数据。它们在任何规模下都能产生无偏见的遥测数据,并具有可预测的量级,使我们能够监控系统健康、性能和利用率。

指标不能有高基数属性,因此我们无法使用它们来检测在特定和狭窄情况下发生的问题 – 对于此,我们需要分布式跟踪或事件。.NET 提供了一个 OpenTelemetry 指标实现,它由Meter类组成,可以创建特定的工具:计数器、仪表和直方图。

计数器用于报告累加值,可以是同步的或异步的。仪表报告当前的、非累加值,而直方图报告值分布。

通过这些,你应该能够识别出指标有益的场景,选择合适的工具,并高效地在你的应用程序中报告指标。你还应该能够配置 OpenTelemetry,最重要的是,开始检测和监控性能问题。

在下一章中,我们将探讨结构化日志和事件,并学习如何使用.NET 和 OpenTelemetry 高效地编写和消费它们。

问题

  1. 假设你想跟踪 meme 下载的数量(从我们的 meme 示例应用程序中)。你会选择哪些遥测信号?为什么?

  2. 报告 HTTP 请求持续时间时,您会将其报告为时间段、指标,还是两者都要?

  3. 您会如何监控活跃的应用实例数量和正常运行时间?

第八章:编写结构化和关联日志

分布式跟踪是一个描述和关联操作的优秀工具,但有时我们需要记录回调和启动配置等信息,或者有条件地写入调试信息。在本章中,我们将探讨日志——这是最古老且最受欢迎的遥测信号,可以描述任何事物。

首先,我们将讨论日志用例,并发现 .NET 中可用的不同 API,然后我们将关注 ILogger —— 一个常见的日志外观。我们将学习如何高效地使用它来编写结构化事件。我们将看到如何使用 OpenTelemetry 导出日志并对其编写丰富的查询。最后,我们将探索日志采样和成本节约策略。

在本章中,你将学习以下内容:

  • 何时写入日志以及使用哪个 .NET API

  • 如何使用 Microsoft.Extentions.Logging.ILogger 类写入日志

  • 如何使用 OpenTelemetry 捕获和导出日志

  • 使用 OpenTelemetry Collector 的成本管理策略

到本章结束时,你将能够使用日志和事件高效地对你的应用程序进行配置,以便调试和分析服务行为。

技术要求

本章的代码可在 GitHub 上的书籍仓库中找到,网址为 github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/tree/main/chapter8

为了运行示例和执行分析,我们需要以下工具:

  • .NET SDK 7.0 或更高版本

  • Docker 和 docker-compose

.NET 中的日志演变

日志是最灵活的遥测信号,通常包括时间戳、级别、类别、消息,有时还有属性。

日志通常旨在人类可读,并且没有严格的格式。以下是一个 ASP.NET Core 应用程序写入 stdout 的日志记录示例:

info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5050

如果我们需要调查某些内容,我们首先会寻找描述有趣操作的日志,然后阅读过滤后的日志。我们理解发生了什么的能力取决于记录了多少上下文以及它有多容易通过工具如 grep 搜索。

结构化日志有时被称为 事件。事件旨在查询,可能跨越多个请求,基于任何属性,并且需要一个严格和一致的结构。以下是之前日志记录的 OpenTelemetry JSON 格式:

"timeUnixNano":"1673832588236902400",
"severityNumber":9, "severityText":"Information",
"body":{"stringValue":"Now listening on: {address}"},
"attributes":[
  {"key":"dotnet.ilogger.category",
     "value":{"stringValue":"Microsoft.Hosting.Lifetime"}},
  {"key":"Id","value":{"intValue":"14"},
  {"key":"address",
     "value":{"stringValue":"http://[::]:5050"}}], "traceId":"",
        "spanId":""}

虽然它不是人类可读的,但即使写入 stdout 或文件,也可以很容易地解析成结构化记录,而不需要任何关于事件语义的先验知识。

正如我们在 第一章 中开始探讨的,现代应用程序的可观察性需求,日志和事件之间的区别是语义上的——相同的信息可以优化并以人类可读的格式打印,或者以结构化格式存储和索引到数据库中。

我们将学习如何使用 Microsoft.Extensions.Logging.ILogger 类编写这样的结构化日志,但首先,让我们快速查看 .NET 中的其他日志记录 API。

控制台

我们可以使用 System.Console 类作为日志记录器并将所有内容写入 stdout。我们需要从头实现所有日志原语,并在转发到日志管理系统的同时解析它,以恢复原始日志结构。将日志记录到 Console 既不便利也不高效。

Trace

System.Diagnostics.TraceSystem.Diagnostics.TraceSource 类提供了写入消息的方法,以及一些参数,并支持日志级别。我们还可以使用 TraceListener 类来监听它们,以便将它们导出到日志管理系统。

这看起来是个不错的开始,但有几个限制:

  • TraceSource API 不提供写入参数的标准方式。因此,很容易将消息格式化为字符串,但我们需要知道特定事件的语义才能知道参数名称。

  • 默认情况下,TraceSourceTraceListener 在每个操作上都使用全局锁。它们可以以无锁的方式使用,但直到负载足够高时才可能容易忽略。

因此,Trace API 解决了一些日志问题,但也引入了新的问题。

EventSource

System.Diagnostics.Tracing.EventSource 是 .NET 中的另一个日志记录 API。它设计用于高性能场景,支持日志级别和丰富的有效负载,并捕获参数的名称和值。可以通过实现 EventListener 类或以边车进程运行 .NET 诊断工具来监听它。

EventSource 是 .NET 平台的一部分,可以直接使用而无需任何额外依赖。EventSource 是在库中不希望添加任何新依赖项的情况下进行日志记录的完美候选者。

当涉及到消费时,许多可观察性供应商提供自定义包来监听事件源,但目前还没有与 OpenTelemetry 集成,这可能在您阅读时有所改变。

如我们在 第四章使用诊断工具进行低级性能分析第二章在 .NET 中的原生监控 中所见,EventSource 事件也可以使用 dotnet 诊断工具 – dotnet-tracedotnet-monitor – 进行捕获。

ILogger

Microsoft.Extensions.Logging.ILogger 是一个与 ASP.NET Core 集成的常见日志外观。它支持结构化日志和级别,并拥有丰富的生态系统,这使得配置和向任何提供者(本地或远程)发送数据变得容易。

使用 ILogger 编写的日志可以从其他日志库中消费,例如 SerilogNLog,并且它也受到 OpenTelemetry 的支持。许多可观察性后端支持 ILogger,使其成为编写应用程序日志的完美工具。

ILogger日志可以使用.NET 诊断工具在进程外捕获。这是通过首先使用Microsoft.Extensions.Logging.EventSource.EventSourceLoggingProvider类将日志转发到EventSource来完成的。此提供程序在 ASP.NET Core 应用程序中默认启用,你可以使用AddEventSourceLogger扩展方法手动配置它,该扩展方法用于ILoggingBuilder接口。我们使用这种机制通过dotnet-monitor捕获日志,并在第二章Native Monitoring in .NET部分动态控制日志详细程度。

让我们更详细地了解ILogger的使用。

使用 ILogger 进行日志记录

ILogger类是Microsoft.Extensions.Logging.Abstractions NuGet 包的一部分。如果你在开发 ASP.NET Core 应用程序、工作服务或使用其他Microsoft.Extensions包,你已经是间接依赖于它的。

ILogger接口公开了一些方法:

  • Log使用给定的级别、ID、异常、状态和格式器记录日志消息。状态类型是泛型的,但应该包含消息,以及所有参数及其名称。

  • IsEnabled检查当前级别的日志记录是否启用。

  • BeginScope向日志作用域添加一个对象,允许你使用它丰富嵌套的日志记录。我们在第二章Native Monitoring in .NET中看到了作用域的实际应用,在那里我们使用跟踪上下文和 ASP.NET Core 请求信息注释了控制台日志。

通常,我们会使用在Microsoft.Extensions.Logging.LoggerExtensions类中定义的方便的扩展方法,而不是使用普通的ILogger.Log方法。

在编写任何日志之前,我们首先获取一个ILogger实例 - 在 ASP.NET Core 应用程序中,我们可以通过构造函数参数注入来实现,如下例所示:

frontend/RetryHandler.cs

private readonly ILogger<RetryHandler> _logger;
public RetryHandler(ILogger<RetryHandler> logger) =>
    _logger = logger;

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter8/memes/frontend/RetryHandler.cs

在这里,我们使用RetryHandler类型参数获取一个ILogger实例。类型参数的完整名称转换为日志类别,这对于控制详细程度和查询日志非常重要,正如我们将在使用 OpenTelemetry 捕获日志部分中看到的。

注意

请参考.NET 文档了解如何创建和配置日志记录器。

现在,我们终于可以记录一些东西了。例如,我们可以使用_logger.LogInformation("hello world")来写入信息日志。

如果你使用标准的日志实现,此调用将广播到所有已注册的日志提供程序,这些提供程序已为该日志类别启用了Information级别。

过滤器在配置时提供,可以是全局的,也可以是针对特定日志提供程序的。例如,以下是我们 memes 应用程序中的全局日志配置:

frontend/appsettings.json

"Logging": {
  "LogLevel": {
    "frontend": "Information",
    "Microsoft.Hosting.Lifetime": "Information",
    "Default": "Warning"
  }
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter8/memes/frontend/appsettings.json

此全局配置将 frontendMicrosoft.Hosting.Lifetime 类别的 Information 级别设置为,将其他所有内容的级别设置为 Warning

让我们回到 ILogger API,看看我们如何可以记录更有用的日志。例如,让我们记录包含响应体的错误响应的调试消息。

我们在这里应该小心——体流通常只能读取一次,并且可能非常长,但无论如何,我们应该能够控制引入的任何开销:

frontend/RetryHandler.cs

if (!response.IsSuccessStatusCode &&
     _logger.IsEnabled(LogLevel.Debug))
  _logger.LogDebug("got response: {status} {body} {url}",
    (int)response.StatusCode,
    await response.Content.ReadAsStringAsync(),
    response.RequestMessage?.RequestUri);
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter8/memes/frontend/RetryHandler.cs

在这里,我们以 Debug 级别记录日志记录,并在读取响应流之前检查级别是否启用。我们还使用 语义(即结构化)日志记录,在消息字符串中用花括号提供参数名称,并作为参数提供它们的值。

注意

确保使用语义日志记录。对于 ILogger 消息,使用字符串插值或显式格式化会移除结构,使得基于日志级别的性能优化变得不可能。

参数作为对象传递。ILogger 实现,如 OpenTelemetryLogger,支持某些类型,并且通常对其他所有内容调用 ToString 方法。如果在此级别禁用日志记录,则永远不会调用 ToString,这可以节省一些 CPU 周期和内存分配。

通过 IsEnabled 检查来保护日志调用,以及检索或计算参数,是保持禁用类别性能影响非常低的好方法。

优化日志

与日志相关的代码经常成为性能退化的来源。避免内存分配和参数计算,尤其是在此级别禁用日志时,是第一步,但我们还应该在启用时优化热路径上的日志。以下是一些技巧:

  • 避免过度日志记录:在进入重要的代码分支、回调被调用或捕获异常时,可能需要写入日志记录。避免在异常传播过程中多次记录异常,或在内联方法中记录相同的回调。

  • 避免重复:统一与同一操作相关的多个日志,当可用时,使用来自 ASP.NET Core 和其他库的日志,而不是添加自己的日志。

  • 避免仅为了日志记录目的计算任何值:通常,序列化对象和解析或格式化字符串是常见的操作,但通常可以通过重用现有对象、缓存值或在查询时格式化文本来优化。

最后,当日志量和参数优化后,我们可以进行一些微优化。其中之一是使用编译时日志源生成,以下是一个示例:

StorageService.cs

[LoggerMessage(EventId = 1, Level = LogLevel.Information,
  Message = "download {memeSize} {memeName}")]
private partial void DownloadMemeEvent(long? memeSize,
  string memeName);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter8/memes/frontend/StorageService.cs

在这里,我们定义了一个部分方法,并用 LoggerMessage 属性进行了注释,提供了事件 ID、级别和消息。此方法的实现是在编译时生成的(你可以在 .NET 文档的learn.microsoft.com/dotnet/core/extensions/logger-message-generator中找到更多相关信息)。

如果我们检查生成的代码,我们可以看到它缓存了日志调用及其静态参数。有关此方法的更多详细信息,请参阅 .NET 文档中的learn.microsoft.com/dotnet/core/extensions/high-performance-logging

我们可以通过运行 logging-benchmark$ dotnet run -c Release 并检查 BenchmarkDotNet.Artifacts 文件夹中的结果来比较不同日志方法的性能。该基准使用了一个虚拟日志记录器,并仅测量仪器侧。如果我们比较编译时日志源生成和 LogInformation(或类似)方法的性能,我们将看到以下结果:

  • 编译时日志源生成消除了在仪器侧的内存分配,即使日志已启用也是如此。因此,垃圾回收(GC)变得更加频繁,导致更高的吞吐量和更小的 P95 延迟。

  • 使用编译时日志源生成时,如果参数值 readily available,则不需要进行 IsEnabled 检查。

  • 当启用日志记录时,单个日志调用的持续时间在很大程度上不依赖于所使用的方法。

这些结果可能会因参数类型和值的不同而有所变化。请确保运行性能、压力和负载测试,或者使用与生产环境中类似的日志配置来分析你的应用程序。

现在,你已经完全准备好编写日志了,所以是时候探索消费端了。

使用 OpenTelemetry 捕获日志

默认情况下,ASP.NET Core 应用程序将日志写入stdout,但因为我们希望将它们与跟踪关联并按任何属性查询,所以我们应将它们导出到支持该功能的可观察性后端或日志管理工具。如果您的供应商支持ILogger,您可以通过配置相应的日志提供程序直接将日志发送到您的供应商。这将由该日志提供程序负责为日志添加跟踪上下文或环境信息。通过使用 OpenTelemetry 收集日志,我们可以与其他信号保持一致地注释它们。

让我们看看如何使用 OpenTelemetry 收集 meme 应用程序的日志。为了充分利用结构,我们将它们导出到ClickHouse——一个支持 SQL 查询的开源数据库。

这里是一个将日志导出为OpenTelemetry 协议OTLP)导出器到 OpenTelemetry Collector 的配置示例:

frontend/Program.cs

builder.Logging.AddOpenTelemetry(b => {
  b.SetResourceBuilder(resource);
  b.ParseStateValues = true;
  b.AddOtlpExporter();
});

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter8/memes/frontend/Program.cs

在这里,我们向应用程序的ILoggingBuilder实例添加了 OpenTelemetry 日志提供程序,然后配置了该提供程序。我们配置了资源属性,启用了解析状态值以填充参数,并添加了 OTLP 导出器。导出器端点配置了OTEL_EXPORTER_OTLP_ENDPOINT环境变量。

OpenTelemetry Collector 配置为将所有日志发送到文件,并将采样日志写入 ClickHouse——我们将在下一节中查看其配置。

让我们继续运行 meme 应用程序,使用memes$ docker-compose up --build。然后,我们将访问http://localhost:5051/的前端来上传和下载一些 meme。

要在 ClickHouse 中查询日志,请运行$ docker exec -it memes-clickhouse-1 /usr/bin/clickhouse-client——这将启动一个客户端,我们可以在其中编写 SQL 查询,例如以下查询,它返回所有日志记录:

$ select * from otel_logs order by Timestamp desc

这里是一个输出示例——我们在本章早期添加的下载 meme 日志(如果您看不到,请记住日志是采样的,您可能需要下载更多 meme):

│ 2023-01-17 03:28:37.446217500 │ 1bf63116f826fcc34a1e255
4b633580e │ 2a6bbdfee21d260a │         1 │ Information │ 9
│ frontend │ download {memeSize} {memeName}│
{'service.instance.id':'833fb55a4717','service.name':'front
end'} │ {'dotnet.ilogger.category':'frontend
.StorageService',
'Id':'1','Name':'DownloadMemeEvent',
'memeSize':'65412', 'memeName':'this is fine'}

它几乎无法阅读但易于查询,因为它包括时间戳、跟踪上下文、日志级别、正文、资源信息和属性——一个事件名称、一个 ID、一个 meme 大小和一个名称。

注意

在撰写本文时,OpenTelemetry 日志规范仍处于实验阶段,因此.NET 实现是最小的,细节可能会发生变化;ClickHouse 导出器处于 alpha 状态,表模式可能在后续版本中发生变化。

我们没有启用捕获日志作用域;否则,我们也会看到一些作为属性的作用域。它们由 ASP.NET Core 填充,并描述传入的 HTTP 请求属性。正如我们在第二章中看到的,Native Monitoring in .NET,作用域包括 OpenTelemetry 为我们捕获的跟踪上下文。

通过这种方式,我们可以使用跟踪上下文或任何属性来关联日志。例如,我们可以使用如下查询找到最受欢迎的梗:

select LogAttributes['memeName'], count(*) as downloads
from otel_logs
where ServiceName='frontend' and
  LogAttributes['Name']='DownloadMemeEvent'
group by LogAttributes['memeName'] order by downloads desc
limit 3

这在做出业务或技术决策时可能很有用。例如,它有助于优化缓存或分区策略,或规划容量。

我们可以编写这样的查询,因为我们日志中已经有了足够的结构,包括可选的事件 ID 和名称。如果没有它们,我们就必须根据消息文本来过滤日志,这既不高效也不可靠。例如,当有人修改消息以修复错别字或添加新参数时,所有已保存的查询都需要更改以反映这一点。

小贴士

要使日志可查询,请确保使用语义日志。提供静态事件 ID 和名称。使用一致的(在整个系统中)属性名称。

通过遵循这种方法,我们可以更改可观察性供应商,以人类可读的格式打印日志,同时将它们以结构化形式存储,如果需要,还可以进行后处理或聚合。

结构化日志与跟踪结合使用允许我们报告业务遥测并运行查询,但它带来了新的成本——让我们看看我们如何控制它们。

管理日志成本

与跟踪和度量类似,日志增加了运行应用程序所需的计算资源,以及(如果有的话)运行日志管道的成本,以及使用(或运行)可观察性后端相关的成本。供应商定价通常基于遥测量、保留时间和 API 调用(包括查询)的组合。

我们已经知道如何高效地编写日志,那么让我们来谈谈管道和后端。

管道

日志管道由将日志发送到您选择的后端所需的基础设施组成。通常,在发送到后端的过程中,会进行一些解析、解析、转换、缓冲、节流和加固。

在简单的情况下,这一切都由您的供应商的日志提供者或进程内部的 OpenTelemetry 处理器和导出器来完成。

在许多情况下,我们需要日志管道来捕获来自外部(如操作系统、自托管的第三方服务、代理和其他基础设施组件)的日志和事件。它们可以是结构化的,如 Kubernetes 事件,具有众所周知的可配置格式,如 HTTP 服务器日志,或者完全没有结构。

日志管道可以帮助解析此类日志并将它们转换为通用格式。在 OpenTelemetry 的世界里,这可以在收集器上完成。

我们会通过一个接收器filessyslogjournaldfluentd、其他系统或收集器接收日志,然后通过一个处理器对它们进行按摩、过滤和路由,最后将它们导出到最终目的地。

管道成本节约策略从一种典型方法开始,以最小化日志量并避免重复和复杂的转换,正如我们在本章前面讨论的那样。

例如,您可能需要启用来自客户端和服务器以及 HTTP 代理的 HTTP 跟踪、指标和日志。您需要代理的日志吗?您使用它们吗?

通过可能用指标、更简洁的事件或其他信号上的属性替换它们来消除重复。如果某些信息很少需要,则可以懒惰地处理它们。

监控您的日志管道也很重要——测量错误率并估计端到端延迟和吞吐量。OpenTelemetry 收集器通过公开其自己的指标和日志来帮助。

曾经,我所在的团队发现,在日志管道中某些日志的丢失率高达 ~80%。我们以火速发布它们,并且直到我们无法在生产环境中调查事件时,才知道它们丢失了。

后端

后端成本优化也是从尽可能产生最少日志开始的。然后,根据您的约束和可观察性后端定价模型,以不同的方式控制成本:

  • 可以通过采样减少日志量。基于采样事件的聚合需要相应地进行扩展,但使用无偏采样时,会提供未偏斜的结果。日志可以与跟踪以相同的或更高的速率进行一致采样。

  • 日志可以在热存储中保留一段时间,然后移动到冷存储。在前几天,热存储中的日志可以用于紧急的临时查询,但之后,查询速度变得不那么重要。

这种策略可以与采样结合——日志可以发送到冷(且便宜)存储,而采样的日志将进入热存储。

  • 某些日志可以进行后处理并汇总成指标或报告,以便频繁查询。

所有这些策略及其组合都可以使用 OpenTelemetry 收集器实现。例如,在我们的 memes 应用程序中,我们使用采样和热/冷存储的组合,如图 图 8**.1 所示:

图 8.1 – 使用采样和热/冷存储记录管道

图 8.1 – 使用采样和热/冷存储记录管道

在这里,我们有两条不同的日志管道。它们都从 OTLP 接收器和批量处理器开始。然后,一条管道将所有日志写入文件,另一条管道根据日志记录属性运行一个过滤器。它检查trace-flags,当父跨度未被记录时丢弃日志。记录了父跨度(或根本没有父跨度,例如启动日志)的日志最终会进入 ClickHouse。以下是相应的日志管道配置:

otel-collector-config.yml

logs:
  receivers: [otlp]
  processors: [batch]
  exporters: [file]
logs/sampled:
  receivers: [otlp]
  processors: [batch, filter]
  exporters: [clickhouse]

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter8/memes/configs/otel-collector-config.yml

过滤器处理器以及许多其他处理器利用了丰富的转换语言——OTTL。OTTL 可以用来重命名属性、更改它们的值、丢弃指标和跨度、创建派生指标或添加和删除属性。以下是过滤器处理器的配置:

filter:
  logs:
    log_record:
      - 'flags == 0 and trace_id != TraceID
        (0x00000000000000000000000000000000)'

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter8/memes/configs/otel-collector-config.yml

收集器可以解决许多常见的后处理需求,并从你的服务中移除这些负担。

这就结束了本章的内容。让我们回顾一下到目前为止我们学到了什么。

摘要

日志是最灵活的遥测信号——它们可以用来以人类可读的格式写入信息,补充跟踪以提供更多信息,或记录结构化事件以分析使用情况或性能。

要编写日志,我们可以使用不同的日志 API——ILogger 对于应用程序代码来说效果最好,而 EventSource 通常是最适合库的选择。

ILogger 使得高效地编写结构化日志变得简单,但它依赖于应用程序作者通过最小化日志量和计算日志参数所需的操作来实现这一点。

ILogger 拥有丰富的与 .NET 框架、库和提供者的集成生态系统,可以将日志几乎发送到任何地方,以平面或结构化格式。

使用 OpenTelemetry 收集和导出 ILogger 日志会产生与其它遥测信号一致且相关的日志。

除了应用程序日志之外,我们通常还需要收集来自基础设施或遗留系统的日志。我们可以使用 OpenTelemetry 收集器来完成这项工作,它允许我们从多个目的地收集和统一日志。收集器的日志管道可以限制、聚合或路由日志,以帮助您管理日志成本。

你现在应该已经准备好使用结构化日志高效地对你的应用程序进行配置,并使用 OpenTelemetry 导出它们。你也为使用 OpenTelemetry 构建日志管道做好了准备,以增加你的基础设施的可观察性并控制日志成本。

这标志着我们对单个遥测信号的深入研究告一段落。在下一章中,我们将讨论根据场景选择一组良好的遥测信号,并基于 OpenTelemetry 语义约定添加适当的信息级别。

问题

  1. 以下代码片段是否正确?你会如何改进它?

    var foo = 42;
    
    var bar = "bar";
    
    logger.LogInformation($"hello world: {foo}, {bar}");
    
  2. 假设你的应用程序使用ILogger API 来记录使用事件。事件被导出到某个地方,然后用于构建业务关键报告。随着应用程序的发展,你可能需要重构代码、重命名命名空间和类、改进日志消息,并添加更多参数。你如何编写日志以保持使用报告对日志更改的鲁棒性?

  3. 假设已经收集了 HTTP 请求的跟踪信息,你是否还需要为相同的 HTTP 调用编写日志?

第三部分:常见云场景的可观察性

本部分提供了针对常见场景(如网络调用、异步消息、数据库和 Web 客户端)的监控配方。它展示了如何编写自己的监控或覆盖自动监控的空白,最重要的是,如何结合分布式跟踪、指标和日志来调查性能问题。

本部分包含以下章节:

  • 第九章, 最佳实践

  • 第十章, 跟踪网络调用

  • 第十一章, 消息场景的监控

  • 第十二章, 监控数据库调用

第九章:最佳实践

在前面的章节中,我们专注于如何收集、丰富、关联和使用单个遥测信号。在本章中,我们将讨论需要收集哪些信息以及如何使用所有可用的信号高效地表示这些信息。我们将首先提供关于如何选择合适的遥测信号的建议,并提出跨信号成本优化策略。最后,我们将探讨 OpenTelemetry 语义约定,并使用它们来创建大多数可观察性供应商支持的统一遥测。

你将学习以下内容:

  • 找到适用于您场景的遥测信号

  • 通过聚合、采样和详细程度控制遥测成本

  • 使用 OpenTelemetry 语义约定报告遥测数据时的常见做法

到本章结束时,你将能够使用现有语义处理常见技术或创建自己的跨信号和跨服务约定。

技术要求

本章没有具体要求,也没有相关的代码文件。

选择正确的信号

当我们在第六章到第八章讨论单个遥测信号时,我们提供了何时使用每个信号的建议。让我们快速回顾一下:

  • 分布式跟踪详细描述了单个网络调用和其他有趣的操作。跨度具有因果关系,使我们能够理解分布式系统中的请求流程。

跟踪记录了系统中的请求流程,对于调查延迟分布长尾中的错误或异常至关重要。跟踪提供了关联其他遥测信号的手段。

  • 指标收集具有低基数属性的聚合数据,并提供对整体系统状态的低分辨率视图。它们有助于优化遥测收集,降低存储成本和查询时间。

  • 事件提供了关于重要事物单个发生的高度结构化信息。与跨度相比,事件的关键区别在于跨度具有唯一上下文,并描述了持续的东西。

事件具有高基数属性,可以帮助回答关于系统行为和使用的即兴问题。

  • 日志以人类可读和不太结构化的格式提供操作细节。

当其他信号提供的信息不足时,它们对于调试事物非常有用。此外,日志是唯一支持详细程度的信号。

  • 配置文件是描述单个进程内单个操作的底层性能数据,有助于优化性能和识别资源瓶颈。

在对特定场景进行仪器化时,我们通常需要信号组合。

例如,为了将可观察性引入网络调用,我们需要跟踪以确保我们可以跟踪服务之间的请求流并关联其他信号。日志是必要的,用于记录异常和警告,描述本地操作,并为复杂的调查提供调试级别的数据。最后,我们可能需要指标来记录非采样测量,优化收集并减少查询时间。

注意

在考虑信号之前,我们应该有一个想法,即我们希望哪些信息可用,我们将如何使用它,我们需要多快多频繁地获取它,我们希望捕获多少细节,我们需要它多长时间,我们负担得起多少,以及停机成本。

这些问题的答案应该塑造我们关于可观察性的决策。

实际上,我们在足够的数据来快速调查问题和可观察性解决方案的成本之间有很多权衡。例如,收集过多的跟踪会给我们提供调查所有类型问题的所有必要细节。它会对性能产生明显的影响,并显著增加可观察性后端成本。结果,跟踪可能会变得非常深入和详细,以至于很难理解问题所在。

在讨论一套良好的遥测信号集时,如果不提及成本,这样的讨论是不可能的。让我们看看我们如何控制它们。

用更少的资源获得更多

由于我们通常需要收集关于同一组件的多个信号,我们需要能够根据我们的需求单独调整它们。

关键是减少昂贵但非必要的数据量,可能用更便宜的选择替换,同时保持系统可观察。我们在第八章,“编写结构化和关联日志”中看到了如何通过结合热存储和冷存储或更改保留期来实现这一点。在这里,让我们专注于收集方面。

虽然可观察性供应商有不同的定价模式,但它们通常根据数据量对跟踪、日志和事件收费,根据时间序列的数量对指标收费。查询(或 API 调用)也可能收费,并且可能有并发限制。

当然,我们总是可以添加或删除仪器或停止编写日志和事件,但还有一些其他因素会影响收集遥测数据的数量:

  • 我们可以通过采样率和添加或删除新属性来控制跟踪量

  • 为了控制指标时间序列的数量,我们可以添加或删除资源属性或丢弃维度或仪器

  • 我们可以调整单个类别的日志详细程度,或者全局调整,并添加或删除属性

应用程序的需求可能因它们的成熟度、更改的数量、它们可以承受的停机时间以及其他因素而异——让我们通过几个例子来展示它们可以应用的可能的折衷方案。

构建新的应用程序

当编写应用程序的第一个版本时,遥测在帮助团队调查问题和加快进度方面可以发挥关键作用。这里有趣的部分是我们不知道需要哪种类型的遥测以及我们将如何使用它。

我们可以利用现有的仪器,使我们能够专注于构建应用程序,并在其发展过程中拥有所有调试手段,同时找到我们之前概述的遥测问题的答案。

在初始阶段,是设计可观察性故事的好时机,并且从最灵活的信号——跟踪、事件和日志开始是有意义的。最初,遥测数据量可能很低,因此以高采样率记录跟踪或仅进行速率限制应该是负担得起的。此外,我们可能还没有严格的 SLA,也不太使用仪表板和警报。

在我们获得一些真实用户之前,指标或事件可能是不必要的,但这是进行实验和熟悉它们的好时机。

即使遥测数据量相当低,我们能够捕获详细的数据,我们也应避免添加过多的遥测数据,并应删除未使用的信号。

应用程序的发展

随着我们的应用程序开始获得一些真实用户,快速获取性能数据变得至关重要。到这时,我们对应用程序中需要测量的内容以及如何调试问题(希望不是依赖于冗长的日志)有了更多的了解。

这是优化和调整遥测收集的时间。随着负载的增长,我们通常希望降低跟踪的采样率并减少日志的冗余。

此外,我们可能需要创建警报并构建比在第七章中讨论的基于指标的仪表板和警报更高效的仪表板,正如我们在第七章中讨论的,添加自定义指标。虽然仪器库应该涵盖基础知识,但我们可能需要在之前依赖于跟踪查询的地方添加自定义指标。随着规模的扩大,时间序列的数量仅随着服务实例数量的增加而增加。

在这个阶段,我们可能还决定使用事件和指标收集精确且未采样的使用数据。

应用程序仍在不断变化,我们经常需要调查特定请求的功能性问题并优化来自延迟长尾的请求。因此,跟踪在日常工作中仍然发挥着关键作用。我们可能需要为应用程序的更多层进行仪器化以捕获逻辑操作或添加应用程序特定的上下文。同时,我们可能发现一些自动仪器化过于冗长,可以调整它们以删除不必要的属性或抑制一些跨度。

有时,我们需要捕获配置文件或使用诊断工具来调查低级问题,因此拥有一个持续的性能分析器或在边车中添加 dotnet-monitor 可以使这种调查变得容易得多。

如果应用程序(或其某些部分)由于问题越来越少而变得更加稳定,那么删除非必要跟踪并减少稳定服务或端点的采样率是有意义的。基于尾部的采样可以帮助捕获更多失败或长时间请求的跟踪。

当应用程序不再发生变化,除了基本维护之外,更重要的是,如果没有许多问题和调查,那么将跟踪仅限于传入和传出请求,可能将日志转发到较冷的存储,可能是合理的。

性能敏感场景

仪器引入了性能开销。在跟踪、指标和日志之间,跟踪是最昂贵的。在仪器化 HTTP 请求时,与调用本身相比,这种开销通常是可以忽略不计的。

但在某些情况下,仪器成本可能过高。例如,当返回缓存响应或对所有服务实例进行速率限制时,记录或跟踪所有此类调用可能会显著影响性能。此外,如果我们为每个请求都记录了跟踪信息,DDOS 攻击或存在错误的客户端可能会破坏我们的可观察性管道,甚至可能影响整个服务。

通过采样,在一定程度上可以减少跟踪开销,这可以保护可观察性管道并在填充属性时减少分配的数量,但无论采样决策如何,都会创建一个新的Activity并生成一个新的SpanId

对于热路径添加跟踪应谨慎进行。尽量减少跟踪的数量:仅当相应的请求将由您的应用程序处理时才跟踪传入请求,并且如果出站网络调用非常快或可靠,则避免跟踪这些调用。例如,与 Redis 通信时报告事件而不是跨度是有意义的。

指标是性能最佳的遥测信号,在可能的情况下应优先选择用于热路径。例如,将 Redis 调用持续时间作为具有缓存命中/未命中维度的指标报告,可能比事件更便宜。并且对于跟踪目的,我们可以在现有的当前跨度(例如,代表传入请求的一个跨度)上添加一个命中/未命中标志作为属性。

从性能角度来看,记录异常和错误通常是可行的,因为异常本身就会产生巨大的开销。但在失败风暴的情况下,我们会得到太多的异常,因此限制异常报告是一个好主意。

实现高效、有用但简约的仪器通常需要多次迭代。幸运的是,OpenTelemetry 提供了一套针对常见场景的语义约定,可以帮助实现这一点。让我们看看如何。

保持与语义约定的一致性

我们尚未讨论的最重要的问题之一是向遥测信号添加哪些信息才能使其有用——这正是 OpenTelemetry 语义约定发挥作用的地方。

语义约定描述了为特定技术(如 HTTP 或 gRPC 调用、数据库操作、消息场景、无服务器环境、运行时指标、资源属性等)收集哪些信息。

语义约定是 OpenTelemetry 规范的一部分,并已发布在规范存储库github.com/open-telemetry/opentelemetry-specification。它们适用于 OpenTelemetry 项目编写的所有仪器。

注意

在撰写本文时,语义约定处于实验状态。社区正在积极努力进行稳定化,本书中使用的属性可能会被重命名或以其他方式更改。

语义约定的目标是统一特定场景或技术跨语言、运行时和库的遥测收集。例如,所有 HTTP 客户端的跟踪和指标看起来非常相似,这使得可视化或查询 HTTP 遥测或以相同方式诊断任何应用程序中的问题成为可能。让我们看看 HTTP 语义约定,了解它们是如何工作的,并给你一个其他约定看起来如何的印象。

HTTP 请求的语义约定

这些约定涵盖了入站和出站 HTTP 请求的跟踪和指标。具有client类型的 span 描述出站请求,而server类型的 span 描述入站请求。仪器为每个尝试创建一个新的 span。

client HTTP span 包含描述请求、响应和远程目的地的属性。根据当前版本,最小 HTTP 客户端仪器必须报告以下属性:http.methodhttp.urlnet.peer.namenet.peer.porthttp.status_code

如果没有收到响应,则http.status_code属性不会被填充;相反,span 状态将指示错误并提供一个状态描述,解释发生了什么。如果端口号(net.peer.port)是 80 或 443,则可能会跳过该属性。其他属性是必需的,因此所有遵循约定的仪器都必须在所有场景中填充它们。这些属性与 span 开始时间戳、持续时间以及状态相结合,提供了 HTTP 请求的最小必要描述。

除了http.status_code之外的所有属性应在 span 开始时间提供——这允许我们根据这些属性做出采样决策。

你可能已经注意到,主机和端口信息在 URL 内部以及通过单独的属性中可用。URL 是一个高基数属性,但主机和端口很可能具有低基数,因此报告所有这些信息允许我们统一仪器代码,并在一个地方报告跟踪和指标。这也使得从跟踪中计算指标并简化查询成为可能。

最小 HTTP 服务器工具报告了 http.methodhttp.status_codehttp.schemehttp.targetnet.host.namenet.host.porthttp.route 属性。

由于 HTTP 服务器没有现成的完整 URL,因此工具不会构建它们,而是报告单个 URL 组件。路由信息由 ASP.NET Core 等 HTTP 框架提供,即使在那些框架中,您也可以在中间件中处理请求而不使用路由。报告路由对于指标非常重要,正如我们在第七章中看到的,添加自定义指标,所以如果您没有现成的路由,您可能需要手动提供一个以区分不同类别的 API 调用。HTTP 客户端和服务器工具通常还会报告推荐属性,例如 User-Agent 头部、请求和响应内容长度、HTTP 协议版本和远程 IP 地址。

约定还标准化了属性值类型 - 例如,http.status_code 具有整型,简化了查询时的比较。

您可以在 opentelemetry.io/docs/reference/specification/trace/semantic_conventions/http 找到完整的 HTTP 跟踪约定。

指标基于相同的跟踪属性,包括请求持续时间、内容大小和服务器上的活动请求数量。指标约定可在 opentelemetry.io/docs/reference/specification/metrics/semantic_conventions/http-metrics 找到。

HTTP 语义约定提供了一套很好的默认收集项。您可以在团队、公司、Web 框架之间移动,或者开始使用不同的编程语言,但 OpenTelemetry 工具会在任何地方提供一个共同的基线。

拥有一组可靠的必需属性有助于后端可视化跟踪和服务映射,构建仪表板,并自动化分析和问题检测。

一般性考虑

当您需要工具化某些特定的技术或场景,并且没有合适的工具库可用时,请确保还检查是否存在适用的语义约定。遵循它,您将能够利用在可观察性后端之上构建的任何经验,防止来自系统不同部分的信号不一致,并且还可以节省一些设计和完善信号的时间。

但如果您想对应用程序中非常具体的东西进行工具化,比如添加逻辑操作的跨度或添加使用指标,该怎么办呢?让我们看看。

跟踪

正如我们在第六章中看到的,跟踪您的代码,我们可以创建一个新的 Activity 实例而不指定任何参数。默认情况下,它以调用方法命名,并具有 internal 类型。

OpenTelemetry 建议使用低基数 span 名称。HTTP 客户端 span 名称遵循HTTP <method>模式(例如,HTTP GET),而 HTTP 服务器 span 名称看起来像<method> <route>(例如,GET /users/{userId})。span 名称描述了一类操作,并经常用于对常见 span 进行分组。

另一个重要的属性是 span 类型:它有助于后端可视化并查询跟踪它们。client span 代表出站请求——它们的上下文通过网络传播,并成为server span 的远程父级。在监控远程调用时,我们通常会为每个尝试创建一个新的 span,以便我们知道尝试花费了多长时间,有多少个尝试,以及退避间隔是多少。

server span 是跟踪传入请求的 span;它们要么没有父级,要么有一个远程父级。

OpenTelemetry 还定义了consumerproducer类型——它们用于请求-响应模式不适用的异步场景。producer span 可以是consumer span 的父级(或与之相关联),但它通常在相应的consumer span 之前结束。

所有其他 span 都是internal。例如,为了表示 I/O 操作或本地长时间运行的调用,我们应该使用internal类型。在监控客户端库调用或可以进行多个 HTTP 请求的逻辑操作时,将它们描述为internal span 是有意义的。

如果一个操作以错误结束,我们应该用 span 状态来反映它,但这可能很棘手。例如,HTTP 语义约定建议在客户端未收到响应、存在太多重定向或状态码在 4xx 或 5xx 范围内时,将状态设置为错误。但对于 HTTP 服务器来说,4xx 响应并不表示错误,应该保持未设置。即使是客户端请求,状态码如 404(未找到)也不一定表示错误,可以用来检查某些资源是否存在。

在记录错误时,状态描述可以用来记录一些可预测的简短信息,例如其异常类型和/或消息。异常遵循它们自己的语义约定——我们曾在第六章,“追踪你的代码”中讨论过。它们可能非常大(因为堆栈跟踪),因此我们应该避免记录已处理的异常。

属性

应用特定的上下文或关于操作的详细信息可以记录在属性中。在发明新的属性名称之前,请确保您检查现有的语义约定,看看是否已经定义了类似的内容。例如,您可以使用通用网络属性来描述远程目的地或主机和 RPC 调用。

如果必须创建一个新的属性,请使用由基本拉丁字符组成的简短名称。OpenTelemetry 建议使用命名空间来避免命名冲突(它们由点(.)字符分隔)并使用snake_case来分隔单词。例如,在http.status_code中,http是一个命名空间。因此,如果您正在定义一个针对您公司的特定属性,在命名空间中使用公司名称是有意义的。

每个跨度中属性的默认数量限制为 128,但这个限制可以被增加。

在整个系统中保持名称和值类型的一致性可能具有挑战性,因此制定一些注册表以保持它们一致是个好主意。

那么,您会添加哪些信息到属性中呢?任何描述您操作的信息,除了敏感信息或机密信息。对长值要谨慎,避免添加需要序列化或计算的内容——使用详细日志记录这些内容。

避免重复并记录合理的信息集也是一个好主意,将静态属性移动到资源而不是跨度中。

指标

在创建仪器时,我们可以提供名称、单位和描述。

仪器名称不区分大小写,由字母数字字符、下划线、点和破折号组成。仪器名称必须简短——最多 63 个字符。

仪器名称的格式与属性名称类似,并支持命名空间——例如,http.server.active_requests计数器或http.server.duration直方图,分别代表活动 HTTP 请求数量和服务器端请求持续时间。

单位通常遵循 UCUM 标准(ucum.org/),并且在整个系统中保持它们的一致性是很重要的。

属性命名规范在不同信号之间是通用的,通常,指标依赖于跟踪属性的一个子集。指标属性最重要的特征是低基数性,我们在第七章中描述了这一点,即添加 自定义指标

在添加自定义指标之前,请确保检查是否存在现有的仪器库或 OpenTelemetry 语义约定。例如,有一个通用的用于 RPC 请求、进程和系统资源利用率指标、数据库以及其他特定技术的,如 Kafka。

摘要

在本章中,我们讨论了遥测收集的建议和推荐。为了描述某些场景或操作,我们通常需要多个信号:跟踪使关联和因果关系成为可能,日志提供由跟踪未涵盖的额外信息,事件收集使用信息,而指标优化了仪器、查询和警报。

根据您应用程序的需求和稳定性,您可以通过调整跟踪上的采样率和使用指标来控制性能数据和使用报告的事件的成本。

OpenTelemetry 语义约定为常见技术和概念提供了配置工具。通过遵循它们,你可以创建具有良好默认值的高质量配置,这些默认值可以根据你的需求进行调整。可观察性后端可以提供它们最好的体验,帮助你可视化、检测异常并执行其他半自动分析。对于专有技术或特定于应用程序的配置,在没有现有约定的场合,遵循通用的 OpenTelemetry 规范和命名模式,并在整个系统中一致地报告遥测数据非常重要。

使用这些工具,你应该准备好使用多个信号来配置高级场景,并在遵循现有实践的同时提供丰富的上下文。在下一章中,我们将应用这些技能来配置 gRPC 流调用,这些调用不受任何现有约定的覆盖。敬请期待。

问题

  1. 你能否仅使用跟踪来配置一个微小的无状态 RESTful 微服务?

  2. 当你在处理每个实例每秒处理数千个请求的应用程序上工作时,你会选择什么样的采样率?

  3. 你的应用程序通过 WebSockets 与客户端设备通信。你将如何处理这种通信的配置?

第十章:跟踪网络调用

在本章中,我们将应用我们在第六章“跟踪您的代码”中学到的跟踪知识,通过 gRPC 对客户端和服务器通信进行仪表化。

我们将首先根据 OpenTelemetry 语义约定在客户端和服务器上仪表化单一 gRPC 调用。然后,我们将切换到流式调用,并探讨为单个消息获取可观察性的不同方法。我们将看到如何用事件或单个跨度来描述它们,并学习如何在单个消息内传播上下文。最后,我们将看到如何使用我们的仪表化来调查问题。

在本章中,您将学习以下内容:

  • 根据 OpenTelemetry 语义约定在客户端和服务器上仪表化网络调用,并通过网络传播上下文

  • 根据您的应用需求对 gRPC 流式调用进行仪表化

  • 应用遥测以深入了解网络调用延迟和失败率,并调查问题

以 gRPC 为例,本章将向您展示如何跟踪网络调用并通过它们传播上下文。通过本章,您还应该能够仪表化高级流式场景,并为您的跟踪选择适当的可观察性信号和粒度。

技术要求

本章的代码可在 GitHub 上本书的仓库中找到:github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/tree/main/chapter10

为了运行示例并执行分析,我们需要以下工具:

  • .NET SDK 7.0 或更高版本

  • Docker 和docker-compose

仪表化客户端调用

在任何分布式应用程序中,网络调用可能是最重要的仪表化对象,因为网络和下游服务是不可靠且复杂的资源。为了了解我们的应用程序是如何工作以及如何出错的,我们需要知道我们依赖的服务是如何表现的。

网络级指标可以帮助我们测量诸如延迟、错误率、吞吐量和活动请求数和连接数等基本指标。跟踪可以实现上下文传播,并帮助我们了解请求是如何通过系统的。因此,如果您要仪表化应用程序,您应该从入站和出站请求开始。

在仪表化调用客户端时,我们需要选择网络堆栈的正确级别。我们想要跟踪 TCP 数据包吗?我们可以吗?答案取决于具体情况,但分布式跟踪通常应用于网络堆栈的应用层,其中存在 HTTP 或 AMQP 等协议。

在.NET 的 HTTP 情况下,我们在HttpClient级别应用了仪表化——更准确地说,是在HttpMessageHandler级别,它执行单个 HTTP 请求,因此我们可以追踪单个重试和重定向。

如果我们仪表化 HttpClient 方法,在许多情况下,我们会收集请求的持续时间,这包括所有尝试在它们之间使用退避间隔获取响应的尝试。错误率将显示没有瞬态故障的速率。这些信息非常有用,但它非常间接地描述了网络级别的调用,并且很大程度上依赖于上游服务的配置和性能。

通常,gRPC 在 HTTP/2 上运行,并在一定程度上可以被 HTTP 仪表库覆盖。对于单一调用来说,当客户端发送请求并等待响应时,情况就是这样。与 HTTP 仪表库的关键区别是我们希望收集一组特定的 gRPC 属性,包括服务和方法名称以及 gRPC 状态码。

然而,当客户端与服务器建立连接后,gRPC 也支持流式传输,它们可以在一个 HTTP/2 调用范围内互相发送多个异步消息。我们将在本章的 流式调用的监控 部分中稍后讨论流式调用。现在,让我们专注于单一调用。

仪表化单一调用

我们将使用 Grpc.Net.Client NuGet 包中的 gRPC 实现,该包提供了一个 OpenTelemetry 仪表库。

注意

OpenTelemetry 提供了两种 gRPC 仪表化版本:一个是为 Grpc.Net.Client 包提供的 OpenTelemetry.Instrumentation.GrpcNetClient,另一个是为底层 Grpc.Core.Api 包提供的 OpenTelemetry.Instrumentation.GrpcCore。根据您如何使用 gRPC,确保使用其中一个。

这些仪表化应该覆盖大多数 gRPC 跟踪需求,并且您可以使用在第 第五章 中描述的技术进一步自定义它们,配置和控制平面。例如,OpenTelemetry.Instrumentation.GrpcNetClient 仪表化允许抑制底层 HTTP 仪表化或丰富相应的活动。

在这里,我们将编写自己的仪表化代码作为一个学习练习,您可以将它应用到其他协议或用于满足您可能有的额外要求。

我们可以将每个 gRPC 调用包裹在仪表化代码中,但这将很难维护,并且会污染应用程序代码。更好的方法是实现 gRPC Interceptor 中的仪表化。

因此,我们知道应该在何处进行仪表化,但我们应该仪表化什么?让我们从 gRPC OpenTelemetry 语义约定开始——跟踪约定可在 github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/rpc.md 找到。

当前,这些约定是实验性的,并且应该预期会有一些变化(例如属性重命名)。

对于单一客户端调用,跟踪规范建议使用{package.service}/{method}模式作为跨度名称,以及以下一组基本属性:

  • rpc.system属性必须匹配grpc——它帮助后端理解这是一个 gRPC 调用。

  • rpc.servicerpc.method属性应该描述 gRPC 服务和方法的名称。尽管这些信息在跨度名称中可用,但单独的服务和方法属性有助于更可靠和高效地查询和过滤跨度。

  • net.peer.namenet.peer.port属性描述远程端点。

  • rpc.grpc.status_code描述了 gRPC 状态代码的数值表示。

因此,在拦截器中,我们需要做一些事情:使用推荐名称和一组属性启动一个新的Activity,将上下文注入到出站请求中,等待响应,设置状态,并结束活动。以下代码片段展示了这一过程:

client/GrpcTracingInterceptor.cs

public override AsyncUnaryCall<Res>
  AsyncUnaryCall<Req, Res>(Req request,
    ClientInterceptorContext<Req, Res> ctx,
    AsyncUnaryCallContinuation<Req, Res> continuation)
{
  var activity = Source.StartActivity(ctx.Method.FullName,
ActivityKind.Client);
  ctx = InjectTraceContext(activity, ctx);
  if (activity?.IsAllDataRequested != true)
    return continuation(request, ctx);
  SetRpcAttributes(activity, ctx.Method);
  var call = continuation(request, context);
  return new AsyncUnaryCall<Res>(
    HandleResponse(call.ResponseAsync, activity, call),
    call.ResponseHeadersAsync,
    call.GetStatus,
    call.GetTrailers,
    call.Dispose);
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter10/client/GrpcTracingInterceptor.cs

这里,我们重写了拦截器的AsyncUnaryCall方法:我们使用客户端类型启动一个新的Activity,并注入一个跟踪上下文,无论采样决策如何。如果活动被采样排除,我们只返回一个延续调用,避免任何额外的性能开销。

如果活动被采样包含,我们设置 gRPC 属性,并返回带有修改后的响应任务的延续调用:

client/GrpcTracingInterceptor.cs

private async Task<Res> HandleResponse<Req, Res>(Task<Res>
  original, Activity act, AsyncUnaryCall<Req> call)
{
  try
  {
    var response = await original;
    SetStatus(act, call.GetStatus());
    return response;
  }
  ...
  finally
  {
    act.Dispose();
  }
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter10/client/GrpcTracingInterceptor.cs

我们在这里显式地释放Activity,因为AsyncUnaryCall方法是同步的,将在请求完成之前结束,但我们需要活动持续到我们从服务器获取响应。

让我们逐一仔细查看每个操作,从上下文注入开始:

client/GrpcTracingInterceptor.cs

private ClientInterceptorContext<Req, Res>
  InjectTraceContext<Req, Res>(Activity? act,
    ClientInterceptorContext<Req, Res> ctx)
  where Req: class where Res: class
{
  ...
  _propagator.Inject(new PropagationContext(
      act.Context, Baggage.Current),
    ctx.Options.Headers,
    static (headers, k, v) => headers.Add(k, v));
  return ctx;
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter10/client/GrpcTracingInterceptor.cs

在这里,我们使用OpenTelemetry.Context.Propagation.TextMapPropagator类和Inject方法注入上下文。我们稍后会看到传播器的配置方式。

我们创建了一个PropagationContext结构的实例——它包含需要传播的一切,即ActivityContext和当前的Baggage

上下文被注入到ctx.Options.Headers属性中,该属性代表 gRPC 元数据。稍后,GrpcNetClient将元数据转换成 HTTP 请求头。

Inject方法的最后一个参数是一个函数,它告诉传播者如何将带有跟踪上下文的关键值对注入到提供的元数据中。传播者根据其实现可能遵循不同的格式并注入不同的头信息。在这里,我们不需要担心这个问题。

好的,我们已经注入了上下文以实现与后端的关联,现在我们需要填充属性:

client/GrpcTracingInterceptor.cs

private void SetRpcAttributes<Req, Res>(Activity act,
  Method<Req, Res> method)
{
  act.SetTag("rpc.system", "grpc");
  act.SetTag("rpc.service", method.ServiceName);
  act.SetTag("rpc.method", method.Name);
  act.SetTag("net.peer.name", _host);
  if (_port != 80 && _port != 443)
    act.SetTag("net.peer.port", _port);
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter10/client/GrpcTracingInterceptor.cs

在这里,我们从调用上下文中提供的信息中填充服务和方法名称。但主机和端口来自我们传递给拦截器构造函数的实例变量——这些信息在客户端拦截器中不可用。

最后,一旦收到响应,我们应该填充 gRPC 状态码和Activity状态:

client/GrpcTracingInterceptor.cs

private static void SetStatus(Activity act, Status status)
{
  act.SetTag("rpc.grpc.status_code",
    (int)status.StatusCode);
  var activityStatus = status.StatusCode != StatusCode.OK ?
    ActivityStatusCode.Error : ActivityStatusCode.Unset;
  act.SetStatus(activityStatus, status.Detail);
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter10/client/GrpcTracingInterceptor.cs

如果请求成功遵循 gRPC 语义约定,我们保留了Activity.Status未设置。对于通用仪表库来说是有意义的,因为它们不知道什么代表成功。在自定义仪表中,我们可能更了解情况,可以更加具体。

就这样;我们刚刚完成了客户端的单例调用仪表。现在,让我们配置一个 gRPC 客户端来使用。

配置仪表

让我们在GrpcClient实例上设置一个跟踪拦截器。在演示应用程序中,我们使用与 ASP.NET Core 集成的GrpcClient,并按以下方式设置:

client/Program.cs

builder.Services
  .AddGrpcClient<Nofitier.NofitierClient>(o => {
    o => o.Address = serverEndpoint; ... })
  .AddInterceptor(() => new GrpcTracingInterceptor(
    serverEndpoint, contextPropagator))
  ...

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter10/client/Program.cs

在这里,我们添加了GrpcClient,配置了端点,并添加了跟踪拦截器。我们明确传递了选项——服务端点和上下文传播者。

传播者是TextMapPropagator类的实现——我们使用一个支持 W3C Trace Context 和 Baggage 格式的复合传播者:

client/Program.cs

CompositeTextMapPropagator contextPropagator = new (
  new TextMapPropagator[] {
    new TraceContextPropagator(),
    new BaggagePropagator() });
Sdk.SetDefaultTextMapPropagator(contextPropagator);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter10/client/Program.cs

最后一步是配置 OpenTelemetry 并启用我们在拦截器中使用的 ActivitySource

client/Program.cs

builder.Services.AddOpenTelemetry()
  .WithTracing(b => b.AddSource("Client.Grpc")...);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter10/client/Program.cs

单例客户端调用就此结束。现在让我们仪器化服务器。

仪器化服务器调用

服务端仪器化类似。我们可以再次使用 gRPC 拦截器,这次覆盖 UnaryServerHandler 方法。一旦收到请求,我们应该提取上下文并启动一个新的活动。它应该具有 server 类型,一个遵循与客户端跨度相同的模式的名字 – {package.service}/{method} – 以及与我们在客户端看到的非常相似的属性。以下是拦截器代码:

server/GrpcTracingInterceptor.cs

var traceContext = _propagator.Extract(default,
  ctx.RequestHeaders,
  static (headers, k) => new[] { headers.GetValue(k) });
Baggage.Current = traceContext.Baggage;
using var activity = Source.StartActivity(ctx.Method,
  ActivityKind.Server, traceContext.ActivityContext);
if (activity?.IsAllDataRequested != true)
  return await continuation(request, ctx);
SetRpcAttributes(activity, ctx.Host, ctx.Method);
try
{
  var response = await continuation(request, ctx);
  SetStatus(activity, ctx.Status);
  return response;
}
catch (Exception ex) {...}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter10/server/GrpcTracingInterceptor.cs

我们使用传播者提取跟踪上下文和行李,然后将提取的父跟踪上下文传递给新的活动并添加属性。服务器拦截器回调是异步的,因此我们可以等待来自服务器的响应并填充状态。

就这样;现在我们只需要配置拦截器并启用 ActivitySource

server/Program.cs

builder.Services
  .AddSingleton<TextMapPropagator>(contextPropagator)
  .AddGrpc(o => {
    o.Interceptors.Add<GrpcTracingInterceptor>(); ...});
builder.Services.AddOpenTelemetry()
    .WithTracing(b => b.AddSource("Server.Grpc")...);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter10/server/Program.cs

我们添加了 gRPC 服务,配置了跟踪拦截器,并启用了新的活动源。现在是检查生成的跟踪的时候了。

使用 $docker-compose up --build 运行应用程序,然后访问前端 http://localhost:5051/single/hello。它将向服务器发送消息并返回响应或显示一个短暂错误。图 10.1 展示了一个带有错误的跟踪示例:

图 10.1 – 服务器上显示错误的 gRPC 跟踪

图 10.1 – 服务器上显示错误的 gRPC 跟踪

在这里,我们看到来自客户端应用程序的两个跨度和一个来自服务器。它们描述了由 ASP.NET Core 仪器化收集的传入请求以及 gRPC 调用的客户端和服务器端。图 10.2 展示了客户端跨度属性,我们可以看到目的地和状态:

图 10.2 – gRPC 客户端属性

图 10.2 – gRPC 客户端属性

这种仪器化使我们能够跟踪任何 gRPC 服务的单例调用,这与我们在 第二章 中看到的 HTTP 仪器化类似,即 .NET 的原生监控。现在让我们探索流调用的仪器化。

仪器化流调用

到目前为止,本书已经涵盖了同步调用的工具配置,其中应用程序发起请求并等待其完成。然而,当客户端和服务器建立连接并发送消息时,通常使用 gRPC 或其他协议,如 SignalR 或 WebSocket,以异步方式进行通信。

这种通信的常见用例包括聊天应用程序、协作工具以及其他数据应实时且频繁双向流动的情况。

调用开始于客户端发起连接,可能持续到客户端决定断开连接、连接变为空闲或发生某些网络问题。在实践中,这意味着此类调用可能持续数天。

在连接活跃期间,客户端和服务器可以向对方相应的网络流写入消息。当客户端和服务器在相对较短的时间内频繁通信时,这种方法要快得多,效率也更高。与请求-响应通信相比,这种方法可以最大限度地减少由 DNS 查找、协议协商、负载均衡、授权和路由等操作产生的开销,因为这些操作至少会在每个请求中发生一次。

从另一方面来看,应用程序可能会变得更加复杂,因为在许多情况下,我们仍然需要将客户端消息与相应的服务回复关联起来,并为元数据和状态代码制定我们自己的语义。

对于可观察性而言,这意味着现成的工具通常不足以满足需求,至少需要一些定制的工具。让我们看看原因是什么。

基本工具配置

一些应用程序在一个流调用中传递完全独立的消息,并希望不同的跟踪描述单个消息。其他应用程序使用流来发送范围批次的消息,并希望有一个跟踪描述流调用中发生的所有事情。当涉及到流时,没有单一的解决方案。

gRPC 自动工具配置遵循 OpenTelemetry 语义约定,并提供了默认体验,其中流调用用客户端和服务器跨度表示,即使调用生命周期是未定义的。单个消息用跨度事件描述,具有涵盖方向、消息标识符和大小的属性。

你可以在本书的存储库中的 client/GrpcTracingInterceptor.csserver/GrpcTracingInterceptor.cs 文件中找到一个遵循这些约定的完整工具配置实现。让我们看看它产生的跟踪。

使用 $ docker-compose up --build 启动应用程序,然后访问客户端应用程序 http://localhost:5051/streaming/hello?count=2。它将向服务器发送两条消息并读取所有响应。

查看 Jaeger,请访问 http://localhost:16686/。你应该会看到一个类似于 图 10.3 中所示的跟踪:

图 10.3 – 带事件的流调用

图 10.3 – 带事件的流调用

与单一调用类似,跟踪由三个跨度组成。唯一的区别是客户端和服务器 gRPC 跨度有事件——每个消息两个事件,指示消息何时被发送和接收。这里的message.id属性代表请求或响应流中消息的序列号,可能用于关联请求和响应消息。

图 10**.3 中显示的跟踪是我们能够通过自动监控(不 aware of our specific streaming usage)实现的最好效果。让我们看看我们如何可以改进它。

跟踪单个消息

让我们假设客户端启动了一个非常长的流——在这种情况下,之前的跟踪不会很有帮助。假设消息不是太频繁且冗长,我们可能想要监控每个特定的消息,并查看服务器响应消息如何与客户端消息相关联。

要监控单个消息,我们必须在消息内部传播上下文,但在我们使用通用消息类型操作的拦截器中这是不可能的。

我们的消息 protobuf 定义包含文本和一个属性映射,我们可以使用它来传递跟踪上下文:

client\Protos\notifier.proto

message Message {
  string text = 1;
  map<string, string> attributes = 2;
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter10/client/notifier.proto

我们将为每个消息创建一个客户端跨度来描述和识别它,以及一个表示处理消息的服务器跨度。

如果在一次流式调用中我们有一百多条消息,将它们全部放在一个跟踪中会很难阅读。此外,典型的采样技术也不适用——根据整个流式调用中做出的采样决策,我们将对每个消息的跨度进行采样或丢弃。

理想情况下,我们希望每个消息流都有一个跟踪,并且有一个链接到承载消息的长运行 HTTP 请求。这样,我们仍然知道传输中发生了什么,以及通过相同的 HTTP 请求发送了什么其他内容,但我们将做出独立的采样决策,并将拥有更小、更易读的跟踪。

注意

当消息相对较大且处理它们需要合理的时间时,跟踪单个消息是合理的。其他方法可能包括仅对采样消息进行自定义关联或上下文传播。

让我们继续对单个消息进行监控:我们需要为每个消息启动一个新的活动,使用producer类型表示异步调用。我们需要启动一个新的跟踪,并使用Activity.Current作为链接而不是父链接:

client/controllers/StreamingController.cs

IEnumerable<ActivityLink>? links = null;
if (Activity.Current != null)
{
  links = new[] {
    new ActivityLink(Activity.Current.Context) };
  Activity.Current = null;
}
using var act = Source.StartActivity("SendMessage",
  ActivityKind.Producer,
  default(ActivityContext),
  links: links);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter10/client/Controllers/StreamingController.cs

我们从当前活动创建了一个链接,然后将Activity.Current设置为null,这强制StartActivity方法创建一个孤儿活动。

注意

设置Activity.Current应谨慎进行。在这个例子中,我们启动了一个新任务,专门确保它不会改变这个任务范围之外的Activity.Current值。

我们有一个活动;现在是我们注入上下文并发送消息到服务器的时候了:

client/controllers/StreamingController.cs

_propagator.Inject(
  new PropagationContext(act.Context, Baggage.Current),
  message,
  static (m, k, v) => m.Attributes.Add(k, v));
try
{
  await requestStream.WriteAsync(message);
}
catch (Exception ex)
{
  act?.SetStatus(ActivityStatusCode.Error, ex.Message);
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter10/client/Controllers/StreamingController.cs

上下文注入看起来与本章前面在客户端拦截器中做的类似,但在这里我们将其注入到消息属性中,而不是 gRPC 调用元数据中。

在服务器端,我们需要从消息中提取上下文,然后将其用作父级。我们还应该将Activity.Current设置为链接,这样我们就不丢失消息处理和流调用之间的相关性。新的活动有一个consumer类型,这表示异步调用的处理端,如这个代码片段所示:

server/NotifierService.cs

var context = _propagator.Extract(default,
  message,
  static (m, k) => m.Attributes.TryGetValue(k, out var v)
      ? new [] { v } : Enumerable.Empty<string>());
var link = Activity.Current == null ?
   default : new ActivityLink(Activity.Current.Context);
using var act = Source.StartActivity(
  "ProcessMessage",
  ActivityKind.Consumer,
  context.ActivityContext,
  links: new[] { link });
...

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter10/server/NotifierService.cs

我们现在可以启用相应的客户端和服务器活动源 – 我们为每条消息跟踪和拦截器使用了不同的名称,因此我们现在可以单独控制仪器。相应地启用客户端的Client.Grpc.Message和服务器上的Server.Grpc.Message源,然后启动应用程序。

如果我们在http://localhost:5051/streaming/hello?count=5上击中流端点,然后转到 Jaeger,我们会看到六个跟踪 – 每个消息发送一个,以及一个 gRPC 调用。

每条消息的跟踪由两个跨度组成,就像图 10.4中显示的那样:

图 10.4 – 在单独的跟踪中跟踪消息

图 10.4 – 在单独的跟踪中跟踪消息

在这里,我们看到发送这条消息大约花费了 1 毫秒,处理它大约花费了 100 毫秒。两个跨度都有链接(Jaeger 术语中的引用)到描述底层 gRPC 调用客户端和服务器端的跨度。

如果我们没有强制为单个消息创建新的跟踪,我们会看到只有一个包含所有跨度的跟踪,如图10.5所示:

图 10.5 – 在一个跟踪中跟踪流调用中的所有消息

图 10.5 – 在一个跟踪中跟踪流调用中的所有消息

根据您的场景,您可能更喜欢分离跟踪,有一个大跟踪,或者想出其他方法。

注意,我们现在可以移除我们的自定义追踪拦截器并启用共享的 gRPC 和 HTTP 客户端仪表化库。如果你这样做,按消息的仪表化将保持完全相同,并且将与自动仪表化一起工作。

通过这种方式,你应该能够仪表化一元或流式 gRPC 调用,并了解如何将其扩展到其他情况,包括 SignalR 或套接字通信。

现在我们来看看如何使用 gRPC 仪表化来调查问题。

可观察性实践

在我们的服务器应用程序中存在几个问题。第一个问题在你多次点击 http://localhost:5051/single/hello 前端时偶尔重现。你可能会注意到一些请求比其他请求耗时更长。如果我们查看持续时间指标或 Jaeger 的持续时间视图,我们会看到类似于 图 10.6 的内容:

图 10.6 – Jaeger 中的持续时间视图

图 10.6 – Jaeger 中的持续时间视图

我们看到大多数调用都很快(大约 100 毫秒),但有一个调用时间超过一秒。如果我们点击它,Jaeger 将打开相应的跟踪,就像 图 10.7 所示的:

图 10.7 – 带有错误的长时间跟踪

图 10.7 – 带有错误的长时间跟踪

显然,发送消息尝试了三次 – 前两次没有成功,但第三次成功了。因此,重试是导致长时间延迟的原因。我们可以通过展开异常事件来调查错误 – 我们将在那里看到一个完整的堆栈跟踪。

值得注意的是,在这里我们只看到服务端有重试。客户端只有一个 gRPC 跨度。这里发生的情况是我们为 gRPC 客户端通道启用了一个重试策略,这会在内部为 HttpClient 层添加一个重试处理器。因此,我们的追踪拦截器在尝试和追踪 gRPC 调用的逻辑部分时不会被调用。

官方的 OpenTelemetry.Instrumentation.GrpcNetClient 仪表化工作正常,并且能够追踪客户端的个别尝试。

让我们看看另一个问题。发送以下请求:http://localhost:5051/streaming/hello?count=10。它将返回几条消息然后停止。如果我们查看 Jaeger 跟踪,我们会看到针对个别消息的大量错误。其中一些将只有一个客户端跨度,就像 图 10.8 所示的:

图 10.8 – 没有服务器跨度的客户端错误

图 10.8 – 没有服务器跨度的客户端错误

跨度中信息不多,但幸运的是,我们有一个指向 gRPC 调用的链接。让我们跟随它看看是否可以解释一些情况。相应的跟踪显示在 图 10.9 中:

图 10.9 – gRPC 流中间的错误

图 10.9 – gRPC 流中间的错误

在这里,我们看到一个熟悉的跟踪,但在解析消息文本时处理失败了。服务器跨度有六个事件,表明收到了两条消息,并且响应已成功发送到服务器。第三条消息收到了,但随后我们没有看到响应,而是看到一个异常,其中包含堆栈跟踪以帮助我们进一步调查。

如果我们展开客户端 gRPC 跨度,我们将看到在服务器错误发生后尝试发送的每条消息的更多异常。

但没有重试——为什么?在我们的情况下,gRPC 重试,如我们之前在示例中看到的,是在 HTTP 层上应用的。在流的情况下,这意味着在从服务器收到第一个响应之后,包括状态码和头部的 HTTP 响应被接收,其余的通信在请求和响应流中进行。你可以在 Microsoft gRPC 文档中了解更多信息:learn.microsoft.com/en-us/aspnet/core/grpc/retries

因此,一旦在服务器上对特定消息抛出未处理的异常,它就会结束客户端和服务器上的 gRPC 调用以及相应的请求和响应流。它影响了客户端上剩余的所有消息,并解释了我们注意到的部分响应。

分布式跟踪帮助我们了解发生了什么,并更多地了解我们使用的科技。除了跟踪之外,OpenTelemetry 定义了一套在客户端和服务器端监控的指标,包括持续时间、从中可以推导出的失败率、请求数和响应数以及有效载荷大小。

摘要

在本章中,我们通过以 gRPC 为例来实际操作网络调用进行了仪表化。在开始仪表化之前,我们了解了可用的仪表化库以及 OpenTelemetry 语义约定建议为 gRPC 记录的内容。

首先,我们使用客户端和服务器跨度对单例调用进行了仪表化,并通过 gRPC 元数据传播上下文。然后,我们实验了 gRPC 流,这需要不同的跟踪方法。流调用的通用仪表化建议为流中的每个单独的请求和响应消息创建一个事件,并提供基本级别的可观察性。根据我们的场景和可观察性需求,我们可以添加另一层仪表化来跟踪单个消息。这些自定义跨度建立在通用 gRPC 仪表化之上。

最后,我们使用了跟踪来深入了解高延迟和短暂错误场景,这也有助于我们理解 gRPC 内部机制。

现在,你已经准备好使用跟踪来仪表化你的网络堆栈,或者通过添加针对你应用程序特定的自定义仪表化层来丰富现有的仪表化库。在下一章中,我们将探讨消息场景,并更深入地研究异步处理的可观察性。

问题

  1. 当使用 gRPC 时,你会编写自己的仪表化工具还是重用现有的一个?

  2. 让我们假设我们想在客户端在启动时发起连接并永远保持打开(直到服务器或客户端停止)的情况下,对客户端和服务器之间的 gRPC 通信进行仪表化,并且然后重用这个连接进行所有通信。你会选择哪种跟踪方法?为什么?

第十一章:配置消息传递场景

通过减少服务之间的耦合,消息和异步处理提高了分布式系统的可扩展性和可靠性。然而,它们也增加了复杂性,并引入了一种新的故障模式,这使得可观察性变得更加重要。

在本章中,我们将使用跟踪和指标来配置消息生产者和消费者,并涵盖单个和批量消息处理。

在本章中,你将学习以下内容:

  • 跟踪消息在创建和发布时的单个消息

  • 仪器接收和处理操作

  • 配置批次

  • 使用配置来诊断常见的消息传递问题

到本章结束时,你应该能够从头开始配置你的消息传递应用程序,或者根据需要调整现有的消息传递配置。

技术要求

本章的代码可在 GitHub 上找到,位于书籍仓库的github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/tree/main/chapter11

为了运行示例并执行分析,我们需要以下工具:

  • .NET SDK 7.0 或更高版本。

  • Docker 和docker-compose

  • 任何 HTTP 基准测试工具,例如,bombardier。如果你有 Go 工具,可以使用$ go get -u github.com/codesenberg/bombardier安装它,或者直接从其 GitHub 仓库github.com/codesenberg/bombardier/releases下载。

我们还将使用 Docker 中的 Azure 存储模拟器。不需要设置或 Azure 订阅。

消息传递场景中的可观察性

第十章 跟踪网络调用中,我们刚刚开始探索异步处理的支持。在那里,我们看到了客户端和服务器如何相互发送一系列可能独立的消息。

在消息传递的情况下,事情变得更加复杂:除了异步通信之外,生产者和消费者通过一个中介——消息代理进行交互。

一旦消息发布到代理,生产者的操作就完成了,而不需要等待消费者处理这条消息。根据场景和应用的健康状况,消费者可能立即处理它,几秒钟后处理,或者几天后处理。

在某些情况下,生产者会收到消息已处理的通知,但这通常是通过另一个消息队列或不同的通信渠道完成的。

实际上,生产者不知道消费者是否存在——处理管道中的故障或延迟在生产者端是不可见的。这改变了我们应该从可观察性角度看待延迟、吞吐量或错误率的方式——现在我们需要考虑由多个独立操作组成的端到端流程。

例如,当仅使用 HTTP 调用时,原始请求的延迟几乎涵盖了请求过程中发生的所有事情。一旦我们引入消息传递,我们需要手段来测量端到端延迟并识别不同组件之间的故障。一个使用消息传递的应用示例在图 11.1中显示:

图 11.1 – 使用消息传递在后台运行任务的应用

图 11.1 – 使用消息传递在后台运行任务的应用

在这样的应用中,当用户向前端发送请求时,一旦后端完成处理并向主题发布消息,他们就会收到响应。索引器、复制器、归档器和任何其他后处理数据的其他服务以它们自己的速度运行。索引器通常处理最新的消息,而归档器只会查看几天前发布的消息。

其中一些组件可能会失败而不会直接影响用户场景,而其他组件则影响用户发布的数据在其他系统部分显示的速度,因此可能是关键的。

让我们探索如何对这样的应用进行工具化。

在从头开始编写自己的工具之前,我们应始终检查是否已有可从中开始的现有工具库,如果没有,则应咨询 OpenTelemetry 语义约定。

我们将以 Azure Queue Storage 为例进行工具化。由于我们将在下一两个部分中看到的原因,现有的工具化没有涵盖队列的消息传递方面,因此我们不得不自己编写;我们将根据 OpenTelemetry 约定进行编写。

消息传递语义约定

跟踪的消息传递约定可在github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/messaging.md找到。

它们目前处于实验状态,并且很可能发生变化。目前还没有通用的度量约定,但你可以找到针对 Kafka 的特定度量。

约定提供了一些关于上下文传播的考虑(我们将在跟踪上下文传播部分讨论),并定义了通用属性来描述消息传递操作。以下是我们将要使用的一些基本属性:

  • messaging.system:表示跨度遵循消息传递语义,并描述了使用的特定消息传递系统,例如kafkarabbitmq。在我们的示例中,我们将使用azqueues

  • messaging.operation:标识标准操作之一:publishreceiveprocess

  • messaging.destination.namemessaging.source.name:描述代理内部的一个队列或主题名称。术语destination用于生产者,而source用于消费者。

  • net.peer.name:标识代理域名。

让我们看看如何使用约定来添加可观察性信号,这些信号可以帮助我们记录应用程序行为或检测和解决在消息场景中发生的新类别问题。

仪表化生成器

生成器是负责向代理发布消息的组件。发布过程本身通常是同步的:我们向代理发送请求,并从它那里获得响应,指示消息是否成功发布。

根据消息系统和生成器需求,一个发布请求可能携带一条或多条消息。我们将在“仪表批处理场景”部分讨论批处理。现在,让我们专注于单条消息的情况。

为了跟踪它,我们需要确保在发布消息时创建一个活动,这样我们就可以跟踪调用持续时间、状态,并调试单个请求。我们也会对持续时间、吞吐量和失败率等指标感兴趣——这对于云消息解决方案的预算或自托管代理的扩展非常重要。

生成器仪表的一部分是上下文传播。让我们在这里稍作停顿,讨论一下。

跟踪上下文传播

当我们仪表化 HTTP 调用时,上下文通过 HTTP 请求头传播,这是请求的一部分。在消息中,上下文通过一个传输调用到代理,并且不会传播到消费者。传输调用跟踪上下文标识请求,但不标识它携带的消息。

因此,我们需要在消息内部传播上下文,以确保它能够到达消费者。但我们应该注入哪种上下文?我们有几个选项:

  • 使用当前活动的上下文:例如,当我们在一个传入 HTTP 请求的范围内发布消息时,我们可以使用代表这个 HTTP 服务器调用的活动的上下文。这只有在每个传入请求只发送一条消息的情况下才有效。如果我们发送多条(每条都在单独的发布调用中),我们就无法确定消费者调用处理了哪条消息,或者确定我们是否向正确的队列发送了消息。

  • 为每条消息创建一个活动并注入其上下文:独特的上下文允许我们单独跟踪消息,在发送多条消息在一个发布调用中的批处理场景中也有效。这也增加了为每条消息创建额外活动的开销。

  • 重用发布活动:当我们向代理发送一条消息时,我们可以通过一个活动唯一地识别一条消息和一个发布调用。

第一个选项违反了 OpenTelemetry 消息语义约定,这允许我们从最后两个选项中选择一个合适的选项。在我们的例子中,我们使用 Azure Queue Storage,它不支持发布消息时的批处理。因此,我们将使用最后一个选项,创建一个活动来跟踪发布调用,并将它的上下文注入到消息中。

注意

当从一个队列将消息分叉或路由到另一个队列时,消息可能在上游服务中已经注入了预存在的跟踪上下文。在这种情况下,默认行为应该是保持消息上下文完整。为了关联与消息发生的所有操作,我们可以在发布或接收消息时始终添加一个链接到现有的跟踪上下文。

Azure Queue Storage 的另一个有趣方面是它不支持消息元数据——消息是一个不透明的有效载荷,没有任何规定的结构或格式,服务会携带它。因此,类似于我们在第十章中讨论的 gRPC 流,跟踪网络调用,我们需要定义自己的消息结构或使用可用的知名事件格式之一,例如CloudEvents

注意

CloudEvents (cloudevents.io) 是一个开放标准,以供应商和技术无关的方式定义事件结构。它通常被云提供商用于通知应用程序有关基础设施更改或实现数据更改馈送时使用。CloudEvents 具有分布式跟踪扩展,可以携带 W3C 跟踪上下文以及可用于其他格式的通用元数据。OpenTelemetry 还提供了 CloudEvents 的语义约定。

为了演示目的,我们将保持简单,并以下述方式定义我们自己的小型消息模型:

producer/Message.cs

public class Message
{
  ...
  public Dictionary<string, string> Headers { get; set; } =
    new ();
  public string? Text { get; set; }
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter11/producer/Message.cs

我们将使用Headers属性来传播跟踪上下文,并将有效载荷保留在Text属性中。

与我们在第十章中看到的 gRPC 流示例类似,跟踪网络调用,我们可以使用以下代码片段通过 OpenTelemetry 传播器将上下文注入此消息:

producer/Controllers/SendController.cs

private void InjectContext(Message message, Activity? act)
{
  if (act != null)
  {
    _propagator.Inject(new (act.Context, Baggage.Current),
      message,
    static (m, k, v) => m.Headers[k] = v);
  }
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter11/producer/Controllers/SendController.cs

现在我们已经有了所有需要的工具来对发布调用进行仪器化——让我们来做吧。

跟踪发布调用

我们需要创建一个新的活动,并在其上放置常见的消息属性以标识代理、队列操作,并添加其他信息。在 Azure Queue Storage 的情况下,我们可以使用账户名称作为代理标识符(因为它们在公共云中是唯一的)。

然后,我们将向消息中注入上下文并继续发布。消息成功发布后,我们还可以记录代理返回的信息,例如消息 ID 和其他可能认为有用的细节。

这是相应的代码:

producer/Controllers/SendController.cs

Stopwatch? duration = PublishDuration.Enabled ?
  Stopwatch.StartNew() : null;
using var act = StartPublishActivity();
InjectContext(message, Activity.Current);
try
{
  var receipt = await _queue.SendMessageAsync(
    BinaryData.FromObjectAsJson(message));
  act?.SetTag("messaging.message.id",
    receipt.Value.MessageId);
  RecordPublishMetrics(duration, "ok");
  ...
}
catch (Exception ex)
{
  act?.SetStatus(ActivityStatusCode.Error, ex.Message);
  RecordPublishMetrics(duration, "fail")
  ...
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter11/producer/Controllers/SendController.cs

在这里,我们使用之前实现的 Inject 方法注入了 Activity.Current 的上下文。如果你想要关闭按消息的活动,这可能很有用。在这种情况下,按消息的跟踪将被限制,但消费者和生产者的调用仍然会关联。我们在这里也记录了指标——请保持关注细节;我们将在下一节中介绍它们。

这里是 StartPublishActivity 方法的实现:

producer/Controllers/SendController.cs

var act = Source.StartActivity($"{_queue.Name} publish",
  ActivityKind.Producer);
if (act?.IsAllDataRequested == true)
  act.SetTag("messaging.system", "azqueues")
    .SetTag("messaging.operation", "publish")
    .SetTag("messaging.destination.name", _queue.Name)
    .SetTag("net.peer.name", _queue.AccountName)
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter11/producer/Controllers/SendController.cs

这里的活动有一个 producer 类型,这表示异步流程的开始。名称遵循 OpenTelemetry 语义约定,建议使用 {queue_name} {operation} 模式。我们也可以将其缓存以避免不必要的字符串格式化。

就这样;我们已经涵盖了生产者跟踪——现在让我们看看指标。

生产者指标

特定于消息的指标作为资源利用率、.NET 运行时、HTTP 等其他你可能想要公开的指标的补充。

在一定程度上,我们可以使用 HTTP 指标来监控对 Azure Queue Storage 的调用,因为它们基于 HTTP。这将允许我们监控对存储的个别 HTTP 调用的持续时间、成功率以及吞吐量,但无法区分同一存储账户内的队列。

因此,如果我们依赖于指标,我们应该记录一些特定于消息的指标,这些指标涵盖了常见的指标,例如每个队列的发布调用持续时间、吞吐量和延迟。

我们可以使用持续时间直方图来报告所有这些指标,就像我们在 第七章 中看到的,添加自定义指标。首先,让我们初始化持续时间直方图,如下面的代码片段所示:

producer/Controllers/SendController.cs

private static readonly Meter Meter = new("Queue.Publish");
private static readonly Histogram<double> PublishDuration =
  Meter.CreateHistogram<double>(
    "messaging.azqueues.publish.duration", ...);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter11/producer/Controllers/SendController.cs

MeterHistogram 是静态的,因为我们是在控制器中定义它们的。控制器的生命周期是针对请求的,所以我们保持它们为静态以保持效率。

正如我们在跟踪示例中看到的,每次我们发布消息时,我们也会记录发布持续时间。以下是它的实现方式:

producer/Controllers/SendController.cs

public void RecordPublishMetrics(Stopwatch? dur,
  string status)
{
  ...
  TagList tags = new() {
    { "net.peer.name", _queue.AccountName },
    { "messaging.destination.name", _queue.Name },
    { "messaging.azqueue.status", status }};
  PublishDuration.Record(dur.Elapsed. TotalMilliseconds,
    tags);
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter11/producer/Controllers/SendController.cs

在这里,我们使用了相同的属性来描述队列,并添加了一个自定义状态属性。请注意,我们需要它具有低基数,所以我们只在使用此方法时使用okfail状态。

我们已经完成了生产者的监控。拥有基本的跟踪和指标应该为我们提供一个良好的起点,以诊断和调试大多数问题,并监控整体生产者健康,正如我们将在后面的消息场景中的性能分析部分中看到的。现在,让我们探索消费者上的监控。

监控消费者

虽然你可能可以在生产者上不使用自定义监控的情况下逃脱,但消费者监控是不可避免的。

一些代理使用同步 HTTP 或 RPC 调用将消息推送到消费者,现有的框架监控可以提供最基本的可观察性数据。在所有其他情况下,消息跟踪和指标是我们检测消费者健康和调试问题的全部。

首先,让我们追踪单个消息——记录它们在消费者中的到达时间和处理方式。这使我们能够通过回答诸如“这条消息现在在哪里?”或“为什么处理数据花了这么长时间?”等问题来调试问题。

跟踪消费者操作

当使用 Azure Queue Storage 时,应用程序从队列请求一条或多条消息。接收到的消息仍然留在队列中,但对其他消费者来说是不可见的,这是可配置的可见性超时。应用程序处理消息,完成后,从队列中删除它们。如果处理因暂时性问题失败,应用程序不会删除消息。当与 AWS SQS 一起工作时,通常使用相同的流程。

基于 RabbitMQ 和 AMQP 的消息流看起来很相似,除了消息可以被推送到消费者,这样应用程序就可以对客户端库回调做出反应,而不是轮询队列。

基于回调的交付允许我们在客户端库中实现监控,或者提供一个共享的监控库,而在基于轮询的模型中,我们实际上被迫至少编写一些自定义监控来处理。让我们这样做。

首先,让我们在接收之外单独对消息处理进行监控。我们需要创建一个活动来跟踪处理,这将捕获那里发生的所有事情,包括消息删除:

consumer/SingleReceiver.cs

using var act = StartProcessActivity(msg);
...
try
{
  await ProcessMessage(msg, token);
  await _queue.DeleteMessageAsync(msg.MessageId,
    msg.PopReceipt, token);
}
catch (Exception ex)
{
  await _queue.UpdateMessageAsync(msg.MessageId,
    msg.PopReceipt, visibilityTimeout: BackoffTimeout,
    cancellationToken: token);
  ...
  act?.SetStatus(ActivityStatusCode.Error, ex.Message);
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter11/consumer/SingleReceiver.cs

在这里,所有处理逻辑都在 ProcessMessage 方法中完成。当它成功完成时,我们从队列中删除消息。否则,我们更新其可见性,以便在回退超时后再次出现在队列中。

这里是 StartProcessActivity 的实现:

consumer/SingleReceiver.cs

PropagationContext ctx = ExtractContext(msg);
var current = new ActivityLink(Activity.Current?.Context ??
  default);
var act = _messageSource.StartActivity(
  $"{_queue.Name} process",
  ActivityKind.Consumer,
  ctx.ActivityContext,
  links: new[] { current });
if (act?.IsAllDataRequested == true)
  act.SetTag("net.peer.name",_queue.AccountName)
     .SetTag("messaging.system", "azqueues")
     .SetTag("messaging.operation", "process")
     .SetTag("messaging.source.name", _queue.Name)
     .SetTag("messaging.message.id", msg.MessageId);
  ...

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter11/consumer/SingleReceiver.cs

在这里,我们从消息中提取了上下文,并将其用作处理活动的父级。它具有 consumer 类型,这表示异步流的延续。我们还保留了 Activity.Current 作为关联的链接。我们还添加了消息属性。

消息删除和更新是通过 HTTP 或 Azure 队列 SDK 仪器跟踪的。它们没有消息语义,但应该提供合理的可观察性。相应活动成为处理活动的子活动,如图 11.2 所示:

图 11.2 – 从生产者到消费者的消息跟踪

图 11.2 – 从生产者到消费者的消息跟踪

消息被发布后,我们看到消费者尝试处理它两次:第一次尝试失败。第二次尝试成功,消息被删除。

上一张截图缺少了什么?我们没有看到消息是如何和何时被接收的。这可能在这个跟踪中并不重要,但看看 11.3 中的另一个跟踪:

图 11.3 – 生产者和消费者之间有九分钟间隔的消息跟踪

图 11.3 – 生产者和消费者之间有九分钟间隔的消息跟踪

在这里,几乎九分钟内没有发生任何事情。在这段时间内,消费者是否收到了消息?消费者是否存活?他们在做什么?Azure 队列服务中是否有任何问题阻止了消息的接收?

我们将在稍后回答这些问题。现在,让我们专注于跟踪接收操作。

接收操作中的挑战在于,在收到消息并即将结束相应操作之后,消息跟踪上下文才可用。那时我们可以添加链接到消息跟踪上下文,但目前只能在活动开始时添加它们。

这可能会改变,但到目前为止,我们将通过跟踪接收和处理迭代,并添加一个带有接收消息 ID 的属性来解决这个问题,这样我们就可以找到所有接触过这条消息的跨度:

consumer/SingleReceiver.cs

using var act = _receiverSource
  .StartActivity("ReceiveAndProcess");
try
{
  var response = await _queue.ReceiveMessagesAsync(1,
    ProcessingTimeout, token);
  QueueMessage[] messages = response.Value;
  if (messages.Length == 0)
  {
    ...; continue;
  }
  act?.SetTag("messaging.message.id",
    messages[0].MessageId);
  await ProcessAndSettle(messages[0], token);
  ...
}
catch (Exception ex)
{
  act?.SetStatus(ActivityStatusCode.Error, ex.Message);
  ...
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter11/consumer/SingleReceiver.cs

在这里,我们从队列中接收最多一条消息。如果收到了消息,我们就处理它。

一个迭代通过 ReceiveAndProcess 活动进行跟踪,该活动成为接收操作的父级。消息处理活动在 ProcessAndSettle 方法中创建,并链接到 ReceiveAndProcess 活动如图 11.4 所示:

图 11.4 – 从处理到外部循环活动的链接

图 11.4 – 从处理到外部循环活动的链接

如果我们跟随链接,我们将看到类似于 图 11.5 中所示的循环跟踪:

图 11.5 – 表示接收和处理迭代的跟踪图

图 11.5 – 表示接收和处理迭代的跟踪图

由于越来越多的可观察性后端提供了更好的链接支持,因此在后端使用它们可能更加方便。

通过迭代进行仪表化,我们现在可以关联接收和处理,或者看到完整循环周期需要多长时间。这有助于我们了解消费者是否活跃并尝试接收和处理某些内容。

我们在所有跨度上标记了 messaging.message.id 属性,以简化查找与任何给定消息相关的所有操作。

现在,回到我们在 图 11.3 中看到的九分钟间隔。那里发生的事情是队列中的消息太多——它们的生产速度超过了我们的消费速度。通过查看单个跟踪中的间隔,我们可以怀疑消息在队列中花费了时间,但无法确定。我们需要看到消息发布的速率、处理的速率和删除的速率。我们还应该了解消息在队列中花费的时间和队列的大小。让我们看看我们如何记录和使用这些指标。

消费者指标

与生产者类似,我们应该启用常见的运行时和进程指标,以便我们知道消费者进程的资源利用率。我们还应该记录处理循环的持续时间,这将给我们提供错误率和吞吐量。

从消息传递的角度来看,我们还想涵盖以下内容:

  • 消息在队列中花费的时间是消费者健康状况和规模的良好指标。当消费者不足时,队列中花费的时间会增长,可以用来扩展消费者。当它持续下降时,这可以作为一个信号来缩小消费者规模。

  • 队列中的消息数量提供了类似的数据,但实时。它包括尚未处理的消息。队列大小指标也可以在生产者端记录,而无需依赖消费者。

这些指标,或者你可以想到的类似指标及其随时间的变化趋势,为消费者健康状况提供了很好的指示。

如果消费者性能下降或错误率增加,这些指标会增加。如果消费者未能处理消息但立即将其从队列中删除,则它们将没有帮助,但这将表现为高错误率。所以,让我们继续用这些指标来仪表化我们的应用程序。

持续时间、吞吐量和故障率

我们将要测量处理循环的持续时间,这包括尝试接收消息及其处理。独立测量接收和处理持续时间将更加精确,这也是你在生产应用中需要考虑的事情。

在循环开始时,我们将启动一个计时器来测量操作持续时间,一旦处理完成,我们将将其作为直方图与队列信息和状态一起报告。让我们首先创建直方图仪表:

consumer/SingleReceiver.cs

private readonly Meter _meter = new ("Queue.Receive");
private readonly Histogram<double> _loopDuration;
...
_loopDuration = _meter.CreateHistogram<double>(
  "messaging.azqueues.process.loop.duration", "ms",
  "Receive and processing duration.");

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter11/consumer/SingleReceiver.cs

在这里,我们将创建计米和持续时间仪表作为实例变量,我们将与SingleReceiver实例一起丢弃它们。接收器扩展了BackgroundService接口,并在依赖注入容器中注册为单例,因此一旦应用程序关闭,它们都会被丢弃。

处理循环的仪表化可以按以下方式进行:

consumer/SingleReceiver.cs

Stopwatch? duration = Stopwatch.StartNew();
try
{
  var response = await _queue.ReceiveMessagesAsync(1,
    ProcessingTimeout, token);
  QueueMessage[] messages = response.Value;
  RecordLag(messages);
  if (messages.Length == 0)
  {
    ...
    RecordLoopDuration(duration, "empty");
    continue;
  }
  ...
  await ProcessAndSettle(messages[0], token);
  RecordLoopDuration(duration, "ok");
}
catch (Exception ex)
{
  RecordLoopDuration(duration, "fail"); ...
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter11/consumer/SingleReceiver.cs

在这里,我们记录了每次迭代的持续时间以及队列信息和状态。状态可以有以下值:okfailempty(如果没有收到消息)。在实际应用中,你可能希望更加精确,并添加更多状态以指示失败原因。例如,记录接收操作失败的原因很重要,无论是序列化或验证错误、处理超时,还是以终端或暂时性错误失败。

RecordLoopDuration方法实现如下所示:

consumer/SingleReceiver.cs

TagList tags = new () {
  { "net.peer.name", _queue.AccountName },
  { "messaging.source.name", _queue.Name },
  { "messaging.azqueue.status", status }};
_loopDuration.Record(duration.ElapsedMilliseconds, tags);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter11/consumer/SingleReceiver.cs

我们将在本章后面看到如何使用这个指标。首先,让我们实现消费者延迟和队列大小。

消费者延迟

在显示处理循环中指标的代码示例中,我们一收到消息就调用了RecordLag方法。消费者延迟记录了消息在队列中花费的大致时间——接收和入队时间之间的差值。

入队时间由 Azure 队列服务记录,并作为QueueMessage实例上的属性公开。我们可以使用以下代码记录指标:

consumer/SingleReceiver.cs

_consumerLag = _meter.CreateHistogram<double>(
  "messaging.azqueues.consumer.lag", "s", ...);
...
long receivedAt = DateTimeOffset.UtcNow
  .ToUnixTimeMilliseconds();
TagList tags = new () {
  { "net.peer.name", _queue.AccountName },
  { "messaging.source.name", _queue.Name }};
foreach (var msg in messages
    .Where(m => m.InsertedOn.HasValue))
{
  long insertedOn = msg.InsertedOn!
    .Value.ToUnixTimeMilliseconds());
  long lag = Math.Max(1, receivedAt - insertedOn);
  _consumerLag.Record(lag/1000d, tags);
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter11/consumer/SingleReceiver.cs

在这里,我们创建一个表示延迟(以秒为单位)的直方图,并将它记录在每条接收到的消息上,作为当前时间和消息被代理接收的时间之间的差异。

注意,这些时间戳通常来自两台不同的计算机——差异可能是负数,并且由于时钟偏差而不精确。误差范围可能达到秒,但在某种程度上,可以在您的系统中进行纠正。

时钟偏差是可以预期的,但有时事情可能会真的出错。我曾经参与调查一起事件,导致我们的服务在数据中心中断。这是由于错误的时间服务器配置,将其中一个服务的时间表向后移动了几小时。它破坏了认证——认证令牌的时间戳来自几小时前,被认为是过期的。

尽管不够精确,消费者延迟应该能让我们了解消息在队列中花费的时间。我们每次收到消息时都会记录它,因此它也反映了重新投递。此外,我们在知道处理是否成功之前就记录它,因此它没有任何状态。

在我们记录消费者延迟之前,我们首先需要接收一条消息。当我们看到巨大的延迟时,这是一个很好的信号,表明某些事情可能不正常,但它并没有告诉我们还有多少消息尚未接收。

例如,当负载低且队列空时,可能会有一些无效的消息卡在那里。这是一个错误,但可以在工作时间内修复。为了检测问题的严重性,我们还需要知道队列中有多少消息。让我们看看如何实现它。

队列大小

Azure 队列存储以及 Amazon SQS 允许我们检索消息的大致数量。我们可以注册另一个 BackgroundService 实现来定期检索计数。这可以在消费者或生产者上完成。

我们将使用一个量度仪表来报告它,如下面的代码片段所示:

consumer/QueueSizeReporter.cs

TagList tags = new () {
  { "net.peer.name", queue.AccountName},
  { "messaging.source.name", queue.Name}};
_queueSize = _meter.CreateObservableGauge(
  "messaging.azqueues.queue.size",
  () => new Measurement<long>(_currentQueueSize, tags), ...);

https://github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter11/consumer/QueueSizeReporter.cs

我们传递了一个返回 _currentQueueSize 实例变量的回调。我们将每隔几秒钟从队列中检索大小来更新它:

consumer/QueueSizeReporter.cs

var res = await _queue.GetPropertiesAsync(token);
_currentQueueSize = res.Value.ApproximateMessagesCount;

https://github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter11/consumer/QueueSizeReporter.cs

就这样——现在我们测量队列大小。这个数字本身并不能告诉我们整个故事,但如果它与基线有显著差异或增长迅速,这将是问题的一个很好的迹象。

一旦负载增加,队列大小也会增加,我们可能会尝试添加更多消费者或优化它们。一种典型的优化是批处理——它有助于减少网络调用的数量,并更好地利用消费者实例。让我们看看我们如何对其进行仪器化。

批处理场景的仪器化

根据用例的不同,批处理场景的仪器化可能不同——与批处理相比,传输级别的批处理需要稍微不同的方法。

传输级别的批处理

消息可以一起批处理以最小化网络调用的数量。它可以由生产者或消费者使用,例如 Kafka、Amazon SQS 或 Azure Service Bus 等系统都支持两端的批处理。

在消费者端,当一起接收多条消息但独立处理时,对于单条消息处理的所有内容仍然适用。

从跟踪的角度来看,我们唯一想要改变的是在外部迭代活动上添加记录所有接收到的消息标识符和批次大小的属性。

从指标方面来看,我们还想测量单个消息处理持续时间、错误率和吞吐量。我们可以通过添加消息处理持续时间直方图来跟踪它们。

当我们在一批中发送多条消息时,我们仍然需要独立跟踪这些消息。为了做到这一点,我们必须为每条消息创建一个活动并将唯一的跟踪上下文注入到每条消息中。然后,发布活动应该链接到所有正在发布的消息。

这里的主要问题是何时创建每条消息的活动并向消息中注入上下文。本质上,消息跟踪上下文应该继续创建消息的操作。因此,如果我们从不同的无关操作中缓冲消息,然后在后台线程中发送它们,我们应该在消息创建时创建消息活动。然后,批量发布操作将链接到独立的、无关的跟踪上下文。

发布操作的持续时间指标与之前我们实现的单条消息案例相同,但我们应该考虑添加另一个指标来描述批次大小和发送的确切消息数量——我们无法从发布持续时间中得出这些信息。

处理批次

在某些情况下,我们以批处理的方式处理消息,例如,为了分析目的聚合数据、复制或存档接收到的数据。在这种情况下,根本无法分离单个消息。在路由或分片等场景中,接收到的批次被分成几个新的批次并发送到下一个目的地时,事情变得更加复杂。

我们可以使用链接来记录关系——这将使我们能够了解消息何时以及多少次被接收,以及它对哪个处理操作做出了贡献。

实际上,我们创建了一个批量处理活动,其中包含所有正在处理的消息的链接。链接有属性,我们可以在那里放置重要的消息元数据,例如投递次数、消息 ID 或插入时间。

从指标的角度来看,消费者延迟(按消息衡量)、队列大小和处理持续时间(吞吐量和失败率)仍然适用。我们可能还想将批处理大小作为直方图报告。

注意

消息和批量处理通常在消息客户端库控制之外进行,由应用程序代码或集成框架完成。自动仪表化很难跟踪或测量处理调用。这些场景因应用程序而异,需要针对特定用例和消息系统进行定制仪表化。

现在我们已经了解了如何对消息场景进行仪表化,让我们看看我们如何在实践中使用它。

消息场景的性能分析

我们将使用我们的演示应用程序来模拟一些常见问题,并使用我们拥有的信号来检测和调试问题。让我们使用以下命令启动应用程序:

$ docker-compose up --build --scale consumer=3

它将运行一个生产者和三个消费者,以及可观察性堆栈。

您现在可以向生产者发送请求到http://localhost:5051/send,该请求向队列发送一条消息,并作为响应返回收据信息。

现在您需要使用您选择的工具添加一些负载。如果您使用bombardier,可以使用以下命令:

$ bombardier -c 1 -d 30m http://localhost:5051/send

它在一个连接中向生产者发送请求。您可以在docker-compose命令中尝试不同的连接数和消费者数,以查看指标如何变化。

您可能还希望安装 Grafana,并从本书的存储库(https://github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter11/grafana-dashboard.json)导入仪表板,以一次性查看所有指标。

我们如何检查消费者是否正常工作?我们可以从消费者延迟和队列大小指标开始。图 11.6显示了以下查询获得的消费者延迟的第 95 百分位数:

histogram_quantile(0.95,
  sum(rate(messaging_azqueues_consumer_lag_seconds_bucket[1m]))
  by (le, messaging_source_name, net_peer_name)
)

图 11.6 – 消费者延迟随时间增长

图 11.6 – 消费者延迟随时间增长

消费者延迟几乎增长到 600 秒,如果我们查看队列大小,如图图 11.7所示,我们将看到队列中最多有大约 11,000 条消息:

图 11.7 – 队列大小先增长然后缓慢下降

图 11.7 – 队列大小先增长然后缓慢下降

这是队列大小的查询:

max by (net_peer_name, messaging_source_name)
(messaging_azqueues_queue_size_messages)

消费者延迟在长时间内保持较高水平,直到大约 19:32 所有消息都处理完毕,但我们可以通过队列大小判断,事情开始改善是在 19:27。

由于我停止了应用程序并重新启动了它,使用 15 个消费者,趋势发生了变化,队列迅速缩小。

但现在我们有太多的消费者,正在浪费资源。我们可以检查我们检索的平均批次大小——如果它持续并且明显低于配置的批次大小,我们可能可以逐渐开始减少消费者的数量,为负载高峰留出一些缓冲。

现在,让我们停止负载并添加一些错误。发送带有 http://localhost:5051/send?malformed=true 的格式不正确的消息。我们应该看到队列大小保持较小,但随着时间的推移,消费者延迟增长。

我们还可以看到,尽管没有发送消息,我们却在接收消息,处理它们,并且反复失败。

例如,我们可以使用以下查询来可视化它:

sum(
 rate(messaging_azqueues_process_loop_duration_milliseconds_
    count[1m]))
by (messaging_source_name, messaging_azqueue_status)

它显示了按队列名称和状态分组的处理和接收迭代速率。这显示在 图 11**.8 中:

图 11.8 – 按状态分组的处理速率

图 11.8 – 按状态分组的处理速率

我们可以看到,大约在 20:57 左右,我们每秒尝试接收四次消息。其中三次调用没有返回任何消息,在另一种情况下,处理失败。没有成功的迭代。

我们发送了几条格式不正确的消息,它们似乎被永久处理了——这是一个错误。如果有更多这样的消息,它们会占用消费者,使他们无法处理任何有效消息。

为了确认这个建议,让我们看看痕迹。让我们打开 Jaeger 在 http://localhost:16686 并过滤来自消费者的错误痕迹。其中一个这样的痕迹显示在 图 11**.9 中:

图 11.9 – 失败的接收和处理迭代

图 11.9 – 失败的接收和处理迭代

在这里,我们看到收到了四条消息,迭代失败并出现错误。如果我们能为此操作添加链接,我们就能导航到每个单独消息的痕迹。相反,我们只有消息 ID。让我们使用相应的属性找到这些消息中的一个的痕迹。结果显示在 图 11**.10 中:

图 11.10 – 失败消息的痕迹

图 11.10 – 失败消息的痕迹

这看起来不太好——我们只有一个消息就有 3,000 个跨度。如果我们打开痕迹并检查最新处理跨度中的 messaging.azqueues.message.dequeue_count 属性,我们会看到消息被接收了超过 1,000 次。

为了解决这个问题,我们应该删除验证失败的消息。我们还确保对任何其他终端错误也这样做,并引入对消息出队次数的限制,超过这个次数后,消息将被删除。

我们刚刚看到了在消息场景中经常出现的一些问题(但通常以不那么明显的方式),并使用配置来检测和调试它们。随着可观察性供应商改进链接的用户体验,这样的调查将变得更加容易。但我们已经拥有了记录遥测数据并在消息流中关联它们的全部手段。

摘要

在本章中,我们探讨了消息配置。我们从消息的具体内容和它们给可观察性带来的新挑战开始。我们简要地了解了 OpenTelemetry 消息语义约定,然后深入到生产者配置。生产者负责将跟踪上下文注入消息并配置发布操作,以便能够跟踪消费者上的每个独立流程。

然后,我们使用指标和跟踪对消费者进行了配置。我们学习了如何使用队列大小和延迟来衡量消费者健康,并探讨了批处理场景的配置选项。最后,我们看到了如何使用配置来检测和调查常见的消息问题。

通过这种方式,你已经准备好配置常见的消息模式,并可以开始设计和调整高级流场景的配置。

在下一章中,我们将为数据库和缓存设计一个全面的可观察性存储。

问题

  1. 你会如何衡量异步操作的全端延迟?例如,在一个用户上传表情包并需要一些时间处理和索引它,然后才出现在搜索结果中的场景。

  2. 你会如何报告批大小作为一个指标?它如何被使用?

  3. 你会如何处理消息场景中的行李传播

第十二章:仪器化数据库调用

在本章中,我们将继续探索流行分布式模式的仪器化方法,并将探讨数据库仪器化。我们将以 MongoDB 为例,并结合 Redis 缓存。我们将为数据库和缓存调用添加跟踪和指标仪器化,并讨论如何在这些复合场景中添加应用程序上下文和提供可观察性。除了客户端仪器化之外,我们还将看到如何使用 OpenTelemetry Collector 抓取 Redis 服务器指标。最后,我们将探索生成的遥测数据,并了解它是如何帮助分析应用程序性能的。

下面是你将了解的内容:

  • 跟踪 MongoDB 操作

  • 跟踪 Redis 缓存和逻辑调用

  • 添加客户端和服务器端指标

  • 使用遥测分析故障和性能

到本章结束时,你将熟悉通用的数据库仪器化,并能够使用数据库或缓存仪器化自己的应用程序并分析其性能。

技术要求

本章的代码可在 GitHub 上本书的仓库中找到,网址为github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/tree/main/chapter12

运行示例和执行分析,我们需要以下工具:

  • .NET SDK 7.0 或更高版本

  • Docker 和docker-compose

仪器化数据库调用

数据库几乎被用于每个分布式应用程序中。许多数据库在服务器端提供高级监控能力,包括数据库特定的指标、日志或昂贵的查询检测和分析工具。客户端仪器化通过提供通信的客户端可观察性、关联数据库操作以及添加应用程序特定的上下文来补充它。

客户端仪器化描述了应用程序与数据库 ORM 系统、驱动程序或客户端库之间的通信,这可能在后台执行负载均衡或批处理操作时相当复杂。

在某些情况下,可能可以跟踪客户端库和数据库集群之间的网络级通信。例如,如果数据库使用 gRPC 或 HTTP 协议,相应的自动仪器化将捕获传输级别的跨度。在这种情况下,我们将看到传输级别的跨度作为由应用程序启动的逻辑数据库操作的子跨度。

在这里,我们将仪器化 MongoDB C#驱动程序的逻辑级别,以展示适用于其他数据库仪器化的原则。

注意

MongoDB.Driver的通用仪器化可在MongoDB.Driver.Core.Extensions.OpenTelemetry NuGet 包中找到。

在我们开始仪器化之前,让我们了解一下 OpenTelemetry 数据库语义约定。

OpenTelemetry 数据库语义约定

这些约定可在github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/database.md找到。它们处于实验状态,您访问链接时可能已经发生变化。

约定定义了逻辑和物理调用都适用的属性。在我们的案例中,我们不会对传输层通信进行配置,因此我们只会使用适用于逻辑操作的属性:

  • db.system: 这是一个必需的属性,跟踪后端使用它来区分数据库跨度与其他所有跨度。它应与mongodb字符串匹配,该字符串是可观察后端可能用于提供数据库或甚至 MongoDB 特定分析和可视化的字符串。

  • db.connection_string: 这是一个推荐属性。也建议在提供之前删除凭据。我们不会将其添加到我们的自定义配置中。可能存在一些情况,其中捕获连接字符串(不包含凭据)是有用的,因为它可以帮助检测配置问题,或者我们也可以在启动时记录一次。

  • db.user: 这又是另一个推荐属性,用于捕获用户信息,并有助于检测配置和访问问题。由于我们只有一个用户,我们不会捕获它。

  • db.name: 这是一个必需的属性,用于定义数据库名称。

  • db.operation: 这是一个必需的属性,用于捕获正在执行的操作的名称,该名称应与 MongoDB 命令名称匹配。

  • db.mongodb.collection: 这是一个必需的属性,代表 MongoDB 集合名称。

除了数据库特定的属性之外,我们还将使用net.peer.namenet.peer.port填充 MongoDB 主机信息——通用网络属性。

在逻辑调用上填充网络级属性并不总是可能或有用。例如,当 MongoDB 驱动程序配置了多个主机时,我们不一定知道哪个用于特定的命令。在实践中,我们应该使用在命令级别操作的自动配置,通过IEventSubscriber订阅命令事件(如 MongoDB 文档中所述mongodb.github.io/mongo-csharp-driver/2.11/reference/driver_core/events)。

除了属性之外,语义约定还要求在跨度上使用客户端类型,并提供一个低基数跨度名称,该名称包括操作和数据库名称。我们将使用{db.operation} {db.name}.{db.mongodb.collection}模式。

既然我们已经知道在跨度中包含哪些信息,那么我们就继续对 MongoDB 操作进行配置。

跟踪实现

在我们的应用程序中,我们在 MongoDB 集合中存储记录,并在自定义的DatabaseService类中处理与集合的所有通信。

首先,我们来对读取集合中单个记录的操作进行追踪:

DatabaseService.cs

using var act = StartMongoActivity(GetOperation);
try {
  var rec = await _records.Find(r => r.Id == id)
    .SingleOrDefaultAsync();
  ...
  return rec;
} catch (Exception ex) {
  act?.SetStatus(ActivityStatusCode.Error,
    ex.GetType().Name);
  ...
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter12/database/DatabaseService.cs

在这里,我们追踪Find方法调用。我们使用GetOperation常量作为操作名称,它被设置为FindSingleOrDefault——一个合成名称,描述了我们在这里所做的事情。如果 MongoDB 命令抛出异常,我们将活动状态设置为error

让我们来看看StartMongoActivity方法的具体实现:

DatabaseService.cs

var act = MongoSource.StartActivity(
    $"{operation} {_dbName}.{_collectionName}",
    ActivityKind.Client);
  if (act?.IsAllDataRequested != true) return act;
  return act.SetTag("db.system", "mongodb")
    .SetTag("db.name", _dbName)
    .SetTag("db.mongodb.collection", _collectionName)
    .SetTag("db.operation", operation)
    .SetTag("net.peer.name", _host)
    .SetTag("net.peer.port", _port);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter12/database/DatabaseService.cs

在这里,我们从之前提到的语义约定中填充活动名称、类型和属性。主机、端口、数据库名和集合名称是从配置中提供的 MongoDB 设置以及在构建时捕获的。

对于任何其他操作,可以使用类似的方法。对于批量操作,我们可能需要考虑在数组属性中添加更多上下文来描述单个请求,如下代码片段所示:

DatabaseService.cs

private static void AddBulkAttributes<T>(
  IEnumerable<WriteModel<T>> requests, Activity? act)
{
  if (act?.IsAllDataRequested == true)
  {
    act.SetTag("db.mongodb.bulk_operations",
      requests.Select(r => r.ModelType).ToArray());
  }
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter12/database/DatabaseService.cs

这种追踪非常通用——即使它知道记录的类型,它也不会记录任何特定于应用程序的内容。例如,我们可以添加一个记录标识符作为属性,或者在找不到记录时将状态设置为error。如果你坚持使用专门的手动追踪,这些都是有效的事情,但更常见的是在可能的情况下使用共享的追踪。

那么,我们如何记录与通用数据库追踪一起的应用程序特定上下文呢?一个解决方案是在第五章中,配置控制平面中我们所做的那样,丰富自动收集的活动。

另一种解决方案是在数据库和缓存调用周围添加另一层逻辑活动。在我们这样做之前,让我们学习如何追踪缓存调用。

追踪缓存调用

如 Redis 和 Memcached 之类的缓存是数据库的特殊类别,也受到数据库语义约定的覆盖。根据约定对缓存调用进行追踪是有益的,因为它有助于你在所有服务中保持一致性,并从可视化和分析方面充分利用你的追踪后端。

因此,让我们根据数据库约定来仪表化 Redis 并添加缓存特定上下文。OpenTelemetry 对于缓存没有特别定义,所以让我们设计一些自己的东西。

注意

StackExchange.Redis 客户端的自动仪表化功能可在 OpenTelemetry.Instrumentation.StackExchangeRedis NuGet 包中找到。

当涉及到跟踪时,我们想知道典型的事情:调用花费了多长时间,是否发生了错误,以及尝试了什么操作。缓存特定的事情包括指示是否从缓存中检索了项目或集合操作的过期策略(如果它是条件性的)。

让我们继续仪表化一个 Get 调用 – 它看起来与我们在上一节中看到的数据库仪表化非常相似:

CacheService.cs

using var act = StartCacheActivity(GetOperationName);
try
{
  var record = await _cache.GetStringAsync(id);
  act?.SetTag("cache.hit", record != null);
  ...
}
catch (Exception ex)
{
  act?.SetStatus(ActivityStatusCode.Error,
    ex.GetType().Name);
  ...
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter12/database/CacheService.cs

在这里,我们创建了一个活动来跟踪对 Redis 的 GetString 调用。如果找到记录,我们将 cache.hit 属性设置为 true,如果发生异常,我们将活动状态设置为 error 并包含异常信息。

让我们看看在 StartCacheActivity 方法中设置的属性:

CacheService.cs

var act = RedisSource.StartActivity(operation,
  ActivityKind.Client);
if (act?.IsAllDataRequested != true) return act;
return act.SetTag("db.operation", operation)
    .SetTag("db.system", "redis")
    .SetTagIfNotNull("db.redis.database_index", _dbIndex)
    .SetTagIfNotNull("net.peer.name", _host)
    .SetTagIfNotNull("net.peer.port", _port)
    .SetTagIfNotNull("net.sock.peer.addr", _address)
    .SetTagIfNotNull("net.sock.family", _networkFamily);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter12/database/CacheService.cs

在这个片段中,我们使用与操作名称匹配的名称启动客户端活动。我们还设置了所有适用的数据库和网络属性,并添加了一个由 OpenTelemetry 定义的 Redis 特定属性 – db.redis.database_index。描述主机、端口、IP 地址和网络家族的网络属性是从 Redis 配置选项中填充的。SetTagIfNotNull 方法是我们项目中定义的一个扩展方法。

这里,我们与 MongoDB 遇到相同的问题 – Redis 配置选项可能包括多个服务器,而我们不知道哪个服务器将被用于特定的调用。OpenTelemetry.Instrumentation.StackExchangeRedis 包中的仪表化(我们在 第三章,*.NET 可观察性生态系统)中提供了更精确的信息。

由于与 MongoDB 相同的原因,这种仪表化非常通用 – 在大多数情况下,我们更愿意丰富自动仪表化或添加另一层特定于应用程序的跨度,而不是编写自定义仪表化。所以,让我们看看我们如何通过添加另一层仪表化来添加上下文。

仪表化复合调用

由于 MongoDB 和 Redis 调用独立且以通用方式进行了仪器化,因此,考虑到涉及对缓存的调用、对数据库的调用以及随后对缓存的另一个调用,要回答诸如“检索具有特定 ID 的记录需要多长时间?”或“检索需要多长时间?”等问题可能会很困难。

我们没有添加一个记录标识符属性来查询,我们只知道单个调用的持续时间,而这些调用并不能真正描述整体操作。

在以下示例中,我们正在添加一个额外的仪器层,该层使用记录标识符跟踪逻辑操作:

RecordsController.cs

using var act = Source.StartActivity("GetRecord");
act?.SetTag("app.record.id", id);
try
{
  var recordStr = await _cache.GetRecord(id);
  if (recordStr != null) return recordStr;
  act?.SetTag("cache.hit", false);
  var record = await _database.Get(id);
  if (record != null) return await Cache(record);
}
catch (Exception ex)
{
  act?.SetStatus(ActivityStatusCode.Error,
    ex.GetType().Name);
  throw;
}
act?.SetStatus(ActivityStatusCode.Error, "not found");

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter12/database/Controllers/RecordsController.cs

在这里,我们将调用序列包装在GetRecord活动内 – 它具有internal类型,并且只有两个属性:app.record.id(它捕获记录标识符)和cache.hit(描述记录是否从数据库中检索)。

当没有找到任何内容时,我们还提供not found状态描述,并以相同的方式报告其他已知问题。

在我们的演示应用程序中,包含数据库和缓存的跨度几乎与 ASP.NET Core 中的状态和持续时间相匹配,但在实际应用中,控制器方法会做很多其他事情。包含的操作帮助我们分离所有与记录检索相关的跨度和对数。

既然我们已经了解了如何处理跟踪,让我们来探索指标。

添加指标

对于数据库,除了技术特定的东西之外,通常还会监控连接、查询执行次数和持续时间、竞争和资源利用率。MongoDB 集群报告了一系列此类指标,您可以使用 OpenTelemetry Collector(在 https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/mongodbreceiver 查看)接收这些指标。这些指标提供了服务器端的故事。我们还应该添加客户端持续时间指标。这将帮助我们处理连接问题和网络延迟。

OpenTelemetry 语义约定目前仅记录连接指标。我们可以通过实现IEventSubscriber接口并监听连接事件来记录它们。

相反,我们将记录基本操作持续时间,这还允许我们推导出吞吐量和故障率,并按操作、数据库或集合名称进行切片和切块。

让我们回到Get操作代码,看看如何添加指标。首先,我们将创建一个持续时间直方图:

DatabaseService.cs

private static readonly Meter MongoMeter = new("MongoDb");
private readonly Histogram<double> _operationDuration;
…
public DatabaseService(IOptions<MongoDbSettings> settings) {
  ...
  _operationDuration = MongoMeter.CreateHistogram<double>(
    "db.operation.duration", "ms",
    "Database call duration");
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter12/database/DatabaseService.cs

现在我们有了直方图,我们可以记录每个操作的持续时间:

DatabaseService.cs

var start = _operationDuration.Enabled ?
    Stopwatch.StartNew() : null;
using var act = StartMongoActivity(GetOperation);
try
{
  var rec = await _records.Find(r => r.Id == id)
    .SingleOrDefaultAsync();
  TrackDuration(start, GetOperation);
  return rec;
}
catch (Exception ex)
{
  ...
  TrackDuration(start, GetOperation, ex);
  throw;
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter12/database/DatabaseService.cs

在这里,我们调用TrackDuration方法并传递一个跟踪持续时间的计时器,低基数操作名称以及一个异常(如果有)。以下是TrackDuration方法:

DatabaseStatus.cs

private void TrackDuration(Stopwatch? start,
  string operation, Exception? ex = null)
{
  if (start == null) return;
  string status = ex?.GetType()?.Name ?? "ok";
  _operationDuration.Record(start.ElapsedMilliseconds,
    new TagList() {
      { "db.name", _dbName },
      { "db.mongodb.collection", _collectionName },
      { "db.system", "mongodb"},
      { "db.operation", operation },
      { "db.mongodb.status", status },
      { "net.peer.name", _host },
      { "net.peer.port", _port }});
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter12/database/DatabaseService.cs

在这里,我们添加了我们用于跟踪的所有属性以及一个新的属性 – db.mongodb.status。我们使用异常类型作为状态以确保度量值的基数保持较低。

虽然使用异常类型的想法看起来很有吸引力且简单,但它仅在我们在整个系统中使用相同语言的相同 MongoDB 驱动程序时才有效。即使如此,状态也可能随着驱动程序的更新而随时间变化。在实际的生产场景中,我建议将已知的异常映射到语言无关的状态代码。测试相应的案例并检查是否捕获了适当的错误代码也是有意义的。如果你的警报基于特定的代码,那么这一点很重要。

持续时间直方图以及我们可以在查询时从中导出的度量值涵盖了常见的监控需求(吞吐量、延迟和错误率)。我们还可以用它来进行容量分析和做出更好的设计决策。例如,在数据库前添加缓存之前,我们可以检查读写比例以查看缓存是否有帮助。

通过对跟踪的定制查询,我们还可以估计相同的记录被访问的频率。这将帮助我们选择合适的过期策略。

记录 Redis 度量值

除了常见的数据库问题之外,我们还想测量与缓存相关的特定指标:命中率、键过期和驱逐率。这有助于优化和扩展缓存。

这些指标由 Redis 报告,并且可以使用 OpenTelemetry Collector 的 Redis 接收器捕获,该接收器位于 https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/redisreceiver。

我们可以通过以下配置来启用它们:

configs/otel-collector-config.yml

receivers:
...
  redis:
    endpoint: "redis:6379"
    collection_interval: 5s
...
service:
  pipelines:
    ...
    metrics:
      receivers: [otlp, redis]
...

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter12/configs/otel-collector-config.yml

OpenTelemetry Collector 连接到 Redis 实例并从中抓取可用的指标。Redis 提供多个指标,包括运行时间和资源利用率指标,最重要的是,计数器测量命令速率、命中、未命中、过期、淘汰和平均生存时间。有了这些,我们可以监控 Redis 的健康状态,并查看它是否被有效使用以及瓶颈在哪里。

例如,低命中率到未命中率的比率可能表明我们没有很好地利用缓存,并且可能需要调整缓存参数以提高其效率。首先,我们应该确保缓存是有意义的——通常,当至少有一些项目被读取的频率高于它们被修改的频率时,它是合理的。我们还需要读取之间的间隔相对较低。

如果根据收集的数据,我们决定添加缓存,我们可以通过查看其他缓存指标进一步优化其配置:

  • 高键值淘汰率可以告诉我们是否内存不足,键值在读取项目之前被淘汰。我们可能需要垂直或水平扩展 Redis,或者更改淘汰策略以更好地匹配使用模式。例如,如果我们有相对较少的定期访问项目,最少使用LFU)策略可能比最近最少使用LRU)策略更有效。

  • 如果我们看到低淘汰率但高过期率,这可能意味着过期时间太低——项目的读取频率低于预期。我们可以尝试逐渐增加过期时间或禁用它并依赖淘汰策略。

除了服务器端指标外,我们还将添加客户端持续时间直方图。这允许我们使用命令和其他数据库特定维度记录调用持续时间分布。实现几乎与 MongoDB 持续时间指标相同。唯一的区别是我们将向 GetString 操作的指标中添加 cache.hit 属性。当服务器端指标不可用或存在多个不同的操作,我们希望独立测量其命中率时,这可能很有帮助。

现在我们已经设置了所有数据库跟踪和指标,让我们将所有部件组合起来,看看我们如何在实践中使用这种遥测。

分析性能

让我们先使用 $ docker-compose up --build 命令运行演示应用程序。它将启动本地 MongoDB 和 Redis 实例以及应用程序和可观察性堆栈。

您可以使用 curl 等工具创建一些记录:

$ curl -X POST http://localhost:5051/records \
  -H "Content-Type: application/json" \
  -d '[{"name":"foo"},{"name":"bar"},{"name":"baz"}]'

它应该返回服务创建的记录标识符列表。

现在,让我们查看 http://localhost:16686 上的 Jaeger 跟踪,就像 图 12.1 中所示的那样:

图 12.1 – 显示批量记录创建的跟踪图

图 12.1 – 显示批量记录创建的跟踪图

我们看到一个控制器跨度(Records)和CreateRecords,它描述了一个包括数据库和缓存的操作。它是BulkWrite跨度的父级,描述了一个 MongoDB 调用和三个单独的 Redis 跨度——每个记录一个。

注意,由于我们不等待它,控制器和CreateRecords跨度在缓存完成之前就结束了。因此,在SetString操作中发生的任何事情,尽管父请求已完成,仍然会被正确关联。

如果我们等待大约 10 秒钟并尝试获取其中一条记录(通过调用http://localhost:5051/records/{id}),我们会看到如图图 12.2所示的跟踪:

图 12.2 – 显示从数据库检索记录的跟踪

图 12.2 – 显示从数据库检索记录的跟踪

如果我们在 10 秒内获取相同的记录,我们会看到它是从缓存中返回的,如图图 12.3所示:

图 12.3 – 显示从缓存检索记录的跟踪

图 12.3 – 显示从缓存检索记录的跟踪

通过查看单个跟踪,我们现在可以快速看到记录是从缓存还是从数据库检索的。我们还可以使用app.record.id属性找到特定记录的所有跨跟踪操作,或者使用cache.hit标志编写即席查询。

现在,让我们通过停止 Redis 容器来模拟一个故障:$ docker stop chapter12-redis-1

如果我们再次尝试获取一条记录,应用程序将返回500 – Internal Server Error响应。跟踪可预测地显示 Redis 调用失败,并抛出RedisConnectionException异常。我们可能想要改变这种行为,如果 Redis 调用失败,则从数据库检索记录。

如果我们这样做,我们会看到类似于图 12.4所示的跟踪:

图 12.4 – 显示 Redis 调用失败并回退到数据库的跟踪

图 12.4 – 显示 Redis 调用失败并回退到数据库的跟踪

在这里,对 Redis 的调用失败了,但整体操作成功了。如果你在CacheService.cs的第 63 行取消注释throw语句,然后使用$ docker-compose up --build重新运行应用程序,你可以重现它。

让我们检查在这种情况下度量指标会发生什么。我们可以通过运行以下命令来应用一些负载:loadgenerator$ dotnet run -c Release --rate 50。给它几分钟时间稳定下来,然后检查我们应用程序的性能。

让我们首先使用以下查询在 Prometheus 中检查服务吞吐量(在http://localhost:9090):

sum by (http_route, http_status_code)
  (rate(http_server_duration_milliseconds_count[1m])
)

正如我们在图 12.6中将要看到的,吞吐量稳定在大约每秒 40-50 个请求——这正是我们在rate参数中配置的。

然后,我们可以使用以下查询检查延迟的第 50 百分位数:

histogram_quantile(0.50,
  sum (rate(http_server_duration_milliseconds_bucket[1m]))
  by (le, http_route, http_method))

之后,在图 12.7中,我们将看到响应速度非常快——延迟的第 50 百分位数仅为几毫秒。

揭示

如果我们检查延迟的 95 百分位数,我们会发现它要大得多,达到 200-300 毫秒。MongoDB 显示这些延迟峰值是因为容器资源在演示目的下受到限制。

现在我们来检查缓存命中率。我们可以从 Redis 服务器指标或客户端操作持续时间直方图中推导它。以下查询使用后者方法:

100 *
sum by (net_peer_name) (
  rate(db_operation_duration_milliseconds_count{cache_hit="true",
          db_operation="GetString",
          db_system="redis"}[1m]))
/
sum by (net_peer_name) (
  rate(
      db_operation_duration_milliseconds_count{db_
        operation="GetString",
      db_redis_status="ok",
      db_system="redis"}[1m]))

该查询获取 Redis 上GetString操作的速率,其中cache.hit属性设置为true,并将其除以整体GetString操作成功率。它还通过乘以比率来计算命中率,该比率约为 80%,正如我们在图 12.5中可以看到的:

图 12.5 – GetString 方法的 Redis 命中率

图 12.5 – GetString 方法的 Redis 命中率

因此,缓存被使用,并且它处理了 80%的读请求。让我们看看如果我们使用$ docker stop chapter12-redis-1命令停止它会发生什么。

提示

通过这个练习,你可能会对探索从 Redis 记录异常的效果感兴趣。一旦 Redis 容器停止,每次调用 Redis 都会导致记录一个异常。在我们的小型应用程序中,仅此一项就将遥测量增加了十倍。你可以使用以下 Prometheus 查询自己检查:

sum by (container_image_name)
  (rate(container_network_io_usage_rx_bytes_total[1m]))

在 Redis 容器停止后立即(大约 14:48),应用程序吞吐量开始下降到每秒不到一条记录,如图图 12.6所示:

图 12.6 – Redis 容器停止前后应用吞吐量

图 12.6 – Redis 容器停止前后应用吞吐量

HTTP 延迟(第 50 百分位数)从几毫秒增加到几秒,正如你在图 12.7中可以看到的:

图 12.7 – Redis 容器停止前后应用延迟 50 百分位数

图 12.7 – Redis 容器停止前后应用延迟 50 百分位数

HTTP 延迟的峰值与图 12.8中显示的 MongoDB 延迟增加一致:

图 12.8 – 毫秒级的 MongoDB 延迟(p50)

图 12.8 – 毫秒级的 MongoDB 延迟(p50)

最后,我们应该检查 MongoDB 吞吐量发生了什么:由于 Redis 不再处理 80%的读请求,数据库的负载增加,并且最初它试图赶上,正如你在图 12.9中可以看到的:

图 12.9 – 容器停止前后 MongoDB 吞吐量

图 12.9 – 容器停止前后 MongoDB 吞吐量

MongoDB 容器上的资源受到显著限制,无法处理这种负载。

如果我们检查跟踪,我们会看到 MongoDB 调用明显变长,并且是缓慢的应用程序响应和低吞吐量的根本原因。一个这样的跟踪示例显示在图 12.10中:

图 12.10 – 当 Redis 停止时显示长时间 MongoDB 请求的跟踪图

图 12.10 – 当 Redis 停止时显示长时间 MongoDB 请求的跟踪图

如果你现在使用$ docker start chapter12-redis-1命令启动 Redis,吞吐量和延迟将在几分钟内恢复到原始值。

我们在知道根本原因的情况下进行了此分析,但它也作为一个通用方法 – 当服务级别指标如延迟和吞吐量发生剧烈变化时,我们应该检查服务依赖项的状态和健康。这里的发现是我们需要更好地保护数据库,例如,通过添加一些更多(可能更小)的 Redis 实例,以便在其中一个实例出现故障时处理负载。我们还可以考虑在服务端对数据库调用进行速率限制,以便即使在较低的吞吐量下也能保持响应。

摘要

在本章中,我们探讨了数据库监控。我们首先研究了 OpenTelemetry 数据库语义约定,并为 MongoDB 实现了跟踪。然后,我们添加了类似的监控对 Redis 和包含调用。我们看到了如何在包含跨度上提供特定于应用程序的上下文,并记录数据是从缓存还是数据库检索的,以改善跨跟踪的性能分析。

然后,我们添加了指标,包括 MongoDB 和 Redis 的客户端持续时间直方图以及 Redis 的服务端指标,这些指标有助于分析和优化缓存使用,从命中率开始,我们能够对其进行测量。

最后,我们模拟了 Redis 故障,并看到了如何通过收集遥测数据使检测和分析故障原因以及故障进展变得容易。我们还发现了我们应用程序中的几个问题,使其变得不可靠。

现在你可以开始在你的应用程序中监控数据库调用,或者通过添加额外的跟踪和指标来丰富自动收集的遥测数据。

这标志着我们通过监控配方之旅的结束。在下一章中,我们将讨论采用和演进跟踪和可观察性的组织方面。

问题

  1. 你会如何监控数据库更改流(数据库暴露的事件流,用于通知数据库记录的变化)?例如,一个应用程序可以订阅云提供商发送的通知,当云存储中的 blob 被创建、更新或删除时(我们可以将其视为数据库)。

  2. 记录 Redis 调用为事件/日志而不是跨度是否有意义?

  3. 尝试移除 MongoDB 容器的资源限制,并检查如果我们现在杀死 Redis 会发生什么。

第四部分:在您的组织中实现分布式跟踪

本部分概述了可观察性采用的社技术方面 – 进行初步推动并进一步改进,在公司内部开发遥测标准,以及在存在遗留服务的情况下监控系统的新的部分。

本部分包含以下章节:

  • 第十三章, 推动变革

  • 第十四章, 创建您自己的约定

  • 第十五章, 对现有应用进行配置

第十三章:推动变革

在整本书中,我们讨论了可观测性的技术方面,并讨论了如何追踪调用、记录指标、报告事件或使用平台和库提供的自动收集遥测数据。在这里,我们将讨论实施可观测性解决方案的组织方面。

首先,我们将探讨改变现有解决方案的好处和原因,并讨论相关的成本。然后,我们将通过实施阶段并制定一个简要概述。最后,我们将看看如何利用可观测性来推动和改进开发过程。

在本章中,你将学习以下内容:

  • 决定你是否需要一个更好的可观测性解决方案以及适合你的级别

  • 制定一个入职计划并开始实施

  • 使用可观测性帮助处理日常开发任务

到本章结束时,你应该准备好为你的组织提出一个可观测性解决方案和入职计划。

理解可观测性的重要性

如果你正在阅读这本书,你很可能至少在考虑改善你应用程序的可观测性故事。也许很难理解客户如何使用你的系统,或者当有人报告问题时,理解到底发生了什么可能需要花费很多时间。在最坏的情况下,可能需要花费很多时间才能注意到系统不健康并且用户受到影响。或者,也许你希望在未来项目中最小化此类风险。

在任何情况下,这些痛点让你来到这里,它们应该指导你进一步找到适合你系统的正确可观测性级别和方法。

即使我们可以清楚地看到问题以及如何通过更好的可观测性来解决它,我们通常仍然需要让其他正在系统上工作的人接受这个愿景。令人惊讶的是,他们可能对同样的问题有不同的看法,并且可能认为它们不值得解决。

让我分享一些我听到的常见观点,认为问题并不重要:

  • 当客户报告问题时,我们可以要求提供时间戳,并通过客户标识符找到当时的服务操作。然后我们可以找到任何可疑的日志,获取请求 ID,然后在其他服务上找到相关的日志。

  • 当我们在生产中看到问题时,我们会打开相关的仪表板,并开始直观地关联指标,直到我们可以猜测出什么出了问题,然后减轻它。我们有一支专家团队和一套优秀的运行手册来处理典型问题。

  • 我们可以进行用户研究或客户研究,以获取有关人们如何使用系统的广泛信息。

  • 我们要求客户启用详细日志并重现问题,然后将日志发送给我们,我们将根据我们对系统的专业知识来解析这些日志。

注意

这些方法都是完全有效的。它们已经解决了问题,你的团队已经知道如何使用它们,而且其中一些即使在完美的可观测性解决方案中也是必要的并且非常有用。

因此,本质上,当我们考虑观察力的方法时,我们需要打破现状,并说服自己和我们的组织,这是值得的。为了实现这一点,我们首先需要清楚地概述痛点并了解保持现状的成本。

观察力不足的成本

您的组织可能已经建立了一些常见的指标来衡量事件,我们可以依赖这些指标,例如MTTM(平均缓解时间),MTTR(平均恢复时间),MTBF(平均故障间隔时间),或其他指标。它们在一定程度上是主观的,取决于什么构成事件,或者恢复意味着什么,但大致显示了我们可以多快地调查事件以及它们发生的频率。

如果事件需要花费大量时间来解决并且频繁发生,那么很可能我们的组织对这些事件非常关心,并且有兴趣改善这种情况。

具有讽刺意味的是,我们需要至少达到一定程度的观察力才能注意到事件的发生并测量解决事件所需的时间。如果我们连这一点都没有,我们可以开始手动跟踪事情何时出错以及我们花费多少时间来发现和解决问题。尽管它可能很主观,但总比没有好。

有些事情并没有直接出现在这些指标中:您的值班体验有多糟糕?在某人能够独立值班之前,入职需要花费多少时间?有多少问题最终以“无法重现”、“信息不足”、“可能是网络或硬件错误”等方式关闭;在团队之间打乒乓球;或者被移至待办事项列表中,永远无法解决?

测量一些这样的东西应该是可行的。例如,我们可以标记由于缺乏遥测而无法进一步调查的问题。如果这些问题代表了您错误报告的很大一部分,那么这值得改进。

作为团队,你们还可以进行一周或两周的实验,大致测量调查问题所花费的时间。当有足够的数据时,调查需要花费多少时间?或者,由于缺乏遥测或发现微不足道的短暂网络问题,调查问题并遇到死胡同浪费了多少时间?

通过最小化找到问题根本原因所需的时间,我们提高了用户体验。我们更早地注意到事件并更快地解决它们。我们还改善了我们的工作与生活平衡,并专注于创造性工作,而不是在日志中搜索兆字节的日志。

注意

可能还有其他数据,例如业务分析、支持统计、公开评论或其他任何东西,表明由于未解决的技术问题,有显著数量的用户正在离开我们。如果您需要说服您的组织投资于观察力,找到这样的数据并展示更好的观察力故事如何改善情况,这可能是一个好的方法。

因此,第一步是了解当前的工具和流程是否有效,并对更好的可观察性如何改善事物有一个大致的了解。下一步是了解解决方案的成本。

可观察性解决方案的成本

我们可以将成本大致分为两组:实施和遥测后端成本。

我们需要添加仪表,调整和定制遥测收集,学习如何使用新工具,创建警报和仪表板,并围绕它们建立新的流程。当引入一个成熟且稳定的系统时,我们还应该考虑风险——我们可能会破坏某些东西,使其暂时变得不太可靠。

如我们在第九章“最佳实践”中讨论的那样,我们总是可以选择详细程度和定制化程度,以帮助我们控制成本在预算范围内。

最小化方法是从网络级别的自动仪表化开始,为积极开发的服务添加上下文、定制和手动仪表化。

通过使用 OpenTelemetry 和共享仪表化库,我们还可以依赖供应商为我们提供常见的可视化、警报、仪表板、查询和分析,对于典型技术来说,几乎可以免费开始。

遥测后端

我们可以自己托管可观察性堆栈或使用可用的平台之一。无论如何,使用该解决方案都会产生相关的持续成本。

这些成本取决于遥测量、保留期、服务和服务实例的数量以及许多其他因素,包括支持计划。

在整本书中,我们讨论了如何在保持系统足够可观察以满足我们需求的同时优化遥测收集:跟踪可以采样,指标应该具有低基数,事件和日志也可以采样或保存在冷存储但已索引的存储中。

尝试几个不同的后端是一个好主意——幸运的是,许多平台都有免费层或试用期,最重要的是,你可以使用 OpenTelemetry 一次性对系统进行仪表化,并将数据泵入多个后端以比较体验,并了解使用它们的成本。一旦你开始依赖特定的后端进行警报或日常任务,切换供应商就会变得更加困难。

在这个实验中,你还将更好地理解必要的数据保留期、采样率以及其他参数,并将能够与供应商一起选择它们。

注意

当在规模下运行现代云应用时,没有可观察性解决方案是无法操作的,因此问题不在于你是否需要它,而在于你需要收集多少细节,以及哪个可观察性供应商最适合你的系统和预算。

实际上,我们可以从小处着手,逐步调整收集以添加或删除细节,同时保持它在合理的预算内。

我们还应该定义成功意味着什么——它可能是一个 MTTR 改善,主观的用户体验,值班工程师的满意度,对你组织来说任何其他重要的事情,或者这些的组合。

现在我们来更多地讨论实施细节,并尝试让这次旅程不那么痛苦。

入门流程

系统各部分分布式跟踪和可见性的需求源于现代应用的复杂性。例如,我们需要知道无服务器环境如何与云存储交互,以便调试配置问题或优化性能。或者,我们可能想知道为什么某些请求在下游服务中失败,而不需要别人帮忙。

为了充分利用分布式跟踪,我们必须将整个系统(或至少其重要部分)纳入,确保所有服务创建相关且一致的遥测数据,将其写入不同团队可以访问的地方,并重用相同的工具进行分析。

因此,实施可观察性解决方案是一个组织范围内的努力,从对系统一小部分进行仪表化的试点项目开始是有意义的。让我们概述其范围和目标。

试点阶段

本项目的目标是获得对可观察性的实际经验,尽早发现任何重大的技术或流程问题,并更好地理解整个系统的范围和所需的工作量。

我们需要几个(至少两个)服务来开始仪表化:

  • 正在积极开发中

  • 互相交互

  • 没有或只有很少的内部依赖,除了相互之间

  • 有团队在紧密合作地工作

从技术角度来看,我们将利用这个阶段做出以下决定:

  • 仪表化 SDK:我希望我已经说服你使用 .NET 平台功能和 OpenTelemetry,但你可能需要决定如何处理现有的仪表化代码和工具。

  • 上下文传播标准:使用 W3C Trace Context 是一个好的开始。我们可能还需要决定是否以及如何传播负载,或者如何通过非 HTTP/专有协议传递上下文。

  • 采样策略:基于速率、基于百分比、基于父级、基于尾部——这些都是早期决定的好事情,并确定你是否需要 OpenTelemetry 收集器或可以依赖可观察性供应商。

  • 选择哪个供应商以及从当前供应商迁移的计划:在 第十五章仪表化 现有应用程序 中,我们将讨论仪表化现有系统时的技术方面和权衡。

到试点阶段结束时,我们应该清楚地了解入门需要什么,挑战是什么,以及我们将如何解决它们。

我们还将对系统的一部分进行仪表化——这是一个检查是否看到任何改进的好时机。

跟踪进度

在理想的世界里,在配置部署之后,我们能够立即解决所有事件,并调查我们几个月来一直在追踪的复杂错误。我希望情况是这样的。

在这个过程中至少有几个挑战:

  • 改变是困难的:人们更喜欢使用他们熟悉的工具,尤其是在处理生产中的事件时。在事件解决后,用新的可观察性解决方案进行相同的调查,并比较经验,这将是一个很好的练习。

最好在开发时间或调查低优先级故障时开始尝试新工具。无论如何,了解和信任新工具需要时间和实践。

  • 你会发现新的问题:第一次查看跟踪或服务图时,我总是能从我的代码中学到一些新东西。发现意外的外部系统调用(例如,在底层进行的身份验证调用)、不必要的网络调用、错误的重试逻辑、本应并行运行的调用却顺序执行,等等,这些都是常见的情况。

  • 基本的自动配置不足以满足需求:没有应用程序上下文或对某些库和场景的手动配置,我们找到、理解和汇总相关遥测数据的能力受到限制。

需要几次迭代才能看到改进——请确保收集反馈并了解哪些有效,哪些无效。

这也需要时间和奉献。演示、成功案例、共享案例研究和如何开始的文档应该提高人们的意识,并帮助他们更快地好奇和学习。

迭代

因此,在初始配置之后,我们还没有完全准备好将其推广到整个系统中。首先需要做几件事情:

  • 调整配置库:移除冗长和嘈杂的信号或启用默认关闭的有用属性。如果您的堆栈中某些部分没有可用的自动配置,请开始编写自己的配置。

  • 添加必要的应用程序上下文:找到共同属性并在整个组织中标准化属性名称或行李键将对未来产生巨大影响。我们将在第十四章中更多地讨论它,创建您的 自己的约定

  • 开始构建特定于后端的工具:警报、仪表板和工作簿将帮助我们验证我们是否有足够的上下文和遥测数据来运行我们的系统并迁移到新解决方案。

注意

到这个阶段结束时,你应该能够看到积极的成果。可能还不足以对整个系统产生显著影响,并且可能没有足够的数据来支持实验中的服务,但至少你应该看到一些成功的案例,并能够展示新解决方案的亮点。

如果你看到新可观察性故事应该有所帮助但并未帮助的情况,那么调查原因并进一步调整仪表化是一个好主意。在迭代过程中,还值得注意后端成本,并在看到潜在的重大减少而没有明显影响的情况下优化遥测收集。

这里的目标是创建一个足够好的仪表化方法及其周围必要的工具。我们快速迭代并保持参与服务的数量较小,这样我们仍然可以改变方向并做出破坏性更改。

一旦我们完成了所有决策,并在系统的小部分上实施和验证了它们,我们就应该能够依赖新的可观察性解决方案来满足大部分的监控和调试需求。在我们推出它们之前,我们仍然需要记录它们并创建可重用的工件。

记录和标准化。

试点阶段的主要成果是明确如何使系统的其余部分可观察,以及它将带来的具体好处。

为了最大化这一阶段的影响,我们需要让其他服务更容易上线。我们可以通过以下方式帮助他们:

  • 记录新的解决方案和流程。

  • 提供演示和入门指南,展示如何使用后端并对其进行配置,以及添加警报和仪表板。

  • 生成包含任何自定义的通用工件:

    • 上下文传播器、采样器或仪表化。

    • 属性名称或高效填充它们的辅助工具。

    • 带来所有 OpenTelemetry 依赖项的入门包,并统一启用遥测收集。

    • 通用配置选项。

最后,我们准备好对系统的其余部分进行仪表化和上线。可能需要一些时间来对齐属性名称、配置或后端计划。我们还需要继续跟踪向原始目标的进展,并在事情不顺利时应用必要的更改。让我们谈谈可能让我们放慢脚步的一些事情。

避免陷阱。

分布式跟踪和可观察性的挑战在于,它们在分布式应用程序产生一致的信号时最为有效:跟踪上下文被传播,采样算法对齐以生成完整跟踪,所有信号使用相同的属性名称,等等。

虽然 OpenTelemetry 解决了这些担忧中的大部分,但它仍然依赖于应用程序将所有部件组合在一起并使用一致的信号。对于大型组织来说,一个服务偏离标准就会破坏整个系统的相关性,这成为一个问题。

在系统上线时,以下是一些需要避免的事项:

  • 起点过大:如果多个团队独立进行仪表化工作,他们不可避免地会开发出针对其服务优化的不同解决方案。在上线后对解决方案进行对齐本身就是一个艰巨的项目。每个团队都会有这样的印象,即事情对他们来说都奏效,但端到端客户问题仍需数月才能解决。

  • 不在整个系统中共享遥测数据:在调查问题或分析性能和用法时,能够看到其他服务如何处理请求是有益的。如果不这样做,我们最终会陷入每个跨服务问题都涉及一些 ping-pong,并且问题不能快速得到解决的地方。

  • 不强制执行标准:不一致的遥测数据会使我们回到 grep 日志,并使客户和我们自己都不高兴。

  • 不使用新的工具和能力:我们讨论了从熟悉的工具迁移是困难的。我们需要投入足够的努力来倡导、推广、解释、记录和改进事物,以确保人们使用它们。一旦新工具更强大且更快,淘汰旧工具(有时不受欢迎)是确保每个人都切换的一种方式。

  • 不在开发或测试时使用可观测性堆栈:调查不可靠的测试是调试棘手问题的最便宜方式之一。跟踪信息可以帮助很多,所以请确保测试默认发送跟踪信息和日志,并且启用跟踪在开发机器上非常简单。

  • 构建难以使用或不可靠的事物:虽然可以预期会有一些摩擦,但我们应确保大多数摩擦发生在试点阶段。如果你决定基于开源解决方案构建自己的可观测性堆栈,你应该预计某些事情,如跨工具导航,将会很困难,并且你需要投入相当多的努力来使它们可用。另一个大的投资是构建和维护可靠的遥测管道。

希望我们能避免这些问题中的大多数,并将系统的大部分功能上线到我们的新可观测性堆栈。

随着我们继续上线,我们应该看到向我们的初始目标迈进,可能需要调整它们。在这个过程中,我们可能对我们的系统有了很多了解,现在正在处理由于可观测性不足而之前无法看到的新的挑战。

旅程不会在这里结束。就像我们永远不会停止编写测试一样,我们应该在日常任务中融入并利用可观测性。

持续可观测性

可观测性不应该在服务或功能开发完成后才作为事后考虑。当在多个服务中实现复杂功能或添加新的外部依赖项时,我们不能依赖用户告诉我们何时出现故障。测试通常不会涵盖每个方面,也不能代表用户行为。

如果我们没有可靠的遥测信号,我们就无法说该功能是否工作,或者客户是否使用它。

将可观测性融入设计过程

确保我们有遥测数据是功能设计工作的一部分。遥测数据应该回答的主要问题是以下这些:

  • 谁使用这个功能以及使用量有多大?

  • 它是否工作?它是否破坏了其他东西?

  • 它是否按预期工作?它是否按预期改进了事物?

如果我们可以依赖现有的遥测数据来回答这些问题,那就太棒了!

我们应该以一次覆盖多个内容的方式设计仪表化。例如,当我们从外部 HTTP 依赖项切换到新的依赖项时,我们可以利用现有的自动收集的跟踪和指标。一个常见的处理器会在所有跨度上打上应用程序上下文,从而处理新的依赖项的跟踪。

如果我们使用功能标志,我们应该确保记录它们在参与实验的操作的遥测中。例如,我们可以在事件或跨度上记录它们,遵循github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/feature-flags.md中提供的 OpenTelemetry 语义约定。

在某些情况下,默认遥测可能不足,我们需要添加自定义事件、跟踪、指标,或者至少额外的属性。除非我们以结构化和可聚合的方式编写,否则限制仪表化到新的日志记录通常不是一个好主意。

一旦一个功能被证明是有用的并且完全推广,我们可能希望移除这个额外的遥测以及功能标志。如果我们确信它不再必要,这是一个很好的方法。清理和迭代仪表化是另一个重要的方面。

清理工作

就像任何其他代码一样,当忽视时,仪表化代码会退化并变得不那么有用。

与测试代码类似,我们编写的任何可观察性代码都比应用程序代码不可靠——当它报告错误时也难以注意到,因为没有功能性问题。因此,通过测试或手动检查来验证它并及时修复它是很重要的。

这是使用流行的仪表化库的一个原因——它们已经经过了其他人的过度测试。保持您的仪表化库更新,并在公司内部(或开源)共享自定义库,这将导致更好的仪表化质量。

另一个重要部分是在注意到问题时进行小的改进:添加缺失的事件、跨度属性(不要忘记检查是否有共同的属性),结构化和优化日志,并调整它们的详细程度。

这些更改可能会有风险。我们可能会移除人们用于警报或分析所依赖的内容。可能会有一些额外的防护措施来防止这种情况发生——代码审查、文档和测试,但很少可能考虑到所有情况,因此在删除或重命名内容时要谨慎。

另一个风险是添加一些昂贵或冗长的内容,这可能会影响应用程序的可用性,压倒遥测管道,或者显著增加您的可观察性账单。关注开发和测试遥测以及了解热点路径应该可以防止明显的错误。

建立具有速率限制的可靠遥测管道可以在它们进入生产时降低此类事件的严重性。

如您所见,可观测性代码与其他任何基础设施代码并没有很大区别。实施它始于一些研究和实验,并且在我们与应用程序一起调整和改进时效果最佳。

摘要

在本章中,我们讨论了如何在您的组织中实施和推广可观测性解决方案。这些努力可以通过当前监控基础设施在调查和解决客户问题时效率不高来激发和证明其合理性。

我们讨论了如何依靠现有的指标或数据来了解是否还有改进的空间,并估计不采取行动的成本。然后我们研究了实施和运行现代可观测性解决方案的常见成本——最简单的方法是运行一个小实验并比较不同的供应商。

我们探讨了如何通过在一个系统的小部分上启动试点项目,并在推广到整个系统之前迭代和验证结果,来着手进行入职。最后,我们讨论了将可观测性融入日常任务并随着代码一起演化的重要性。

本章应有助于您证明初始可观测性投资的合理性,并逐步在整个系统中实施解决方案。在下一章中,我们将更多地讨论统一遥测收集和引入您自己的标准。

问题

  1. 我们应该寻找一个用于所有遥测信号的单一后端,还是寻找针对单个遥测信号优化的它们的组合?

  2. 您将如何处理在您的系统中标准化行李传播和使用的标准化?

  3. 您正在向服务中添加缓存。您何时会添加仪表?您将如何处理?

进一步阅读

  • 《成为摇滚明星 SRE》,作者:Jeremy Proffitt 和 Rod Anami

第十四章:创建您自己的约定

相关性是可观察性最重要的部分之一。分布式跟踪通过传播跟踪上下文带来相关性,使我们能够跟踪单个操作,而一致的属性使跟踪和其他遥测信号之间的相关性成为可能。

第九章最佳实践 中,我们讨论了重用标准属性和遵循 OpenTelemetry 语义约定的重要性。有时我们需要更进一步,定义我们自己的约定。在这里,我们将探讨如何定义自定义属性和约定,并在整个系统中使用它们。

首先,我们将列出应在整个系统中标准化的属性,然后我们将探讨如何使用共享代码来填充它们。最后,我们将查看 OpenTelemetry 的语义约定模式,并了解它如何简化自定义约定的文档和验证。

在本章中,你将学习以下内容:

  • 识别和记录常见的属性和约定

  • 在整个系统中共享仪器和自定义约定

  • 使用 OpenTelemetry 工具创建约定

通过这种方式,你应该能够创建易于使用的流程和工具,以保持自定义遥测和属性的一致性和稳定性。

技术要求

本章的代码可在 GitHub 上的书籍存储库中找到:github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/tree/main/chapter14

要运行示例并执行分析,我们需要以下工具:

  • .NET SDK 7.0 或更高版本

  • Docker 和 docker-compose

定义自定义约定

表达最基本的事情也有多种方式。如果我们以 第五章 的 meme 应用程序示例,配置和控制平面 为例,我们为所有跨度添加了 meme 名称属性,以便我们可以在 meme 上传时找到它或了解它被访问的频率。

我们选择了那种方法,但我们可以先记录一次 meme 名称的日志,然后使用稍微复杂一些的查询来找到与该 meme 相关的所有跟踪。我们可以想出其他方法,但重要的是在整个系统中保持方法的一致性。

即使为每个跨度添加了自定义属性,在记录这样的属性时仍有许多事情需要考虑:

  • meme_namememe.namememeName 是不同的属性。除非我们记录确切的名称并将其定义为某个地方的一个常量,否则最终有人会使用错误的变体。

  • 类型:主题名称只是一个字符串。如果我们想捕获大小或图像格式呢?我们需要记录类型,并可能提供辅助方法来记录属性,以便更容易地正确设置它们。

  • image/png 或作为一个枚举?

当记录 meme 名称时,我们从上传的文件名中提取它,并需要记录在属性值上应该记录什么(绝对路径或相对路径、文件名、带或不带扩展名)。如果在编写业务逻辑时,我们对 meme 名称进行清理或转义,或生成一个唯一的名称,我们可能想要捕获业务逻辑使用的那个。我们也可能只记录一次原始名称,用于调试目的。

  • 何时填充属性:记录在哪些指标、跨度和对数上应该记录属性。例如,meme 名称具有高基数,不属于指标。meme 名称属性可以记录在跨度和对数上,但哪些可以?在我们的例子中,在第五章配置和控制平面中,我们在所有跨度上记录了 meme 名称,并在几个特定的日志记录上记录了它。

你可能还想记录其他方面:跨度之间的关系、事件名称、是否在跨度上记录异常、属性基数、稳定性等等。

我们将在本章的使用 OpenTelemetry 模式和工具部分中看到如何正式定义属性。现在,让我们专注于命名。

命名属性

命名被认为是计算机科学中最难的问题之一。

如果我们将 meme 名称属性命名为document.id,它可能与数据库模式中的一个属性精确匹配。然而,它将非常通用,并且与系统中另一个类似概念发生冲突的概率很高。不熟悉内部结构但分析业务数据的人可能很难理解document.id代表什么。

meme_name看起来直观、简短且描述性强,与其他事物的冲突概率很低。这似乎是可行的,但我们可能还有其他属性,应该将它们放入特定于应用程序或公司的命名空间中。

命名空间

命名空间允许我们设置唯一、具体、描述性和一致的名称。

由于我们称我们的系统为memes,让我们将其用作根命名空间。这将帮助我们理解这个空间中的所有属性都来自我们的自定义,而不是由某些自动工具设置。

这有助于我们在遥测之间导航,并使得在遥测管道中进行过滤、编辑和其他后处理成为可能。例如,如果我们想从日志中删除未知属性,我们可以考虑memes命名空间(http、db等)中的所有内容都是已知的。

我们可以有嵌套的命名空间。由于我们希望记录其他 meme 属性,例如大小和类型,我们最终可能会得到以下一组属性:memes.meme.namememes.meme.sizememes.meme.type。我们可以根据需要轻松添加其他属性,例如memes.meme.authormemes.meme.description

虽然memes.meme可能看起来重复,但一旦我们添加了诸如memes.user.namememes.tag.description之类的元素,它就会开始更有意义。

OpenTelemetry 命名约定(可在github.com/open-telemetry/opentelemetry-specification/blob/main/specification/common/attribute-naming.md找到)使用点(.)作为命名空间之间的分隔符。

对于多词命名空间或属性,OpenTelemetry 建议使用snake_case:例如,我们可以引入memes.meme.creation_date

遵循此约定为我们自定义属性,使我们能够在所有属性中保持一致性。它还将减少在编写查询时的错误机会。

在某种形式下定义和记录模式是一个基本步骤。但如何使其与代码保持同步并确保所有服务都遵循它?做到这一点的一种方法是将它捕获在代码中并在整个系统中重用它。

共享公共模式和代码

一致的遥测报告适用于遥测收集配置。首先,我们需要在所有服务上启用基本层级的仪器,这应该包括资源利用率指标、跟踪和 HTTP、gRPC 或系统使用的任何其他 RPC 协议的指标。

我们还应该配置采样和资源属性,添加丰富处理程序,并设置上下文传播器。

单个服务应该能够在一定程度上自定义配置:添加更多仪器、启用自定义活动源和仪表、或控制日志详细程度。

统一配置的最简单方法是将相应的代码作为公共库(或一组库)分发,这些库在系统中的所有服务之间共享。这样的库将定义配置选项,提供辅助方法以启用遥测收集,实现常见的丰富处理程序,声明跨服务事件等。让我们继续实现这样的配置助手。

共享设置代码

第五章“配置和控制平面”以及其他章节中,我们使用了 meme 应用程序,我们在每个服务中单独应用了 OpenTelemetry 配置。

我们永远不会在生产代码中这样做——很难保持我们的配置、仪器选项、OpenTelemetry 包版本和其他一切同步。

为了解决这个问题,我们可以开始将常见的仪器代码片段提取到一个共享库中。由于配置可能从服务到服务略有不同,我们需要定义一些配置选项。

我们需要一些选项来帮助我们设置服务名称,指定采样率或策略,启用额外的仪器,等等。你可以在书籍仓库中的MemesTelemetryConfiguration类中找到一个这样的选项示例。

然后,我们可以声明一个处理 OpenTelemetry 配置的辅助方法。以下是这个方法的示例:

OpenTelemetryExtensions.cs

public static void ConfigureTelemetry(
  this WebApplicationBuilder builder,
  MemesTelemetryConfiguration config)
{
  var resourceBuilder = GetResourceBuilder(config);
  var sampler = GetSampler(config.SamplingStrategy,
    config.SamplingProbability);
  builder.Services.AddOpenTelemetry()
    .WithTracing(builder => builder
      .SetSampler(sampler)
      .AddProcessor<MemeNameEnrichingProcessor>()
      .SetResourceBuilder(resourceBuilder)
      .AddHttpClientInstrumentation(o =>
        o.ConfigureHttpClientCollection(
          config.RecordHttpExceptions))
      .AddAspNetCoreInstrumentation(o =>
         o.ConfigureAspNetCoreCollection(
           config.RecordHttpExceptions,
           config.AspNetCoreRequestFilter))
      .AddCustomInstrumentations(config.ConfigureTracing)
      .AddOtlpExporter())
  ...
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter14/Memes.OpenTelemetry.Common/OpenTelemetryExtensions.cs

使用此方法的所有服务都将应用和丰富相同的基本级别的仪表。

这里是一个在存储服务中使用此方法的示例:

storage/Program.cs

var config = new MemesTelemetryConfiguration();
builder.Configuration.GetSection("Telemetry").Bind(config);
config.ConfigureTracing = o => o
  .AddEntityFrameworkCoreInstrumentation();
builder.ConfigureTelemetry(config);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter14/storage/Program.cs

在这里,我们从 ASP.NET Core 配置的 Telemetry 部分读取遥测选项,您可以根据自己的需要以任何方式填充它。

然后,我们添加了 Entity Framework 仪表。只有存储服务需要它。

注意

拥有一个中央库,它能够实现收集功能,有助于减少版本混乱。通过依赖它,各个服务包会获得对 OpenTelemetry 包的传递依赖,并且不应将其作为直接依赖添加。需要不常见仪表库的服务仍然需要安装相应的 NuGet 包。

现在我们有了共同的设置,让我们看看我们可以做些什么来帮助服务遵循我们的自定义语义约定。

约定法典化

在上一个示例中,我们开始记录 meme 名称属性——我们启用了 MemeNameEnrichingProcessor,它将 memes.meme.name 属性设置在每个跨度上。各个服务不需要做任何事情来启用它,也不能设置错误的属性名称。

尽管如此,我们可能还需要在其他某些部分(例如日志)中直接使用属性,因此声明属性名称为常量并永远不在代码中使用字符串字面量是很重要的。以下是一个演示如何声明属性名称的示例:

SemanticConventions.cs

public class SemanticConventions
{
    public const string MemeNameKey = "memes.meme.name";
    public const string MemeSizeKey = "memes.meme.size";
    public const string MemeTypeKey = "memes.meme.type";
    …
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter14/Memes.OpenTelemetry.Common/SemanticConventions.cs

OpenTelemetry 还提供了 OpenTelemetry.SemanticConventions NuGet 包,它声明了规范中定义的常见属性。在重用常见属性时添加对其的依赖可能是有意义的。

因此,我们现在已经为属性名称定义了常量,并且有一个填充 meme 名称的处理器。我们能做更多吗?

我们可以提供助手以高效且一致地报告常见事件。让我们看看我们如何使用我们在第八章中探索的高性能日志记录,编写结构和关联日志,来填充我们的属性:

EventService.cs

private static readonly Action<ILogger, string, string?,
  long?, string, string, Exception> LogDownload =
    LoggerMessage.Define<string, string?,
      long?, string, string>(
    LogLevel.Information,
    new EventId(1),
    $"download {{{SemanticConventions.MemeNameKey}}}
    {{{SemanticConventions.MemeTypeKey}}}
    {{{SemanticConventions.MemeSizeKey}}}
    {{{SemanticConventions.EventNameKey}}}
    {{{SemanticConventions.EventDomainKey}}}");
  …
  public void DownloadMemeEvent(string memeName,
    MemeContentType type,
    long? memeSize) =>
  LogDownload(_logger,
    memeName,
    ContentTypeToString(type),
    memeSize,
    SemanticConventions.DownloadMemeEventName,
    SemanticConventions.MemesEventDomain,
    default!);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter14/Memes.OpenTelemetry.Common/EventService.cs

在这里,我们有一些难以阅读的代码。它通过遵循 OpenTelemetry 约定和我们的约定,定义了一个表示表情包下载事件的日志记录。我们并不希望每次需要记录某些内容时都编写此代码。

在公共库中实现此事件一次,并使其易于重用,是记录事件一致性和低性能开销的最佳方式。

现在,任何人都可以使用DownloadMemeEvent方法,如下面的示例所示:

StorageService.cs

_events.DownloadMemeEvent(name, MemeContentType.PNG,
    response.Content.Headers.ContentLength);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter14/frontend/StorageService.cs

这很容易使用且性能良好,无需担心属性、它们的类型或任何约定。如果属性被重命名,无需更新服务代码——所有这些都隐藏在共享库中。

如果我们遵循这种方法,我们可以定义其他事件并为指标和跟踪添加辅助方法来填充属性组。

如果我们需要任何自定义的仪器,就像我们为 gRPC 或消息传递所做的那样,我们应该将它们放入共享库中,并在那里应用所有属性,而不是在服务代码中。

将与遥测相关的代码从业务逻辑中分离出来,使它们都更容易阅读和维护。这也使得与遥测相关的代码可测试,并帮助我们保持它与文档的一致性。这也使得通过测试强制执行约定变得容易,并在代码审查期间,当共享代码更改由语义约定控制的某些内容时,可以注意到这些更改。

在代码中定义语义约定对于某些应用来说是足够的。其他可能使用不同语言或有一些其他约束的应用,不能仅仅依赖共享代码。无论如何,公司中的每个人都可以使用遥测数据来进行业务报告和满足任何非技术需求。因此,将它们与代码分开单独记录是很重要的。

让我们看看我们如何使用 OpenTelemetry 工具来实现这一点。

使用 OpenTelemetry 模式和工具

我们实际上并不关心如何记录自定义语义约定。目标是有一个一致且具体的约定,易于阅读和遵循。让我们看看 OpenTelemetry 语义约定模式如何帮助解决这个问题。

语义约定模式

到目前为止,当我们谈论语义约定时,我们提到了如 github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md 这样的 Markdown 文件。这些文件是真相的来源,但在这里,我们将看看其背后的实现细节。

这些文件中描述属性的表格通常是自动生成的。属性定义在遵循 OpenTelemetry 语义约定架构的 YAML 文件中。

YAML 文件可以在不同的语义约定和信号之间共享,然后使用脚本一致地写入所有 Markdown 文件。

让我们看看如何在 YAML 文件中定义我们的 meme 属性,以了解其架构:

memes-common.yaml

groups:
  - id: memes.meme
    type: attribute_group
    brief: "Describes memes attributes."
    prefix: memes.meme
    attributes:
      - id: name
        type: string
        requirement_level: required
        brief: 'Unique and sanitized meme name'
        examples: ["this is fine"]

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter14/semconv/memes-common.yaml

在这里,我们在 memes.meme 命名空间(由 prefix 属性定义)内定义了 name 属性,其类型为 string。这是一个必需属性,因为在 第五章配置和控制平面 中,我们决定在所有跨度上记录 meme 名称属性。

OpenTelemetry 支持多个需求级别:

  • required:遵循此约定的任何遥测项都必须设置该属性。

  • conditionally_required:当满足条件时,必须填充该属性。例如,http.route 仅在启用路由且存在路由时填充。

  • recommended:应填充该属性,但可能被删除或禁用。可观察性后端和工具不应依赖于其可用性。这是默认级别。

  • opt_in:默认情况下不填充该属性,但已知并已记录,并且可以在明确启用时添加。

让我们看看如何定义 size 属性:

memes-common.yaml

- id: size
  type: int
  requirement_level: opt_in
  brief: 'Meme size in bytes.'
  examples: [49335, 12345]

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter14/semconv/memes-common.yaml

size 属性具有 int 类型(并映射到 int64long),并且具有 opt-in 级别,因为我们不希望在默认情况下记录所有遥测数据。

最后,我们可以定义 type 属性:

memes-common.yaml

- id: type
  type:
  members:
    - id: png
      value: "png"
      brief: 'PNG image type.'
    - id: jpg
      value: "jpg"
      brief: 'JPG image type.'
    - id: unknown
      value: "unknown"
      brief: 'unknown type.'
  requirement_level: opt_in
  brief: 'Meme image type.'
  examples: ['png', 'jpg']

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter14/semconv/memes-common.yaml

在这里,我们将 type 定义为一个枚举。仪器必须使用这里定义的值之一或将 type 设置为 unknown

希望你的团队不会在 JPEG 和 JPG 之间花费太多时间决定——两者都可以。重要的是要选择并记录一个选项。

你可以在 GitHub 上 OpenTelemetry 构建工具仓库中找到完整的模式定义(https://github.com/open-telemetry/build-tools/blob/main/semantic-conventions/syntax.md)。它还包含你的 IDE 可能用于自动完成和验证模式文件的模式定义。

现在我们已经定义了一些属性,让我们使用另一个 OpenTelemetry 工具来验证模式文件,并在 Markdown 文件中生成内容。

如果你查看书籍仓库中的原始 memes.md 文件,它包含以下注释的文档:

memes.md

<!-- semconv memes.meme -->
...
<!-- endsemconv -->

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter14/semconv/memes.md

这些行之间的内容是从具有 memes.meme 标识符的 YAML 组自动生成的。我们可以使用以下命令重新生成此内容(确保指定路径):

chapter14$ docker run \
  -v /path/to/chapter14/semconv:/source \
  -v /path/to/chapter14/semconv:/destination \
  otel/semconvgen:latest \
  -f /source markdown -md /destination

在这里,我们使用 otel/semconvgen 图像中的 Markdown 生成器。我们挂载了 sourcedestination 卷。生成器递归地解析 source 文件夹中找到的所有 YAML 文件,然后根据我们之前看到的 semconv 注释,在 destination 文件夹中生成 Markdown 文件中的属性表。

生成是蛋糕上的樱桃,你可能一开始不需要它。不过,如果你决定使用 OpenTelemetry 语义约定模式,请确保使用 otel/semconvgen 工具来 验证 YAML 文件,你可以在 CI 运行过程中这样做;只需将 –md-check 标志添加到之前的命令中。

工具还支持使用 Jinja 模板(https://jinja.palletsprojects.com)在代码中生成属性定义。我们只需要为 SemanticConventions.cs 文件创建一个 Jinja 模板,然后运行 otel/semconvgen 生成器。

我们还可以定义跟踪、指标或特定事件的约定。让我们为事件做这件事。

定义事件约定

段子上传和下载事件对于业务报告非常重要。我们实际上不能将它们作为指标暴露——段子名称具有高基数,我们感兴趣的是找到最受欢迎的或者测量每个段子的其他事物。

为了避免破坏业务报告,我们需要确保事件定义得足够精确,并且有良好的文档记录。为了实现这一点,我们可以以下述方式声明一个事件:

memes-events.yaml

- id: meme.download.event
  type: event
  prefix: download_meme
  brief: "Describes meme download event."
  Attributes:
    - ref: memes.meme.name
    - ref: memes.meme.size
      requirement_level: required
    - ref: memes.meme.type
      requirement_level: required

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter14/semconv/memes-events.yaml

在这里,我们声明了一个具有 event 类型的组(之前,我们使用 attribute_group,它是信号无关的)。我们提供了一个前缀(download_name),用于记录事件名称。我们添加了对先前定义的属性的引用,但现在仅在这些事件上需要 sizetype 属性的存在。

您可能已经注意到事件名称不包含命名空间——在这里,我们遵循 event 语义约定。如果您查看 EventService 类的相应代码片段,我们也会记录 event.domain 属性,该属性作为命名空间。

采用这种方法,我们可以定义跨度的或度量约定,这些约定重用了相同的公共属性。

这些模式或相应的 Markdown 文件将定义和记录遥测生产者和消费者之间的合同,无论他们使用什么语言。

摘要

在本章中,我们讨论了在不同系统内保持自定义遥测和属性一致性的不同方法。我们确定了需要记录的属性属性,并了解了属性命名约定。

保持遥测的一致性是一个挑战。我们探讨了如何通过共享公共仪表化代码来使其更容易,包括 OpenTelemetry 设置和实用方法,这些方法报告具有正确名称和类型的属性。

最后,我们了解了 OpenTelemetry 语义约定模式和工具,这可能有助于您定义、验证和自动化自定义约定的文档过程。

在项目的早期阶段为遥测定义一个公共模式将为您组织节省大量时间,现在您拥有了知识和工具来实现这一点。在下一章中,我们将讨论棕色地带系统,其中新解决方案与旧解决方案共存,我们将看到对齐不同标准和约定有多么困难。

问题

  1. 警报、仪表板和用法报告很可能依赖于自定义遥测并依赖于约定。你将如何处理演变约定以防止破坏关键部分?

  2. 是否可以验证来自某些服务的遥测是否符合定义的语义约定?

第十五章:仪器化棕色地带应用程序

当构建全新的服务和系统时,使用 OpenTelemetry 仪器库很容易实现基本级别的可观察性,包括分布式跟踪、指标和日志。

然而,我们通常不会从头开始创建应用程序——相反,我们演进现有的系统,这些系统包括处于不同生命周期的服务,从实验性的到过于风险而无法更改的遗留服务。

这样的系统通常已经实施了一些监控解决方案,包括自定义的相关格式、遥测模式、日志和指标管理系统、仪表板、警报,以及围绕这些工具的文档和流程。

在本章中,我们将探讨此类异构系统的仪器化选项,这些系统通常被称为棕色地带。首先,我们将讨论系统遗留部分的仪器化选项,然后深入探讨上下文传播和与遗留相关格式的互操作性。最后,我们将讨论现有的监控解决方案并研究迁移策略。

你将学习以下内容:

  • 为遗留服务选择合理的仪器化级别

  • 利用遗留的相关格式或透明地传播上下文,以实现端到端跟踪

  • 将遗留服务的前向遥测转发到新的可观察性后端

到本章结束时,你将能够在你自己的棕色地带应用程序中实现分布式跟踪,将系统遗留部分的更改保持在最低限度。

技术要求

本章的代码可在 GitHub 上本书的仓库中找到,地址为github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/tree/main/chapter15

要运行本章的示例,我们需要一台装有以下工具的 Windows 机器:

  • .NET SDK 7.0 或更高版本

  • .NET SDK 4.6.2

  • Docker 和docker-compose

仪器化遗留服务

在软件开发中,单词遗留具有负面含义,意味着过时且不吸引人去工作。在本节中,我们将关注不同的方面,并将遗留服务定义为主要成功完成其工作但不再发展的东西。这些服务可能仍然会收到安全更新或针对关键问题的修复,但它们不会获得新功能、重构或优化。

维护此类服务需要不同的技能集和较少的人员,因此特定系统的上下文很容易丢失,尤其是在开发该系统的团队转向其他工作之后。

因此,更改此类组件的风险非常高,即使是在更新运行时或依赖版本时也是如此。任何修改都可能唤醒沉睡的问题,略微改变性能,导致新的竞争条件或死锁。这里的主要问题是,由于资源有限且缺乏上下文,没有人可能知道一个服务是如何工作的,或者如何调查和修复此类问题。也可能不再有适当的测试基础设施来验证更改。

从可观察性的角度来看,此类组件通常已经实施了一定程度的监控,这可能是维护目的的足够。

实际上,当我们在系统可观察性方面工作时,我们只有在系统的新部分至关重要时才会触及旧服务。

让我们看看几个例子,以更好地理解何时更改旧服务很重要,以及我们如何最小化风险。

旧服务作为叶节点

假设我们正在使用几个旧服务作为依赖来构建系统的新的部分,如图图 15.1所示。1*:

图 15.1 – 新服务依赖于旧服务

图 15.1 – 新服务依赖于旧服务

为了我们新的可观察性解决方案,我们可能能够将旧系统视为一个黑盒。我们可以跟踪客户端对旧组件的调用并测量客户端延迟和其他统计数据。有时,我们需要了解旧组件内部发生的事情——例如,为了理解客户端问题或绕过旧系统限制。为此,我们可以利用在旧服务中可用的现有日志和监控工具。这可能不太方便,但如果这种情况很少见,它可能是一个合理的选项。

如果旧组件支持任何用于传入请求的相关头,我们可以在客户端填充它们以跨系统的不同部分进行关联。我们将在本章的传播上下文部分探讨这一点。

另一件事,我们可能在不更改旧系统的情况下做到的是分叉并将其遥测数据转发到相同的可观察性后端——我们将在从旧监控工具中整合遥测数据工具部分更详细地探讨这一点。

能够将新组件和旧组件的遥测数据关联起来并存储在同一位置,可能就足以调试偶尔出现的集成问题。

如果旧系统位于我们的应用程序中间,事情会更有趣——让我们看看原因。

中间的旧服务

当我们重构一个分布式系统时,我们可以更新围绕旧组件的下游和上游服务,如图图 15.2所示:

图 15.2 – 旧服务-b 位于较新的服务-a 和 service-c 之间

图 15.2 – 旧服务-b 位于较新的服务-a 和 service-c 之间

从跟踪方面来看,这里的挑战是历史组件不传播 W3C 跟踪上下文。通过legacy-service-b进行的操作被记录为两个跟踪 - 一个由service-a启动,另一个由service-c启动。

我们需要在系统的较新部分支持历史上下文传播格式,或者更新历史组件本身以启用上下文传播。

在我们深入讨论上下文传播的细节之前,让我们讨论我们应该考虑应用于服务的适当更改水平,这取决于其成熟度。

选择合理的仪器水平

为系统的成熟部分找到合适的仪器水平取决于需要多大的更改以及风险有多大。以下是一些需要考虑的事项:

  • 历史服务将遥测数据发送到何处?它是我们想要用于较新部分的相同可观察性后端吗?

  • 获取历史组件的遥测数据对整个系统的可观察性有多重要?

  • 历史服务支持某种上下文传播格式吗?我们能否从新服务中与之交互?

  • 我们能否更改一些我们的历史服务?.NET 运行时有多老?我们是否有足够的测试基础设施?这个服务的负载有多大?该组件有多关键?

让我们根据您的回答,探讨一些可能适用的解决方案。

不更改历史服务

当系统的历史部分使用特定供应商的 SDK 或代理进行仪器化并向我们想要用于较新部分的相同可观察性后端发送遥测数据时,我们可能不需要做任何事情 - 关联可能默认或在新系统的较新部分中通过一点上下文传播适配器工作。

您的供应商可能有一个迁移计划和相关文档,解释如何使用他们的旧 SDK 和基于 OpenTelemetry 的解决方案,使服务产生一致的遥测数据。

另一个不需要采取任何行动的情况是,当我们的历史组件大部分是隔离的,要么与较新部分并行工作,要么是叶子节点,如图图 15**.1所示。在这种情况下,我们通常可以在没有来自历史服务的数据的情况下开发和调试新组件。

我们也可能能够容忍存在损坏的跟踪,特别是如果它们不影响关键流程,并且我们打算很快退役历史服务。

不采取任何行动可能是最好的选择,但如果这对整体可观察性造成问题,下一个可行的选项是通过历史系统传递上下文。

仅传播上下文

如果较新部分与历史服务双向通信,而我们无法使跟踪上下文传播工作,这可能会阻止我们通过系统跟踪关键操作。那时我们可以做的最小侵入性更改是透明地通过历史服务传播跟踪上下文。

当此类服务收到请求时,我们会读取 W3C(B3 或另一种格式)中的跟踪上下文,然后将其无修改地传递给所有下游服务。

这样,旧服务将不会出现在跟踪中,但我们将拥有一致的全端到端跟踪。

我们可能可以更进一步,在旧遥测数据上打上跟踪上下文,以简化调试。

如果透明的上下文传播仍然不够,并且我们需要将所有服务的遥测数据集中在一个地方,下一个要考虑的选项是分叉旧遥测数据并将其发送到新的可观察性后端。

将旧遥测数据转发到新的可观察性后端

在不同的可观察性后端和日志管理工具之间调试问题可能具有挑战性,即使数据是相关的。

为了改进它,我们可能能够在旧系统前往其后端的过程中拦截遥测数据,或者启用从该后端到系统其他部分使用的新后端的不间断导出。

转发可能需要在旧系统中进行配置更改,即使这些更改很小,仍然存在减缓遥测管道并导致旧服务发生事故的风险。

系统越年轻、越灵活,我们可以考虑的更改就越多,最具侵入性的是将旧系统加入 OpenTelemetry 并启用网络仪器。

添加网络级仪器

很可能旧遥测数据与来自新服务的分布式跟踪不一致。我们可能能够转换它,或者有时可以容忍这种差异,但我们也应考虑在旧服务中启用最小化分布式跟踪。这将处理上下文传播,并产生与系统其他部分一致的遥测数据。

采用这种方法,我们将从旧服务向新后端泵送新的遥测数据,并保持所有现有仪器和管道运行,以避免破坏现有的报告、仪表板和警报。

这里需要注意的一点是,OpenTelemetry 在.NET 4.6.2 或更新的.NET 版本上运行。虽然 IIS、经典 ASP.NET 和 OWIN 的仪器在contrib存储库(在github.com/open-telemetry/opentelemetry-dotnet-contrib)中可用,但这些仪器并不像新仪器那样受到关注。

当使用 IIS 时,您可能会遇到一些与Activity.Current相关的边缘情况——它可能在在托管线程和本地线程之间跳转时丢失。

在保持旧工具运行的同时将现有服务加入 OpenTelemetry,可以是迁移项目的一个第一步,最终将淘汰旧监控解决方案。

这是对任何成熟服务都可行的解决方案,除非该服务已经在退休路径上,否则应该考虑。然而,如果这不是一个选项,我们仍然可以结合并演变这里提到的其他方法。现在让我们看看实际的一面,看看我们如何能实现它。

传播上下文

上下文传播的第一个目标是实现新服务的端到端分布式跟踪,即使它们通过遗留系统进行通信,如图15.2所示。作为一个挑战目标,我们还可以尝试关联新和遗留部分的数据。

在大多数情况下,该解决方案涉及在遗留服务中启用上下文传播。根据遗留服务的实现方式,这种更改可能是重大且风险较高的。因此,在我们这样做之前,让我们检查是否可以避免它。

利用现有的关联格式

我们遗留的服务可能已经传播了上下文,只是格式不同。一种流行的方法是传递一个关联 ID,它具有与 W3C Trace Context 标准中的跟踪 ID 相同的作用,标识一个逻辑的端到端操作。

虽然关联 ID 与跟踪上下文不兼容,但可能可以将一个转换为另一个。

在简单的情况下,关联 ID 只是一个字符串,然后我们只需将其传递到遗留服务的一个头中。然后,我们可以期望它按原样传播到下游调用,如图15.3所示:

图 15.3 – 通过遗留关联头传递 W3C 跟踪 ID

图 15.3 – 通过遗留关联头传递 W3C 跟踪 ID

在这里,correlation-id头与traceparentcorrelation-id一起,忽略未知的traceparent,并将其传递给traceparentcorrelation-id值。它只有correlation-id,所以它使用它来继续由service-a启动的跟踪。

让我们用一个自定义的 OpenTelemetry 上下文传播器来实现它,从注入方面开始,如下面的代码片段所示:

CorrelationIdPropagator.cs

public override void Inject<T>(PropagationContext context,
  T carrier, Action<T, string, string> setter)
{
  if (context.ActivityContext.IsValid())
setter.Invoke(carrier,
      CorrelationIdHeaderName,
      context.ActivityContext.TraceId.ToString());
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter15/Brownfield.OpenTelemetry.Common/CorrelationIdPropagator.cs

在这里,我们检查活动上下文是否有效,并在correlation-id头中将TraceId设置为字符串。我们设置这个传播器在 OpenTelemetry 中可用的TraceContextPropagator实现之后运行,因此在这里不需要处理跟踪上下文头。

这里是提取代码:

CorrelationIdPropagator.cs

public override PropagationContext Extract<T>(
  PropagationContext context, T carrier,
  Func<T, string, IEnumerable<string>> getter)
{
  if (context.ActivityContext.IsValid()) return context;
  var correlationIds = getter.Invoke(carrier,
   CorrelationIdHeaderName);
  if (TryGetTraceId(correlationIds, out var traceId))
  {
    var traceContext = new ActivityContext(
      ActivityTraceId.CreateFromString(traceId),
      ActivitySpanId.CreateRandom(),
      ActivityTraceFlags.Recorded,
      isRemote: true);
    return new PropagationContext(traceContext,
     context.Baggage);
  }
  ...
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter15/Brownfield.OpenTelemetry.Common/CorrelationIdPropagator.cs

我们在这里实现的自定义提取在追踪上下文提取之后运行,因此如果传入请求中有一个有效的traceparent头,那么在调用Extract方法时context.ActivityContext就会被填充。在这里,我们优先考虑 W3C 追踪上下文并忽略correlation-id值。

如果context.ActivityContext没有被填充,我们检索correlation-id值并尝试将其转换为追踪 ID。如果我们能这样做,那么我们创建一个新的ActivityContext实例,使用correlation-id作为追踪 ID 和一个假的父跨度 ID。

这是TryGetTraceId方法的实现:

CorrelationIdPropagator.cs

traceId = correlationId.Replace("-", "");
if (correlationId.Length < 32)
  traceId = correlationId.PadRight(32, '0');
else if (traceId.Length > 32)
  traceId = correlationId.Substring(0, 32);

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter15/Brownfield.OpenTelemetry.Common/CorrelationIdPropagator.cs

在这个片段中,我们支持多种可能的correlation-id格式——如果是 GUID,则删除连字符;如果长度不正确,则填充或修剪它。

注意

在更复杂的情况下,我们可能需要在上下文提取和注入期间进行其他转换。例如,当遗留系统需要一个 GUID 时,我们可以添加连字符。或者,如果它需要一个base64编码的字符串,我们可以解码并重新编码追踪 ID。

让我们现在检查我们使用这种方法得到的追踪记录。

首先,使用$ docker-compose up --build命令运行系统的新的部分。它从service-aservice-c和可观察性堆栈开始。

我们还需要启动legacy-service-b,这是一个运行在 Windows 上的.NET Framework 4.6.2 应用程序。您可以使用您的 IDE 或以下命令启动它:

legacy-service-b$ dotnet run --correlation-mode correlation-id

然后,在您的浏览器中点击以下 URL:http://localhost:5051/a?to=c。这将向service-a发送一个请求,该请求将通过legacy-service-b调用service-c

现在,让我们打开 Jaeger,在 http://localhost:16686 上,找到来自service-a的追踪,它应该看起来像图 15.4中所示的那样:

图 15.4 – 覆盖服务-a 和 service-c 的端到端追踪

图 15.4 – 覆盖服务-a 和 service-c 的端到端追踪

如您所见,没有5050)属于legacy-service-b

只有一条追踪记录,但它看起来仍然损坏了——跨度是相关的,但是客户端跨度在service-a和服务器跨度在service-c之间的父子关系丢失了。

尽管如此,这仍然是一个改进。现在,让我们在 docker-compose.yml 中的 Compatibility__SupportLegacyCorrelation 环境变量上禁用 correlation-id 支持,将其在两个服务中都设置为 false,然后重启 docker compose 应用程序。然后,我们将看到针对 service-aservice-c 的两个独立跟踪,因此关联也将丢失。

注意

通过依赖现有的上下文传播格式并实现自定义传播适配器,我们通常可以在不修改旧服务的情况下为新服务记录端到端跟踪。

我们是否也可以关联旧服务和新服务的遥测数据?通常,旧服务会在所有日志上盖章其版本的 correlation-id。如果是这样,我们可以使用跟踪 ID 在所有遥测数据中搜索,但可能需要将跟踪 ID 映射到关联 ID,然后再映射回来,就像我们处理传播者一样。

然而,如果我们没有在旧服务中实现自定义关联,或者无法实现适配器,该怎么办呢?我们需要修改旧服务以启用上下文传播——让我们看看如何实现。

通过旧服务传递上下文

实质上,如果没有现有的上下文传播机制,我们可以实现一个。为了最小化对旧系统的修改,我们可以透明地传播上下文,而不需要显式修改它。

我们需要拦截入站和出站请求以提取和注入跟踪上下文,我们还需要一种在进程内传递上下文的方法。

这种方法的实现,特别是拦截,取决于特定旧服务中使用的科技、库和模式。

入站请求拦截可以通过某些中间件或请求过滤器实现。如果使用 IIS,也可以在自定义 HTTP 遥测模块中完成,但那时由于托管到本地线程的跳跃,我们无法完全依赖环境上下文传播。

在进程内传递上下文通常可以通过 .NET 4.6+ 上的 AsyncLocal 或 .NET 4.5 上的 LogicalCallContext 实现——这样,它将包含在新代码中,并且不需要显式地处理上下文。

在我们的演示系统中,legacy-service-b 是一个自托管的 OWIN 应用程序,我们可以在 OWIN 中间件中实现上下文提取:

PassThroughMiddleware.cs

private static readonly
  AsyncLocal<IDictionary<string, object>> _currentContext =
    new AsyncLocal<IDictionary<string, object>>();
public static IDictionary<string, object> CurrentContext =>
  _currentContext.Value;
public override async Task Invoke(IOwinContext context)
{
  var tc = EmptyContext;
  if (context.Request.Headers.TryGetValue("traceparent",
    out var traceparent))
  {
    tc = new Dictionary<string, object>
      {{ "traceparent", traceparent[0] }};
    ...
  }
  _currentContext.Value = tc;
  ...
  using (var scope = _logger.BeginScope(tc))
  {
    await Next.Invoke(context);
  }
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter15/legacy-service-b/PassThrough/PassThroughMiddleware.cs

首先,我们声明一个静态的 AsyncLocal 值,它包含跟踪上下文,用简单的字典表示。

在中间件的Invoke方法中,我们读取traceparenttracestatebaggage头(为了简洁起见省略了)。我们在跟踪上下文字典中填充它们。根据您的需求,您始终可以限制支持的上下文字段仅为traceparent,并进一步优化代码。

然后,我们在_currentContext字段上填充上下文字典,然后我们可以通过公共的CurrentContext静态属性访问它。

我们在这里做的最后一件事是调用下一个中间件,我们将它包装在一个包含上下文字典的日志作用域中。这允许我们在来自legacy-service-b的所有日志中填充跟踪上下文,从而将它们与来自新服务的遥测数据相关联。

在实践中,遗留应用程序很少使用ILogger,但日志库通常有一些其他机制来在日志记录中填充环境上下文。根据库的不同,您可能能够通过稍微修改日志配置代码来访问和填充CurrentContext

回到上下文传播,我们现在需要将CurrentContext值注入到发出的请求中。

在 HTTP 的情况下,当使用.NET HttpClient时,我们可以通过自定义DelegatingHandler实现来完成。如果没有创建它们的辅助方法,且在应用程序代码中广泛使用WebRequest时,这将会更加繁琐。

处理器实现如下代码片段所示:

PassThroughHandler.cs

protected override Task<HttpResponseMessage> SendAsync(
  HttpRequestMessage request, CancellationToken token)
{
  foreach (var kvp in PassThroughMiddleware.CurrentContext)
    request.Headers.Add(kvp.Key, pair.Value?.ToString());
  return base.SendAsync(request, token);
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter15/legacy-service-b/PassThrough/PassThroughMiddleware.cs

在这里,我们只是将CurrentContext中的所有字段注入到发出的请求头中,然后调用下一个处理器。就是这样。

注意

System.Diagnostics.DiagnosticSource包版本 6.0.0 开始,.NET 提供了一个DistributedContextPropagator基类以及几个实现,包括 W3C 跟踪上下文和透传传播器。如果您可以添加对新DiagnosticSource包的依赖,或者在配置 ASP.NET Core 和HttpClient中的原生分布式跟踪工具的传播时,这可能很有用。在我们的遗留服务中,提取和注入本身是微不足道的,因此添加新的依赖并不真正合理。

现在,我们可以再次运行应用程序并检查跟踪:

  1. 使用以下命令启动新服务并构建:$ docker-compose up --build,然后启动legacy-service-b

    legacy-service-b$ dotnet run --correlation-mode pass-through
    
  2. 然后再次使用 http://localhost:5051/a?to=c 调用service-a并打开 Jaeger。我们应该看到像Figure 15.5*中的那样的跟踪:

Figure 15.5 – An end-to-end trace with transparent service-b

Figure 15.5 – An end-to-end trace with transparent service-b

在这里,我们有关联和因果关系——service-a上的客户端跨度是service-c上服务器跨度的直接父级。然而,service-b却无处可寻,因为它没有积极参与跟踪。

现在,我们有几种方法可以通过遗留系统传递上下文,但我们可以发挥创意,为我们的应用程序提出更多特定的选项——例如,我们可以在新的遥测中添加遗留相关性或请求 ID,或者记录它们,然后后处理遥测以关联损坏的跟踪。

使用这些选项,我们应该能够实现至少某种程度的关联。现在让我们检查如何将遗留服务中的遥测转发到新的可观察性后端。

整合遗留监控工具的遥测数据

一个好的可观察性解决方案可以提供的最大好处之一是在调试应用程序和阅读遥测数据时的低认知负荷。即使关联完美且遥测数据质量高,如果它们分散在多个工具中且无法一起可视化和分析,也非常难以使用。

当使用 OpenTelemetry 重新对遗留服务进行仪器化不是一个选项时,我们应该检查是否有可能将遗留服务中的现有数据转发到新的可观察性后端。

与上下文传播一样,我们可以发挥创意,并应首先利用现有解决方案。例如,旧的.NET 系统通常报告和消费 Windows 性能计数器,并将日志发送到 EventLog,或者将它们存储在硬盘上。

OpenTelemetry Collector 通过接收器提供对这种案例的支持,这些接收器位于 contrib 存储库中(在 https://github.com/open-telemetry/opentelemetry-collector-contrib)。

例如,我们可以使用以下片段配置一个文件接收器:

otel-collector-config.yml

filelog:
  include: [ /var/log/chapter15*.log ]
  operators:
    - type: json_parser
      timestamp:
        parse_from: attributes.Timestamp
        layout: '%Y-%m-%dT%H:%M:%S.%f'
      severity:
        parse_from: attributes.LogLevel

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter15/configs/otel-collector-config.yml

在这里,我们配置收集器接收器并指定日志文件的位置和名称模式。我们还为日志记录中的单个属性配置映射和转换规则。在这个例子中,我们只映射时间戳和日志级别,但如果日志记录是结构化的,则可以使用类似的运算符解析其他属性。

如果我们很少需要数据,我们也可以依赖我们的后端来解析非结构化日志记录或在查询时解析记录。

这是一个带有解析日志记录的收集器输出的示例,根据您的收集器配置,可以将日志发送到新的可观察性后端:

Timestamp: 2023-05-27 01:00:41.074 +0000 UTC
SeverityText: Information
…
Attributes:
     -> Scopes: Slice([{"Message":"System.Collections.Generic.Dictionary`2[System.String,System.Object]","traceparent":"00-78987df9861c2d7e46c10bd084570122-53106042475b3e32-01"}])
     -> Category: Str(LegacyServiceB.LoggingHandler)
...
     -> State: Map({"Message":"Request complete. GET http://localhost:5049/c, OK","method":"GET","status":"OK","url":"http://localhost:5049/c","{OriginalFormat}":"Request complete. {method} {url}, {status}"})
Trace ID:
Span ID:

如您所见,我们还可以配置接收器解析在日志作用域中填充的traceparent值,以记录Trace IDSpan ID以进行适当的关联。

你可以通过以下命令运行legacy-service-b并直接向其发送一些请求,或者通过service-a来重现它:

legacy-service-b $ dotnet run --correlation-mode pass-through > ../
tmp/logs/chapter15.log

收集器在边车模式下可以很有帮助,将运行遗产服务实例的机器上的可用数据转发,并收集性能计数器或日志。它还可以假装是我们的旧后端,接收 Zipkin 或 Jaeger 跨度、Prometheus 指标和供应商特定的信号,例如 Splunk 指标和日志。

我们可以编写自定义接收器并利用收集器转换处理器,尽可能产生一致的遥测数据。

除了 OpenTelemetry 收集器可以提供的无限可能性,我们还应该检查我们用于遗产服务的可观察性供应商是否允许对收集的遥测数据进行连续导出,这将使我们能够在不更改遗产系统的情况下获取数据。

摘要

在本章中,我们探讨了棕色字段应用程序中的跟踪,其中一些服务可能难以更改,并加入 OpenTelemetry 的完整可观察性解决方案。

我们讨论了此类服务的可能仪器化级别,并发现了一些我们可以完全避免更改旧组件的情况。然后,我们讨论了我们可以应用的变化,从最小化的透明上下文传播开始,一直到最后加入 OpenTelemetry。

最后,我们在实践中应用了一些这些选项,通过遗产服务实现了端到端关联,并将文件日志转发到 OpenTelemetry 收集器。

现在,你应该准备好为你的遗产组件制定策略,并拥有实施它的构建块。

本章结束了我们对.NET 上分布式跟踪和可观察性的探索之旅——希望您喜欢!可观察性领域发展迅速,但现在您已经有了设计和管理具有可观察性意识系统的基本知识,通过依赖相关遥测数据来演进它们,并更有信心地操作它们,了解遥测数据代表什么以及它是如何收集的。现在,是时候应用你的知识或基于它创建新事物了。

问题

  1. 你将如何处理对现有服务的仪器化,该服务是您系统中大多数用户场景的关键部分?这个服务已经成熟,很少改变,但近期内没有计划将其退役。

  2. 当我们将 OpenTelemetry 添加到遗产服务时,可能会出什么问题?

  3. 在实现透明上下文传播时,我们能否利用Activity类而不是添加我们自己的上下文原语和AsyncLocal字段?

评估

第一章 - 现代应用程序的可观察性需求

  1. 您可以将 Span 视为具有严格但可扩展模式的结构化事件,允许您跟踪任何有趣的操作。Span 具有描述它们之间关系的跟踪上下文。它们还有一个名称、开始时间、结束时间、状态和一个属性包,其中包含表示操作详细信息的属性。

复杂和分布式操作需要多个 Span 来描述至少每个传入和传出的请求。一组具有相同trace-id的关联 Span 称为跟踪。

  1. Span(在.NET 中也称为活动)由许多库和应用创建。为了启用关联,我们需要在进程内和进程间传播上下文。

在.NET 中,我们使用Activity.Current在进程内传播上下文。这是一个与执行上下文同步或异步调用一起流动的当前 Span。每当启动一个新的活动时,它使用Activity.Current作为其父项,然后成为当前项。

要在进程间传播跟踪上下文,我们将它通过线路传递给下一个服务。W3C 跟踪上下文是 HTTP 协议的标准传播格式,但某些服务使用 B3 格式。

  1. 对于这个问题没有唯一的答案,但以下是一些关于如何利用来自您服务的信号组合的一般考虑:

    • 检查问题是否普遍且影响超过此用户和请求。您的服务整体是否健康?是否特定于用户触发的 API 路径、区域、分区、功能标志或新服务版本?您的可观察性后端可能能够帮助解决这个问题。

    • 如果问题不是普遍的,如果已知,使用跟踪上下文查找有问题的请求,或者通过已知属性进行过滤。如果您在跟踪中看到差距,检索此操作的日志。如果这还不够,使用分析进一步调查。考虑添加更多遥测数据。

    • 对于普遍问题,您可能可以通过识别与报告问题相关的特定属性来找到问题的根本原因。

    • 否则,逐层缩小问题范围。依赖项是否运行正常?上游是否有新情况?负载是否有变化?

    • 如果问题不是特定于任何属性组合,请检查依赖项健康状态和资源利用率。检查崩溃和重启次数、CPU 负载、内存利用率、大量垃圾回收、I/O 和网络瓶颈。

第二章 – .NET 的本地监控

  1. 在页面上使用Activity.Current?.Id。例如,如下所示:<p>traceparent: <code>@System.Diagnostics.Activity.Current?.Id</code></p>

  2. 如果我们以 sidecar 的形式运行dotnet-monitor,我们可以连接到对应于有问题的服务实例的实例,检查指标和日志,并创建转储。我们甚至可以配置dotnet-monitor,使其基于某些事件或资源消耗阈值触发转储收集。

如果我们没有 dotnet-monitor,但可以访问服务实例,我们可以在那里安装 dotnet-monitor 并从运行进程获取诊断信息。

如果实例运行正常,但问题出在遥测管道内部,则故障排除步骤将取决于我们使用的工具。例如,使用 Jaeger 我们可以检查日志;Prometheus UI 显示与目标的连接性;OpenTelemetry 收集器提供日志和指标以进行自我诊断。

  1. 查询:

    sum by (service_name, http_route)
    
      (rate(http_server_duration_ms_count[1m]))
    

查询汇总了所有运行服务实例的请求速率,按服务名称和 http_route(表示 API 路由)进行分组。

速率函数 (rate(http_server_duration_ms_count) 首先计算每秒的速率,然后平均一分钟的速率。

  1. 使用 Jaeger 的 URL 和方法过滤器搜索跟踪。对于上传,将是 http.url=http://storage:5050/memes/<name> http.method=PUT。要查找下载,我们将使用 http.url=http://storage:5050/memes/<name> http.method=GET。然而,这并不方便,我们应该考虑将 meme 名称作为所有跨度的一个属性。

第三章 – .NET 可观测性生态系统

  1. 检查注册表 (opentelemetry.io/registry/) 和 OpenTelemetry .NET 仓库。如果您在它们中看不到您的库,请在问题和 PR 中搜索。在库的 GitHub 仓库或文档中搜索是否有任何可用的内容也是一个好主意。

当您找到仪器时,有几个方面需要检查:

  • 版本和稳定性:Beta 测试的仪器可能仍然具有高质量且经过实战检验,但不能保证 API 或遥测的稳定性

  • 性能和线程安全:理解仪器背后的机制对于提前识别可能的限制和问题很重要

  1. 仪器化库和框架最常见的方式是 ActivitySource——它是 OpenTelemetry Tracer 的 .NET 等价物,可以启动活动。您可以通过名称配置 OpenTelemetry 以监听源。您也可能看到使用 DiagnosticSource 的仪器化——它是在 .NET 中可用的一种较旧且结构较松散的机制。

利用库提供的钩子也很常见,这些钩子可以是全局的,也可以应用于客户端的特定实例。

  1. 服务网格可以跟踪服务网格边车之间的请求,并提供关于重试、服务发现或负载均衡的见解。如果它们处理与云服务、远程数据库或队列的通信,它们可以检测相应的通信。服务网格可以从一个应用程序传播上下文到另一个应用程序,但不能在服务内部从传入调用传播到传出调用。

第四章 – 使用诊断工具进行低级性能分析

  1. 如果您的服务定义了服务级别指标(SLIs),请首先检查它们,看看它们是否在您的服务级别目标(SLOs)定义的边界内。换句话说,检查衡量您用户体验的关键指标,看看它们是否在健康范围内。对于基于 REST API 的服务,通常是通过 API 和其他对您的应用程序重要的事情来分组成功的请求吞吐量和延迟。

资源消耗指标可能与用户体验相关联,但并不决定它。它们(以及描述您服务内部结构的其他指标)可以帮助您了解用户体验为何下降,并可以在一定程度上预测未来的问题。

  1. 首先,我们应该尝试找出哪个服务负责:检查上游和下游服务,看看您的服务的负载是否正常并且合理地分布在实例之间。当可能时,使用它们的服务器端指标检查依赖项是否健康。

如果我们可以将问题缩小到特定的服务,我们可以检查问题是否特定于某个实例或一组实例,或者实例是否频繁重启。对于受影响的实例,我们可以检查它们的资源利用率模式,包括内存、CPU、GC 频率、线程、竞争,或任何看起来异常高或低的内容。然后,我们可以从有问题的实例中捕获转储以分析内存和线程栈。

  1. 性能追踪(也称为性能分析或简称为追踪)是一种技术,它允许我们捕获关于应用程序行为和代码的详细诊断信息——调用栈、垃圾回收、竞争、网络事件,或.NET 或第三方库想要暴露的任何其他内容。这些事件默认是关闭的,但可以在进程内和进程外启用和控制。例如,dotnet-tracedotnet-monitor、PerfView、PerfCollect、JetBrains dotTrace、Visual Studio 和连续性能分析工具可以收集和可视化这些信息。性能追踪可用于调查功能和性能问题或优化您的代码。

第五章 - 配置和控制平面

  1. 我们需要基于尾部的采样,在跨度或追踪结束后应用,并且我们知道持续时间或是否有任何失败。由于我们拥有分布式多实例应用程序,基于尾部的采样无法在进程内进行,但我们可以使用 OpenTelemetry Collector 中的基于尾部的采样处理器,该处理器缓冲追踪并基于延迟或状态码进行采样。

如果我们只捕获可疑的追踪,我们将不再有基线——我们将无法使用追踪来观察正常系统行为、构建分析等。因此,我们应该额外捕获一定比例或速率的随机追踪——如果我们以某种方式标记它们,我们可以将它们与有问题的追踪分开来创建无偏的分析。

限制所有追踪的速率总是一个好主意,这样我们就不至于因为流量突增而超载遥测管道。

除了在 OpenTelemetry Collector 上进行采样配置之外,我们还应该考虑在单个 .NET 服务上配置概率采样——根据这一点,我们将为 Collector 分配适当数量的资源,并平衡仪表化的性能影响。

  1. 让我们使用 OpenTelemetry 的 http.resend_count 属性来记录尝试次数,该属性应设置在每个表示重试或重定向的 HTTP span 上。我们可以使用 HTTP 客户端仪表化的 EnrichWithHttpRequestMessage 钩子来拦截出站请求及其活动,但我们从哪里获取重试次数呢?

好吧,我们可以在我们的重试处理器中维护它(如果你使用 Polly,你可以使用 Context 代替)并通过 HttpRequestMessage.Options 将其传递给钩子。所以,最终的解决方案可能看起来像这样:

Program.cs

AddHttpClientInstrumentation(options =>
{
options.EnrichWithHttpRequestMessage = (act, req) =>
{
if (req.Options.TryGetValue(
new HttpRequestOptionsKey<int>("try"),
out var tryCount) && tryCount > 0)
act.SetTag("http.resend_count", tryCount);
...
}
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter5/memes/frontend/Program.cs

RetryHandler.cs

for (int i = 0; i < MaxTryCount; i++)
{
request.Options.Set(new
HttpRequestOptionsKey<int>("try"), i);
try
{
var response = await base.SendAsync(request,
token);
...
}
catch (Exception e) { ... }
await Task.Delay(delays[i]);
}

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter5/memes/frontend/RetryHandler.cs

  1. 让我们查看 OpenTelemetry Collector 的基于尾部采样的文档,网址为 https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/processor/tailsamplingprocessor/README.md。我们需要声明和配置 tail_sampling 处理器并将其添加到管道中。以下是一个示例配置:

otel-collector-config.yml

processors:
...
tail_sampling:
decision_wait: 2s
expected_new_traces_per_sec: 500
policies:
[{ name: limit-rate,
type: rate_limiting,
rate_limiting: {spans_per_second: 50}}]
service:
pipelines:
traces:
receivers: [otlp]
processors: [tail_sampling, batch]
exporters: [jaeger]

github.com/PacktPublishing/Modern-Distributed-Tracing-in-.NET/blob/main/chapter5/memes/configs/otel-collector-config.yml

您可以使用 Prometheus 中的 rate(otelcol_receiver_ accepted_spans[1m]) 查询检查当前记录的 span 的速率,并使用 rate(otelcol_exporter_sent_spans[1m]) 查询监控导出的速率。

第六章 – 跟踪您的代码

  1. 在设置 OpenTelemetry 时,您可以通过调用 TracerProviderBuilder.AddSource 方法并传递源名称来启用 ActivitySource。然后,OpenTelemetry 将创建一个 ActivityListener —— 这是一个低级别的 .NET API,它监听 ActivitySource 实例。监听器使用 OpenTelemetry 提供的回调来采样活动,并在活动开始或结束时通知 OpenTelemetry。

  2. 活动或 span 事件可以用来表示在某个时间点发生的事情,或者太短以至于不能成为 span,并且不需要单独的上下文。同时,事件必须在某些活动的范围内发生,并与其一起记录。活动事件将保留在内存中,直到活动被垃圾回收,并且它们在导出器侧的数量有限。

日志通常比Activity事件更好,因为它们不一定与特定的活动、采样或导出器限制相关。OpenTelemetry 将事件和日志视为类似。表示为日志记录的事件是结构化的,并可以遵循特定的语义约定。

  1. 当跨度有多个父级或与多个其他跨度同时相关时,链接提供了另一种关联跨度与覆盖场景的方法。没有链接,跨度只能有一个父级和多个子级,并且不能与其他跟踪中的跨度相关。

在消息场景中,链接被用来表示同时接收或处理多个独立消息。当我们处理多个消息时,我们需要从每个消息中提取跟踪上下文并创建一个ActivityLink。然后,我们可以将这些链接的集合传递给ActivitySource.StartActivity方法。一旦相应的Activity开始,我们就不能更改这些链接。可观察性后端以不同的方式支持(或不支持)链接,我们可能需要根据后端能力调整仪器。

第七章 – 添加自定义度量

  1. 我们首先应该决定我们需要度量做什么。例如,如果我们需要它来对搜索结果中的 meme 进行排名或计算广告点击,我们应该将其与遥测分开。假设我们为了业务逻辑目的将 meme 下载计数器存储在数据库中,我们也可以在计数器更新时将其作为属性标记在跟踪或事件上。

从仅遥测的角度来看,每个 meme 的度量会有很高的基数,因为我们可能系统中有成千上万的 meme,每分钟有数千个活跃的。通过一些额外的逻辑(例如,如果我们能忽略很少访问的 meme),我们甚至可能能够引入一个以 meme 名称作为属性的度量。

我会从跟踪开始,通过丰富的查询按 meme 名称聚合跨度。即使跟踪被采样,我仍然可以计算估计的下载次数,比较不同 meme 之间的差异,并查看趋势。

  1. 通常,两者都需要,但这取决于:我们需要传入的 HTTP 请求跟踪来调查个别故障和延迟,并了解在不同条件下正常请求流的样子。我们还需要度量吗?可能需要。在高度缩放的情况下,我们积极采样跟踪,但可能需要比估计计数更精确的数据。另一个问题是,即使我们不采样或不在乎粗略估计,在时间窗口内查询所有跨度可能既昂贵又耗时——可能需要处理数百万条记录。如果我们在这个数据上构建仪表板和警报,我们希望查询既快又便宜。即使它们用于事件期间的临时分析,我们仍然希望查询速度快。

因此,答案取决于可观察性后端,它优化了什么,以及其定价模型,但收集两者为我们提供了一个良好的起点。

  1. 对于活动实例的数量,我们可以通过包含实例信息的资源属性来报告 ObservableUpDownCounter。计数器将始终报告 1,因此在任何给定时间所有实例的值之和将代表活动进程的数量。这就是 Kubernetes 使用 kube_node_infokube_pod_info 指标(有关更多信息,请参阅github.com/kubernetes/kube-state-metrics)的方式。

运行时间可以通过多种方式报告——例如,作为一个包含静态启动时间的仪表(参见 kube_node_createdkube_pod_start_time)或作为资源属性。

确保检查您的环境是否已经发出类似的内容,或者 OpenTelemetry 语义约定是否定义了一种报告您感兴趣指标的通用方式。

第八章 - 编写结构化和关联日志

  1. 代码使用字符串插值而不是语义日志。日志消息立即格式化,因此 ILogger.Log 方法在下面被调用,使用 "hello world: 43, bar" 字符串,没有任何指示存在两个具有特定名称和值的参数。

如果禁用了 Information 级别,字符串插值仍然会发生,序列化所有参数,仅计算要丢弃的消息。

此代码应更改为 logger.LogInformation("hello world: {foo}, {bar}", 42, "bar")

  1. 我们需要确保使用不改变的日志记录属性构建使用报告:

    • 当添加新参数或重构代码时,日志消息会有很大变化。

    • 记录分类通常基于命名空间,这可能在重构期间发生变化。我们可以考虑明确以字符串形式传递分类,而不是通用类型参数,但更好的选择是确保报告不依赖于记录分类。我们可以使用事件名称或 ID——它们必须明确指定;我们只需要确保它们是唯一的且不会改变。一种方法是在单独的文件中声明它们,并记录使用报告依赖于它们。

  2. 描述 HTTP 请求的跟踪和日志包含类似的信息。日志更详细,因为我们通常会有可读性强的文本,并且需要为单个请求(在请求之前和之后)记录两条记录,带有重复的跟踪上下文和其他范围。

如果你的应用程序记录了所有 HTTP 跟踪,就没有必要启用 HTTP 日志记录。如果跟踪被采样,捕获所有遥测数据的成本与调查罕见问题的能力之间存在权衡。许多应用程序实际上不需要捕获所有遥测数据来有效地调查问题。对于他们来说,收集带有 HTTP 日志的采样跟踪将是最佳选择。如果你必须调查罕见的问题,一个选项是增加跟踪的采样率。记录 HTTP 日志是另一种选择,但这会带来额外的成本来收集、存储、检索和分析日志。

第九章 – 最佳实践

  1. HTTP 跟踪,可能结合一些特定于应用程序的属性,可以帮助回答关于小型 RESTful 服务行为的大部分问题。我们可以使用 OpenTelemetry Collector 或后端查询时间从跟踪中聚合指标。尽管如此,我们仍然需要资源利用的指标。这里要问的正确问题是这个解决方案给我们带来了多少成本,以及是否有通过采样降低成本的可能性以及我们必须花费多少来保持基于跟踪查询的警报运行。如果是很多,那么我们应该考虑添加指标。所以,答案是——是的,但添加其他信号可能更经济高效。

  2. 在高负载的应用程序中,每个错误都会反复发生。无论我们选择多小的采样率,我们都会记录至少一些此类问题的发生。高采样率可能会对性能产生一些影响,但更重要的是,存储所有这些跟踪数据将非常昂贵。因此,小采样率应该是首选。

  3. 套接字通信可能非常频繁,因此为每个请求都添加一个跨度可能会产生巨大的开销。一个好的起点是确定典型会话的持续时间,如果它在秒或分钟内,就可以使用跨度来记录会话。小请求可以通过服务端的指标记录,或者有时通过日志/事件记录。

OpenTelemetry 的一般和 RPC 语义约定应该涵盖表示客户端和服务器以及描述请求所需的必要网络属性。我们还可以应用合适的 RPC 指标来跟踪持续时间和吞吐量。

第十章 – 跟踪网络调用

  1. 重复使用现有的跟踪工具应该是首选,尤其是如果你在跟踪和 gRPC 堆栈方面没有太多经验。正如你在本章中看到的,与重试、执行顺序和其他难以考虑的微小细节相关的多个细节。

如果现有的跟踪工具不能满足你的需求,自定义 gRPC 跟踪是有意义的。例如,在我们的流实验中,我们可以通过将它们合并为一个来优化两层跟踪(单个消息和 gRPC 调用)。如果我们知道拦截器中的消息类型,我们还可以更好地关联请求、响应和跨度事件。

注意,即使是自定义的监控工具也受益于遵循语义约定并依赖于常见的工具和文档。

  1. 在这样的应用程序中,我们应该预期看到一个非常长的跨度,描述客户端和服务器之间的连接。如果我们采样跨度,我们应该定制采样器以确保我们捕获这个跨度。或者,我们也可以简单地丢弃它,转而捕获描述包含连接中发生的任何重要事件的事件。

然后,我们应该考虑如何/是否跟踪单个消息。如果它们非常小且速度快,由于几个担忧,单独跟踪它们可能太昂贵:

  • 第一个关注点是消息大小。跟踪上下文可以用二进制格式节俭地传播,但仍然至少需要 26 个字节。你可以发挥创意,提出更节俭的格式,通过在网络上传播消息索引而不是跨度 ID。最简单的解决方案是只为采样进入的消息传播上下文,并依赖于指标和事件来了解整体情况。

  • 第二个关注点是性能开销。如果你的处理非常快,跟踪它可能太昂贵了。采样可以帮助抵消一些这些成本,但你可能不需要跟踪单个消息。日志和事件可能提供适当的可观察性,并且你可以将它们与消息标识符相关联。

如果你的消息处理复杂且涉及其他网络调用,你将受益于减轻这些担忧并跟踪单个消息。

第十一章 – 监控消息场景

  1. 最困难的部分是找到重要的操作来进行测量。在我们的例子中,是模因上传和可供其他用户访问之间的时间。

我们可以发出几个事件来捕获这两个时间戳以及模因标识符和任何其他上下文。然后,我们可以通过在模因标识符上连接事件来找到 delta。

另一个选项是记录模因发布的时间戳以及模因元数据,并在我们的系统中传递。然后,我们可以将 delta 作为指标或事件进行报告。

  1. 当使用批处理时,通常想知道批次中的消息数量和有效载荷大小。通过调整这些数字,我们可以减少网络开销,因此,在遥测中随时可用这些信息非常有用。

关键问题是使用哪种监控工具:计数器或直方图(仪表在这里不适用)。

我们可以用两个指标来计数消息和批次。它们之间的比率将给出平均批次大小。

我们还可以记录批次中的消息数量和有效载荷大小作为直方图。这将给我们一个分布,除了平均数之外。

我曾想将批处理大小记录为现有指标的一个属性,但最终决定不这么做。在一般情况下,它是一个高基数属性,在 Prometheus 中可视化也比较困难;作为单独的指标可能更有意义。

  1. 行李代表应用程序特定的上下文传播服务。如果你需要跨消息系统传播它,可以使用 OpenTelemetry 传播器将其注入到每个消息中,类似于跟踪上下文。

行李通常不需要流向消息系统,但可能难以防止。附加到每个消息上,它可能在有效载荷大小方面造成显著的开销,所以请确保考虑到这一点,并准备好做出权衡。

在消费端,事情变得更有趣。如果消息是独立处理的,确保在处理消息时从消息中恢复行李信息。

对于批处理,没有单一的答案。从多个消息中合并行李在你的应用程序中可能或可能没有意义。

如果你想在你的遥测信息上标记行李信息,一个选项可能是记录已知行李值在链接属性中,以及与消息特定的信息一起。

第十二章 – 仪器化数据库调用

  1. 数据库变更流的概念与消息类似,我们可以应用之前章节中使用的相同方法。关键问题是如何传播上下文以及关联更改记录的操作和处理通知。

一个解决方案可能是添加一个记录标识符属性,并使用它来查找与特定记录相关的所有操作。当多个操作同时修改同一记录时,它将生成多个通知,我们无法使用记录 ID 将生产者操作映射到通知处理。可能还有其他通知标识符可以使用,例如记录 ETags。但在一般情况下,关联修改数据的操作和处理相应通知的操作意味着我们必须将跟踪上下文添加到记录中,并在每次操作中对其进行修改。

  1. 答案取决于你的追踪后端如何处理事件,以及缓存配置和基础设施的成熟度、健壮性和可靠性。

使用事件的论点如下:

  • 与事件相比,跨度/活动有稍微大的性能开销。在遥测量方面,事件也可能更小。

  • 我们不需要每个操作的精确 Redis 持续时间,因为我们有逻辑层活动跟踪复合调用和 Redis 指标。

  • 单个 Redis 调用的状态并不非常重要:一个设置操作甚至是以火速忘掉的方式完成的。只有当失败率显著增加时,这才有关系,但我们会从指标中看到它。

  1. 使用跨度(spans)的论点是,它更常见且方便,因为追踪后端在可视化跨度以及对其执行任何自动化分析方面做得更好。

要移除限制,请从docker-compose.yml中的mongo容器下删除deploy部分。如果你运行应用程序并杀死 Redis,你会看到 MongoDB 可以轻松处理负载和吞吐量变化,这可能意味着在如此小的负载的应用程序中 Redis 不是必需的。

第十三章 – 推动变革

  1. 使用单个后端处理所有信号有一定的优势。在信号之间导航应该更容易:例如,获取所有与跟踪相关的日志,查询事件和跟踪以及额外的上下文,以及使用示例从指标跳转到跟踪。因此,使用单个后端将减少认知负荷并最小化与后端相关的配置和工具的重复。

使用多个后端可以帮助降低成本。例如,假设你已经为日志和指标设置了所有必要的基础设施,通常可以将日志存储在更便宜的日志管理系统。但是,这些后端并不总是很好地支持跟踪。仅添加用于跟踪和事件的新的后端才真正有意义。

工具如 Grafana 可能能够在不同后端之上提供统一的 UX,以减轻一些不利因素。

  1. 我们需要做几件事情:

    • 锁定上下文传播格式:使用 W3C Baggage 规范是一个好的默认选择,除非你已经有现成的方案。它应该被文档化,理想情况下,在内部共享库中实现和配置,该库由你的应用程序中的所有服务共享。

    • 记录关键命名模式:确保使用命名空间并定义系统的根命名空间。这将有助于过滤其他所有内容。记录几个你想要放入那里的常见属性——我们希望确保人们使用它们,而不是想出一些自定义的东西。添加填充这些属性的辅助方法也会很棒。

    • 使用通用工件:如果你想对遥测进行标记、定制传播或只是统一工件键,确保提供具有这些功能的通用内部库。

  2. 当添加缓存时,我们可能试图减少数据库的负载并优化服务响应时间。我们应该已经对服务和数据库调用有可观察性,并可以看到缓存是否有所帮助。

如果我们逐步且条件性地推出这个功能,我们需要能够根据功能标志过滤和比较遥测数据,因此我们需要确保它们被记录。

最后,我们应该确保我们围绕缓存有遥测数据,这将帮助我们理解其工作原理,以及为什么它失败时没有工作。在开发、测试和初始迭代期间,添加这些遥测数据以及功能代码将产生最大的积极影响。

第十四章 – 创建你自己的约定

  1. 一种可能的解决方案是定义和记录属性的稳定性级别。

例如,新约定总是在 alpha 稳定性级别添加。一旦完全实施并部署,并且你对结果大多满意,该约定就可以升级到 beta 版。

约定应该在 beta 版中保持,直到有人尝试使用它们进行警报、报告或仪表板。如果运行良好,或者反馈得到解决后,约定变为稳定。之后,它不能以破坏性的方式更改。

  1. 应该能够在某种程度上验证实际的遥测数据。

例如,应该能够编写一个测试处理器(一个进程内处理器或自定义收集器组件),该处理器可以识别应该遵循约定的特定跨度、事件或指标,并检查这些约定是否得到一致的应用。这个测试处理器可以警告发现的问题,标记未知属性,在未收到预期信号时通知,等等。应该能够将其作为 CI 管道中集成测试的一部分运行。

另一种方法是对生产遥测的随机子集进行常规审计,这也可以自动化。

第十五章 – 仪器化棕色地带应用程序

  1. 这样的服务是迁移到 OpenTelemetry 的良好候选者——因为我们仍在更新它,所以可能有一个合理的测试基础设施和团队内部的环境来防止和减轻故障。作为一个首选方案,我们应该考虑添加带有网络仪器的 OpenTelemetry,然后逐步将现有工具和流程迁移到新的可观察性解决方案,同时发展基于 OpenTelemetry 的方法。

我们可以通过采样来控制这种方法的成本,只将必要的部分移动到 OpenTelemetry。在某个时候,当我们能够依赖新的可观察性解决方案时,我们可以移除相应的旧版报告。

  1. 很可能,旧版服务运行的 .NET 运行时版本早于 .NET 4.6.2,那么使用 OpenTelemetry 就是不可能的。即使使用较新的 .NET Framework 版本,添加新的依赖项,如 System.Diagnostics.DiagnosticSource 和 OpenTelemetry 带来的不同 Microsoft.Extensions 包,也可能由于版本冲突而导致运行时问题。

其他风险来自于应用程序的工作方式和性能中的微小变化和转变,唤醒或放大沉睡的问题,如竞态条件、死锁或线程池饥饿。

  1. 如果你可以将 System.Diagnostics.DiagnosticSource 的新版本作为依赖项添加,那么使用 Activity 就是一个选择。

注意,Activity 类从 .NET 的 DiagnosticSource 包版本 4.4.0 和 .NET Core 3.0 开始可用;然而,它经历了很多变化。本书中涵盖的大多数功能,包括 W3C 跟踪上下文,在初始版本中都是不可用的。

使用较新的DiagnosticSource版本,通过使用Activity,我们会修改跟踪上下文——而不是原样传递traceparent,我们会创建服务器和客户端跨度,然后将原始traceparent的祖先传递给下游服务。如果遗留服务没有向公共可观察性后端报告跨度,我们将看到相关的跟踪信息,但会缺少父子关系,正如我们在图 15.4中看到的。

因此,我们需要实现完整的分布式跟踪,或者如果没有跟踪信息被报告,就原样传递上下文,而不使用Activity

posted @ 2025-10-23 15:09  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报