知识屋:更实用的电脑技术知识网站
所在位置:首页 > 科技  > 软件

初学者应当怎样学习ebpf?

发表时间:2022-03-26来源:网络

缘起

云原生复杂性

在 200x 年时代,服务端软件架构,组成的复杂度,异构程度相对于云原生,可谓简单很多。那个年代,大多数基础组件,要么由使用企业开发,要么是购买组件服务支持。

到了 201x 年代,开源运动,去 IOE 运动兴起。企业更倾向选择开源基础组件。然而开源基础的维护和问题解决成本其实并不是看起来那么低。给你源码,你以为就什么都看得透吗?对于企业,现在起码有几个大问题:

从高处看:

企业要投入多少人力才、财力可以找到或培养一个看得透开源基础组件的人?开源的版本、安全漏洞、更迭快速,即使专业人才也很难快速看得透运行期的软件行为。组件之间错综复杂的依赖、调用关系,再加上版本依赖和更迭,没有可能运行过完全相同环境的测试(哪怕你用了vm/docker image)
或者你还很迷恋向后兼容,即使它已经伤害过无数程序员的心和夜晚就像 古希腊哲学家赫拉克利特说:no one can step into the same river once(人不能两次踏进同一条河流)


从细节看:

对于大型的开源项目,一般企业没可能投入人力看懂全部代码(注意,是看懂,不是看过)。而企业真正关心或使用的,可能只是一小部分和切身故障相关的子模块。对于大型的开源项目,即使你认为看懂全部代码。你也不太可能了解全部运行期的状态。哪怕是项目作者,也不一定可以。
项目的作者不在企业,也不可能完全了解企业中数据的特性。更何况无处不在的 bug


开源软件的精神在于开放与 free(这里不是指免费,这里只能用英文),而 free 不单单是 read only,它还是 writable 的。
开源软件大都不是大公司中某天才产品经理、天才构架师设计出来。而是众多使用者一起打磨出来的。但如果要看懂全部代码才能 writable,恐怕没人可以修改 Linux 内核了。


静态的代码。这点我认为是最重要的。我们所谓的看懂全部代码,是指静态的代码。但有经验的程序员都知道,代码只有跑起来,才真正让人看得通透。而能分析一个跑起来的程序,才可以说,我看懂全部代码。
这让我想起,一般的 code review,都在 review 什么?


云原生现场分析的难

卖了半天的关子,那么有什么方法可以卖弄?可以快速理点,分析开源项目运行期行为?

加日志。
如果要解决的问题刚才源码中有日志,或者提供日志开关,当然就打开完事。收工开饭。但这运气得多好?修改开源源码,加入日志,来个紧急上线。这样你得和运维关系有多铁?你确定加一次就够了吗?


语言级别的动态 instrumentation 注入代码
在注入代码中分析数据或出日志。如 alibaba/arthas 。golang instrumentation这对语言有要求,如果是 c/c++ 等就 爱莫能助 了。对性能影响一般也不少。


debug
java debug / golang Delve / gdb 等,都有一定的使用门槛,如程序打包时需要包含了 debug 信息。这在当下喜欢计较 image 大小的年代,debug 信息多被翦掉。同时,断点时可能挂起线程甚至整个进程。生产环境上发生就是灾难。


uprobe/kprobe/eBPF
在上面方法都不可行时,这个方法值得一试。下面,我们分析一下,什么是 uprobe/kprobe/eBPF。为何有价值。


逆向工程思维

我们知道现在大部分程序都是用高级语言编码,再编译生成可执行的文件( .exe / ELF ) 或中间文件在运行期 JIT 编译。最终一定要生成计算机指令,计算机才能运行。对于开源项目,如果我们找到了这堆生成的计算机指令和源代码之间映射关系。然后:

在这堆计算机指令的一个合理的位置(可以先假设这个位置就是我们关注的一个高级语言函数的入口)中放入一个钩子如果程序运行到钩子时,我们可以探视:
当前程序的函数调用堆栈当前函数调用的参数、返回值当前进程的静态/全局变量


对于开源项目,知道运行期的实际状态是现场分析问题解决的关键。

