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

在 200x 年时代,服务端软件架构,组成的复杂度,异构程度相对于云原生,可谓简单很多。那个年代,大多数基础组件,要么由使用企业开发,要么是购买组件服务支持。
到了 201x 年代,开源运动,去 IOE 运动兴起。企业更倾向选择开源基础组件。然而开源基础的维护和问题解决成本其实并不是看起来那么低。给你源码,你以为就什么都看得透吗?对于企业,现在起码有几个大问题:
从高处看:
企业要投入多少人力才、财力可以找到或培养一个看得透开源基础组件的人?开源的版本、安全漏洞、更迭快速,即使专业人才也很难快速看得透运行期的软件行为。组件之间错综复杂的依赖、调用关系,再加上版本依赖和更迭,没有可能运行过完全相同环境的测试(哪怕你用了vm/docker image)从细节看:
对于大型的开源项目,一般企业没可能投入人力看懂全部代码(注意,是看懂,不是看过)。而企业真正关心或使用的,可能只是一小部分和切身故障相关的子模块。对于大型的开源项目,即使你认为看懂全部代码。你也不太可能了解全部运行期的状态。哪怕是项目作者,也不一定可以。卖了半天的关子,那么有什么方法可以卖弄?可以快速理点,分析开源项目运行期行为?
加日志。我们知道现在大部分程序都是用高级语言编码,再编译生成可执行的文件( .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 (这版本有问题,后面说)上面的 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] 深入了解一下。
根本原因类似 https://github.com/iovisor/bcc/issues/2648 。我可能以后写文章详述。
Evnoy 和 Istio Proxy 的 Release ELF 中,到底默认有没函数元信息(.symtab)
https://github.com/istio/istio/issues/14331逆向工程思维解决云原生现场分析问题 Part1(预览版本v3) —— eBPF 跟踪 Istio/Envoy/K8S
中国天气通专业版最新版下载v9.1.0.4 官方安卓版
56.95MB |系统工具
新疆联通网上营业厅官方版(又名中国联通)下载v12.8 安卓客户端
118.17MB |生活服务
联通手机营业厅关怀版(又名中国联通)下载v12.8 安卓最新版
118.17MB |生活服务
28hse香港租屋网APP下载v3.14.0 手机版
51.07MB |生活服务
唐山联通掌上营业厅(中国联通)下载v12.8 安卓版
118.17MB |生活服务
新货多app下载v2.6.2 安卓最新版
65.91MB |生活服务
东梨短剧免费正版app下载v4.0.3 安卓版
61MB |影音播放
甘草云管家软件下载v4.0.8 安卓版
65.3MB |商务办公
2022-03-26
2022-03-26
2022-03-26
2022-03-26
2022-03-26
2022-03-26
2022-03-26
2022-03-26
2022-02-15
2022-02-14