探索 Ebpf 在 Node.Js 中的应用

时间:2022-11-30 23:06:12

探索 Ebpf 在 Node.Js 中的应用

前言

ebpf 是现代 Linux 内核提供的非常复杂和强大的技术,它使得 Linux 内核变得可编程,不再是完全的黑盒子。随着 ebpf 的发展和成熟,其应用也越来越广泛,本文介绍如何使用 ebpf 来追踪 Node.js 底层的代码。

介绍

ebpf 的设计思想虽然很简单,但是实现和使用上非常复杂。ebpf 本质上内核实现了一个虚拟机,用户可以把自己编写的 c 代码加载进内核中执行,从而参与内核的逻辑处理。这听起来很简单,但是整个技术其实非常复杂,从实现来说,内核需要对加载的代码进行非常多而复杂的校验,以保证安全性,内核还需要实现一个虚拟机来执行用户的代码和在内核代码中加入支持 ebpf 机制的逻辑。从使用来说,使用或编写 ebpf 代码对我们来说成本非常高,我们需要学会搭建环境,需要了解如何编译 ebpf 程序,甚至还需要了解 Linux 内核的一些知识。不过随着 ebpf 多年的发展,这种情况已经改善了很多。ebpf 的介绍在网上有很多,这里就不多介绍。

使用

下面来看一下如何基于 libbpf 写一个 ebpf 程序。ebpf 程序分为两个部分,第一部分是 ebpf 代码。hello.bpf.c

  1. #include
  2. #include
  3. SEC("tracepoint/syscalls/sys_enter_execve")
  4. int handle_tp(void *ctx){
  5. int pid = bpf_get_current_pid_tgid()>> 32;
  6. char fmt[] = "BPF triggered from PID %d.\n";
  7. bpf_trace_printk(fmt, sizeof(fmt), pid);
  8. return 0;
  9. }
  10. char LICENSE[] SEC("license") = "Dual BSD/GPL";

以上是被加载进内核执行的代码,主要是利用内核的 tracepoint 机制,给 sys_enter_execve 函数插入一个钩子,每次执行到这个函数时,钩子函数就会被执行。另一部分是负责把 ebpf 代码加载进内核的代码。hello.c

  1. #include
  2. #include
  3. #include
  4. #include
  5. #include
  6. #include
  7. #include
  8. #include
  9. #include
  10. #include "hello.skel.h"
  11. int main(int argc, char **argv){
  12. struct hello_bpf *skel;
  13. int err;
  14. /* Open BPF application */
  15. skel = hello_bpf__open();
  16. /* Load & verify BPF programs */
  17. err = hello_bpf__load(skel);
  18. /* Attach tracepoint handler */
  19. err = hello_bpf__attach(skel);
  20. printf("Hello BPF started, hit Ctrl+C to stop!\n");
  21. // output
  22. read_trace_pipe();
  23. cleanup:
  24. hello_bpf__destroy(skel);
  25. return -err;
  26. }

这里只列出核心的代码,hello.c 的逻辑很简单,打开 ebpf 然后加载到内核,最后查看 ebpf 程序的输入。这就是 ebpf 程序的整体逻辑,过程都差不多,重点是确定我们需要做什么事情,然后写不同的代码。最后,如果不再需要追踪的时候,可以销毁 ebpf 代码。

应用

在 ebpf 之前,内核对我们来说是一个黑盒子。有了 ebpf 之后,内核对我们透明了很多。但是软件是分层的,我们平时直接和内核打交道并不多,我们更关心上层软件的情况。具体来说,当我们使用一个 Node.js 的时候,除了关心业务代码,我们也需要关心 Node.js 本身的代码。但是 Node.js 对我们来说也是个黑盒子,我们不知道它具体做了什么事情或者某一个时刻的运行状态,这样非常不利于我们排查问题或者了解系统的运行情况。有了 ebpf 后,我们就可以做更多的事情了。Linux 内核提供了非常多的代码追踪技术,其中有一种是 uprobe,uprobe 是一种动态追踪应用代码的技术,比如我们想了解 Node.js 的 Libuv 中的 uv_tcp_listen 函数,那么我们就可以通过 ebpf 去实现这种效果。有了这种能力,我们就可以掌握系统更多的数据和信息。

实现