由于不想让本文开头过于理论,吓跑人,我把 细说逆向工程思维 一节移到最后。

实践

我之前写技术文章很少写几千字还没一行代码。不过最近不知道是年纪渐长,还是怎的,总想多说点废话。

Show me the code.

实践目标

我们探视所谓的云原生服务网格之背骨的 Envoy sidecar 代理为例子,看看 Envoy 启动过程和建立客户端连接过程中:

是在什么代码去监听 TCP 端口监听的 socket 是否设置了中外驰名的 SO_REUSEADDRTCP 连接又是否启用了臭名昭著的增大网络时延的 Nagle 算法(还是相反 socket 设置了 TCP_NODELAY),见 https://en.wikipedia.org/wiki/Nagle%27s_algorithm

说了那么多废话,主角来了,eBPF技术和我们这次要用的工具 bpftrace。

先说说我的环境:

Ubuntu Linux 20.04系统默认的 bpftrace v0.9.4 (这版本有问题,后面说)

Hello World

上面的 3 实践目标很“伟大”。但我们在实现前,还是先来个小目标,写个 Hello World 吧。

我们知道 envoy 源码的主入口在 http://main_common.cc 的:

int MainCommon::main(int argc, char** argv, PostServerHook hook) { ... }

我们目标是在 envoy 初始化时,调用这个函数时输出一行信息,代表成功拦截。

首先看看 envoy 可执行文件中带有的函数地址元信息:

➜ ~ readelf -s --wide ./envoy | egrep 'MainCommon.*main' 114457: 00000000016313c0 635 FUNC GLOBAL DEFAULT 14 _ZN5Envoy10MainCommon4mainEiPPcNSt3__18functionIFvRNS_6Server8InstanceEEEE

这里需要说明一下,c++ 代码编译时,内部表示函数的名字不是直接使用源码的名字,是规范化变形(mangling)后的名字(可以用 c++filt 命令手工转换)。这里我们得知变形后的函数名是:_ZN5Envoy10MainCommon4mainEiPPcNSt3__18functionIFvRNS_6Server8InstanceEEEE。于是可以用 bpftrace去拦截了。

bpftrace -e 'uprobe:./envoy:_ZN5Envoy10MainCommon4mainEiPPcNSt3__18functionIFvRNS_6Server8InstanceEEEE { printf("Hello world: Got MainCommon::main"); }'

这时,在另外一个终端中运行 envoy

./envoy -c envoy-demo.yaml

卡脖子的现实

在我初学摄影时,老师告诉我一个情况叫:Beginner's luck。而技术界往往相反。这次,我什么都没拦截到。用自以为是的经验摸索了各种方法,均无果。我在这种摸索、无果的循环中折腾了大概半年……

突破

折腾了大概半年后,我实在想放弃了。想不到,一个 Hello World 小目标也完成不了。直到一天,我醒悟到说到底是自己基础知识不好,才不能定位到问题的根源。于是恶补了 程序链接、ELF文件格式、ELF 加载进程内存 等知识。后来,千辛万苦最于找到根本原因(如果一定要一句话说完,就是 bpftrace 旧版本错误解释了函数元信息的地址 )。相关的细节我将写成一编独立的技术文章。这里先不多说。解决方法却很简单,升级 bpftrace,我直接自己编译了 bpftrace v0.14.1 。

终于,在启动 envoy 后输出了:

Hello world: Got MainCommon::main ^C

实践

我尝试不按正常的顺序思维讲这部分。因为一开始去分析实现原理,脚本程序,还不如先浏览一下代码,然后运行一次给大家看。

我们先简单浏览 bpftrace 程序,trace-envoy-socket.bt :

