前言Ebpf是现代Linux内核提供的一项非常复杂和强大的技术,它使得Linux内核可编程,不再是一个完整的黑盒子。随着ebpf的发展和成熟,其应用也越来越广泛。本文介绍如何使用ebpf跟踪Node.js的底层代码。简介虽然ebpf的设计思想很简单,但是实现和使用起来却非常复杂。ebpf本质上是在内核中实现了一个虚拟机,用户可以将自己编写的c代码加载到内核中执行,从而参与内核的逻辑处理。这听起来很简单,但整个技术其实非常复杂。在实现上,内核需要对加载的代码进行大量复杂的检查,以确保安全。内核还需要实现一个虚拟机来执行用户代码,并在内核代码中添加支持ebpf机制的逻辑。在使用上,使用或者编写ebpf代码对我们来说成本是非常高的。我们需要学习如何搭建环境,如何编译ebpf程序,甚至是Linux内核的一些知识。不过随着ebpf这些年的发展,这种情况已经改善了很多。网上关于ebpf的介绍很多,这里就不多介绍了。下面使用看如何基于libbpf编写ebpf程序。ebpf程序分为两部分,第一部分是ebpf代码。hello.bpf.c#include#includeSEC("tracepoint/syscalls/sys_enter_execve")inthandle_tp(void*ctx){intpid=bpf_get_current_pid_tgid()>>32;charfmt[]="BPFtriggeredfromPID%d.\n";bpf_trace_printk(fmt,sizeof(fmt),pid);return0;}charLICENSE[]SEC("license")="DualBSD/GPL";上面加载到内核中执行的代码主要是利用了内核的tracepoint机制,在sys_enter_execve函数中插入了一个hook。每次执行这个函数,都会执行钩子函数。另一部分是负责将ebpf代码载入内核的代码。hello.c#include#include#include#include#include#include#include#include#include#include"hello.skel.h"intmain(intargc,char**argv){structhello_bpf*skel;interr;/*OpenBPFApplication*/skel=hello_bpf__open();/*Load&verifyBPFprograms*/err=hello_bpf__load(skel);/*Attachtracepointhandler*/err=hello_bpf__attach(skel);printf("HelloBPFstarted,hitCtrl+Ctostop!\n");//outputread_trace_pipe();cleanup:hello_bpf__destroy(skel);return-err;}这里只列出核心代码,hello.c的逻辑很简单,打开ebpf加载到内核,最后查看ebpf程序的输入。这就是ebpf程序的整体逻辑,过程大同小异,重点是确定我们需要做什么,然后编写不同的代码。最后,当不再需要跟踪时,可以销毁ebpf代码。在应用ebpf之前,内核对我们来说是一个黑盒子。使用ebpf,内核对我们来说更加透明。但是软件是分层的。我们通常不直接和内核打交道,更关心的是上层软件。具体来说,我们在使用一个Node.js的时候,除了关心业务代码之外,还需要关心Node.js本身的代码。但是Node.js对我们来说也是一个黑盒子。我们不知道它做了什么,也不知道它在某一时刻的运行状态,这对我们排查问题或了解系统的运行状态是非常不利的。有了ebpf,我们可以做更多的事情。Linux内核提供了很多代码跟踪技术,其中之一就是uprobe,它是一种动态跟踪应用程序代码的技术。比如我们想了解Node.js的Libuv中的uv_tcp_listen函数,那么我们可以通过ebpf这个效果来实现。有了这个能力,我们就可以掌握更多关于系统的数据和信息。uprobe在应用层的使用要比kprobe复杂一些。kprobe是用来跟踪内核函数的,因为内核知道它的函数对应的虚拟地址,所以我们只需要告诉它函数名就可以跟踪函数了,但是uprobe不同,uprobe是用来跟踪应用层代码的,内核不知道或者不应该关注某个函数对应的虚拟地址,所以这个问题需要应用层来解决。我们来看看具体的实现。uprobe.bpf.c#include#include#include#include#include"uv.h"charLICENSE[]SEC("license")="DualBSD/GPL";SEC("uprobe/uv_tcp_listen")intBPF_KPROBE(uprobe,uv_tcp_t*tcp,intbacklog,uv_connection_cbcb){bpf_printk("uv_tcp_listenstart%d\n",backlog);return0;}SEC("uretprobe/uv_tcp_listen")intBPF_KRETPROBE(uretprobe,intret){bpf_printk("uv_tcp_listenend%d\n",ret);return0;}这里实现了对libuv的uv_tcp_listen函数的跟踪,包括函数启动和执行完成两个跟踪点。定义好ebpf程序后,我们来看看它是如何加载到内核中的。uprobe.cintmain(intargc,char**argv){structuprobe_bpf*skel;longbase_addr,uprobe_offset;interr,i;//可执行文件charexecpath[50]="/usr/bin/node";char*func="uv_tcp_listen";//计算一个函数在可执行文件中的地址偏移progs.uprobe,false/*noturetprobe*/,-1,/*anypid*/execpath,uprobe_offset);skel->links.uretprobe=bpf_program__attach_uprobe(skel->progs.uretprobe,true/*uretprobe*/,-1/*anypid*/,execpath,uprobe_offset);//...cleanup:uprobe_bpf__destroy(skel);return-err;}uprobe.c的重点是计算一个函数在一个可执行文件中的地址信息,这个主要通过判断elf文件,elf是代码编译后生成的可执行文件,它可以记录一些关于可执行文件的元数据(也可以通过readelf-Wsexen_file查看),比如符号T函数信息记录在表中。得到相关信息后,设置uprobe和uretprobe。通过上面的ebpf代码,我们可以跟踪到uv_tcp_listen函数的调用。有了这个能力,我们就可以收听我们想收听的功能了。除了uprobe,我们还可以使用内核的kprobe来监控内核函数。例如,下面的ebpf代码可以跟踪创建过程。SEC("kprobe/__x64_sys_execve")intBPF_KPROBE(__x64_sys_execve){pid_tpid;pid=bpf_get_current_pid_tgid()>>32;bpf_printk("KPROBEENTRYpid=%d",pid);return0;}SEC("kretprobe/__x64_sys_execve")intBPF_KRET4exit_proBE(__x6){pid_tpid;pid=bpf_get_current_pid_tgid()>>32;bpf_printk("KPROBEEXIT:pid=%d\n",pid);return0;}Summary简单介绍了强大的ebpf技术及其在Node.js中的应用,不过这是只是一个简单的例子,我们还有很多事情要做,比如能不能和addon结合使用,如何支持动态能力等等。另外,由于C++代码编译后的函数名和原来的不一样,这可能会导致我们无法通过函数名找到虚拟地址,这里还有很多值得研究的地方。总的来说,ebpf不仅对Node.js很有价值,对其他应用层也很有价值。这是一个值得探索的技术方向。代码库:https://github.com/theanarkh/libbpf-code