逆向工程思维解决云原生现场分析问题 Part1 —— eBPF

2022-02-10  本文已影响0人  MarkZhu
image.png

缘起

云原生复杂性

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

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

从高处看:

从细节看:

云原生现场分析的难

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

  1. 加日志。
    1. 如果要解决的问题刚才源码中有日志,或者提供日志开关,当然就打开完事。收工开饭。但这运气得多好?
    2. 修改开源源码,加入日志,来个紧急上线。这样你得和运维关系有多铁?你确定加一次就够了吗?
  2. 语言级别的动态 instrumentation 注入代码
    1. 在注入代码中分析数据或出日志。如 alibaba/arthas 。golang instrumentation
    2. 这对语言有要求,如果是 c/c++ 等就 爱莫能助 了。
    3. 对性能影响一般也不少。
  3. debug
    1. java debug / golang Delve / gdb 等,都有一定的使用门槛,如程序打包时需要包含了 debug 信息。这在当下喜欢计较 image 大小的年代,debug 信息多被翦掉。同时,断点时可能挂起线程甚至整个进程。生产环境上发生就是灾难。
  4. uprobe/kprobe/eBPF
    1. 在上面方法都不可行时,这个方法值得一试。下面,我们分析一下,什么是 uprobe/kprobe/eBPF。为何有价值。

逆向工程思维

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

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

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

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

实践

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

Show me the code.

实践目标

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

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

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

先说说我的环境:

Hello World

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

我们知道 envoy 源码的主入口在 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 <linux/in.h>
#include <linux/in6.h>

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 << 8) & 0xff00);
                     $bind_ip = ntop(AF_INET, $s->sin_addr.s_addr);                         
                     printf("comm:%-16s: bind AF_INET: ip:%-16s port:%-5d fd=%d \n", comm,
                         $bind_ip,
                         $port, $fd);
              } else { //IPv6
                     $s6 = (struct sockaddr_in6 *)$sa;
                     $port = ($s6->sin6_port >> 8) |
                         (($s6->sin6_port << 8) & 0xff00);
                     $bind_ip = ntop(AF_INET6, $s6->sin6_addr.in6_u.u6_addr8);
                     printf("comm:%-16s: bind AF_INET6:%-16s %-5d \n", comm,
                         $bind_ip,
                         $port);
              }
              printf("stack: %s\n", ustack);

              // @bind[comm, args->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 << 8) & 0xff00);
                            printf("peerIP:%-16s peerPort:%-5d fd:%d\n",
                            ntop(AF_INET, $s->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 << 8) & 0xff00);
                            printf("%-16s %-5d %d\n",
                            ntop(AF_INET6, $s6->sin6_addr.in6_u.u6_addr8),
                            $port, $error);
                            printf("stack: %s\n", ustack);
                     }
              }

              delete(@sockaddr[tid]);
       }
}

END
{
       clear(@sockaddr);
       clear(@fam2str);
}

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

  1. 启动壳进程,以让我们预先可以得到将启动的 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 ...
  1. 启动跟踪 bpftrace 脚本。在新的终端中执行:
$ bpftrace trace-envoy-socket.bt 5678
  1. 回到步骤 1 的壳进程终端。按下空格键,Envoy 正式运行,PID 保持为 5678
  2. 这时,我们在运行 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<Envoy::Network::NetworkSocketTrait<...)0> >::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<void (Envoy::Server::Instance&)>)+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<Envoy::Network::Address::Instance const>)+101
        Envoy::Network::SocketImpl::bind(std::__1::shared_ptr<Envoy::Network::Address::Instance const>)+383
        Envoy::Network::ListenSocketImpl::bind(std::__1::shared_ptr<Envoy::Network::Address::Instance const>)+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<void (Envoy::Server::Instance&)>)+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::Event::DispatcherImpl::createFileEvent(...)+65
        Envoy::Event::FileEventImpl::assignEvents(unsigned int, event_base*)::$_1::__invoke(int, short, void*)+92
        event_process_active_single_queue+1416
        event_base_loop+1953
        Envoy::Server::WorkerImpl::threadRoutine(Envoy::Server::GuardDog&, std::__1::function<void ()> const&)+621
        Envoy::Thread::ThreadImplPosix::ThreadImplPosix(...)+19
        start_thread+217


########## 4.setsockopt() ##########
comm:wrk:worker_1    : setsockopt: fd=20, optname=1, optval=1, optlen=4. stack:
        setsockopt+14
        Envoy::Network::IoSocketHandleImpl::setOption(int, int, void const*, unsigned int)+90
        Envoy::Network::ConnectionImpl::noDelay(bool)+143
        Envoy::Server::ActiveTcpConnection::ActiveTcpConnection(...)+141
        Envoy::Server::ActiveTcpListener::newConnection(...)+650
        Envoy::Server::ActiveTcpSocket::newConnection()+377
        Envoy::Server::ActiveTcpSocket::continueFilterChain(bool)+107
        Envoy::Server::ActiveTcpListener::onAcceptWorker(...)+163
        Envoy::Network::TcpListenerImpl::onSocketEvent(short)+856
        Envoy::Event::FileEventImpl::assignEvents(unsigned int, event_base*)::$_1::__invoke(int, short, void*)+92
        event_process_active_single_queue+1416
        event_base_loop+1953
        Envoy::Server::WorkerImpl::threadRoutine(Envoy::Server::GuardDog&, std::__1::function<void ()> const&)+621
        Envoy::Thread::ThreadImplPosix::ThreadImplPosix(...)+19
        start_thread+217


