.NET无侵入自动化探针原理和主流实现(一)


风晓
风晓 2023-12-31 09:18:28 49409 赞同 0 反对 0
分类: 资源
许多人对基于 OpenTelemetry .NET 的观测指标和无侵入自动化探针颇感兴趣。事实上,我已计划抽出时间,与大家分享这方面的内容。 然而,因为 .NET 无侵入自动化探针的实现原理相当复杂,理解和完全掌握原理有很大差别。为确保文章质量和严谨性,撰写过程耗时较长,因此现在才能与大家见面。

APM探针#

当我们提到 .NET 的 APM 时,许多人首先会想到 SkyWalking 。这是因为 SkyAPM-dotnet 是第一个支持.NET应用程序的开源非商业 APM 探针实现,目前很多 .NET 项目都采用了它。在此,我们要特别感谢刘浩杨等社区领袖的辛勤付出。

除了 SkyWalking 之外, Datadog APM 也是一款功能强大的商业应用性能监测工具,旨在帮助开发人员跟踪、优化并排查应用程序中的性能问题。Datadog APM 适用于多种编程语言和框架,包括 .NET 。通过使用 Datadog 丰富的功能和可视化仪表板,我们能够轻松地识别并改进性能瓶颈。

另一个比较知名的选择是 OpenTelemetry-dotnet-contrib ,这是 CNCF-OpenTelemetry 的 .NET 应用程序 APM 探针实现。虽然它的推出时间比 SkyAPM 和 Datadog APM 稍晚,但由于其开放的标准和开源的实现,许多 .NET 项目也选择使用它。

关于 APM 探针的实现原理,我们主要分为两类来介绍:平台相关指标和组件相关指标。接下来,我们将讨论如何采集这两类指标。

平台相关指标采集#

那么APM探针都是如何采集 .NET 平台相关指标呢?其实采集这些指标在 .NET 上是非常简单的,因为.NET提供了相关的API接口,我们可以直接获得这些指标,这里指的平台指标是如 CPU 占用率、线程数量、GC 次数等指标。

比如在 SkyAPM-dotne t项目中,我们可以查看 SkyApm.Core 项目中的 Common 文件夹,文件夹中就有诸如里面有 CPU 指标、GC 指标等平台相关指标采集实现帮助类。

同样,在 OpenTelemetry-dotnet-contrib 项目中,我们可以在 Process 和 Runtime 文件夹中,查看进程和运行时等平台相关指标采集的实现。

这些都是简单的 API 调用,有兴趣的同学可以自行查看代码,本文就不再赘述这些内容。

组件相关指标采集#

除了平台相关指标采集,还有组件相关的指标,这里所指的组件相关指标拿 ASP.NET Core 应用程序举例,我们接口秒并发是多少、一个请求执行了多久,在这个请求执行的时候访问了哪些中间件( Redis 、MySql 、Http 调用、RPC 等等),访问中间件时传递的参数(Redis 命令、Sql 语句、请求响应体等等)是什么,访问中间件花费了多少时间。

在 SkyAPM-dotnet 项目中,我们可以直接在src目录找到这些组件相关指标采集的实现代码。

同样在 OpenTelemetry-dotnet-contrib 项目中,我们也可以在src目录找到这些组件相关指标采集代码。

如果看过这两个APM探针实现的朋友应该都知道,组件指标采集是非常依赖DiagnosticSource技术。.NET官方社区一直推荐的的方式是组件开发者自己在组件的关键路径进行埋点,使用DiagnosticSource的方式将事件传播出去,然后其它监测软件工具可以订阅DiagnosticListener来获取组件运行状态。

