缘起
云原生复杂性
在 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_REUSEADDR
- TCP 连接又是否启用了臭名昭著的增大网络时延的 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 源码的主入口在 main_common.cc 的:
|
|
我们目标是在 envoy 初始化时,调用这个函数时输出一行信息,代表成功拦截。
首先看看 envoy 可执行文件中带有的函数地址元信息:
|
|
这里需要说明一下,c++ 代码编译时,内部表示函数的名字不是直接使用源码的名字,是规范化变形(mangling)后的名字(可以用 c++filt 命令手工转换)。这里我们得知变形后的函数名是:_ZN5Envoy10MainCommon4mainEiPPcNSt3__18functionIFvRNS_6Server8InstanceEEEE
。于是可以用 bpftrace
去拦截了。
|
|
这时,在另外一个终端中运行 envoy
|
|
卡脖子的现实
在我初学摄影时,老师告诉我一个情况叫: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 :
|
|
现在开始行动,如果你看不懂为何如此,不要急,后面会解析为何:
- 启动壳进程,以让我们预先可以得到将启动的 envoy 的 PID
|
|
输出:
|
|
- 启动跟踪 bpftrace 脚本。在新的终端中执行:
|
|
- 回到步骤 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<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 端过来连接:
|
|
连接成功后,可以看到 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 输出结果分析
-
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 为当前线程的函数调用堆栈。通过这,可以对应上项目源码了。
-
-
envoy 主线程把主监听 socket 的绑定监听在 IP 0.0.0.0 的端口 10000 上,调用 bind
- comm:envoy。说明这是主线程
- fd=22。 说明 socket 文件句柄为 22,即和上一步是相同的 socket
- ip:0.0.0.0 port:10000。说明 socket 的监听地址
- 其它就是当前线程的函数调用堆栈。通过这,可以对应上项目源码。
-
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。
-
wrk:worker_1 线程 setsockopt 新客户端 socket 连接
- fd:20。 说明新接受的 socket 文件句柄为 20。
- optname=1, optval=1。说明设置项id为 1(TCP_NODELAY),値为 1。
-
暂时忽略这个,这很可能是传说中的 epoll 假 wakeup。
上面应该算说得还清楚,但肯定要补充的是 setsockopt 中,设置项id的意义:
setsockopt 参数说明:
level | optname | 描述名 | 描述 |
---|---|---|---|
IPPROTO_TCP=8 | 1 | TCP_NODELAY | 0: 打开 Nagle 算法,延迟发 TCP 包 1:禁用 Nagle 算法 |
SOL_SOCKET=1 | 2 | SO_REUSEADDR | 1:打开地址重用 |
通过这个跟踪,我们实现了既定目标。同时可以看到线程函数调用堆栈,可以从我们选择关注的埋点去分析 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。
回来吧,说说本文的重点函数调用。我们知道,高级语言的函数调用,其实绝大部分情况下会编译成机器语言的函数调用,其中的堆栈处理和高级语言是相近的。
如以下一段代码:
|
|
生成汇编:
gcc -S ./blogc.c
汇编结果片段:
|
|
即实际上,计算机底层也是有函数调用指令,内存中也有堆栈内存的概念。
堆栈在内存中的结构和 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
, notalpha
).
envoy binary file size - currently 127MB #240: https://github.com/envoyproxy/envoy/issues/240mattklein123 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.
原文:
https://blog.mygraphql.com/zh/posts/low-tec/trace/trace-istio/trace-istio-part1/