揭秘自动插桩:魔法是如何实现的

尽管 OpenTelemetry 和 eBPF 取得了长足发展,但大多数开发者仍然不了解自动插桩的内部工作原理。本文将深入剖析——并非建议您自行构建,而是帮助您理解当您的工具神奇地“自动工作”时,背后发生了什么。

我们将探讨五种关键的自动插桩技术:monkey patching、字节码插桩、编译时插桩、eBPF 和语言运行时 API。每种技术都利用了不同编程语言和运行时环境的独特特性,在不修改代码的情况下添加可观测性。

什么是自动插桩?

根据 术语表,“自动插桩”是指“无需最终用户修改应用程序源代码即可进行遥测收集的方法。方法因编程语言而异,例如字节码注入或 monkey patching。

值得注意的是,“自动插桩”通常用于描述两个相关但不同的概念。在上定义和本文中,它指的是可用于在不修改代码的情况下实现可观测性的特定技术(如字节码注入或 monkey patching)。但是,当人们在对话中使用“自动插桩”时,他们通常指的是完整的零代码解决方案,如 OpenTelemetry Java 代理

区分这两者很重要:实际上存在一个三层层次结构。最底层是本博文探讨的 自动插桩技术(字节码注入、monkey patching 等)。这些技术由针对特定框架的 插桩库 使用,例如,用于 Spring 和 Spring BootExpress.jsLaravel 或其他流行框架的插桩库。最后,像 OpenTelemetry Java 代理这样的完整解决方案将这些插桩库捆绑在一起,并添加了所有用于导出器、采样器和其他构建块的样板配置。

可观测性社区中关于正确术语的讨论仍在进行中,本文博客文章不会试图解决这些讨论。

另外请注意,对一个人来说“自动”的东西,对另一个人来说可能是“手动”的:如果库的开发人员将 OpenTelemetry API 集成到他们的代码中,当用户将 OpenTelemetry SDK 添加到他们的应用程序时,他们将从该库中“自动”获得跟踪、日志和指标。

想亲手尝试这些技术吗?

本博文包含一些小型代码片段来说明这些概念。您可以在 lab repository 中尝试完整的有效示例。

在探讨这些技术之前,需要强调的是,您不应该从头开始构建自己的自动插桩,尤其是不要以此博文为蓝图。这里的示例为了教学目的而简化,并省略了真实实现中会遇到的许多复杂细节。有成熟的工具和机制可用于处理从头开始构建插桩时会面临的大部分复杂性和边缘情况。如果您有兴趣深入研究该领域,最好的方法是 为 OpenTelemetry 等现有项目贡献力量,在那里您可以向经验丰富的维护者学习并使用生产级代码。

自动插桩技术

现在让我们深入了解这些技术是如何在后台工作的。

Monkey patching:运行时函数替换

Monkey patching 可能是最简单的自动插桩技术,通常用于 JavaScript、Python 和 Ruby 等动态语言。概念很简单:在运行时,我们用经过插桩的版本替换现有函数,这些版本在调用原始函数之前和之后注入遥测数据。

以下是 Node.js 中的实现方式:

const originalFunction = exports.functionName;

function instrumentedFunction(...args) {
  const startTime = process.hrtime.bigint();
  const result = originalFunction.apply(this, args);
  const duration = process.hrtime.bigint() - startTime;
  console.log(`functionName(${args[0]}) took ${duration} nanoseconds`);
  return result;
}

exports.functionName = instrumentedFunction;

require-in-the-middle 库允许我们在模块加载时进行此替换,拦截模块加载过程,在应用程序使用导出的函数之前对其进行修改。

const hook = require("require-in-the-middle");
hook(["moduleName"], (exports, name, basedir) => {
  const functionName = exports.fibonacci;
  ...
  exports.functionName = instrumentedFunction;
  return exports;
});

然而,monkey patching 存在局限性。它无法插桩已经编译成机器码的代码,也可能不适用于在插桩加载之前调用的函数。此外,对于性能关键型应用程序,函数包装的开销可能很大。当被插桩代码的实现发生重大变化时,monkey patching 也很脆弱,因为插桩代码需要更新以匹配新接口。

要亲自尝试,请查看 lab 中的 Node.js 示例

如果您想查看 monkey patching 的实际使用实现,可以查看 OpenTelemetry 为 JavaScriptPython 提供的插桩库。

字节码插桩:修改虚拟机

对于运行在虚拟机上的语言,字节码插桩提供了一种强大的方法。这项技术通过在虚拟机加载编译后的字节码时修改它来实现,允许我们在指令级别注入代码。

