李成笔记网

专注域名、站长SEO知识分享与实战技巧

DataKit APM 自动注入原理篇(ioc自动注入方式)

从 DataKit 1.60.0 版本开始,正式支持 Java、Python 应用 APM 自动注入,目前主要支持 DDTrace。

通过利用 LD_PRELOAD 能力影响动态库加载,从而实现自动调整应用程序的启动命令,达到往应用注入 APM 的目的。

LD_PRELOAD 简介

Linux 系统中利用 LD_PRELOAD 环境变量影响程序运行时链接,方法包括修改 /etc/ld.so.preload 配置文件和启动进程前设置 LD_PRELOAD,它允许你在程序启动之前预先加载一个或多个共享库(动态链接库)。这个环境变量在 Linux 系统中被广泛用于调试、监控、修改程序行为等目的。

/etc/ld.so.preload 的作用主要体现在以下几个方面:

  • 预加载共享库:在程序运行前,动态链接器会按照 LD_PRELOAD 环境变量和 /etc/ld.so.preload 文件中指定的顺序加载共享库,这比 LD_LIBRARY_PATH 环境变量所指定的目录中的共享库还要优先。
  • 函数劫持:通过在预加载的共享库中定义与目标函数完全一样的函数,可以覆盖或劫持系统中的同名函数,实现函数的自定义行为。
  • 调试和测试:预加载共享库可以用于调试和测试,例如,通过预加载一个包含自定义 malloc 或 free 函数的共享库来检测内存泄漏。

DataKit 实现 APM 自动注入

启动应用程序通常有多种方法,例如通过 shell 启动。在 Linux 系统上,常见的 shell 有 bash、sh、zsh 等多种 shell 程序。在 Ubuntu 上,默认的 shell 是 bash ,我们以通过 bash 启动应用为例。

使用 file 命令查看 /bin/bash 可执行文件的信息:

liurui@liurui:~$ file /bin/bash
/bin/bash: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=33a5554034feb2af38e8c75872058883b2988bc5, for GNU/Linux 3.2.0, stripped

可知,bash 程序为动态链接的程序,将由指定的解释器(动态链接器)
/lib64/ld-linux-x86-64.so.2
加载和运行。由于是动态链接程序,修改 /etc/ld.so.preload 可影响程序运行时链接。

当 bash 运行一个程序时,将执行 Standard C library (libc.so) 的 exec 系列函数中的 execve 函数,并最终执行 execve 系统调用,实现将当前 bash 进程的映像(process image)替换为新的映像,如 java 等。

u22$ objdump --dynamic-syms /bin/bash | grep execve | grep GLIBC
0000000000000000      DF *UND*        0000000000000000 (GLIBC_2.2.5) execve

u22$ objdump --private-headers /bin/bash | grep NEEDED
  NEEDED               libtinfo.so.6
  NEEDED               libc.so.6

我们将用于重写命令的动态库路径
/usr/local/datakit/apm_inject/inject/apm_launcher.so
写入 /etc/ld.so.preload,该动态库实现了 execve 函数,将替代 libc.so.6 中的 execve 函数。

execve 函数将重写 Java 和 Python 程序的启动命令,并添加 APM Library 相关参数或环境变量等,而后执行系统调用 execve

Bash 程序的启动

解释器将根据 LD_PRELOAD/etc/ld.so.preload 预加载用户指定的共享库;以下为解释器在对 bash 程序的函数 forkexecve 的查找和绑定过程:

u22$ LD_DEBUG=bindings,symbols  bash --version 1>/dev/null  2>/tmp/ld_debug_log && cat /tmp/ld_debug_log | grep "execve\|fork"
   1315997:        symbol=execve;  lookup in file=bash [0]
   1315997:        symbol=execve;  lookup in file=/usr/local/datakit/apm_inject/inject/apm_launcher.so [0]
   1315997:        binding file bash [0] to /usr/local/datakit/apm_inject/inject/apm_launcher.so [0]: normal symbol `execve' [GLIBC_2.2.5]
   1315997:        symbol=fork;  lookup in file=bash [0]
   1315997:        symbol=fork;  lookup in file=/usr/local/datakit/apm_inject/inject/apm_launcher.so [0]
   1315997:        symbol=fork;  lookup in file=/lib/x86_64-linux-gnu/libtinfo.so.6 [0]
   1315997:        symbol=fork;  lookup in file=/lib/x86_64-linux-gnu/libc.so.6 [0]
   1315997:        binding file bash [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `fork' [GLIBC_2.2.5]

可以根据以上对 symbol 的 lookup 和 binding 信息看到,apm_launcher.so 查找优先级高于 libc.so.6,bash 的 execve 函数被绑定到我们指定的动态库,而 fork 则被绑定到了 C 标准库 libc.so.6

我们可以借助 strace 跟踪通过 bash 解析和执行输入的 java 应用的启动命令时涉及的系统调用:

$ strace bash -c "java -jar not_found_jar.jar" 2>/tmp/j_test || grep execve /tmp/j_test
execve("/usr/bin/bash", ["bash", "-c", "java -jar not_found_jar.jar"], 0x7ffca65a0620 /* 73 vars */) = 0
execve("/usr/bin/java", ["java", "-jar", "not_found_jar.jar"], 0x56686018b990 /* 73 vars */) = 0

$ LD_PRELOAD=/usr/local/datakit/apm_inject/inject/apm_launcher.so strace bash -c "java -jar not_found_jar.jar" 2>/tmp/j_test || grep execve /tmp/j_test
execve("/usr/bin/bash", ["bash", "-c", "java -jar not_found_jar.jar"], 0x7ffd09bdace0 /* 74 vars */) = 0
execve("/usr/bin/java", ["java", "-javaagent:/usr/local/datakit/ap"..., "-Ddd.agent.host=0.0.0.0", "-Ddd.trace.agent.port=9529", "-jar", "not_found_jar.jar"], 0x5bb3d0afc230 /* 74 vars */) = 0

由输出结果可知,strace 先启动了一个 bash 程序,而后 bash 程序启动了 java 程序。在设置 LD_PRELOAD 后,java 程序的启动命令被重写,往命令中添加了 APM Library 并增加了相关参数。

Java 命令执行流程

当我们在终端输入一个命令来启动 Java 程序时,Bash 首先通过 tty 设备读取您的输入,然后利用 GNU Readline 库来处理命令行输入,提供编辑和补全等功能。Bash 解析这个命令后,使用 execve 系统调用来替换当前的 Bash 进程映像为 Java 虚拟机(JVM)的映像。JVM 随后加载 Java 类并执行 main 方法,开始运行您的 Java 程序。一旦程序执行完毕,控制权返回到 Bash,等待下一个命令的输入。这个过程涉及到从终端读取输入、命令行处理、系统调用执行以及 JVM 的启动和运行等多个步骤。

简化一下以上流程并进行实现,大致流程如下:

  • 在 Linux 系统执行 java 命令时,会首先检查 /etc/ld.so.preload 文件中列出的共享库。
  • 如果没有配置共享库,则执行默认操作。
  • 如果配置了共享库,则加载共享库信息,最后调用共享库的execve 函数来执行最终的指令。

所以当我们使用了 java 命令启动了应用的时候,将被替代为指定的共享库中的 execve 函数,该函数先将命令重写为 java
-javaagent:/usr/local/datakit/apm_inject/lib/java/dd-java-agent.jar -Ddd.agent.host=0.0.0.0 -Ddd.trace.agent.port=9529 ...
进而实现了自动注入。

发表评论:

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言