应用层使用 uprobe 比 kprobe 复杂,kprobe 是用于追踪内核函数,因为内核知道它的函数对应的虚拟地址,所以我们只需要告诉它函数名就可以实现对该函数的追踪,但是 uprobe 则不一样,uprobe 是用于追踪应用层代码的,内核并不知道或者说不应该关注某个函数对应的虚拟地址,所以这个难题需要应用层解决。下面来看一下具体的实现。uprobe.bpf.c

  1. #include
  2. #include
  3. #include
  4. #include
  5. #include "uv.h"
  6. char LICENSE[] SEC("license") = "Dual BSD/GPL";
  7. SEC("uprobe/uv_tcp_listen")
  8. int BPF_KPROBE(uprobe, uv_tcp_t* tcp, int backlog, uv_connection_cb cb){
  9. bpf_printk("uv_tcp_listen start %d \n", backlog);
  10. return 0;
  11. }
  12. SEC("uretprobe/uv_tcp_listen")
  13. int BPF_KRETPROBE(uretprobe, int ret){
  14. bpf_printk("uv_tcp_listen end %d \n", ret);
  15. return 0;
  16. }

这里我们实现了对 libuv 的 uv_tcp_listen 函数进行追踪,包括函数开始执行和执行完毕两个追踪点。定义完 ebpf 程序后,来看一下如何加载到内核。uprobe.c

  1. int main(int argc, char **argv){
  2. struct uprobe_bpf *skel;
  3. long base_addr, uprobe_offset;
  4. int err, i;
  5. // 要追踪的可执行文件
  6. char execpath[50] = "/usr/bin/node";
  7. char * func = "uv_tcp_listen";
  8. // 计算某个函数在可执行文件里的地址偏移
  9. uprobe_offset = get_elf_func_offset(execpath, func);
  10. /* Load and verify BPF application */
  11. skel = uprobe_bpf__open_and_load();
  12. /* Attach tracepoint handler */
  13. skel->links.uprobe = bpf_program__attach_uprobe(skel->progs.uprobe,
  14. false /* not uretprobe */,
  15. -1, /* any pid */
  16. execpath,
  17. uprobe_offset);
  18. skel->links.uretprobe = bpf_program__attach_uprobe(skel->progs.uretprobe,
  19. true /* uretprobe */,
  20. -1 /* any pid */,
  21. execpath,
  22. uprobe_offset);
  23. // ...
  24. cleanup:
  25. uprobe_bpf__destroy(skel);
  26. return -err;
  27. }

uprobe.c 的重点在于计算某个函数在某个可执行文件的地址信息,这个主要是利用 elf 文件来判断,elf 是代码编译后生成的一个可执行文件,它里面可以记录了关于可执行文件的一些元数据(也可以通过 readelf -Ws exen_file 查看),比如符号表里记录了函数的信息,拿到相关信息后,设置 uprobe 和 uretprobe就可以了。通过上面的 ebpf 代码,我们就可以追踪到 uv_tcp_listen 函数的调用情况,有了这种能力,我们就可以随便监听自己想监听的函数。除了 uprobe 之后,我们还可以利用内核的 kprobe 监听内核函数。比如下面的 ebpf 代码就可以实现对创建进程的追踪。

  1. SEC("kprobe/__x64_sys_execve")
  2. int BPF_KPROBE(__x64_sys_execve){
  3. pid_t pid;
  4. pid = bpf_get_current_pid_tgid() >> 32;
  5. bpf_printk("KPROBE ENTRY pid = %d", pid);
  6. return 0;
  7. }
  8. SEC("kretprobe/__x64_sys_execve")
  9. int BPF_KRETPROBE(__x64_sys_execve_exit){
  10. pid_t pid;
  11. pid = bpf_get_current_pid_tgid() >> 32;
  12. bpf_printk("KPROBE EXIT: pid = %d\n", pid);
  13. return 0;
  14. }

总结

简单地介绍了一下强大的 ebpf 技术和在 Node.js 中的应用,但是这只是个简单的例子,我们还有很多事情需要做,比如能否结合 addon 来使用,如何支持动态能力等等。另外因为 C++ 代码编译后的函数名和原来的是不太一样的,这可能会导致我们通过函数名找虚拟地址时找不到,这里也还有很多需要研究的地方。总的来说,ebpf 不仅对 Node.js 来说非常有价值,对其他应用层来说意义也是一样的。这是一个非常值得探索的技术方向。

代码仓库:https://github.com/theanarkh/libbpf-code

原文链接:https://mp.weixin.qq.com/s/ZCyLJljSo_oGZeo2eCMx3g