Java 的 Instrumentation API 为这种方法提供了基础。当使用 -javaagent 标志指定 Java 代理时,JVM 会在主应用程序启动之前调用代理的 premain 方法。这为我们提供了注册一个类转换器 (class transformer) 的机会,该转换器可以在类加载时修改任何类。

public static void premain(String args, Instrumentation inst) {
    new AgentBuilder.Default()
        .type(ElementMatchers.nameStartsWith("com.example.TargetApp"))
        .transform((builder, typeDescription, classLoader, module, protectionDomain) ->
            builder.method(ElementMatchers.named("targetMethod"))
                   .intercept(MethodDelegation.to(MethodInterceptor.class))
        ).installOn(inst);
}

拦截器随后用计时逻辑包装原始方法调用。

@RuntimeType
public static Object intercept(@Origin String methodName,
                            @AllArguments Object[] args,
                            @SuperCall Callable<?> callable) throws Exception {
    long startTime = System.nanoTime();
    Object result = callable.call();
    long duration = System.nanoTime() - startTime;

    System.out.printf("targetMethod(%s) took %d ns%n", args[0], duration);
    return result;
}

字节码插桩之所以特别强大,是因为它在 JVM 级别工作,使其在 JVM 生态系统内与语言无关。它可以在不修改 Java、Kotlin、Scala 和其他 JVM 语言的情况下对其进行插桩。

字节码插桩的主要优势在于其全面的覆盖范围——它可以插桩在 JVM 上运行的任何代码,包括动态加载的代码或来自外部源的代码。然而,由于字节码转换过程,它会带来一些开销。

在实际实现中,ByteBuddy 是 Java 中字节码插桩的首选库,它提供了一个强大而灵活的 API 来创建 Java 代理。它隐藏了许多字节码操作的复杂性,并提供了一种干净、类型安全的方式来定义插桩规则。

要亲自尝试,请查看 lab 中的 Java 示例

如果您想查看字节码插桩的实际使用实现,可以查看 OpenTelemetry 为 Java.NET 提供的插桩库。

编译时插桩:将可观测性烘焙到二进制文件中

对于 Go 等静态编译的语言,编译时插桩提供了一种不同的方法。我们不在线时修改代码,而是使用 抽象语法树 (AST) 操作,在构建过程中转换源代码。

该过程包括将源代码解析为 AST,修改树以添加插桩代码,然后在编译之前生成修改后的源代码。这种方法确保插桩被烘焙到最终的二进制文件中,从而为插桩机制本身提供零运行时开销。

func instrumentFunction() {
    fset := token.NewFileSet()
    file, err := parser.ParseFile(fset, "app/target.go", nil, parser.ParseComments)

    // Find the target function and add timing logic
    ast.Inspect(file, func(n ast.Node) bool {
        if fn, ok := n.(*ast.FuncDecl); ok && fn.Name.Name == "targetFunction" {
            // Add defer statement for timing
            deferStmt := &ast.DeferStmt{
                Call: &ast.CallExpr{
                    Fun: &ast.CallExpr{
                        Fun: &ast.Ident{Name: "trace_targetFunction"},
                    },
                },
            }
            fn.Body.List = append([]ast.Stmt{deferStmt}, fn.Body.List...)
        }
        return true
    })

    // Write the modified file back
    printer.Fprint(f, fset, file)
}

编译时插桩有几个优点。它为插桩机制提供了零运行时开销,并且生成的二进制文件包含其所需的所有代码。这种方法适用于编译型语言,并且可以集成到现有的构建过程中。

不过,它也有权衡。它需要访问源代码和构建系统,这使得它不适用于插桩第三方应用程序或库。它还需要更复杂的工具来正确且一致地操作抽象语法树 (AST),增加了构建管道的复杂性,并可能需要更改您的 CI/CD 工作流。

要亲自尝试,请查看 lab 中的 Go 编译时示例

如果您想查看编译时插桩的实际使用实现,可以查看 OpenTelemetry Go Compile Instrumentation 项目。

eBPF 插桩:内核级可观测性

eBPF(扩展 Berkeley 包过滤器)代表了一种根本不同的自动插桩方法。eBPF 不是修改应用程序代码或字节码,而是在内核级别工作,将探针附加到正在运行的应用程序的函数入口点和出口点。

eBPF 程序是运行在内核中的小型、安全程序,可以观察系统调用、函数调用和其他事件。对于自动插桩,我们使用 uprobes(用户空间探针)来附加到应用程序中的特定函数。

#!/usr/bin/env bpftrace