########## 5.exit accept4() ##########
accept4: pid:219185 comm:wrk:worker_1     family:2   peerIP:127.0.0.1        peerPort:38686 fd:-11
stack:
        accept4+96
        Envoy::Network::IoSocketHandleImpl::accept(sockaddr*, unsigned int*)+82
        Envoy::Network::TcpListenerImpl::onSocketEvent(short)+216
        std::__1::__function::__func<Envoy::Event::DispatcherImpl::createFileEvent(...)+65
        Envoy::Event::FileEventImpl::assignEvents(unsigned int, event_base*)::$_1::__invoke(int, short, void*)+92
        event_process_active_single_queue+1416
        event_base_loop+1953
        Envoy::Server::WorkerImpl::threadRoutine(Envoy::Server::GuardDog&, std::__1::function<void ()> const&)+621
        Envoy::Thread::ThreadImplPosix::ThreadImplPosix(...)+19
        start_thread+217

如果你之前没接触过 bpftrace(相信大部分人是这种情况),你可以先猜想分析一下前面的信息,再看我下面的说明。

bpftrace 脚本分析

回到上面的 bpftrace 脚本 trace-envoy-socket.bt 。

可以看到有很多的 tracepoint:syscalls:sys_enter_xyz 函数,每个其实都是一些钩子方法,在进程调用 xzy 方法时,相应的钩子方法会被调用。而在钩子方法中,可以分析 xyz 函数的入参、返回值(出参)、当前线程的函数调用堆栈等信息。并可以把信息分析状态保存在一个 BPF map 中。

在上面例子里,我们拦截了 setsockopt、bind、accept4(进入与返回),4个事件,并打印出相关入出参数、进程当前线程的堆栈。

每个钩子方法都有一个:/pid == $1/ 。它是个附加的钩子方法调用条件。因 tracepoint 类型拦截点是对整个操作系统的,但我们只关心自己启动的 envoy 进程,所以要加入 envoy 进程的 pid 作为过滤。其中 $1 是我们运行 bpftrace trace-envoy-socket.bt 5678 命令时的第 1 个参数,即为 enovy 进程的 pid。

bpftrace 输出结果分析

  1. envoy 主线程设置了主监听 socket 的 setsockopt

    • comm:envoy。说明这是主线程

    • fd=22。 说明 socket 文件句柄为 22(每个socket都对应一个文件句柄编号,相当于 socket id)。

    • optname=2, optval=1。说明设置项id为 2(SO_REUSEADDR),値为 1。

    • setsockopt+14 到 __libc_start_main+243 为当前线程的函数调用堆栈。通过这,可以对应上项目源码了。

  2. envoy 主线程把主监听 socket 的绑定监听在 IP 0.0.0.0 的端口 10000 上,调用 bind

    • comm:envoy。说明这是主线程
    • fd=22。 说明 socket 文件句柄为 22,即和上一步是相同的 socket
    • ip:0.0.0.0 port:10000。说明 socket 的监听地址
    • 其它就是当前线程的函数调用堆栈。通过这,可以对应上项目源码。
  3. envoy 的 worker 线程之一的 wrk:worker_1 线程接受了一个新客户端的连接。并 setsockopt

    • comm:wrk:worker_1 。envoy 的 worker 线程之一的 wrk:worker_1 线程
    • peerIP:127.0.0.1 peerPort:38686。说明新客户端对端的地址。
    • fd:20。 说明新接受的 socket 文件句柄为 20。
  4. wrk:worker_1 线程 setsockopt 新客户端 socket 连接

    • fd:20。 说明新接受的 socket 文件句柄为 20。
    • optname=1, optval=1。说明设置项id为 1(TCP_NODELAY),値为 1。
  5. 暂时忽略这个,这很可能是传说中的 epoll 假 wakeup。

上面应该算说得还清楚,但肯定要补充的是 setsockopt 中,设置项id的意义:

setsockopt 参数说明:

level optname 描述名 描述
IPPROTO_TCP=8 1 TCP_NODELAY 0: 打开 Nagle 算法,延迟发 TCP 包<br />1:禁用 Nagle 算法
SOL_SOCKET=1 2 SO_REUSEADDR 1:打开地址重用

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

展望

//TODO

细说逆向工程思维

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

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

可执行文件格式

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

image.png

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

进程的内存

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

image.png

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 <----- 调用 funcA
    nop
    popq    %rbp
    ret

即实际上,计算机底层也是有函数调用指令,内存中也有堆栈内存的概念。

image.png

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

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

埋点

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

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

精彩的参考

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

卡脖子根本原因

根本原因类似 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

上一篇下一篇

猜你喜欢

热点阅读