#!/usr/local/bin/bpftrace #include #include BEGIN { @fam2str[AF_UNSPEC] = "AF_UNSPEC"; @fam2str[AF_UNIX] = "AF_UNIX"; @fam2str[AF_INET] = "AF_INET"; @fam2str[AF_INET6] = "AF_INET6"; } tracepoint:syscalls:sys_enter_setsockopt /pid == $1/ { // socket opts: https://elixir.bootlin.com/linux/v5.16.3/source/include/uapi/linux/tcp.h#L92 $fd = args->fd; $optname = args->optname; $optval = args->optval; $optval_int = *$optval; $optlen = args->optlen; printf("\n########## setsockopt() ##########\n"); printf("comm:%-16s: setsockopt: fd=%d, optname=%d, optval=%d, optlen=%d. stack: %s\n", comm, $fd, $optname, $optval_int, $optlen, ustack); } tracepoint:syscalls:sys_enter_bind /pid == $1/ { // printf("bind"); $sa = (struct sockaddr *)args->umyaddr; $fd = args->fd; printf("\n########## bind() ##########\n"); if ($sa->sa_family == AF_INET || $sa->sa_family == AF_INET6) { // printf("comm:%-16s: bind AF_INET(6): %-6d %-16s %-3d \n", comm, pid, comm, $sa->sa_family); if ($sa->sa_family == AF_INET) { //IPv4 $s = (struct sockaddr_in *)$sa; $port = ($s->sin_port >> 8) | (($s->sin_port
sin6_port >> 8) | (($s6->sin6_port uservaddr->sa_family, // @fam2str[args->uservaddr->sa_family]] = count(); } } //tracepoint:syscalls:sys_enter_accept, tracepoint:syscalls:sys_enter_accept4 /pid == $1/ { @sockaddr[tid] = args->upeer_sockaddr; } //tracepoint:syscalls:sys_exit_accept, tracepoint:syscalls:sys_exit_accept4 /pid == $1/ { if( @sockaddr[tid] != 0 ) { $sa = (struct sockaddr *)@sockaddr[tid]; if ($sa->sa_family == AF_INET || $sa->sa_family == AF_INET6) { printf("\n########## exit accept4() ##########\n"); printf("accept4: pid:%-6d comm:%-16s family:%-3d ", pid, comm, $sa->sa_family); $error = args->ret; if ($sa->sa_family == AF_INET) { //IPv4 $s = (struct sockaddr_in *)@sockaddr[tid]; $port = ($s->sin_port >> 8) | (($s->sin_port sin_addr.s_addr), $port, $error); printf("stack: %s\n", ustack); } else { //IPv6 $s6 = (struct sockaddr_in6 *)@sockaddr[tid]; $port = ($s6->sin6_port >> 8) | (($s6->sin6_port sin6_addr.in6_u.u6_addr8), $port, $error); printf("stack: %s\n", ustack); } } delete(@sockaddr[tid]); } } END { clear(@sockaddr); clear(@fam2str); }

现在开始行动,如果你看不懂为何如此,不要急,后面会解析为何:

启动壳进程,以让我们预先可以得到将启动的 envoy 的 PID$ bash -c ' echo "pid=$$"; echo "Any key execute(exec) envoy ..." ; read; exec ./envoy -c ./envoy-demo.yaml'

输出:

pid=5678 Any key execute(exec) envoy ...启动跟踪 bpftrace 脚本。在新的终端中执行:$ bpftrace trace-envoy-socket.bt 5678回到步骤 1 的壳进程终端。按下空格键,Envoy 正式运行,PID 保持为 5678这时,我们在运行 bpftrace 脚本的终端中看到跟踪的准实时输出结果:$ bpftrace trace-envoy-socket.bt ​ ########## 1.setsockopt() ########## comm:envoy : setsockopt: fd=22, optname=2, optval=1, optlen=4. stack: setsockopt+14 Envoy::Network::IoSocketHandleImpl::setOption(int, int, void const*, unsigned int)+90 Envoy::Network::NetworkListenSocket::setPrebindSocketOptions()+50 ... Envoy::Server::ListenSocketFactoryImpl::createListenSocketAndApplyOptions()+114 ... Envoy::Server::ListenerManagerImpl::createListenSocketFactory(...)+133 ... Envoy::Server::Configuration::MainImpl::initialize(...)+2135 Envoy::Server::InstanceImpl::initialize(...)+14470 ... Envoy::MainCommon::MainCommon(int, char const* const*)+398 Envoy::MainCommon::main(int, char**, std::__1::function)+67 main+44 __libc_start_main+243 ​ ​ ########## 2.bind() ########## comm:envoy : bind AF_INET: ip:0.0.0.0 port:10000 fd=22 stack: bind+11 Envoy::Network::IoSocketHandleImpl::bind(std::__1::shared_ptr)+101 Envoy::Network::SocketImpl::bind(std::__1::shared_ptr)+383 Envoy::Network::ListenSocketImpl::bind(std::__1::shared_ptr)+77 Envoy::Network::ListenSocketImpl::setupSocket(...)+76 ... Envoy::Server::ListenSocketFactoryImpl::createListenSocketAndApplyOptions()+114 ... Envoy::Server::ListenerManagerImpl::createListenSocketFactory(...)+133 Envoy::Server::ListenerManagerImpl::setNewOrDrainingSocketFactory... Envoy::Server::ListenerManagerImpl::addOrUpdateListenerInternal(...)+3172 Envoy::Server::ListenerManagerImpl::addOrUpdateListener(...)+409 Envoy::Server::Configuration::MainImpl::initialize(...)+2135 Envoy::Server::InstanceImpl::initialize(...)+14470 ... Envoy::MainCommon::MainCommon(int, char const* const*)+398 Envoy::MainCommon::main(int, char**, std::__1::function)+67 main+44 __libc_start_main+243

这时,模拟一个 client 端过来连接:

$ telnet localhost 10000

连接成功后,可以看到 bpftrace 脚本继续输出了:

########## 3.exit accept4() ########## accept4: pid:219185 comm:wrk:worker_1 family:2 peerIP:127.0.0.1 peerPort:38686 fd:20 stack: accept4+96 Envoy::Network::IoSocketHandleImpl::accept(sockaddr*, unsigned int*)+82 Envoy::Network::TcpListenerImpl::onSocketEvent(short)+216 std::__1::__function::__func

通过这个跟踪,我们实现了既定目标。同时可以看到线程函数调用堆栈,可以从我们选择关注的埋点去分析 envoy 的实际行为。结合源码分析运行期的程序行为。比光看静态源码更快和更有目标性地达成目标。特别是现代大项目大量使用的高级语言特性、OOP多态和抽象等技术,有时候让直接阅读代码去分析运行期行为和设计实际目的变得相当困难。而有了这种技术,会简化这个困难。

展望

//TODO

细说逆向工程思维

这小节有点深。不是必须的知识,只是介绍一点背景,因篇幅问题也不可能说得清晰,要清晰直接看参考资料一节。本节不喜可跳过。勇敢如你能读到这里,就不要被本段吓跑了。

进程的内存与可执行文件的关系

可执行文件格式

程序代码被编译和链接成包含二进制计算机指令的可执行文件。而可执行文件是有格式规范的,在 Linux 中,这个规范叫 Executable and linking format (ELF)。ELF 中包含二进制计算机指令、静态数据、元信息。

静态数据 - 我们在程序中 hard code 的东西数据,如字串常量等二进制计算机指令集合,程序代码逻辑生成的计算机指令。代码中的每个函数都在编译时生成一块指令,而链接器负责把一块块指令连续排列到输出的 ELF 文件的 .text section(区域) 中。而元信息中的.symtab section(区域) 记录了每个函数在 .text section 的地址。说白了,就是代码中的函数名到 ELF 文件地址或运行期进程内存地址的 mapping 关系。.symtab section 对我们逆向工程分析很有用。元信息 - 告诉操作系统,如何加载和动态链接可执行文件,完成进程内存的初始化。其中可以包括一些非运行期必须,但可以帮助定位问题的信息。如上面说的 .symtab section(区域)

​Typical ELF executable object file.
From [Computer Systems - A Programmer’s Perspective]:


进程的内存

一般意义的进程是指可执行文件运行实例。进程的内存结构可能大致划分为:

​Process virtual address space. From [Computer Systems - A Programmer’s Perspective]​


其中的 Memory-mapped region for shared libraries 是二进制计算机指令部分,可先简单认为是直接 copy 或映射自可执行文件的 .text section(区域) (虽然这不完全准确)。

计算机底层的函数调用

有时候不知是幸运还是不幸。现在的程序员的程序视角和90年代时的大不相同。高级语言/脚本语言、OOP、等等都告诉程序员,你不需要了解底层细节。

但有时候了解底层细节,才可以创造出通用共性的创新。如 kernel namespace 到 container,netfiler 到 service mesh。

回来吧,说说本文的重点函数调用。我们知道,高级语言的函数调用,其实绝大部分情况下会编译成机器语言的函数调用,其中的堆栈处理和高级语言是相近的。

如以下一段代码:

//main.c void funcA() { int a; } void main() { int m; funcA(); }

生成汇编:

gcc -S ./blogc.c

汇编结果片段:

funcA: endbr64 pushq %rbp movq %rsp, %rbp nop popq %rbp ret ... ​ ​ main: endbr64 pushq %rbp movq %rsp, %rbp movl $0, %eax call funcA

​堆栈在内存中的结构和 CPU 寄存器的引用
From [BPF Performance Tools]

所以,只要在代码中埋点,分析当前 CPU 寄存器的引用。加上分析堆栈的结构,就可以得到当前线程的函数调用链。而当前函数的出/入参也是放入了指定的寄存器。所以也可以探视到出/入参。具体原理可以看参考一节的内容。

埋点

ebpf 工具的埋点的方法有很多,常用最少包括:

uprobe 应用函数埋点:参考:https://blog.mygraphql.com/zh/posts/low-tec/trace/trace-quick-start/#如何监听函数kprobe 内核函数埋点tracepoint 内核预定义事件埋点硬件事件埋点:如异常(如内存分页错误)、CPU 事件(如 cache miss)

使用哪个还得参考 [BPF Performance Tools] 深入了解一下。

精彩的参考

[Computer Systems - A Programmer’s Perspective - Third edition] - Randal E. Bryant • David R. O’Hallaron - 一本用程序员、操作系统角度深入计算机原理的书。介绍了编译和链接、程序加载、进程内存结构、函数调用堆栈等基本原理https://cs61.seas.harvard.edu/site/2018/Asm2/ - 函数调用堆栈等基本原理[Learning Linux Binary Analysis] - Ryan "elfmaster" O'Neill - ELF 格式深入分析和利用The ELF format - how programs look from the inside[BPF Performance Tools] - Brendan Gregg


卡脖子的现实的一点参考信息

卡脖子根本原因

根本原因类似 https://github.com/iovisor/bcc/issues/2648 。我可能以后写文章详述。

有没函数元信息(.symtab)?

Evnoy 和 Istio Proxy 的 Release ELF 中,到底默认有没函数元信息(.symtab)

https://github.com/istio/istio/issues/14331
Argh, we ship envoy binary without symbols.
Could you get the version of your istio-proxy by calling /usr/local/bin/envoy --version? It should include commit hash. Since you're using 1.1.7, I believe the version output will be:
version: 73fa9b1f29f91029cc2485a685994a0d1dbcde21/1.11.0-dev/Clean/RELEASE/BoringSSL
Once you have the commit hash, you can download envoy binary with symbols from https://storage.googleapis.com/istio-build/proxy/envoy-alpha-73fa9b1f29f91029cc2485a685994a0d1dbcde21.tar.gz (change commit hash if you have a different version of istio-proxy).
You can use gdb with that binary, use it instead of /usr/local/bin/envoy and you should see more useful backtrace.
Thanks!
@Multiply sorry, I pointed you at the wrong binary, it should be this one instead: https://storage.googleapis.com/istio-build/proxy/envoy-symblol-73fa9b1f29f91029cc2485a685994a0d1dbcde21.tar.gz (symbol, not alpha). envoy binary file size - currently 127MB #240: https://github.com/envoyproxy/envoy/issues/240
mattklein123 commented on Nov 23, 2016
The default build includes debug symbols and is statically linked. If you strip symbols that's what takes you down to 8MB or so. If you want to go down further than that you should dynamically link against system libraries.FWIW, we haven't really focused very much on the build/package/install side of things. I'm hoping the community can help out there. Different deployments are going to need different kinds of compiles.

原文:

逆向工程思维解决云原生现场分析问题 Part1(预览版本v3) —— eBPF 跟踪 Istio/Envoy/K8S

收藏
  • 人气文章
  • 最新文章
  • 下载排行榜
  • 热门排行榜