uprobe:/app/fibonacci:main.fibonacci
{
    @start[tid] = nsecs;
}

uretprobe:/app/fibonacci:main.fibonacci /@start[tid]/
{
    $delta = nsecs - @start[tid];
    printf("fibonacci() duration: %d ns\n", $delta);
    delete(@start[tid]);
}

这个 bpftrace 脚本将一个探针附加到我们应用程序中的函数。当函数被调用时,它会记录开始时间。当函数返回时,它会计算持续时间并打印结果。

eBPF 插桩与语言无关,适用于运行在 Linux 上的任何语言。它提供了深入的系统级可观测性,而无需修改应用程序代码或构建过程。由于插桩在内核中运行,因此开销极小。

然而,eBPF 插桩也有一些限制。它需要 Linux 和 root 权限才能运行,因此不太适合容器化环境或无法以提升权限运行的应用程序。

对于实际用例,bpftrace 只是众多 eBPF 工具之一。虽然它非常适合学习和原型设计,但生产环境通常使用更复杂的框架,如 BCC (BPF Compiler Collection) 或 libbpf,它们提供更好的性能、更多的功能和更强的安全保证。

要亲自尝试,请查看 lab 中的 Go eBPF 示例

如果您想查看 eBPF 插桩的实际使用实现,可以查看 OpenTelemetry eBPF Instrumentation 项目(“OBI”),这是 Grafana Labs 捐赠 Beyla 的结果

语言运行时 API:原生插桩支持

一些语言提供了内置的插桩 API,提供了一种更集成的方案。PHP 的 Observer API(引入于 PHP 8.0)是这种方法的典型代表。

Observer API 允许 C 扩展在 Zend 引擎级别钩住 PHP 引擎的执行流程。这提供了对 PHP 应用程序行为的深入可见性,而无需修改代码。

static void observer_begin(zend_execute_data *execute_data) {
    if (execute_data->func && execute_data->func->common.function_name) {
        const char *function_name = ZSTR_VAL(execute_data->func->common.function_name);
        if (strcmp(function_name, "fib") == 0) {
            start_time = clock();
        }
    }
}

static void observer_end(zend_execute_data *execute_data, zval *retval) {
    if (execute_data->func && execute_data->func->common.function_name) {
        const char *function_name = ZSTR_VAL(execute_data->func->common.function_name);
        if (strcmp(function_name, "fib") == 0) {
            clock_t end_time = clock();
            double duration = (double)(end_time - start_time) / CLOCKS_PER_SEC * 1000;
            php_printf("Function %s() took %.2f ms\n", function_name, duration);
        }
    }
}

Observer API 提供了一种干净、受支持的方式来为 PHP 应用程序添加插桩。它在语言运行时级别运行,类似于其他语言实现其插桩 API 的方式。这种方法高效且与语言生态系统集成良好。

然而,它需要编写 C 扩展,这增加了复杂性,并且对于不熟悉 C 或 PHP 内部 API 的开发人员来说不太容易使用。它也仅限于 PHP,因此知识无法转移到其他语言。

要亲自尝试,请查看 lab 中的 PHP Observer API 示例

如果您想查看 API 插桩的实际使用实现,可以查看 OpenTelemetry 为 PHP 提供的插桩库。

关于上下文传播的说明

虽然我们已经介绍了自动插桩的核心技术,但有一个重要方面我们尚未讨论:上下文传播。这涉及到将跟踪上下文信息(跟踪 ID、Span ID)注入 HTTP 头、消息元数据和其他通信通道,以实现跨服务边界的分布式跟踪。

与我们已经探讨的纯粹的观察技术不同,上下文传播通过修改跨服务边界传输的数据来主动改变应用程序的行为。这引入了额外的复杂性,值得专门的博文来讨论。

结论

我们已经探讨了自动插桩背后的核心技术,从 monkey patching 到字节码插桩再到 eBPF 探针。每种方法都利用了不同编程语言和运行时环境的独特特性。

这些技术支撑着 OpenTelemetry 等生产级可观测性工具,使开发人员无需修改源代码即可快速添加遥测数据。最成功的可观测性策略结合了自动插桩和手动插桩:自动插桩为常见模式提供了广泛的覆盖,而手动插桩则捕获特定业务的指标。

如果您想亲手尝试这些技术,可以使用 Automatic Instrumentation Lab

如果您有兴趣为这些技术做出贡献,请考虑参与 OpenTelemetry 的各个特别兴趣小组 (SIG)。

最后修改于 2025 年 10 月 20 日:修复 eBPF 插桩部分错字 (#8152) (ce572e10)