就拿 ASP.NET Core 来举例,组件源码中有[HostingApplicationDiagnostics.cs](https://github.com/dotnet/aspnetcore/blob/main/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs)这样一个类,这个类中定义了 Hosting 在请求处理过程中的几个事件。

internal const string ActivityName = "Microsoft.AspNetCore.Hosting.HttpRequestIn";
private const string ActivityStartKey = ActivityName + ".Start";
private const string ActivityStopKey = ActivityName + ".Stop";

当 Hosting 开始处理请求时,会检测当前是否有监听者监听这些事件,如果有的话就会写入事件,事件也会携带当前的一些上下文信息,代码如下所示:

以 SkyAPM-dotnet 举例,有对应的HostingTracingDiagnosticProcessor.cs监听事件,然后获取上下文信息记录 APM 埋点信息,代码如下所示:

这种方式的优点有:

  • 高效和高性能:DiagnosticSource 是 .NET 平台自带的框架,使用它硬编码可以享受到编译器和 JIT 相关优化可以避免一些性能开销。组件开发者可以控制事件传递的频率和内容,以达到最佳的性能和资源利用率。
  • 灵活:通过使用 DiagnosticSource,组件开发者可以灵活地定义自己的事件模型,并按需发布事件。这意味着可以轻松地定制自己的监测需求,而不必担心过多的日志数据产生过大的开销。
  • 可扩展性:使用DiagnosticSource可以让组件的监测需求随着时间的推移而演变,而不必担心日志系统的限制。开发者可以根据自己的需要添加新的事件类型,以适应不断变化的监测需求。
  • 易用性:DiagnosticSource的 API 简单易用,订阅事件数据也很容易。这使得使用它进行组件监测变得非常容易,并且可以快速地集成到现有的监测系统中。
  • 可移植性:DiagnosticSource可以在多个平台上运行,包括 Windows、Linux 和 macOS 等。这意味着可以使用相同的事件模型来监测不同的应用程序和服务,从而简化了监测系统的设计和管理。

不过这种方式的缺点也很明显,就是必须由组件开发者显式的添加事件代码,探针的开发者也因此束手束脚,这就导致一些没有进行手动埋点的三方组件都无法添加事件监听,所以现阶段 SkyAPM-dotnet 支持的第三方组件还不是很丰富。

那么其实只要解决如何为没有进行手动埋点的组件库加入埋点就能解决 SkyAPM-dotnet 支持第三方组件多样性的问题。

.NET方法注入#

从上一节我们可以知道,目前制约APM支持组件不够丰富的原因之一就是很多组件库都没有进行可观测性的适配,没有在关键路径进行埋点。

那么要解决这个问题其实很简单,我们只需要修改组件库关键路径代码给加上一些埋点就可以了,那应该如何给这些第三方库的代码加点料呢?聊到这个问题我们需要知道一个 .NET 程序是怎么从源代码变得可以运行的。

通常情况下,一个 .NET 程序从源码到运行会经过两次编译(忽略 ReadyToRun 、NativeAOT 、分层编译等情况)。如下图所示:

第一次是使用编译器将 C#/F#/VB/Python/PHP 源码使用 Roslyn 等对应语言编译器编译成 CIL(Common Intermediate Language,公共中间语言)。第二次使用 RuyJit 编译器将 CIL 编译为对应平台的机器码,以 C# 语言举了个例子,如下图所示:

方法注入也一般是发生在这两次编译前后,一个是在 Roslyn 静态编译期间进行方法注入,期间目标 .NET 程序并没有运行,所以这种 .NET 程序未运行的方法注入我们叫它编译时静态注入。而在 RuyJit 期间 .NET程序已经在运行,这时进行方法注入我们叫它运行时动态注入。下表中列出了比较常见方法注入方式:

框架 类型 实现原理 优点 缺点
metalama 静态注入 重写Roslyn编译器,运行时插入代码 源码修改难度低,兼容性好 目前该框架不开源,只能修改源码,不能修改已编译好的代码,会增加编译耗时
Mono.Cecil、Postsharp 静态注入 加载编译后的*.dll文件,修改和替换生成后的CIL代码 兼容性好 使用难度高,需要熟悉 CIL ,会增加编译耗时,会增加程序体积
Harmony 动态注入 创建一个方法签名与原方法一致的方法,修改Jit后原方法汇编,插入jmp跳转到重写后方法 高性能,使用难度低 泛型、分层编译支持不友好
CLR Profile API 动态注入 调用CLR接口重写方法IL代码 功能强大,公开的API支持 实现困难,需要熟悉 CIL ,稍有不慎导致程序崩溃

综合各种优缺点现阶段APM使用最多的是 CLR Profile API 的方式进行方法注入,比如 Azure AppInsights、DataDog、Elastic等.NET探针都是使用这种方式。

基于CLR Profile API 实现APM探针原理#

CLR Profile API 简介#

在下面的章节中和大家聊一聊基于 CLR Profile API 是如何实现方法注入,以及 CLR Profile API 是如何使用的。

聊到 CLR 探查器,我们首先就得知道 CLR 是什么,CLR(Common Language Runtime,公共语言运行时),可以理解为是托管运行 .NET 程序的平台,它提供了基础类库、线程、JIT 、GC 等语言运行的环境(如下图所示),它功能和 Java 的 JVM 有相似之处,但定位有所不同。

.NET 程序、CLR 和操作系统的关系如下图所示:

那么 CLR 探查器是什么东西呢?根据官方文档的描述,CLR 探查器和相关API的支持从 .NET Framework 1.0就开始提供,它是一个工具,可以使用它来监视另一个 .NET 应用程序的执行情况,它也是一个( .dll )动态链接库,CLR 在启动运行时加载探查器,CLR 会将一些事件发送给探查器,另外探查器也可以通过 Profile API 向 CLR 发送命令和获取运行时信息。下方是探查器和 CLR 工作的简单交互图:

ICorProfilerCallback提供的事件非常多,常用的主要是下方提到这几类:

  • CLR 启动和关闭事件
  • 应用程序域创建和关闭事件
  • 程序集加载和卸载事件
  • 模块加载和卸载事件
  • COM vtable 创建和析构事件
  • 实时 (JIT) 编译和代码间距调整事件
  • 类加载和卸载事件
  • 线程创建和析构事件
  • 函数入口和退出事件
  • 异常
  • 托管和非托管代码执行之间的转换
  • 不同运行时上下文之间的转换
  • 有关运行时挂起的信息
  • 有关运行时内存堆和垃圾回收活动的信息
    ICorProfilerInfo提供了很多查询和命令的接口,主要是下方提到的这几类:
  • 方法信息接口
  • 类型信息接口
  • 模块信息接口
  • 线程信息接口
  • CLR 版本信息接口
  • Callback 事件设置接口
  • 函数 Hook 接口
  • 还有 JIT 相关的接口
    通过 CLR Profile API 提供的这些事件和信息查询和命令接口,我们就可以使用它来实现一个无需改动原有代码的 .NET 探针。

自动化探针执行过程#

APM 使用 .NET Profiler API 对应用程序进行代码插桩方法注入,以监控方法调用和性能指标从而实现自动化探针。下面详细介绍这一过程:

  1. Profiler注册:在启动应用程序时,.NET Tracer 作为一个分析器(profiler)向 CLR(Common Language Runtime)注册。这样可以让它在整个应用程序生命周期内监听和操纵执行流程。
  2. JIT编译拦截:当方法被即时编译(JIT)时,Profiler API 发送事件通知。.NET Tracer 捕获这些事件,如JITCompilationStarted,从而有机会在方法被编译之前修改其 IL(Intermediate Language)代码。
  3. 代码修改插桩:通过操纵IL代码,.NET Tracer 在关键方法的入口和退出点插入跟踪逻辑。这种操作对原始应用程序是透明的,不需要修改源代码。跟踪逻辑通常包括记录方法调用数据、计时、捕获异常等。
  4. 上下文传播:为了连接跨服务或异步调用的请求链,.NET Tracer 会将 Trace ID 和 Span ID在分布式系统中进行传递。这使得在复杂的微服务架构中追踪请求变得更加容易。
  5. 数据收集:插桩后的代码在运行期间会产生跟踪数据,包括方法调用时间、执行路径、异常信息等。这些数据会被封装成跟踪和跨度(spans),并且通过 APM Agent 发送到 APM 平台进行后续分析和可视化。

通过使用 .NET Profiler API 对应用程序进行方法注入插桩,APM 可以实现对 .NET 程序的详细性能监控,帮助开发者和运维人员发现并解决潜在问题。

第一步,向 CLR 注册分析器的步骤是很简单的,CLR 要求分析器需要实现COM组件接口标准,微软的 COM(Component Object Model)接口是一种跨编程语言的二进制接口,用于实现在操作系统中不同软件组件之间的通信和互操作。通过 COM 接口,组件可以在运行时动态地创建对象、调用方法和访问属性,实现模块化和封装。COM 接口使得开发人员能够以独立、可复用的方式构建软件应用,同时还有助于降低维护成本和提高开发效率。COM 一般需要实现以下接口:

  1. 接口(Interfaces):COM 组件使用接口提供一套预定义的函数,这样其他组件就可以调用这些函数。每个接口都有一个唯一的接口标识(IID)。
  2. 对象(Objects):COM 对象是实现了一个或多个接口的具体实例。客户端代码通过对象暴露的接口与其进行交互。
  3. 引用计数(Reference Counting):COM 使用引用计数管理对象的生命周期。当一个客户端获取到对象的接口指针时,对象的引用计数加一;当客户端不再需要该接口时,引用计数减一。当引用计数减至零时,COM 对象会被销毁。
  4. 查询接口(QueryInterface):客户端可以通过 QueryInterface 函数获取 COM 对象所实现的特定接口。这个函数接收一个请求的接口 IID,并返回包含该接口指针的 HRESULT。
  5. 类工厂(Class Factories):为了创建对象实例,COM 使用类工厂。类工厂是实现了 IClassFactory 接口的对象,允许客户端创建新的对象实例。

比如 OpenTelemetry 中的class_factory.cpp就是声明了COM组件,其中包括了查询接口、引用计数以及创建实例对象等功能。

然后我们只需要设置三个环境变量,如下所示:

  • COR_ENABLE_PROFILING:将其设置为1,表示启用 CLR 分析器。
  • COR_PROFILER: 设置分析器的COM组件ID,使 CLR 能正确的加载分析器。
  • COR_PROFILER_PATH_32/64: 设置分析器的路径,32位或者是64位应用程序。

通过以上设置,CLR 就可以在启动时通过 COM 组件来调用分析器实现的函数,此时也代表着分析器加载完成。在 OpenTelemetry 和 data-dog 等 APM 中都有这样的设置。

那后面的JIT编译拦截以及其它功能如何实现呢?我们举一个现实存在的例子,如果我们需要跟踪每一次 Reids 操作的时间和执行命令的内容,那么我们在应该修改StackExchange.Redis ExecuteAsyncImpl方法,从message中读取执行命令的内容并记录整个方法耗时。

那么APM如何实现对Redis ExecuteAsyncImpl进行注入的?可以打开dd-trace-dotnet仓库也可以打开opentelemetry-dotnet-instrumentation仓库,这两者的方法注入实现原理都是一样的,只是代码实现上有一些细微的差别。这里我们还是以 dd-trace-dotnet 仓库代码为例。

打开tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation目录,里面所有的源码都是通过方法注入的方式来实现APM埋点,有非常多的组件埋点的实现,比如 MQ 、Redis 、 CosmosDb 、Couchbase 等等。

打开 Redis 的文件夹,可以很容易找到 Redis 进行方法注入的源码,这相当于是一个 AOP 切面实现方法:

[InstrumentMethod(
        AssemblyName = "StackExchange.Redis",
        TypeName = "StackExchange.Redis.ConnectionMultiplexer",
        MethodName = "ExecuteAsyncImpl",
        ReturnTypeName = "System.Threading.Tasks.Task`1<T>",
        ParameterTypeNames = new[] { "StackExchange.Redis.Message", "StackExchange.Redis.ResultProcessor`1[!!0]", ClrNames.Object, "StackExchange.Redis.ServerEndPoint" },
        MinimumVersion = "1.0.0",
        MaximumVersion = "2.*.*",
        IntegrationName = StackExchangeRedisHelper.IntegrationName)]
    [InstrumentMethod(
        AssemblyName = "StackExchange.Redis.StrongName",
        TypeName = "StackExchange.Redis.ConnectionMultiplexer",
        MethodName = "ExecuteAsyncImpl",
        ReturnTypeName = "System.Threading.Tasks.Task`1<T>",
        ParameterTypeNames = new[] { "StackExchange.Redis.Message", "StackExchange.Redis.ResultProcessor`1[!!0]", ClrNames.Object, "StackExchange.Redis.ServerEndPoint" },
        MinimumVersion = "1.0.0",
        MaximumVersion = "2.*.*",
        IntegrationName = StackExchangeRedisHelper.IntegrationName)]
    [Browsable(false)]
    [EditorBrowsable(EditorBrowsableState.Never)]
    public class ConnectionMultiplexerExecuteAsyncImplIntegration
    {
        /// <summary>
        /// OnMethodBegin callback
        /// </summary>
        /// <typeparam name="TTarget">Type of the target</typeparam>
        /// <typeparam name="TMessage">Type of the message</typeparam>
        /// <typeparam name="TProcessor">Type of the result processor</typeparam>
        /// <typeparam name="TServerEndPoint">Type of the server end point</typeparam>
        /// <param name="instance">Instance value, aka `this` of the instrumented method.</param>
        /// <param name="message">Message instance</param>
        /// <param name="resultProcessor">Result processor instance</param>
        /// <param name="state">State instance</param>
        /// <param name="serverEndPoint">Server endpoint instance</param>
        /// <returns>Calltarget state value</returns>
        internal static CallTargetState OnMethodBegin<TTarget, TMessage, TProcessor, TServerEndPoint>(TTarget instance, TMessage message, TProcessor resultProcessor, object state, TServerEndPoint serverEndPoint)
            where TTarget : IConnectionMultiplexer
            where TMessage : IMessageData
        {
            string rawCommand = message.CommandAndKey ?? "COMMAND";
            StackExchangeRedisHelper.HostAndPort hostAndPort = StackExchangeRedisHelper.GetHostAndPort(instance.Configuration);

            Scope scope = RedisHelper.CreateScope(Tracer.Instance, StackExchangeRedisHelper.IntegrationId, StackExchangeRedisHelper.IntegrationName, hostAndPort.Host, hostAndPort.Port, rawCommand);
            if (scope is not null)
            {
                return new CallTargetState(scope);
            }

            return CallTargetState.GetDefault();
        }

        /// <summary>
        /// OnAsyncMethodEnd callback
        /// </summary>
        /// <typeparam name="TTarget">Type of the target</typeparam>
        /// <typeparam name="TResponse">Type of the response, in an async scenario will be T of Task of T</typeparam>
        /// <param name="instance">Instance value, aka `this` of the instrumented method.</param>
        /// <param name="response">Response instance</param>
        /// <param name="exception">Exception instance in case the original code threw an exception.</param>
        /// <param name="state">Calltarget state value</param>
        /// <returns>A response value, in an async scenario will be T of Task of T</returns>
        internal static TResponse OnAsyncMethodEnd<TTarget, TResponse>(TTarget instance, TResponse response, Exception exception, in CallTargetState state)
        {
            state.Scope.DisposeWithException(exception);
            return response;
        }
    }

这段代码是一个用于监控和跟踪 StackExchange.Redis 库的 APM(应用性能监控)工具集成。它针对 StackExchange.Redis.ConnectionMultiplexer 类的 ExecuteAsyncImpl 方法进行了注入以收集执行过程中的信息。

  1. 使用了两个 InstrumentMethod 属性,分别指定 StackExchange.Redis 和 StackExchange.Redis.StrongName 两个程序集。属性包括程序集名称、类型名、方法名、返回类型名等信息以及版本范围和集成名称。
  2. ConnectionMultiplexerExecuteAsyncImplIntegration 类定义了 OnMethodBegin 和 OnAsyncMethodEnd 方法。这些方法在目标方法开始和结束时被调用。
  3. OnMethodBegin 方法创建一个新的 Tracing Scope,其中包含了与执行的 Redis 命令相关的信息(如 hostnameportcommand 等)。
  4. OnAsyncMethodEnd 方法在命令执行结束后处理 Scope,在此过程中捕获可能的异常,并返回结果。
  5. 而这个 CallTargetState state 中其实包含了上下文信息,有 Span Id 和 Trace Id ,就可以将其收集发送到 APM 后端进行处理。

但是,仅仅只有声明了一个 AOP 切面类不够,我们还需将这个 AOP 切面类应用到 Redis SDK 原有的方法中,这又是如何做到的呢?那么我们就需要了解一下 CLR Profiler API 实现方法注入的原理了。

如果您发现该资源为电子书等存在侵权的资源或对该资源描述不正确等,可点击“私信”按钮向作者进行反馈;如作者无回复可进行平台仲裁,我们会在第一时间进行处理!

评价 0 条
风晓L1
粉丝 1 资源 2038 + 关注 私信
最近热门资源
银河麒麟桌面操作系统备份用户数据  123
统信桌面专业版【全盘安装UOS系统】介绍  116
银河麒麟桌面操作系统安装佳能打印机驱动方法  108
银河麒麟桌面操作系统 V10-SP1用户密码修改  101
最近下载排行榜
银河麒麟桌面操作系统备份用户数据 0
统信桌面专业版【全盘安装UOS系统】介绍 0
银河麒麟桌面操作系统安装佳能打印机驱动方法 0
银河麒麟桌面操作系统 V10-SP1用户密码修改 0
作者收入月榜
1

prtyaa 收益393.62元

2

zlj141319 收益217.55元

3

1843880570 收益214.2元

4

IT-feng 收益208.98元

5

风晓 收益208.24元

6

777 收益172.71元

7

Fhawking 收益106.6元

8

信创来了 收益105.84元

9

克里斯蒂亚诺诺 收益91.08元

10

技术-小陈 收益79.5元

请使用微信扫码

加入交流群

请使用微信扫一扫!