This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,176 @@
<audio id="audio" title="加餐01 | 案例分析怎么解决海量IPVS规则带来的网络延时抖动问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ee/1d/ee78385cc9fa55c431e199c7eefdce1d.mp3"></audio>
你好,我是程远。
今天我们进入到了加餐专题部分。我在结束语的彩蛋里就和你说过在这个加餐案例中我们会用到perf、ftrace、bcc/ebpf这几个Linux调试工具了解它们的原理熟悉它们在调试问题的不同阶段所发挥的作用。
加餐内容我是这样安排的专题的第1讲我先完整交代这个案例的背景带你回顾我们当时整个的调试过程和思路然后用5讲内容对这个案例中用到的调试工具依次进行详细讲解。
好了,话不多说。这一讲,我们先来整体看一下这个容器网络延时的案例。
## 问题的背景
在2020年初的时候我们的一个用户把他们的应用从虚拟机迁移到了Kubernetes平台上。迁移之后用户发现他们的应用在容器中的出错率很高相比在之前虚拟机上的出错率要高出一个数量级。
那为什么会有这么大的差别呢我们首先分析了应用程序的出错日志发现在Kubernetes平台上几乎所有的出错都是因为网络超时导致的。
经过网络环境排查和比对测试,我们排除了网络设备上的问题,那么这个超时就只能是容器和宿主机上的问题了。
这里要先和你说明的是尽管应用程序的出错率在容器中比在虚拟机里高出一个数量级不过这个出错比例仍然是非常低的在虚拟机中的出错率是0.001%而在容器中的出错率是0.01%~0.04%。
因为这个出错率还是很低,所以对于这种低概率事件,我们想复现和排查问题,难度就很大了。
当时我们查看了一些日常的节点监控数据比如CPU使用率、Load Average、内存使用、网络流量和丢包数量、磁盘I/O发现从这些数据中都看不到任何的异常。
既然常规手段无效,那我们应该如何下手去调试这个问题呢?
你可能会想到用 **tcpdump**看一看,因为它是网络抓包最常见的工具。其实我们当时也这样想过,不过马上就被自己否定了,因为这个方法存在下面三个问题。
第一,我们遇到的延时问题是偶尔延时,所以需要长时间地抓取数据,这样抓取的数据量就会很大。
第二,在抓完数据之后,需要单独设计一套分析程序来找到长延时的数据包。
第三,即使我们找到了长延时的数据包,也只是从实际的数据包层面证实了问题。但是这样做无法取得新进展,也无法帮助我们发现案例中网络超时的根本原因。
## 调试过程
对于这种非常偶然的延时问题,之前我们能做的是依靠经验,去查看一些可疑点碰碰“运气”。
不过这一次我们想用更加系统的方法来调试这个问题。所以接下来我会从ebpf破冰perf进一步定位以及用ftrace最终锁定这三个步骤带你一步步去解决这个复杂的网络延时问题。
### ebpf的破冰
我们的想法是这样的因为延时产生在节点上所以可以推测这个延时有很大的概率发生在Linux内核处理数据包的过程中。
沿着这个思路,还需要进一步探索。我们想到,可以给每个数据包在内核协议栈关键的函数上都打上时间戳,然后计算数据包在每两个函数之间的时间差,如果这个时间差比较大,就可以说明问题出在这两个内核函数之间。
要想找到内核协议栈中的关键函数还是比较容易的。比如下面的这张示意图里就列出了Linux内核在接收数据包和发送数据包过程中的主要函数
<img src="https://static001.geekbang.org/resource/image/7a/9d/7aeb58d336ab808b74e8a34e56efa69d.jpeg" alt=""><br>
找到这些主要函数之后,下一个问题就是,想给每个数据包在经过这些函数的时候打上时间戳做记录,应该用什么方法呢?接下来我们一起来看看。
在不修改内核源代码的情况,要截获内核函数,我们可以利用[kprobe](https://www.kernel.org/doc/Documentation/kprobes.txt)或者[tracepoint](https://www.kernel.org/doc/Documentation/trace/tracepoints.txt)的接口。
使用这两种接口的方法也有两种一是直接写kernel module来调用kprobe或者tracepoint的接口第二种方法是通过[ebpf](https://www.kernel.org/doc/html/latest/bpf/index.html)的接口来调用它们。在后面的课程里我还会详细讲解ebpf、kprobe、tracepoint这里你先有个印象就行。
在这里我们选择了第二种方法也就是使用ebpf来调用kprobe或者tracepoint接口记录数据包处理过程中这些协议栈函数的每一次调用。
选择ebpf的原因主要是两个一是ebpf的程序在内核中加载会做很严格的检查这样在生产环境中使用比较安全二是ebpf map功能可以方便地进行内核态与用户态的通讯这样实现一个工具也比较容易。
决定了方法之后这里我们需要先实现一个ebpf工具然后用这个工具来对内核网络函数做trace。
我们工具的具体实现是这样的针对用户的一个TCP/IP数据流记录这个流的数据发送包与数据接收包的传输过程也就是数据发送包从容器的Network Namespace发出一直到它到达宿主机的eth0的全过程以及数据接收包从宿主机的eth0返回到容器Network Namespace的eth0的全程。
在收集了数十万条记录后我们对数据做了分析找出前后两步时间差大于50毫秒ms的记录。最后我们终于发现了下面这段记录
<img src="https://static001.geekbang.org/resource/image/31/4a/31da6708b94be43cccfd5dd70aa34e4a.jpg" alt="">
在这段记录中我们先看一下“Network Namespace”这一列。编号3对应的Namespace ID 4026535252是容器里的而ID4026532057是宿主机上的Host Namespace。
数据包从1到7的数据表示了一个数据包从容器里的eth0通过veth发到宿主机上的peer veth cali29cf0fa56ce然后再通过路由从宿主机的obr0openvswitch接口和eth0接口发出。
为了方便你理解,我在下面画了一张示意图,描述了这个数据包的传输过程:
<img src="https://static001.geekbang.org/resource/image/89/67/8941bdb41a760382e7382124e6410f67.jpeg" alt="">
在这个过程里我们发现了当数据包从容器的eth0发送到宿主机上的cali29cf0fa56ce也就是从第3步到第4步之间花费的时间是10865291752980718-10865291551180388=201800330。
因为时间戳的单位是纳秒ns而201800330超过了200毫秒ms这个时间显然是不正常的。
你还记得吗?我们在容器网络模块的[第17讲](https://time.geekbang.org/column/article/324122)说过veth pair之间数据的发送它会触发一个softirq并且在我们ebpf的记录中也可以看到当数据包到达cali29cf0fa56ce后就是softirqd进程在CPU32上对它做处理。
那么这时候我们就可以把关注点放到CPU32的softirq处理上了。我们再仔细看看CPU32上的sisoftirq的CPU使用情况运行top命令之后再按一下数字键1就可以列出每个CPU的使用率了会发现在CPU32上时不时出现si CPU使用率超过20%的现象。
具体的输出情况如下:
```
%Cpu32 : 8.7 us, 0.0 sy, 0.0 ni, 62.1 id, 0.0 wa, 0.0 hi, 29.1 si, 0.0 st
```
其实刚才说的这点在最初的节点监控数据上我们是不容易注意到的。这是因为我们的节点上有80个CPU单个CPUsi偶尔超过20%平均到80个CPU上就只有 0.25%了。要知道对于一个普通节点1%的si使用率都是很正常的。
好了到这里我们已经缩小了问题的排查范围。可以看到使用了ebpf帮助我们在毫无头绪的情况找到了一个比较明确的方向。那么下一步我们自然要顺藤摸瓜进一步去搞清楚为什么在CPU32上的softirq CPU使用率会时不时突然增高
### perf 定位热点
对于查找高CPU使用率情况下的热点函数perf显然是最有力的工具。我们只需要执行一下后面的这条命令看一下CPU32上的函数调用的热度。
```
# perf record -C 32 -g -- sleep 10
```
为了方便查看,我们可以把 `perf record` 输出的结果做成一个火焰图,具体的方法我在下一讲里介绍,这里你需要先理解定位热点的整体思路。
<img src="https://static001.geekbang.org/resource/image/7f/2e/7f66d31a3e32f8bcfc8600abe713962e.jpg" alt="">
结合前面的数据分析我们已经知道了问题出现在softirq的处理过程中那么在查看火焰图的时候就要特别关注在softirq中被调用到的函数。
从上面这张图里我们可以看到run_timer_softirq所占的比例是比较大的而在run_timer_softirq中的绝大部分比例又是被一个叫作estimation_timer()的函数所占用的。
运行完perf之后我们离真相又近了一步。现在我们知道了CPU32上softirq的繁忙是因为TIMER softirq引起的而TIMER softirq里又在不断地调用 **estimation_timer() 这个函数**
沿着这个思路继续分析对于TIMER softirq的高占比一般有这两种情况一是softirq发生的频率很高二是softirq中的函数执行的时间很长。
那怎么判断具体是哪种情况呢?我们用/proc/softirqs查看CPU32上TIMER softirq每秒钟的次数就会发现TIMER softirq在CPU32上的频率其实并不高。
这样第一种情况就排除了那我们下面就来看看Timer softirq中的那个函数estimation_timer(),是不是它的执行时间太长了?
### ftrace 锁定长延时函数
我们怎样才能得到estimation_timer()函数的执行时间呢?
你还记得我们在容器I/O与内存[那一讲](https://time.geekbang.org/column/article/321330)里用过的[ftrace](https://www.kernel.org/doc/Documentation/trace/ftrace.txt)么当时我们把ftrace的tracer设置为function_graph通过这个办法查看内核函数的调用时间。在这里我们也可以用同样的方法查看estimation_timer()的调用时间。
这时候我们会发现在CPU32上的estimation_timer()这个函数每次被调用的时间都特别长比如下面图里的记录可以看到CPU32上的时间高达310毫秒
<img src="https://static001.geekbang.org/resource/image/88/29/880a8ce02a8412d2e8d31b4c923cdd29.png" alt="">
现在我们可以确定问题就出在estimation_timer()这个函数里了。
接下来我们需要读一下estimation_timer()在内核中的源代码,看看这个函数到底是干什么的,它为什么耗费了这么长的时间。其实定位到这一步,后面的工作就比较容易了。
estimation_timer()是[IPVS](http://www.linuxvirtualserver.org/software/ipvs.html)模块中每隔2秒钟就要调用的一个函数它主要用来更新节点上每一条IPVS规则的状态。Kubernetes Cluster里每建一个service在所有的节点上都会为这个service建立相应的IPVS规则。
通过下面这条命令我们可以看到节点上IPVS规则的数目
```
# ipvsadm -L -n | wc -l
79004
```
我们的节点上已经建立了将近80K条IPVS规则而estimation_timer()每次都需要遍历所有的规则来更新状态这样就导致estimation_timer()函数时间开销需要上百毫秒。
我们还有最后一个问题estimation_timer()是TIMER softirq里执行的函数那它为什么会影响到网络RX softirq的延时呢
这个问题我们只要看一下softirq的处理函数[__do_softirq()](https://github.com/torvalds/linux/blob/219d54332a09e8d8741c1e1982f5eae56099de85/kernel/softirq.c#L280)就会明白了。因为在同一个CPU上__do_softirq()会串行执行每一种类型的softirq所以TIMER softirq执行的时间长了自然会影响到下一个RX softirq的执行。
好了,分析这里,这个网络延时问题产生的原因我们已经完全弄清楚了。接下来,我带你系统梳理一下这个问题的解决思路。
## 问题小结
首先回顾一下今天这一讲的问题我们分析了一个在容器平台的生产环境中用户的应用程序网络延时的问题。这个延时只是偶尔发生并且出错率只有0.01%~0.04%,所以我们从常规的监控数据中无法看到任何异常。
那调试这个问题该如何下手呢?
我们想到的方法是使用ebpf调用kprobe/tracepoint的接口这样就可以追踪数据包在内核协议栈主要函数中花费的时间。
我们实现了一个ebpf工具并且用它缩小了排查范围我们发现当数据包从容器的veth接口发送到宿主机上的veth接口在某个CPU上的softirq的处理会有很长的延时。并且由此发现了在对应的CPU上si的CPU使用率时不时会超过20%。
找到了这个突破口之后我们用perf工具专门查找了这个CPU上的热点函数发现TIMER softirq中调用estimation_timer()的占比是比较高的。
接下来我们使用ftrace进一步确认了在这个特定CPU上estimation_timer()所花费的时间需要几百毫秒。
通过这些步骤我们最终锁定了问题出在IPVS的这个estimation_timer()函数里,也找到了问题的根本原因:**在我们的节点上存在大量的IPVS规则每次遍历这些规则都会消耗很多时间最终导致了网络超时现象。**
知道了原因之后因为我们在生产环境中并不需要读取IPVS规则状态所以为了快速解决生产环境上的问题我们可以使用内核[livepatch](https://www.kernel.org/doc/html/latest/livepatch/livepatch.html)的机制在线地把estimation_timer()函数替换成了一个空函数。
这样我们就暂时规避了因为estimation_timer()耗时长而影响其他softirq的问题。至于长期的解决方案我们可以把IPVS规则的状态统计从TIMER softirq中转移到kernel thread中处理。
## 思考题
如果不使用ebpf工具你还有什么方法来找到这个问题的突破口呢
欢迎你在留言区和我交流讨论。如果这一讲的内容对你有帮助的话,也欢迎转发给你的朋友、同事,和他一起学习进步。

View File

@@ -0,0 +1,295 @@
<audio id="audio" title="加餐02 | 理解perf怎么用perf聚焦热点函数" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f8/e0/f86a7ece88be444273f5b45291aa64e0.mp3"></audio>
你好我是程远。今天我要和你聊一聊容器中如何使用perf。
[上一讲](https://time.geekbang.org/column/article/338413)中我们分析了一个生产环境里的一个真实例子由于节点中的大量的IPVS规则导致了容器在往外发送网络包的时候时不时会有很高的延时。在调试分析这个网络延时问题的过程中我们会使用多种Linux内核的调试工具利用这些工具我们就能很清晰地找到这个问题的根本原因。
在后面的课程里我们会挨个来讲解这些工具其中perf工具的使用相对来说要简单些所以这一讲我们先来看perf这个工具。
## 问题回顾
在具体介绍perf之前我们先来回顾一下上一讲中我们是在什么情况下开始使用perf工具的使用了perf工具之后给我们带来了哪些信息。
在调试网路延时的时候我们使用了ebpf的工具之后发现了节点上一个CPU也就是CPU32的Softirq CPU Usage在运行top时%Cpu那行中的si数值就是Softirq CPU Usage时不时地会增高一下。
在发现CPU Usage异常增高的时候我们肯定想知道是什么程序引起了CPU Usage的异常增高这时候我们就可以用到perf了。
具体怎么操作呢?我们可以通过**抓取数据、数据读取和异常聚焦**三个步骤来实现。
第一步抓取数据。当时我们运行了下面这条perf命令这里的参数 `-C 32` 是指定只抓取CPU32的执行指令`-g` 是指call-graph enable也就是记录函数调用关系 `sleep 10` 主要是为了让perf抓取10秒钟的数据。
```
# perf record -C 32 -g -- sleep 10
```
执行完 `perf record` 之后,我们可以用 `perf report` 命令进行第二步也就是读取数据。为了更加直观地看到CPU32上的函数调用情况我给你生成了一个火焰图火焰图的生产方法我们在后面介绍
通过这个火焰图我们发现了在Softirq里TIMER softirq run_timer_softirq的占比很高并且timer主要处理的都是estimation_timer()这个函数也就是看火焰图X轴占比比较大的函数。这就是第三步异常聚焦也就是说我们通过perf在CPU Usage异常的CPU32上找到了具体是哪一个内核函数使用占比较高。这样在后面的调试分析中我们就可以聚焦到这个内核函数estimation_timer() 上了。
<img src="https://static001.geekbang.org/resource/image/7f/2e/7f66d31a3e32f8bcfc8600abe713962e.jpg" alt="">
好了通过回顾我们在网络延时例子中是如何使用perf的我们知道了这一点**perf可以在CPU Usage增高的节点上找到具体的引起CPU增高的函数然后我们就可以有针对性地聚焦到那个函数做分析。**
既然perf工具这么有用想要更好地使用这个工具我们就要好好认识一下它那我们就一起看看perf的基本概念和常用的使用方法。
## 如何理解Perf的概念和工作机制
Perf这个工具最早是Linux内核著名开发者Ingo Molnar开发的它的源代码在[内核源码](https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/tools/perf)tools目录下在每个Linux发行版里都有这个工具比如CentOS里我们可以运行 `yum install perf` 来安装在Ubuntu里我们可以运行 `apt install linux-tools-common` 来安装。
### Event
第一次上手使用perf的时候我们可以先运行一下 `perf list` 这个命令然后就会看到perf列出了大量的event比如下面这个例子就列出了常用的event。
```
# perf list
branch-instructions OR branches [Hardware event]
branch-misses [Hardware event]
bus-cycles [Hardware event]
cache-misses [Hardware event]
cache-references [Hardware event]
cpu-cycles OR cycles [Hardware event]
instructions [Hardware event]
ref-cycles [Hardware event]
alignment-faults [Software event]
bpf-output [Software event]
context-switches OR cs [Software event]
cpu-clock [Software event]
cpu-migrations OR migrations [Software event]
dummy [Software event]
emulation-faults [Software event]
major-faults [Software event]
minor-faults [Software event]
page-faults OR faults [Software event]
task-clock [Software event]
block:block_bio_bounce [Tracepoint event]
block:block_bio_complete [Tracepoint event]
block:block_bio_frontmerge [Tracepoint event]
block:block_bio_queue [Tracepoint event]
block:block_bio_remap [Tracepoint event]
```
从这里我们可以了解到event都有哪些类型 `perf list` 列出的每个event后面都有一个"[]"里面写了这个event属于什么类型比如"Hardware event"、"Software event"等。完整的event类型我们在内核代码枚举结构perf_type_id里可以看到。
接下来我们就说三个主要的event它们分别是Hardware event、Software event还有Tracepoints event。
**Hardware event**
Hardware event来自处理器中的一个PMUPerformance Monitoring Unit这些event数目不多都是底层处理器相关的行为perf中会命名几个通用的事件比如cpu-cycles执行完成的instructionsCache相关的cache-misses。
不同的处理器有自己不同的PMU事件对于Intel x86处理器PMU的使用和编程都可以在“[Intel 64 and IA-32 Architectures Developer's Manual: Vol. 3B](https://www.intel.in/content/www/in/en/architecture-and-technology/64-ia-32-architectures-software-developer-vol-3b-part-2-manual.html)”Intel 架构的开发者手册)里查到。
我们运行一下 `perf stat` 就可以看到在这段时间里这些Hardware event发生的数目。
```
# perf stat
^C
Performance counter stats for 'system wide':
58667.77 msec cpu-clock # 63.203 CPUs utilized
258666 context-switches # 0.004 M/sec
2554 cpu-migrations # 0.044 K/sec
30763 page-faults # 0.524 K/sec
21275365299 cycles # 0.363 GHz
24827718023 instructions # 1.17 insn per cycle
5402114113 branches # 92.080 M/sec
59862316 branch-misses # 1.11% of all branches
0.928237838 seconds time elapsed
```
**Software event**
Software event是定义在Linux内核代码中的几个特定的事件比较典型的有进程上下文切换内核态到用户态的转换事件context-switches、发生缺页中断的事件page-faults等。
为了让你更容易理解这里我举个例子。就拿page-faults这个perf事件来说我们可以看到在内核代码处理缺页中断的函数里就是调用了perf_sw_event()来注册了这个page-faults。
```
/*
* Explicitly marked noinline such that the function tracer sees this as the
* page_fault entry point. __do_page_fault 是Linux内核处理缺页中断的主要函数
*/
static noinline void
__do_page_fault(struct pt_regs *regs, unsigned long hw_error_code,
unsigned long address)
{
prefetchw(&amp;current-&gt;mm-&gt;mmap_sem);
if (unlikely(kmmio_fault(regs, address)))
return;
/* Was the fault on kernel-controlled part of the address space? */
if (unlikely(fault_in_kernel_space(address)))
do_kern_addr_fault(regs, hw_error_code, address);
else
do_user_addr_fault(regs, hw_error_code, address);
/* 在do_user_addr_fault()里面调用了perf_sw_event() */
}
/* Handle faults in the user portion of the address space */
static inline
void do_user_addr_fault(struct pt_regs *regs,
unsigned long hw_error_code,
unsigned long address)
{
perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS, 1, regs, address);
}
```
**Tracepoints event**
你可以在 `perf list` 中看到大量的Tracepoints event这是因为内核中很多关键函数里都有Tracepoints。它的实现方式和Software event类似都是在内核函数中注册了event。
不过这些tracepoints不仅是用在perf中它已经是Linux内核tracing的标准接口了ftraceebpf等工具都会用到它后面我们还会再详细介绍tracepoint。
好了,讲到这里,你要重点掌握的内容是,**event是perf工作的基础主要有两种有使用硬件的PMU里的event也有在内核代码中注册的event。**
那么在这些event都准备好了之后perf又是怎么去使用这些event呢前面我也提到过有计数和采样两种方式下面我们分别来看看。
### 计数count
计数的这种工作方式比较好理解就是统计某个event在一段时间里发生了多少次。
那具体我们怎么进行计数的呢?`perf stat` 这个命令就是来查看event的数目的前面我们已经运行过 `perf stat` 来查看所有的Hardware events。
这里我们可以加上"-e"参数指定某一个event来看它的计数比如page-faults这里我们看到在当前CPU上这个event在1秒钟内发生了49次
```
# perf stat -e page-faults -- sleep 1
Performance counter stats for 'sleep 1':
49 page-faults
1.001583032 seconds time elapsed
0.001556000 seconds user
0.000000000 seconds sys
```
### 采样sample
说完了计数,我们再来看看采样。在开头回顾网路延时问题的时候,我提到通过 `perf record -C 32 -g -- sleep 10` 这个命令来找到CPU32上CPU开销最大的Softirq相关函数。这里使用的 `perf record` 命令就是通过采样来得到热点函数的,我们来分析一下它是怎么做的。
`perf record` 在不加 `-e` 指定event的时候它缺省的event就是Hardware event cycles。我们先用 `perf stat`来查看1秒钟cycles事件的数量在下面的例子里这个数量是1878165次。
我们可以想一下如果每次cycles event发生的时候我们都记录当时的IP就是处理器当时要执行的指令地址、IP所属的进程等信息的话这样系统的开销就太大了。所以perf就使用了对event采样的方式来记录IP、进程等信息。
```
# perf stat -e cycles -- sleep 1
Performance counter stats for 'sleep 1':
1878165 cycles
```
Perf对event的采样有两种模式
第一种是按照event的数目period比如每发生10000次cycles event就记录一次IP、进程等信息 `perf record` 中的 `-c` 参数可以指定每发生多少次,就做一次记录。
比如在下面的例子里我们指定了每10000 cycles event做一次采样之后在1秒里总共就做了191次采样比我们之前看到1秒钟1878165次cycles的次数要少多了。
```
# perf record -e cycles -c 10000 -- sleep 1
[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 0.024 MB perf.data (191 samples) ]
```
第二种是定义一个频率frequency `perf record` 中的 `-F` 参数就是指定频率的,比如 `perf record -e cycles -F 99 -- sleep 1` 就是指采样每秒钟做99次。
`perf record` 运行结束后会在磁盘的当前目录留下perf.data这个文件里面记录了所有采样得到的信息。然后我们再运行 `perf report` 命令,查看函数或者指令在这些采样里的分布比例,后面我们会用一个例子说明。
说到这里我们已经把perf的基本概念和使用机制都讲完了。接下来我们看看在容器中怎么使用perf
## 容器中怎样使用perf
如果你的container image是基于Ubuntu或者CentOS等Linux发行版的你可以尝试用它们的package repo安装perf的包。不过这么做可能会有个问题我们在前面介绍perf的时候提过perf是和Linux kernel一起发布的也就是说perf版本最好是和Linux kernel使用相同的版本。
如果容器中perf包是独立安装的那么容器中安装的perf版本可能会和宿主机上的内核版本不一致这样有可能导致perf无法正常工作。
所以我们在容器中需要跑perf的时候最好从相应的Linux kernel版本的源代码里去编译并且采用静态库-static的链接方式。然后我们把编译出来的perf直接copy到容器中就可以使用了。
如何在Linux kernel源代码里编译静态链接的perf你可以参考后面的代码
```
# cd $(KERNEL_SRC_ROOT)/tools/perf
# vi Makefile.perf
#### ADD “LDFLAGS=-static” in Makefile.perf
# make clean; make
# file perf
perf: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 3.2.0, BuildID[sha1]=9a42089e52026193fabf693da3c0adb643c2313e, with debug_info, not stripped, too many notes (256)
# ls -lh perf
-rwxr-xr-x 1 root root 19M Aug 14 07:08 perf
```
我这里给了一个带静态链接perfkernel 5.4的container image[例子](https://github.com/chengyli/training/tree/master/perf),你可以运行 `make image` 来生成这个image。
在容器中运行perf还要注意一个权限的问题有两点注意事项需要你留意。
第一点Perf 通过系统调用perf_event_open()来完成对perf event的计数或者采样。不过Docker使用seccompseccomp是一种技术它通过控制系统调用的方式来保障Linux安全会默认禁止perf_event_open()。
所以想要让Docker启动的容器可以运行perf我们要怎么处理呢
其实这个也不难在用Docker启动容器的时候我们需要在seccomp的profile里允许perf_event_open()这个系统调用在容器中使用。在我们的例子中启动container的命令里已经加了这个参数允许了参数是"--security-opt seccomp=unconfined"。
第二点需要允许容器在没有SYS_ADMIN这个capabilityLinux capability我们在[第19讲](https://time.geekbang.org/column/article/326253)说过的情况下也可以让perf访问这些event。那么现在我们需要做的就是在宿主机上设置出 `echo -1 &gt; /proc/sys/kernel/perf_event_paranoid`这样普通的容器里也能执行perf了。
完成了权限设置之后在容器中运行perf就和在VM/BM上运行没有什么区别了。
最后我们再来说一下我们在定位CPU Uage异常时最常用的方法常规的步骤一般是这样的
首先,调用 `perf record` 采样几秒钟,一般需要加 `-g` 参数也就是call-graph还需要抓取函数的调用关系。在多核的机器上还要记得加上 `-a` 参数保证获取所有CPU Core上的函数运行情况。至于采样数据的多少在讲解perf概念的时候说过我们可以用 `-c` 或者 `-F` 参数来控制。
接着,我们需要运行 `perf report` 读取数据。不过很多时候,为了更加直观地看到各个函数的占比,我们会用 `perf script` 命令把perf record生成的perf.data转化成分析脚本然后用FlameGraph工具来读取这个脚本生成火焰图。
下面这组命令就是刚才说过的使用perf的常规步骤
```
# perf record -a -g -- sleep 60
# perf script &gt; out.perf
# git clone --depth 1 https://github.com/brendangregg/FlameGraph.git
# FlameGraph/stackcollapse-perf.pl out.perf &gt; out.folded
# FlameGraph/flamegraph.pl out.folded &gt; out.sv
```
## 重点总结
我们这一讲学习了如何使用perf这里我来给你总结一下重点。
首先我们在线上网络延时异常的那个实际例子中使用了perf。我们发现可以用perf工具通过**抓取数据、数据读取和异常聚焦这**三个步骤的操作在CPU Usage增高的节点上找到具体引起CPU增高的函数。
之后我带你更深入地学习了perf是什么它的工作方式是怎样的这里我把perf的重点再给你强调一遍
Perf的实现基础是event有两大类一类是基于硬件PMU的一类是内核中的软件注册。而Perf 在使用时的工作方式也是两大类,计数和采样。
先看一下计数,它执行的命令是 `perf stat`用来查看每种event发生的次数
采样执行的命令是`perf record`它可以使用period方式就是每N个event发生后记录一次event发生时的IP/进程信息或者用frequency方式每秒钟以固定次数来记录信息。记录的信息会存在当前目录的perf.data文件中。
如果我们要在容器中使用perf要注意这两点
1.容器中的perf版本要和宿主机内核版本匹配可以直接从源代码编译出静态链接的perf。<br>
2.我们需要解决两个权限的问题一个是seccomp对系统调用的限制还有一个是内核对容器中没有SYC_ADMIN capability的限制。
**在我们日常分析系统性能异常的时候使用perf最常用的方式是`perf record`获取采样数据然后用FlameGraph工具来生成火焰图。**
## 思考题
你可以在自己的一台Linux机器上运行一些带负载的程序然后使用perf并且生成火焰图看看开销最大的函数是哪一个。
欢迎在留言区分享你的疑惑和见解。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,351 @@
<audio id="audio" title="加餐03 | 理解ftrace1怎么应用ftrace查看长延时内核函数" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f5/7a/f5b7940dd91077257a401ef0dc7b977a.mp3"></audio>
你好,我是程远。
上一讲里我们一起学习了perf这个工具。在我们的案例里使用perf找到了热点函数之后我们又使用了ftrace这个工具最终锁定了长延时的函数estimation_timer()。
那么这一讲我们就来学习一下ftrace这个工具主要分为两个部分来学习。
第一部分讲解ftrace的最基本的使用方法里面也会提到在我们的案例中是如何使用的。第二部分我们一起看看Linux ftrace是如何实现的这样可以帮助你更好地理解Linux的ftrace工具。
## ftrace的基本使用方法
ftrace这个工具在2008年的时候就被合入了Linux内核当时的版本还是Linux2.6.x。从ftrace的名字function tracer其实我们就可以看出它最初就是用来trace内核中的函数的。
当然了现在ftrace的功能要更加丰富了。不过function tracer作为ftrace最基本的功能也是我们平常调试Linux内核问题时最常用到的功能。那我们就先来看看这个最基本同时也是最重要的function tracer的功能。
ftrace的操作都可以在tracefs这个虚拟文件系统中完成对于CentOS这个tracefs的挂载点在/sys/kernel/debug/tracing下
```
# cat /proc/mounts | grep tracefs
tracefs /sys/kernel/debug/tracing tracefs rw,relatime 0 0
```
你可以进入到 /sys/kernel/debug/tracing目录下看一下这个目录下的文件
```
# cd /sys/kernel/debug/tracing
# ls
available_events dyn_ftrace_total_info kprobe_events saved_cmdlines_size set_graph_notrace trace_clock tracing_on
available_filter_functions enabled_functions kprobe_profile saved_tgids snapshot trace_marker tracing_thresh
available_tracers error_log max_graph_depth set_event stack_max_size trace_marker_raw uprobe_events
buffer_percent events options set_event_pid stack_trace trace_options uprobe_profile
buffer_size_kb free_buffer per_cpu set_ftrace_filter stack_trace_filter trace_pipe
buffer_total_size_kb function_profile_enabled printk_formats set_ftrace_notrace synthetic_events trace_stat
current_tracer hwlat_detector README set_ftrace_pid timestamp_mode tracing_cpumask
dynamic_events instances saved_cmdlines set_graph_function trace tracing_max_latency
```
tracefs虚拟文件系统下的文件操作其实和我们常用的Linux proc和sys虚拟文件系统的操作是差不多的。通过对某个文件的echo操作我们可以向内核的ftrace系统发送命令然后cat某个文件得到ftrace的返回结果。
对于ftrace它的输出结果都可以通过 `cat trace` 这个命令得到。在缺省的状态下ftrace的tracer是nop也就是ftrace什么都不做。因此我们从`cat trace`中也看不到别的只是显示了trace输出格式。
```
# pwd
/sys/kernel/debug/tracing
# cat trace
# tracer: nop
#
# entries-in-buffer/entries-written: 0/0 #P:12
#
# _-----=&gt; irqs-off
# / _----=&gt; need-resched
# | / _---=&gt; hardirq/softirq
# || / _--=&gt; preempt-depth
# ||| / delay
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
# | | | |||| | |
```
下面,我们可以执行 `echo function &gt; current_tracer` 来告诉ftrace我要启用function tracer。
```
# cat current_tracer
nop
# cat available_tracers
hwlat blk mmiotrace function_graph wakeup_dl wakeup_rt wakeup function nop
# echo function &gt; current_tracer
# cat current_tracer
function
```
在启动了function tracer之后我们再查看一下trace的输出。这时候我们就会看到大量的输出每一行的输出就是当前内核中被调用到的内核函数具体的格式你可以参考trace头部的说明。
```
# cat trace | more
# tracer: function
#
# entries-in-buffer/entries-written: 615132/134693727 #P:12
#
# _-----=&gt; irqs-off
# / _----=&gt; need-resched
# | / _---=&gt; hardirq/softirq
# || / _--=&gt; preempt-depth
# ||| / delay
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
# | | | |||| | |
systemd-udevd-20472 [011] .... 2148512.735026: lock_page_memcg &lt;-page_remove_rmap
systemd-udevd-20472 [011] .... 2148512.735026: PageHuge &lt;-page_remove_rmap
systemd-udevd-20472 [011] .... 2148512.735026: unlock_page_memcg &lt;-page_remove_rmap
systemd-udevd-20472 [011] .... 2148512.735026: __unlock_page_memcg &lt;-unlock_page_memcg
systemd-udevd-20472 [011] .... 2148512.735026: __tlb_remove_page_size &lt;-unmap_page_range
systemd-udevd-20472 [011] .... 2148512.735027: vm_normal_page &lt;-unmap_page_range
systemd-udevd-20472 [011] .... 2148512.735027: mark_page_accessed &lt;-unmap_page_range
systemd-udevd-20472 [011] .... 2148512.735027: page_remove_rmap &lt;-unmap_page_range
systemd-udevd-20472 [011] .... 2148512.735027: lock_page_memcg &lt;-page_remove_rmap
```
看到这个trace输出你肯定会觉得输出的函数太多了查看起来太困难了。别担心下面我给你说个技巧来解决输出函数太多的问题。
其实在实际使用的时候我们可以利用ftrace里的filter参数做筛选比如我们可以通过set_ftrace_filter只列出想看到的内核函数或者通过set_ftrace_pid只列出想看到的进程。
为了让你加深理解我给你举个例子比如说如果我们只是想看do_mount这个内核函数有没有被调用到那我们就可以这么操作:
```
# echo nop &gt; current_tracer
# echo do_mount &gt; set_ftrace_filter
# echo function &gt; current_tracer
```
在执行了mount命令之后我们查看一下trace。
这时候我们就只会看到一条do_mount()函数调用的记录,我们一起来看看,输出结果里的几个关键参数都是什么意思。
输出里"do_mount &lt;- ksys_mount"表示do_mount()函数是被ksys_mount()这个函数调用到的,"2159455.499195"表示函数执行时的时间戳,而"[005]"是内核函数do_mount()被执行时所在的CPU编号还有"mount-20889"它是do_mount()被执行时当前进程的pid和进程名。
```
# mount -t tmpfs tmpfs /tmp/fs
# cat trace
# tracer: function
#
# entries-in-buffer/entries-written: 1/1 #P:12
#
# _-----=&gt; irqs-off
# / _----=&gt; need-resched
# | / _---=&gt; hardirq/softirq
# || / _--=&gt; preempt-depth
# ||| / delay
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
# | | | |||| | |
mount-20889 [005] .... 2159455.499195: do_mount &lt;-ksys_mount
```
这里我们只能判断出ksys mount()调用了do mount()这个函数这只是一层调用关系如果我们想要看更加完整的函数调用栈可以打开ftrace中的func_stack_trace选项
```
# echo 1 &gt; options/func_stack_trace
```
打开以后我们再来做一次mount操作就可以更清楚地看到do_mount()是系统调用(syscall)之后被调用到的。
```
# umount /tmp/fs
# mount -t tmpfs tmpfs /tmp/fs
# cat trace
# tracer: function
#
# entries-in-buffer/entries-written: 3/3 #P:12
#
# _-----=&gt; irqs-off
# / _----=&gt; need-resched
# | / _---=&gt; hardirq/softirq
# || / _--=&gt; preempt-depth
# ||| / delay
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
# | | | |||| | |
mount-20889 [005] .... 2159455.499195: do_mount &lt;-ksys_mount
mount-21048 [000] .... 2162013.660835: do_mount &lt;-ksys_mount
mount-21048 [000] .... 2162013.660841: &lt;stack trace&gt;
=&gt; do_mount
=&gt; ksys_mount
=&gt; __x64_sys_mount
=&gt; do_syscall_64
=&gt; entry_SYSCALL_64_after_hwframe
```
结合刚才说的内容我们知道了通过function tracer可以帮我们判断内核中函数是否被调用到以及函数被调用的整个路径 也就是调用栈。
这样我们就理清了整体的追踪思路如果我们通过perf发现了一个内核函数的调用频率比较高就可以通过function tracer工具继续深入这样就能大概知道这个函数是在什么情况下被调用到的。
那如果我们还想知道某个函数在内核中大致花费了多少时间就像加餐第一讲案例中我们就拿到了estimation_timer()时间开销,又要怎么做呢?
这里需要用到ftrace中的另外一个tracer它就是function_graph。我们可以在刚才的ftrace的设置基础上把current_tracer设置为function_graph然后就能看到do_mount()这个函数调用的时间了。
```
# echo function_graph &gt; current_tracer
# umount /tmp/fs
# mount -t tmpfs tmpfs /tmp/fs
# cat trace
# tracer: function_graph
#
# CPU DURATION FUNCTION CALLS
# | | | | | | |
0) ! 175.411 us | do_mount();
```
通过function_graph tracer还可以让我们看到每个函数里所有子函数的调用以及时间这对我们理解和分析内核行为都是很有帮助的。
比如说我们想查看kfree_skb()这个函数是怎么执行的,就可以像下面这样配置:
```
# echo '!do_mount ' &gt;&gt; set_ftrace_filter ### 先把之前的do_mount filter给去掉。
# echo kfree_skb &gt; set_graph_function ### 设置kfree_skb()
# echo nop &gt; current_tracer ### 暂时把current_tracer设置为nop, 这样可以清空trace
# echo function_graph &gt; current_tracer ### 把current_tracer设置为function_graph
```
设置完成之后我们再来看trace的输出。现在我们就可以看到kfree_skb()下的所有子函数的调用,以及它们花费的时间了。
具体输出如下,你可以做个参考:
```
# cat trace | more
# tracer: function_graph
#
# CPU DURATION FUNCTION CALLS
# | | | | | | |
0) | kfree_skb() {
0) | skb_release_all() {
0) | skb_release_head_state() {
0) | nf_conntrack_destroy() {
0) | destroy_conntrack [nf_conntrack]() {
0) 0.205 us | nf_ct_remove_expectations [nf_conntrack]();
0) | nf_ct_del_from_dying_or_unconfirmed_list [nf_conntrack]() {
0) 0.282 us | _raw_spin_lock();
0) 0.679 us | }
0) 0.193 us | __local_bh_enable_ip();
0) | nf_conntrack_free [nf_conntrack]() {
0) | nf_ct_ext_destroy [nf_conntrack]() {
0) 0.177 us | nf_nat_cleanup_conntrack [nf_nat]();
0) 1.377 us | }
0) | kfree_call_rcu() {
0) | __call_rcu() {
0) 0.383 us | rcu_segcblist_enqueue();
0) 1.111 us | }
0) 1.535 us | }
0) 0.446 us | kmem_cache_free();
0) 4.294 us | }
0) 6.922 us | }
0) 7.665 us | }
0) 8.105 us | }
0) | skb_release_data() {
0) | skb_free_head() {
0) 0.470 us | page_frag_free();
0) 0.922 us | }
0) 1.355 us | }
0) + 10.192 us | }
0) | kfree_skbmem() {
0) 0.669 us | kmem_cache_free();
0) 1.046 us | }
0) + 13.707 us | }
```
好了对于ftrace的最基本的、也是最重要的内核函数相关的tracer我们已经知道怎样操作了。那你有没有好奇过这个ftrace又是怎么实现的呢下面我们就来看一下。
## ftrace的实现机制
下面这张图描述了ftrace实现的high level的架构用户通过tracefs向内核中的function tracer发送命令然后function tracer把收集到的数据写入一个ring buffer再通过tracefs输出给用户。
<img src="https://static001.geekbang.org/resource/image/22/52/2220b9346955d55361a2fe5ce1e62552.jpeg" alt="">
这里的整个过程看上去比较好理解。不过还是有一个问题,不知道你有没有思考过,
frace可以收集到内核中任意一个函数被调用的情况这点是怎么做到的
你可能想到这是因为在内核的每个函数中都加上了hook点了吗这时我们来看一下内核的源代码显然并没有这样的hook点。那Linux到底是怎么实现的呢
其实这里ftrace是利用了gcc编译器的特性再加上几步非常高明的代码段替换操作就很完美地实现了对内核中所有函数追踪的接口这里的“所有函数”不包括“inline函数”。下面我们一起看一下这个实现。
Linux内核在编译的时候缺省会使用三个gcc的参数"-pg -mfentry -mrecord-mcount"。
其中,"-pg -mfentry"这两个参数的作用是,给编译出来的每个函数开头都插入一条指令"callq &lt;**fentry**&gt;"。
你如果编译过内核,那么你可以用"objdump -D vmlinux"来查看一下内核函数的汇编比如do_mount()函数的开头几条汇编就是这样的:
```
ffffffff81309550 &lt;do_mount&gt;:
ffffffff81309550: e8 fb 83 8f 00 callq ffffffff81c01950 &lt;__fentry__&gt;
ffffffff81309555: 55 push %rbp
ffffffff81309556: 48 89 e5 mov %rsp,%rbp
ffffffff81309559: 41 57 push %r15
ffffffff8130955b: 49 89 d7 mov %rdx,%r15
ffffffff8130955e: ba 00 00 ed c0 mov $0xc0ed0000,%edx
ffffffff81309563: 41 56 push %r14
ffffffff81309565: 49 89 fe mov %rdi,%r14
ffffffff81309568: 41 55 push %r13
ffffffff8130956a: 4d 89 c5 mov %r8,%r13
ffffffff8130956d: 41 54 push %r12
ffffffff8130956f: 53 push %rbx
ffffffff81309570: 48 89 cb mov %rcx,%rbx
ffffffff81309573: 81 e1 00 00 ff ff and $0xffff0000,%ecx
ffffffff81309579: 48 83 ec 30 sub $0x30,%rsp
...
```
而"-mrecord-mcount"参数在最后的内核二进制文件vmlinux中附加了一个mcount_loc的段这个段里记录了所有"callq &lt;**fentry**&gt;"指令的地址。这样我们很容易就能找到每个函数的这个入口点。
为了方便你理解我画了一张示意图我们编译出来的vmlinux就像图里展示的这样
<img src="https://static001.geekbang.org/resource/image/9f/49/9f62b0951b764fa61b7e5fe9b2d05449.jpeg" alt="">
不过你需要注意的是,**尽管通过编译的方式我们可以给每个函数都加上一个额外的hook点但是这个额外"<strong>fentry**"函数调用的开销是很大的。</strong>
即使"**fentry**"函数中只是一个retq指令也会使内核性能下降13%这对于Linux内核来说显然是不可以被接受的。那我们应该怎么办呢
ftrace在内核启动的时候做了一件事就是把内核每个函数里的第一条指令"callq &lt;**fentry**&gt;"5个字节替换成了"nop"指令0F 1F 44 00 00也就是一条空指令表示什么都不做。
虽然是空指令不过在内核的代码段里这相当于给每个函数预留了5个字节。这样在需要的时候内核可以再把这5个字节替换成callq指令call的函数就可以指定成我们需要的函数了。
同时内核的mcount_loc段里虽然已经记录了每个函数"callq &lt;**fentry**&gt;"的地址不过对于ftrace来说除了地址之外它还需要一些额外的信息。
因此在内核启动初始化的时候ftrace又申请了新的内存来存放mcount_loc段中原来的地址信息外加对每个地址的控制信息最后释放了原来的mcount_loc段。
所以Linux内核在机器上启动之后在内存中的代码段和数据结构就会发生变化。你可以参考后面这张图它描述了变化后的情况
<img src="https://static001.geekbang.org/resource/image/02/6c/020548718a7a1819fac0c61d73f52e6c.jpeg" alt="">
当我们需要用function tracer来trace某一个函数的时候比如"echo do_mount &gt; set_ftrace_filter"命令执行之后do_mount()函数的第一条指令就会被替换成调用ftrace_caller的指令。
你可以查看后面的示意图,结合这张图来理解刚才的内容。
<img src="https://static001.geekbang.org/resource/image/a2/5c/a2b469b754ab63c686318d2c427fb55c.jpeg" alt="">
这样每调用一次do_mount()函数它都会调用function_trace_call()函数把ftrace function trace信息放入ring buffer里再通过tracefs输出给用户。
## 重点小结
这一讲我们主要讲解了Linux ftrace这个工具。
首先我们学习了ftrace最基本的操作对内核函数做trace。在这里最重要的有两个tracers分别是function和function_graph。
function tracer可以用来记录内核中被调用到的函数的情况。在实际使用的时候我们可以设置一些ftrace的filter来查看某些我们关心的函数或者我们关心的进程调用到的函数。
我们还可以设置func_stack_trace选项来查看被trace函数的完整调用栈。
而function_graph trracer可以用来查看内核函数和它的子函数调用关系以及调用时间这对我们理解内核的行为非常有帮助。
讲完了ftrace的基本操作之后我们又深入研究了ftrace在Linux中的实现机制。
在ftrace实现过程里**最重要的一个环节是利用gcc编译器的特性为每个内核函数二进制码中预留了5个字节这样内核函数就可以调用调试需要的函数从而实现了ftrace的功能。**
## 思考题
我们讲ftrace实现机制时说过内核中的“inline函数”不能被ftrace到你知道这是为什么吗那么内核中的"static函数"可以被ftrace追踪到吗
欢迎你在留言区跟我分享你的思考与疑问,如果这一讲对你有启发,也欢迎转发给你的同事、朋友,跟他一起交流学习。

View File

@@ -0,0 +1,360 @@
<audio id="audio" title="加餐04 | 理解ftrace2怎么理解ftrace背后的技术tracepoint和kprobe" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7c/f8/7c2138b9eec7237a42a28yy80a43ebf8.mp3"></audio>
你好,我是程远。
前面两讲我们分别学习了perf和ftrace这两个最重要 Linux tracing工具。在学习过程中我们把重点放在了这两个工具最基本的功能点上。
不过你学习完这些之后,我们内核调试版图的知识点还没有全部点亮。
如果你再去查看一些perf、ftrace或者其他Linux tracing相关资料你可能会常常看到两个单词“tracepoint”和“kprobe”。你有没有好奇过这两个名词到底是什么意思它们和perf、ftrace这些工具又是什么关系呢
这一讲我们就来学习这两个在Linux tracing系统中非常重要的概念它们就是**tracepoint**和**kprobe**。
## tracepoint和kprobe的应用举例
如果你深入地去看一些perf或者ftrace的功能这时候你会发现它们都有跟tracepoint、kprobe相关的命令。我们先来看几个例子通过这几个例子你可以大概先了解一下tracepoint和kprobe的应用这样我们后面做详细的原理介绍时你也会更容易理解。
首先看看tracepointtracepoint其实就是在Linux内核的一些关键函数中埋下的hook点这样在tracing的时候我们就可以在这些固定的点上挂载调试的函数然后查看内核的信息。
我们通过下面的这个 `perf list` 命令就可以看到所有的tracepoints
```
# perf list | grep Tracepoint
alarmtimer:alarmtimer_cancel [Tracepoint event]
alarmtimer:alarmtimer_fired [Tracepoint event]
alarmtimer:alarmtimer_start [Tracepoint event]
alarmtimer:alarmtimer_suspend [Tracepoint event]
block:block_bio_backmerge [Tracepoint event]
block:block_bio_bounce [Tracepoint event]
block:block_bio_complete [Tracepoint event]
block:block_bio_frontmerge [Tracepoint event]
block:block_bio_queue [Tracepoint event]
```
至于ftrace你在tracefs文件系统中也会看到一样的tracepoints
```
# find /sys/kernel/debug/tracing/events -type d | sort
/sys/kernel/debug/tracing/events
/sys/kernel/debug/tracing/events/alarmtimer
/sys/kernel/debug/tracing/events/alarmtimer/alarmtimer_cancel
/sys/kernel/debug/tracing/events/alarmtimer/alarmtimer_fired
/sys/kernel/debug/tracing/events/alarmtimer/alarmtimer_start
/sys/kernel/debug/tracing/events/alarmtimer/alarmtimer_suspend
/sys/kernel/debug/tracing/events/block
/sys/kernel/debug/tracing/events/block/block_bio_backmerge
/sys/kernel/debug/tracing/events/block/block_bio_bounce
/sys/kernel/debug/tracing/events/block/block_bio_complete
/sys/kernel/debug/tracing/events/block/block_bio_frontmerge
```
为了让你更好理解我们就拿“do_sys_open”这个tracepoint做例子。在内核函数do_sys_open()中有一个trace_do_sys_open()调用其实它这就是一个tracepoint
```
long do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode)
{
struct open_flags op;
int fd = build_open_flags(flags, mode, &amp;op);
struct filename *tmp;
if (fd)
return fd;
tmp = getname(filename);
if (IS_ERR(tmp))
return PTR_ERR(tmp);
fd = get_unused_fd_flags(flags);
if (fd &gt;= 0) {
struct file *f = do_filp_open(dfd, tmp, &amp;op);
if (IS_ERR(f)) {
put_unused_fd(fd);
fd = PTR_ERR(f);
} else {
fsnotify_open(f);
fd_install(fd, f);
trace_do_sys_open(tmp-&gt;name, flags, mode);
}
}
putname(tmp);
return fd;
}
```
接下来我们可以通过perf命令利用tracepoint来查看一些内核函数发生的频率比如在节点上统计10秒钟内调用do_sys_open成功的次数也就是打开文件的次数。
```
# # perf stat -a -e fs:do_sys_open -- sleep 10
Performance counter stats for 'system wide':
7 fs:do_sys_open
10.001954100 seconds time elapsed
```
同时如果我们把tracefs中do_sys_open的tracepoint打开那么在ftrace的trace输出里就可以看到具体do_sys_open每次调用成功时打开的文件名、文件属性、对应的进程等信息。
```
# pwd
/sys/kernel/debug/tracing
# echo 1 &gt; events/fs/do_sys_open/enable
# cat trace
# tracer: nop
#
# _-----=&gt; irqs-off
# / _----=&gt; need-resched
# | / _---=&gt; hardirq/softirq
# || / _--=&gt; preempt-depth
# ||| / delay
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
# | | | |||| | |
systemd-1 [011] .... 17133447.451839: do_sys_open: "/proc/22597/cgroup" 88000 666
bash-4118 [009] .... 17133450.076026: do_sys_open: "/" 98800 0
salt-minion-7101 [010] .... 17133450.478659: do_sys_open: "/etc/hosts" 88000 666
systemd-journal-2199 [011] .... 17133450.487930: do_sys_open: "/proc/6989/cgroup" 88000 666
systemd-journal-2199 [011] .... 17133450.488019: do_sys_open: "/var/log/journal/d4f76e4bf5414ac78e1c534ebe5d0a72" 98800 0
systemd-journal-2199 [011] .... 17133450.488080: do_sys_open: "/proc/6989/comm" 88000 666
systemd-journal-2199 [011] .... 17133450.488114: do_sys_open: "/proc/6989/cmdline" 88000 666
systemd-journal-2199 [011] .... 17133450.488143: do_sys_open: "/proc/6989/status" 88000 666
systemd-journal-2199 [011] .... 17133450.488185: do_sys_open: "/proc/6989/sessionid" 88000 666
```
请注意Tracepoint是在内核中固定的hook点并不是在所有的函数中都有tracepoint。
比如在上面的例子里我们看到do_sys_open()调用到了do_filp_open()但是do_filp_open()函数里是没有tracepoint的。那如果想看到do_filp_open()函数被调用的频率或者do_filp_open()在被调用时传入参数的情况,我们又该怎么办呢?
这时候我们就需要用到kprobe了。kprobe可以动态地在所有的内核函数除了inline函数上挂载probe函数。我们还是结合例子做理解先看看perf和ftraces是怎么利用kprobe来做调试的。
比如对于do_filp_open()函数,我们可以通过`perf probe`添加一下,然后用`perf stat` 看看在10秒钟的时间里这个函数被调用到的次数。
```
# perf probe --add do_filp_open
# perf stat -a -e probe:do_filp_open -- sleep 10
Performance counter stats for 'system wide':
11 probe:do_filp_open
10.001489223 seconds time elapsed
```
我们也可以通过ftrace的tracefs给do_filp_open()添加一个kprobe event这样就能查看do_filp_open()每次被调用的时候,前面两个参数的值了。
这里我要给你说明一下在写入kprobe_event的时候对于参数的定义我们用到了“%di”和“%si”。这是x86处理器里的寄存器根据x86的[Application Binary Interface的文档](https://github.com/hjl-tools/x86-psABI/wiki/x86-64-psABI-1.0.pdf),在函数被调用的时候,%di存放了第一个参数%si存放的是第二个参数。
```
# echo 'p:kprobes/myprobe do_filp_open dfd=+0(%di):u32 pathname=+0(+0(%si)):string' &gt; /sys/kernel/debug/tracing/kprobe_event
```
完成上面的写入之后我们再enable这个新建的kprobe event。这样在trace中我们就可以看到每次do_filp_open被调用时前两个参数的值了。
```
# echo 1 &gt; /sys/kernel/debug/tracing/events/kprobes/myprobe/enable
# cat /sys/kernel/debug/tracing/trace
irqbalance-1328 [005] .... 2773211.189573: myprobe: (do_filp_open+0x0/0x100) dfd=4294967295 pathname="/proc/interrupts"
irqbalance-1328 [005] .... 2773211.189740: myprobe: (do_filp_open+0x0/0x100) dfd=638399 pathname="/proc/stat"
irqbalance-1328 [005] .... 2773211.189800: myprobe: (do_filp_open+0x0/0x100) dfd=638399 pathname="/proc/irq/8/smp_affinity"
bash-15864 [004] .... 2773211.219048: myprobe: (do_filp_open+0x0/0x100) dfd=14819 pathname="/sys/kernel/debug/tracing/"
bash-15864 [004] .... 2773211.891472: myprobe: (do_filp_open+0x0/0x100) dfd=6859 pathname="/sys/kernel/debug/tracing/"
bash-15864 [004] .... 2773212.036449: myprobe: (do_filp_open+0x0/0x100) dfd=4294967295 pathname="/sys/kernel/debug/tracing/"
bash-15864 [004] .... 2773212.197525: myprobe: (do_filp_open+0x0/0x100) dfd=638259 pathname="/sys/kernel/debug/tracing/
```
好了我们通过perf和ftrace的几个例子简单了解了tracepoint和kprobe是怎么用的。那下面我们再来看看它们的实现原理。
## Tracepoint
刚才我们已经看到了内核函数do_sys_open()里调用了trace_do_sys_open()这个treacepoint那这个tracepoint是怎么实现的呢我们还要再仔细研究一下。
如果你在内核代码中直接搜索“trace_do_sys_open”字符串的话并不能找到这个函数的直接定义。这是因为在Linux中每一个tracepoint的相关数据结构和函数主要是通过"DEFINE_TRACE"和"DECLARE_TRACE"这两个宏来定义的。
完整的“[DEFINE_TRACE](https://github.com/torvalds/linux/blob/v5.4/include/linux/tracepoint.h#L282)”和“[DECLARE_TRACE](https://github.com/torvalds/linux/blob/v5.4/include/linux/tracepoint.h#L231)”宏里给每个tracepoint都定义了一组函数。在这里我会选择最主要的几个函数把定义一个tracepoint的过程给你解释一下。
首先我们来看“trace_##name”这个函数(提示一下,这里的“##”是C语言的预编译宏表示把两个字符串连接起来
对于每个命名为“name”的tracepoint这个宏都会帮助它定一个函数。这个函数的格式是这样的以“trace_”开头再加上tracepoint的名字。
我们举个例子吧。比如说对于“do_sys_open”这个tracepoint它生成的函数名就是trace_do_sys_open。而这个函数会被内核函数do_sys_open()调用从而实现了一个内核的tracepoint。
```
static inline void trace_##name(proto) \
{ \
if (static_key_false(&amp;__tracepoint_##name.key)) \
__DO_TRACE(&amp;__tracepoint_##name, \
TP_PROTO(data_proto), \
TP_ARGS(data_args), \
TP_CONDITION(cond), 0); \
if (IS_ENABLED(CONFIG_LOCKDEP) &amp;&amp; (cond)) { \
rcu_read_lock_sched_notrace(); \
rcu_dereference_sched(__tracepoint_##name.funcs);\
rcu_read_unlock_sched_notrace(); \
} \
}
```
在这个tracepoint函数里主要的功能是这样实现的通过__DO_TRACE来调用所有注册在这个tracepoint上的probe函数。
```
#define __DO_TRACE(tp, proto, args, cond, rcuidle) \
it_func_ptr = rcu_dereference_raw((tp)-&gt;funcs); \
\
if (it_func_ptr) { \
do { \
it_func = (it_func_ptr)-&gt;func; \
__data = (it_func_ptr)-&gt;data; \
((void(*)(proto))(it_func))(args); \
} while ((++it_func_ptr)-&gt;func); \
}
```
而probe函数的注册它可以通过宏定义的“register_trace_##name”函数完成。
```
static inline int \
register_trace_##name(void (*probe)(data_proto), void *data) \
{ \
return tracepoint_probe_register(&amp;__tracepoint_##name, \
(void *)probe, data); \
}
```
我们可以自己写一个简单[kernel module](https://github.com/chengyli/training/tree/main/tracepoint)来注册一个probe函数把它注册到已有的treacepoint上。这样这个probe函数在每次tracepoint点被调用到的时候就会被执行。你可以动手试一下。
好了说到这里tracepoint的实现方式我们就讲完了。简单来说**就是在内核代码中需要被trace的地方显式地加上hook点然后再把自己的probe函数注册上去那么在代码执行的时候就可以执行probe函数。**
## Kprobe
我们已经知道了tracepoint为内核trace提供了hook点但是这些hook点需要在内核源代码中预先写好。如果在debug的过程中我们需要查看的内核函数中没有hook点就需要像前面perf/ftrace的例子中那样要通过Linux kprobe机制来加载probe函数。
那我们要怎么来理解kprobe的实现机制呢
你可以先从内核samples代码里看一下
[kprobe_example.c](https://github.com/torvalds/linux/blob/v5.4/samples/kprobes/kprobe_example.c)代码。这段代码里实现了一个kernel module可以在内核中任意一个函数名/符号对应的代码地址上注册三个probe函数分别是“pre_handler”、 “post_handler”和“fault_handler”。
```
#define MAX_SYMBOL_LEN 64
static char symbol[MAX_SYMBOL_LEN] = "_do_fork";
module_param_string(symbol, symbol, sizeof(symbol), 0644);
/* For each probe you need to allocate a kprobe structure */
static struct kprobe kp = {
.symbol_name = symbol,
};
static int __init kprobe_init(void)
{
int ret;
kp.pre_handler = handler_pre;
kp.post_handler = handler_post;
kp.fault_handler = handler_fault;
ret = register_kprobe(&amp;kp);
if (ret &lt; 0) {
pr_err("register_kprobe failed, returned %d\n", ret);
return ret;
}
pr_info("Planted kprobe at %p\n", kp.addr);
return 0;
}
```
当这个内核函数被执行的时候已经注册的probe函数也会被执行 handler_fault只有在发生异常的时候才会被调用到
比如我们加载的这个kernel module不带参数那么缺省的情况就是这样的在“_do_fork”内核函数的入口点注册了这三个probe函数。
当_do_fork()函数被调用到的时候换句话说也就是创建新的进程时我们通过dmesg就可以看到probe函数的输出了。
```
[8446287.087641] &lt;_do_fork&gt; pre_handler: p-&gt;addr = 0x00000000d301008e, ip = ffffffffb1e8c9d1, flags = 0x246
[8446287.087643] &lt;_do_fork&gt; post_handler: p-&gt;addr = 0x00000000d301008e, flags = 0x246
[8446288.019731] &lt;_do_fork&gt; pre_handler: p-&gt;addr = 0x00000000d301008e, ip = ffffffffb1e8c9d1, flags = 0x246
[8446288.019733] &lt;_do_fork&gt; post_handler: p-&gt;addr = 0x00000000d301008e, flags = 0x246
[8446288.022091] &lt;_do_fork&gt; pre_handler: p-&gt;addr = 0x00000000d301008e, ip = ffffffffb1e8c9d1, flags = 0x246
[8446288.022093] &lt;_do_fork&gt; post_handler: p-&gt;addr = 0x00000000d301008e, flags = 0x246
```
kprobe的基本工作原理其实也很简单。当kprobe函数注册的时候其实就是把目标地址上内核代码的指令码替换成了“cc”也就是int3指令。这样一来当内核代码执行到这条指令的时候就会触发一个异常而进入到Linux int3异常处理函数do_int3()里。
在do_int3()这个函数里如果发现有对应的kprobe注册了probe就会依次执行注册的pre_handler()原来的指令最后是post_handler()。
<img src="https://static001.geekbang.org/resource/image/54/96/5495fee9d95a7f0df6b7f48d8bd25196.jpeg" alt="">
理论上kprobe其实只要知道内核代码中任意一条指令的地址就可以为这个地址注册probe函数kprobe结构中的“addr”成员就可以接受内核中的指令地址。
```
static int __init kprobe_init(void)
{
int ret;
kp.addr = (kprobe_opcode_t *)0xffffffffb1e8ca02; /* 把一条指令的地址赋值给 kprobe.addr */
kp.pre_handler = handler_pre;
kp.post_handler = handler_post;
kp.fault_handler = handler_fault;
ret = register_kprobe(&amp;kp);
if (ret &lt; 0) {
pr_err("register_kprobe failed, returned %d\n", ret);
return ret;
}
pr_info("Planted kprobe at %p\n", kp.addr);
return 0;
}
```
还要说明的是如果内核可以使用我们上一讲ftrace对函数的trace方式也就是函数头上预留了“callq &lt;__fentry__&gt;”的5个字节在启动的时候被替换成了nop。Kprobe对于函数头指令的trace方式也会用“ftrace_caller”指令替换的方式而不再使用int3指令替换。
不论是哪种替换方式kprobe的基本实现原理都是一样的那就是**把目标指令替换替换的指令可以使程序跑到一个特定的handler里去执行probe的函数。**
## 重点小结
这一讲我们主要学习了tracepoint和kprobe这两个概念在Linux tracing系统中非常重要。
为什么说它们重要呢因为从Linux tracing系统看我的理解是可以大致分成大致这样三层。
第一层是最基础的提供数据的机制这里就包含了tracepoints、kprobes还有一些别的events比如perf使用的HW/SW events。
第二层是进行数据收集的工具这里包含了ftrace、perf还有ebpf。
第三层是用户层工具。虽然有了第二层,用户也可以得到数据。不过,对于大多数用户来说,第二层使用的友好程度还不够,所以又有了这一层。
<img src="https://static001.geekbang.org/resource/image/90/8b/9048753d623f0aec9e8b513623f1ec8b.jpeg" alt="">
很显然如果要对Linux内核调试很难绕过tracepoint和kprobe。如果不刨根问底的话前面我们讲的perf、trace工具对你来说还是黑盒。因为你只是知道了这些工具怎么用但是并不知道它们依赖的底层技术。
在后面介绍ebpf的时候我们还会继续学习ebpf是如何使用tracepoint和kprobe来做Linux tracing的希望你可以把相关知识串联起来。
## 思考题
想想看当我们用kprobe为一个内核函数注册了probe之后怎样能看到对应内核函数的第一条指令被替换了呢
欢迎你在留言区记录你的思考或者疑问。如果这一讲对你有帮助,也欢迎你转发给同事、朋友,跟他们一起交流、进步。

View File

@@ -0,0 +1,338 @@
<audio id="audio" title="加餐05 | eBPF怎么更加深入地查看内核中的函数" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/04/2f/0400ce12c9e469c21e97d0118394312f.mp3"></audio>
你好,我是程远。
今天这一讲我们聊一聊eBPF。在我们专题加餐第一讲的分析案例时就说过当我们碰到网络延时问题在毫无头绪的情况下就是依靠了我们自己写的一个eBPF工具找到了问题的突破口。
由此可见eBPF在内核问题追踪上的重要性是不言而喻的。那什么是eBPF它的工作原理是怎么样它的编程模型又是怎样的呢
在这一讲里,我们就来一起看看这几个问题。
## eBPF的概念
eBPF它的全称是“Extended Berkeley Packet Filter”。从名字看你可能会觉奇怪似乎它就是一个用来做网络数据包过滤的模块。
其实这么想也没有错eBPF的概念最早源自于BSD操作系统中的BPFBerkeley Packet Filter1992伯克利实验室的一篇论文 [“The BSD Packet Filter: A New Architecture for User-level Packet Capture”](https://www.tcpdump.org/papers/bpf-usenix93.pdf)。这篇论文描述了BPF是如何更加高效灵活地从操作系统内核中抓取网络数据包的。
我们很熟悉的tcpdump工具它就是利用了BPF的技术来抓取Unix操作系统节点上的网络包。Linux系统中也沿用了BPF的技术。
那BPF是怎样从内核中抓取数据包的呢我借用BPF论文中的图例来解释一下
<img src="https://static001.geekbang.org/resource/image/ae/04/ae55e7de056120c57af0703e0afd7b04.png" alt="">
结合这张图我们一起看看BPF实现有哪些特点。
第一内核中实现了一个虚拟机用户态程序通过系统调用把数据包过滤代码载入到个内核态虚拟机中运行这样就实现了内核态对数据包的过滤。这一块对应图中灰色的大方块也就是BPF的核心。
第二BPF模块和网络协议栈代码是相互独立的BPF只是通过简单的几个hook点就能从协议栈中抓到数据包。内核网络协议代码变化不影响BPF的工作图中右边的“protocol stack”方块就是指内核网络协议栈。
第三内核中的BPF filter模块使用buffer与用户态程序进行通讯把filter的结果返回给用户态程序例如图中的 network monitor这样就不会产生内核态与用户态的上下文切换context switch
在BPF实现的基础上Linux在2014年内核3.18的版本上实现了eBPF全名是Extended BPF也就是BPF的扩展。这个扩展主要做了下面这些改进。
首先,对虚拟机做了增强,扩展了寄存器和指令集的定义,提高了虚拟机的性能,并且可以处理更加复杂的程序。
其次增加了eBPF maps这是一种存储类型可以保存状态信息从一个BPF事件的处理函数传递给另一个或者保存一些统计信息从内核态传递给用户态程序。
最后eBPF可以处理更多的内核事件不再只局限在网络事件上。你可以这样来理解eBPF的程序可以在更多内核代码hook点上注册了比如tracepoints、kprobes等。
在Brendan Gregg 写的书《[BPF Performance Tools](https://www.amazon.com/Performance-Tools-Addison-Wesley-Professional-Computing/dp/0136554822/ref=cm_cr_arp_d_product_top?ie=UTF8)》里有一张eBPF的架构图这张图对eBPF内核部分的模块和工作流的描述还是挺完整的我也推荐你阅读这本书。图书的网上预览部分也可以看到这张图我把它放在这里你可以先看一下。
这里我想提醒你,我们在后面介绍例子程序的时候,你可以回头再来看看这张图,那时你会更深刻地理解这张图里的模块。
<img src="https://static001.geekbang.org/resource/image/1f/8d/1f1af6f7ab8d4a3a2f58cbcd9e9c2e8d.png" alt=""><br>
当BPF增强为eBPF之后 它的应用范围自然也变广了。从单纯的网络包抓取,扩展到了下面的几个领域:
<li>
网络领域,内核态网络包的快速处理和转发,你可以看一下[XDP](https://www.iovisor.org/technology/xdp)eXpress Data Path
</li>
<li>
安全领域,通过[LSM](https://www.kernel.org/doc/html/v4.15/admin-guide/LSM/index.html)Linux Security Module的hook点eBPF可以对Linux内核做安全监控和访问控制你可以参考[KRSI](https://lwn.net/Articles/808048/)Kernel Runtime Security Instrumentation的文档。
</li>
<li>
内核追踪/调试eBPF能通过tracepoints、kprobes、 perf-events等hook点来追踪和调试内核这也是我们在调试生产环境中解决容器相关问题时使用的方法。
</li>
## eBPF的编程模型
前面说了很多eBPF概念方面的内容如果你是刚接触eBPF也许还不能完全理解。所以接下来我们看一下eBPF编程模型然后通过一个编程例子再帮助你理解eBPF。
eBPF程序其实也是遵循了一个固定的模式Daniel Thompson的“[Kernel analysis using eBPF](https://events19.linuxfoundation.org/wp-content/uploads/2017/12/Kernel-Analysis-Using-eBPF-Daniel-Thompson-Linaro.pdf)”里的一张图解读得非常好它很清楚地说明了eBPF的程序怎么编译、加载和运行的。
<img src="https://static001.geekbang.org/resource/image/c4/a2/c4ace7ab4a77d6a9522801c96fd6d2a2.png" alt="">
结合这张图我们一起分析一下eBPF的运行原理。
一个eBPF的程序分为两部分第一部分是内核态的代码也就是图中的foo_kern.c这部分的代码之后会在内核eBPF的虚拟机中执行。第二部分是用户态的代码对应图中的foo_user.c。它的主要功能是负责加载内核态的代码以及在内核态代码运行后通过eBPF maps从内核中读取数据。
然后我们看看eBPF内核态程序的编译因为内核部分的代码需要被编译成eBPF bytecode二进制文件也就是eBPF的虚拟机指令而在Linux里最常用的GCC编译器不支持生成eBPF bytecode所以这里**必须要用 Clang/LLVM 来编译**编译后的文件就是foo_kern.o。
foo_user.c编译链接后就会生成一个普通的用户态程序它会通过bpf() 系统调用做两件事第一是去加载eBPF bytecode文件foo_kern.o使foo_kern.o这个eBPF bytecode在内核eBPF的虚拟机中运行第二是创建eBPF maps用于内核态与用户态的通讯。
接下来在内核态eBPF bytecode会被加载到eBPF内核虚拟机中这里你可以参考一下前面的eBPF架构图。
执行BPF程序之前BPF Verifier先要对eBPF bytecode进行很严格的指令检查。检查通过之后再通过JITJust In Time编译成宿主机上的本地指令。
编译成本地指令之后eBPF程序就可以在内核中运行了比如挂载到tracepoints hook点或者用kprobes来对内核函数做分析然后把得到的数据存储到eBPF maps中这样foo_user这个用户态程序就可以读到数据了。
我们学习eBPF的编程的时候可以从编译和执行Linux内核中 [samples/bpf](https://github.com/torvalds/linux/tree/v5.4/samples/bpf) 目录下的例子开始。在这个目录下的例子里包含了eBPF各种使用场景。每个例子都有两个.c文件命名规则都是xxx_kern.c和xxx_user.c ,编译和运行的方式就和我们刚才讲的一样。
本来我想拿samples/bpf 目录下的一个例子来具体说明的不过后来我在github上看到了一个更好的例子它就是[ebpf-kill-example](https://github.com/chengyli/ebpf-kill-example.git)。下面我就用这个例子来给你讲一讲如何编写eBPF程序以及eBPF代码需要怎么编译与运行。
我们先用git clone取一下代码
```
# git clone https://github.com/niclashedam/ebpf-kill-example
# cd ebpf-kill-example/
# ls
docs img LICENSE Makefile README.md src test
```
这里你可以先看一下Makefile请注意编译eBPF程序需要Clang/LLVM以及由Linux内核源代码里的tools/lib/bpf中生成的libbpf.so库和相关的头文件。如果你的OS是Ubuntu可以运行`make deps;make kernel-src`这个命令,准备好编译的环境。
```
# cat Makefile
deps:
sudo apt update
sudo apt install -y build-essential git make gcc clang libelf-dev gcc-multilib
kernel-src:
git clone --depth 1 --single-branch --branch ${LINUX_VERSION} https://github.com/torvalds/linux.git kernel-src
cd kernel-src/tools/lib/bpf &amp;&amp; make &amp;&amp; make install prefix=../../../../
```
完成上面的步骤后在src/目录下我们可以看到两个文件分别是bpf_program.c和loader.c。
在这个例子里bpf_program.c对应前面说的foo_kern.c 文件也就是说eBPF内核态的代码在bpf_program.c里面。而loader.c就是eBPF用户态的代码它主要负责把eBPF bytecode加载到内核中并且通过eBPF Maps读取内核中返回的数据。
```
# ls src/
bpf_program.c loader.c
```
我们先看一下bpf_program.c中的内容
```
# cat src/bpf_program.c
#include &lt;linux/bpf.h&gt;
#include &lt;stdlib.h&gt;
#include "bpf_helpers.h"
//这里定义了一个eBPF Maps
//Data in this map is accessible in user-space
struct bpf_map_def SEC("maps") kill_map = {
.type = BPF_MAP_TYPE_HASH,
.key_size = sizeof(long),
.value_size = sizeof(char),
.max_entries = 64,
};
// This is the tracepoint arguments of the kill functions
// /sys/kernel/debug/tracing/events/syscalls/sys_enter_kill/format
struct syscalls_enter_kill_args {
long long pad;
long syscall_nr;
long pid;
long sig;
};
// 这里定义了BPF_PROG_TYPE_TRACEPOINT类型的BPF Program
SEC("tracepoint/syscalls/sys_enter_kill")
int bpf_prog(struct syscalls_enter_kill_args *ctx) {
// Ignore normal program terminations
if(ctx-&gt;sig != 9) return 0;
// We can call glibc functions in eBPF
long key = labs(ctx-&gt;pid);
int val = 1;
// Mark the PID as killed in the map
bpf_map_update_elem(&amp;kill_map, &amp;key, &amp;val, BPF_NOEXIST);
return 0;
}
// All eBPF programs must be GPL licensed
char _license[] SEC("license") = "GPL";
```
在这一小段代码中包含了eBPF代码最重要的三个要素分别是
- BPF Program Types
- BPF Maps
- BPF Helpers
“BPF Program Types”定义了函数在eBPF内核态的类型这个类型决定了这个函数会在内核中的哪个hook点执行同时也决定了函数的输入参数的类型。在内核代码[bpf_prog_type](https://github.com/torvalds/linux/blob/v5.4/include/uapi/linux/bpf.h#L149)的枚举定义里你可以看到eBPF支持的所有“BPF Program Types”。
比如在这个例子里的函数bpf_prog()通过SEC()这个宏,我们可以知道它的类型是 BPF_PROG_TYPE_TRACEPOINT并且它注册在syscalls subsystem下的 sys_enter_kill这个tracepoint上。
既然我们知道了具体的tracepoint那么这个tracepoint的注册函数的输入参数也就固定了。在这里我们就把参数组织到syscalls_enter_kill_args{}这个结构里里面最主要的信息就是kill()系统调用中,输入信号的**编号sig**和**信号发送目标进程的pid**。
“BPF Maps”定义了key/value 对的一个存储结构它用于eBPF内核态程序之间或者内核态程序与用户态程序之间的数据通讯。eBPF中定义了不同类型的Maps在内核代码[bpf_map_type](https://github.com/torvalds/linux/blob/v5.4/include/uapi/linux/bpf.h#L112)的枚举定义中,你可以看到完整的定义。
在这个例子里定义的kill_map是BPF_MAP_TYPE_HASH 类型这里也用到了SEC()这个宏,等会儿我们再解释,先看其他的。
kill_map是HASH Maps里的一个key它是一个long数据类型value是一个char字节。bpf_prog()函数在系统调用kill()的tracepoint上运行可以得到目标进程的pid参数Maps里的key值就是这个pid参数来赋值的而val只是简单赋值为1。
然后这段程序调用了一个函数bpf_map_update_elem()把这组新的key/value对写入了到kill_map中。这个函数bpf_map_update_elem()就是我们要说的第三个要素BPF Helpers。
我们再看一下“[BPF Helpers](https://man7.org/linux/man-pages/man7/bpf-helpers.7.html)”它定义了一组可以在eBPF内核态程序中调用的函数。
尽管eBPF程序在内核态运行但是跟kernel module不一样eBPF程序不能调用普通内核export出来的函数而是只能调用在内核中为eBPF事先定义好的一些接口函数。这些接口函数叫作BPF Helpers具体有哪些你可以在”Linux manual page”中查看。
看明白这段代码之后,我们就可以运行 `make build` 命令把C代码编译成eBPF bytecode了。这里生成了 src/bpf_program.o 这个文件:
```
# make build
clang -O2 -target bpf -c src/bpf_program.c -Ikernel-src/tools/testing/selftests/bpf -Ikernel-src/tools/lib/bpf -o src/bpf_program.o
# ls -l src/bpf_program.o
-rw-r----- 1 root root 1128 Jan 24 00:50 src/bpf_program.o
```
接下来你可以用LLVM工具来看一下eBPF bytecode里的内容这样做可以确认下面两点。
1. 编译生成了BPF虚拟机的汇编指令而不是x86的指令。
1. 在代码中用SEC宏添加的“BPF Program Types”和“BPF Maps”信息也在后面的section里。
查看eBPF bytecode信息的操作如下
```
### 用objdump来查看bpf_program.o里的汇编指令
# llvm-objdump -D src/bpf_program.o
Disassembly of section tracepoint/syscalls/sys_enter_kill:
0000000000000000 &lt;bpf_prog&gt;:
0: 79 12 18 00 00 00 00 00 r2 = *(u64 *)(r1 + 24)
1: 55 02 10 00 09 00 00 00 if r2 != 9 goto +16 &lt;LBB0_2&gt;
2: 79 11 10 00 00 00 00 00 r1 = *(u64 *)(r1 + 16)
3: bf 12 00 00 00 00 00 00 r2 = r1
4: c7 02 00 00 3f 00 00 00 r2 s&gt;&gt;= 63
5: 0f 21 00 00 00 00 00 00 r1 += r2
6: af 21 00 00 00 00 00 00 r1 ^= r2
7: 7b 1a f8 ff 00 00 00 00 *(u64 *)(r10 - 8) = r1
8: b7 01 00 00 01 00 00 00 r1 = 1
9: 63 1a f4 ff 00 00 00 00 *(u32 *)(r10 - 12) = r1
10: bf a2 00 00 00 00 00 00 r2 = r10
11: 07 02 00 00 f8 ff ff ff r2 += -8
12: bf a3 00 00 00 00 00 00 r3 = r10
13: 07 03 00 00 f4 ff ff ff r3 += -12
14: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
16: b7 04 00 00 01 00 00 00 r4 = 1
17: 85 00 00 00 02 00 00 00 call 2
### 用readelf读到bpf_program.o中的ELF section信息。
# llvm-readelf -sections src/bpf_program.o
There are 9 section headers, starting at offset 0x228:
Section Headers:
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
[ 3] tracepoint/syscalls/sys_enter_kill PROGBITS 0000000000000000 000040 0000a0 00 AX 0 0 8
[ 4] .reltracepoint/syscalls/sys_enter_kill REL 0000000000000000 000190 000010 10 8 3 8
[ 5] maps PROGBITS 0000000000000000 0000e0 00001c 00 WA 0 0 4
```
好了看完了eBPF程序的内核态部分我们再来看看它的用户态部分loader.c
```
# cat src/loader.c
#include "bpf_load.h"
#include &lt;unistd.h&gt;
#include &lt;stdio.h&gt;
int main(int argc, char **argv) {
// Load our newly compiled eBPF program
if (load_bpf_file("src/bpf_program.o") != 0) {
printf("The kernel didn't load the BPF program\n");
return -1;
}
printf("eBPF will listen to force kills for the next 30 seconds!\n");
sleep(30);
// map_fd is a global variable containing all eBPF map file descriptors
int fd = map_fd[0], val;
long key = -1, prev_key;
// Iterate over all keys in the map
while(bpf_map_get_next_key(fd, &amp;prev_key, &amp;key) == 0) {
printf("%ld was forcefully killed!\n", key);
prev_key = key;
}
return 0;
}
```
这部分的代码其实也很简单,主要就是做了两件事:
<li>
通过执行load_bpf_file()函数加载内核态代码生成的eBPF bytecode也就是编译后得到的文件“src/bpf_program.o”。
</li>
<li>
等待30秒钟后从BPF Maps读取key/value对里的值。这里的值就是前面内核态的函数bpf_prog()在kill()系统调用的tracepoint上执行这个函数以后写入到BPF Maps里的值。
</li>
至于读取BPF Maps的部分就不需要太多的解释了这里我们主要看一下load_bpf_file()这个函数load_bpf_file()是Linux内核代码samples/bpf/bpf_load.c 里封装的一个函数。
这个函数可以读取eBPF bytecode中的信息然后决定如何在内核中加载BPF Program以及创建 BPF Maps。这里用到的都是[bpf()](https://man7.org/linux/man-pages/man2/bpf.2.html)这个系统调用,具体的代码你可以去看一下内核中[bpf_load.c](https://github.com/torvalds/linux/blob/v5.4/samples/bpf/bpf_load.c)和[bpf.c](https://github.com/torvalds/linux/blob/v5.4/tools/lib/bpf/bpf.c)这两个文件。
理解了用户态的load.c这段代码后我们最后编译一下就生成了用户态的程序ebpf-kill-example
```
# make
clang -O2 -target bpf -c src/bpf_program.c -Ikernel-src/tools/testing/selftests/bpf -Ikernel-src/tools/lib/bpf -o src/bpf_program.o
clang -O2 -o src/ebpf-kill-example -lelf -Ikernel-src/samples/bpf -Ikernel-src/tools/lib -Ikernel-src/tools/perf -Ikernel-src/tools/include -Llib64 -lbpf \
kernel-src/samples/bpf/bpf_load.c -DHAVE_ATTR_TEST=0 src/loader.c
# ls -l src/ebpf-kill-example
-rwxr-x--- 1 root root 23400 Jan 24 01:28 src/ebpf-kill-example
```
你可以运行一下这个程序如果在30秒以内有别的程序执行了 `kill -9 &lt;pid&gt;`那么在内核中的eBPF代码就可以截获这个操作然后通过eBPF Maps把信息传递给用户态进程并且把这个信息打印出来了。
```
# LD_LIBRARY_PATH=lib64/:$LD_LIBRARY_PATH ./src/ebpf-kill-example &amp;
[1] 1963961
# eBPF will listen to force kills for the next 30 seconds!
# kill -9 1
# 1 was forcefully killed!
```
## 重点小结
今天我们一起学习了eBPF接下来我给你总结一下重点。
eBPF对早年的BPF技术做了增强之后为Linux网络 Linux安全以及Linux内核的调试和跟踪这三个领域提供了强大的扩展接口。
虽然整个eBPF技术是很复杂的不过对于用户编写eBPF的程序还是有一个固定的模式。
eBPF的程序都分为两部分一是内核态的代码最后会被编译成eBPF bytecode二是用户态代码它主要是负责加载eBPF bytecode并且通过eBPF Maps与内核态代码通讯。
这里我们重点要掌握eBPF程序里的三个要素**eBPF Program TypeseBPF Maps和eBPF Helpers。**
eBPF Program Types可以定义函数在eBPF内核态的类型。eBPF Maps定义了key/value对的存储结构搭建了eBPF Program之间以及用户态和内核态之间的数据交换的桥梁。eBPF Helpers是内核事先定义好了接口函数方便eBPF程序调用这些函数。
理解了这些概念后你可以开始动手编写eBPF的程序了。不过eBPF程序的调试并不方便基本只能依靠bpf_trace_printk()同时也需要我们熟悉eBPF虚拟机的汇编指令。这些就需要你在实际的操作中不断去积累经验了。
## 思考题
请你在[ebpf-kill-example](https://github.com/niclashedam/ebpf-kill-example) 这个例子的基础上做一下修改让用户态程序也能把调用kill()函数的进程所对应的进程号打印出来。
欢迎你在留言区记录你的思考或疑问。如果这一讲让你有所收获,也欢迎转发给你的朋友,同事,跟他一起学习进步。

View File

@@ -0,0 +1,353 @@
<audio id="audio" title="加餐06 | BCC入门eBPF的前端工具" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/83/c0/837e167040f0fcd6b2413a00fe62f0c0.mp3"></audio>
你好,我是程远。
今天是我们专题加餐的最后一讲,明天就是春节了,我想给还在学习的你点个赞。这里我先给你拜个早年,祝愿你牛年工作顺利,健康如意!
上一讲我们学习了eBPF的基本概念以及eBPF编程的一个基本模型。在理解了这些概念之后从理论上来说你就能自己写出eBPF的程序对Linux系统上的一些问题做跟踪和调试了。
不过从上一讲的例子里估计你也发现了eBPF的程序从编译到运行还是有些复杂。
为了方便我们用eBPF的程序跟踪和调试系统社区有很多eBPF的前端工具。在这些前端工具中BCC提供了最完整的工具集以及用于eBPF工具开发的Python/Lua/C++的接口。那么今天我们就一起来看看怎么使用BCC这个eBPF的前端工具。
## 如何使用BCC工具
[BCC](https://github.com/iovisor/bcc)BPF Compiler Collection这个社区项目开始于2015年差不多在内核中支持了eBPF的特性之后BCC这个项目就开始了。
BCC的目标就是提供一个工具链用于编写、编译还有内核加载eBPF程序同时BCC也提供了大量的eBPF的工具程序这些程序能够帮我们做Linux的性能分析和跟踪调试。
这里我们可以先尝试用几个BCC的工具通过实际操作来了解一下BCC。
大部分Linux发行版本都有BCC的软件包你可以直接安装。比如我们可以在Ubuntu 20.04上试试用下面的命令安装BCC
```
# apt install bpfcc-tools
```
安装完BCC软件包之后你在Linux系统上就会看到多了100多个BCC的小工具 在Ubuntu里这些工具的名字后面都加了bpfcc的后缀
```
# ls -l /sbin/*-bpfcc | more
-rwxr-xr-x 1 root root 34536 Feb 7 2020 /sbin/argdist-bpfcc
-rwxr-xr-x 1 root root 2397 Feb 7 2020 /sbin/bashreadline-bpfcc
-rwxr-xr-x 1 root root 6231 Feb 7 2020 /sbin/biolatency-bpfcc
-rwxr-xr-x 1 root root 5524 Feb 7 2020 /sbin/biosnoop-bpfcc
-rwxr-xr-x 1 root root 6439 Feb 7 2020 /sbin/biotop-bpfcc
-rwxr-xr-x 1 root root 1152 Feb 7 2020 /sbin/bitesize-bpfcc
-rwxr-xr-x 1 root root 2453 Feb 7 2020 /sbin/bpflist-bpfcc
-rwxr-xr-x 1 root root 6339 Feb 7 2020 /sbin/btrfsdist-bpfcc
-rwxr-xr-x 1 root root 9973 Feb 7 2020 /sbin/btrfsslower-bpfcc
-rwxr-xr-x 1 root root 4717 Feb 7 2020 /sbin/cachestat-bpfcc
-rwxr-xr-x 1 root root 7302 Feb 7 2020 /sbin/cachetop-bpfcc
-rwxr-xr-x 1 root root 6859 Feb 7 2020 /sbin/capable-bpfcc
-rwxr-xr-x 1 root root 53 Feb 7 2020 /sbin/cobjnew-bpfcc
-rwxr-xr-x 1 root root 5209 Feb 7 2020 /sbin/cpudist-bpfcc
-rwxr-xr-x 1 root root 14597 Feb 7 2020 /sbin/cpuunclaimed-bpfcc
-rwxr-xr-x 1 root root 8504 Feb 7 2020 /sbin/criticalstat-bpfcc
-rwxr-xr-x 1 root root 7095 Feb 7 2020 /sbin/dbslower-bpfcc
-rwxr-xr-x 1 root root 3780 Feb 7 2020 /sbin/dbstat-bpfcc
-rwxr-xr-x 1 root root 3938 Feb 7 2020 /sbin/dcsnoop-bpfcc
-rwxr-xr-x 1 root root 3920 Feb 7 2020 /sbin/dcstat-bpfcc
-rwxr-xr-x 1 root root 19930 Feb 7 2020 /sbin/deadlock-bpfcc
-rwxr-xr-x 1 root root 7051 Dec 10 2019 /sbin/deadlock.c-bpfcc
-rwxr-xr-x 1 root root 6830 Feb 7 2020 /sbin/drsnoop-bpfcc
-rwxr-xr-x 1 root root 7658 Feb 7 2020 /sbin/execsnoop-bpfcc
-rwxr-xr-x 1 root root 10351 Feb 7 2020 /sbin/exitsnoop-bpfcc
-rwxr-xr-x 1 root root 6482 Feb 7 2020 /sbin/ext4dist-bpfcc
...
```
这些工具几乎覆盖了Linux内核中各个模块它们可以对Linux某个模块做最基本的profile。你可以看看下面[这张图](https://github.com/iovisor/bcc/blob/master/images/bcc_tracing_tools_2019.png)图里把BCC的工具与Linux中的各个模块做了一个映射。
<img src="https://static001.geekbang.org/resource/image/eb/db/eb90017c78byyyy5399d275fe63783db.png" alt="">
在BCC的github repo里也有很完整的[文档和例子](https://github.com/iovisor/bcc/tree/master/examples)来描述每一个工具。[Brendan D. Gregg](http://www.brendangregg.com/)写了一本书书名叫《BPF Performance Tools》我们上一讲也提到过这本书这本书从Linux CPU/Memory/Filesystem/Disk/Networking等角度介绍了如何使用BCC工具感兴趣的你可以自行学习。
为了让你更容易理解,这里我给你举两个例子。
第一个是使用opensnoop工具用它来监控节点上所有打开文件的操作。这个命令有时候也可以用来查看某个文件被哪个进程给动过。
比如说我们先启动opensnoop然后在其他的console里运行 `touch test-open` 命令,这时候我们就会看到 `touch` 命令在启动时读取到的库文件和配置文件以及最后建立的“test-open”这个文件。
```
# opensnoop-bpfcc
PID COMM FD ERR PATH
2522843 touch 3 0 /etc/ld.so.cache
2522843 touch 3 0 /lib/x86_64-linux-gnu/libc.so.6
2522843 touch 3 0 /usr/lib/locale/locale-archive
2522843 touch 3 0 /usr/share/locale/locale.alias
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_IDENTIFICATION
2522843 touch 3 0 /usr/lib/x86_64-linux-gnu/gconv/gconv-modules.cache
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_MEASUREMENT
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_TELEPHONE
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_ADDRESS
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_NAME
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_PAPER
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_MESSAGES
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_MESSAGES/SYS_LC_MESSAGES
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_MONETARY
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_COLLATE
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_TIME
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_NUMERIC
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_CTYPE
2522843 touch 3 0 test-open
```
第二个是使用softirqs这个命令查看节点上各种类型的softirqs花费时间的分布图 (直方图模式)。
比如在下面这个例子里每一次timer softirq执行时间在01us时间区间里的有16次在2-3us时间区间里的有49次以此类推。
在我们分析网络延时的时候也用过这个softirqs工具用它来确认timer softirq花费的时间。
```
# softirqs-bpfcc -d
Tracing soft irq event time... Hit Ctrl-C to end.
^C
softirq = block
usecs : count distribution
0 -&gt; 1 : 2 |******************** |
2 -&gt; 3 : 3 |****************************** |
4 -&gt; 7 : 2 |******************** |
8 -&gt; 15 : 4 |****************************************|
softirq = rcu
usecs : count distribution
0 -&gt; 1 : 189 |****************************************|
2 -&gt; 3 : 52 |*********** |
4 -&gt; 7 : 21 |**** |
8 -&gt; 15 : 5 |* |
16 -&gt; 31 : 1 | |
softirq = net_rx
usecs : count distribution
0 -&gt; 1 : 1 |******************** |
2 -&gt; 3 : 0 | |
4 -&gt; 7 : 2 |****************************************|
8 -&gt; 15 : 0 | |
16 -&gt; 31 : 2 |****************************************|
softirq = timer
usecs : count distribution
0 -&gt; 1 : 16 |************* |
2 -&gt; 3 : 49 |****************************************|
4 -&gt; 7 : 43 |*********************************** |
8 -&gt; 15 : 5 |**** |
16 -&gt; 31 : 13 |********** |
32 -&gt; 63 : 13 |********** |
softirq = sched
usecs : count distribution
0 -&gt; 1 : 18 |****** |
2 -&gt; 3 : 107 |****************************************|
4 -&gt; 7 : 20 |******* |
8 -&gt; 15 : 1 | |
16 -&gt; 31 : 1 | |
```
BCC中的工具数目虽然很多但是你用过之后就会发现它们的输出模式基本上就是上面我说的这两种。
第一种类似事件模式就像opensnoop的输出一样发生一次就输出一次第二种是直方图模式就是把内核中执行函数的时间做个统计然后用直方图的方式输出也就是 `softirqs -d` 的执行结果。
用过BCC工具之后我们再来看一下BCC工具的工作原理这样以后你有需要的时候自己也可以编写和部署一个BCC工具了。
## BCC的工作原理
让我们来先看一下BCC工具的代码结构。
因为目前BCC的工具都是用python写的所以你直接可以用文本编辑器打开节点上的一个工具文件。比如打开/sbin/opensnoop-bpfcc文件也可在github bcc项目中查看 [opensnoop.py](https://github.com/iovisor/bcc/blob/v0.12.0/tools/opensnoop.py)这里你可以看到大概200行左右的代码代码主要分成了两部分。
第一部分其实是一块C代码里面定义的就是eBPF内核态的代码不过它是以python字符串的形式加在代码中的。
我在下面列出了这段C程序的主干其实就是定义两个eBPF Maps和两个eBPF Programs的函数
```
# define BPF program
bpf_text = """
#include &lt;uapi/linux/ptrace.h&gt;
#include &lt;uapi/linux/limits.h&gt;
#include &lt;linux/sched.h&gt;
BPF_HASH(infotmp, u64, struct val_t); //BPF_MAP_TYPE_HASH
BPF_PERF_OUTPUT(events); // BPF_MAP_TYPE_PERF_EVENT_ARRAY
int trace_entry(struct pt_regs *ctx, int dfd, const char __user *filename, int flags)
{
}
int trace_return(struct pt_regs *ctx)
{
}
“””
```
第二部分就是用python写的用户态代码它的作用是加载内核态eBPF的代码把内核态的函数trace_entry()以kprobe方式挂载到内核函数do_sys_open()把trace_return()以kproberet方式也挂载到do_sys_open()然后从eBPF Maps里读取数据并且输出。
```
# initialize BPF
b = BPF(text=bpf_text)
b.attach_kprobe(event="do_sys_open", fn_name="trace_entry")
b.attach_kretprobe(event="do_sys_open", fn_name="trace_return")
# loop with callback to print_event
b["events"].open_perf_buffer(print_event, page_cnt=64)
start_time = datetime.now()
while not args.duration or datetime.now() - start_time &lt; args.duration:
try:
b.perf_buffer_poll()
except KeyboardInterrupt:
exit()
```
从代码的结构看其实这和我们上一讲介绍的eBPF标准的编程模式是差不多的只是用户态的程序是用python来写的。不过这里有一点比较特殊用户态在加载程序的时候输入的是C程序的文本而不是eBPF bytecode。
BCC可以这么做是因为它通过python[BPF()](https://github.com/iovisor/bcc/blob/v0.12.0/src/python/bcc/__init__.py#L342) 加载C代码之后调用libbcc库中的函数bpf_module_create_c_from_string() 把C代码编译成了eBPF bytecode。也就是说libbcc库中集成了clang/llvm的编译器。
```
def __init__(self, src_file=b"", hdr_file=b"", text=None, debug=0,
cflags=[], usdt_contexts=[], allow_rlimit=True, device=None):
"""Create a new BPF module with the given source code.
...
self.module = lib.bpf_module_create_c_from_string(text, self.debugcflags_array, len(cflags_array), allow_rlimit, device)
...
```
我们弄明白libbcc库的作用之后再来整体看一下BCC工具的工作方式。为了让你理解我给你画了一张示意图
<img src="https://static001.geekbang.org/resource/image/94/72/94b146c3f35ca0b9aa04c32f29fdf572.jpeg" alt="">
BCC的这种设计思想是为了方便eBPF程序的开发和使用特别是eBPF内核态的代码对当前运行的内核版本是有依赖的比如在4.15内核的节点上编译好的bytecode放到5.4内核的节点上很有可能是运行不了的。
那么让编译和运行都在同一个节点出现问题就可以直接修改源代码文件了。你有没有发现这么做有点像把C程序的处理当成python的处理方式。
BCC的这种设计思想虽然有好处但是也带来了问题。其实问题也是很明显的首先我们需要在运行BCC工具的节点上必须安装内核头文件这个在编译内核态eBPF C代码的时候是必须要做的。
其次在libbcc的库里面包含了clang/llvm的编译器这不光占用磁盘空间在运行程序前还需要编译也会占用节点的CPU和Memory同时也让BCC工具的启动时间变长。这两个问题都会影响到BCC生产环境中的使用。
## BCC工具的发展
那么我们有什么办法来解决刚才说的问题呢eBPF的技术在不断进步最新的BPF CO-RE技术可以解决这个问题。我们下面就来看BPF CO-RE是什么意思。
CO-RE是“Compile Once Run Everywhere”的缩写BPF CO-RE通过对Linux内核、用户态BPF loaderlibbpf库以及Clang编译器的修改来实现编译出来的eBPF程序可以在不同版本的内核上运行。
不同版本的内核上用CO-RE编译出来的eBPF程序都可以运行。在Linux内核和BPF程序之间会通过[BTF](https://www.kernel.org/doc/html/latest/bpf/btf.html)BPF Type Format来协调不同版本内核中数据结构的变量偏移或者变量长度变化等问题。
在BCC的github repo里有一个目录[libbpf-tools](https://github.com/iovisor/bcc/tree/master/libbpf-tools)在这个目录下已经有一些重写过的BCC工具的源代码它们并不是用python+libbcc的方式实现的而是用到了libbpf+BPF CO-RE的方式。
如果你的系统上有高于版本10的CLANG/LLVM编译器就可以尝试编译一下libbpf-tools下的工具。这里可以加一个“V=1”参数这样我们就能清楚编译的步骤了。
```
# git remote -v
origin https://github.com/iovisor/bcc.git (fetch)
origin https://github.com/iovisor/bcc.git (push)
# cd libbpf-tools/
# make V=1
mkdir -p .output
mkdir -p .output/libbpf
make -C /root/bcc/src/cc/libbpf/src BUILD_STATIC_ONLY=1 \
OBJDIR=/root/bcc/libbpf-tools/.output//libbpf DESTDIR=/root/bcc/libbpf-tools/.output/ \
INCLUDEDIR= LIBDIR= UAPIDIR= \
Install
ar rcs /root/bcc/libbpf-tools/.output//libbpf/libbpf.a …
clang -g -O2 -target bpf -D__TARGET_ARCH_x86 \
-I.output -c opensnoop.bpf.c -o .output/opensnoop.bpf.o &amp;&amp; \
llvm-strip -g .output/opensnoop.bpf.o
bin/bpftool gen skeleton .output/opensnoop.bpf.o &gt; .output/opensnoop.skel.h
cc -g -O2 -Wall -I.output -c opensnoop.c -o .output/opensnoop.o
cc -g -O2 -Wall .output/opensnoop.o /root/bcc/libbpf-tools/.output/libbpf.a .output/trace_helpers.o .output/syscall_helpers.o .output/errno_helpers.o -lelf -lz -o opensnoop
```
我们梳理一下编译的过程。首先这段代码生成了libbpf.a这个静态库然后逐个的编译每一个工具。对于每一个工具的代码结构是差不多的编译的方法也是差不多的。
我们拿opensnoop做例子来看一下它的源代码分为两个文件。opensnoop.bpf.c是内核态的eBPF代码opensnoop.c是用户态的代码这个和我们之前学习的eBPF代码的标准结构是一样的。主要不同点有下面这些。
内核态的代码不再逐个include内核代码的头文件而是只要include一个“vmlinux.h”就可以。在“vmlinux.h”中包含了所有内核的数据结构它是由内核文件vmlinux中的BTF信息转化而来的。
```
# cat opensnoop.bpf.c | head
// SPDX-License-Identifier: GPL-2.0
// Copyright (c) 2019 Facebook
// Copyright (c) 2020 Netflix
#include "vmlinux.h"
#include &lt;bpf/bpf_helpers.h&gt;
#include "opensnoop.h"
#define TASK_RUNNING 0
const volatile __u64 min_us = 0;
```
我们使用[bpftool](https://github.com/torvalds/linux/tree/v5.4/tools/bpf/bpftool)这个工具可以把编译出来的opensnoop.bpf.o重新生成为一个C语言的头文件opensnoop.skel.h。这个头文件中定义了加载eBPF程序的函数eBPF bytecode的二进制流也直接写在了这个头文件中。
```
bin/bpftool gen skeleton .output/opensnoop.bpf.o &gt; .output/opensnoop.skel.h
```
用户态的代码opensnoop.c直接include这个opensnoop.skel.h并且调用里面的eBPF加载的函数。这样在编译出来的可执行程序opensnoop就可以直接运行了不用再找eBPF bytecode文件或者eBPF内核态的C文件。并且这个opensnoop程序可以运行在不同版本内核的节点上当然这个内核需要打开CONFIG_DEBUG_INFO_BTF这个编译选项
比如我们可以把在kernel5.4节点上编译好的opensnoop程序copy到一台kernel5.10.4的节点来运行:
```
# uname -r
5.10.4
# ls -lh opensnoop
-rwxr-x--- 1 root root 235K Jan 30 23:08 opensnoop
# ./opensnoop
PID COMM FD ERR PATH
2637411 opensnoop 24 0 /etc/localtime
1 systemd 28 0 /proc/746/cgroup
```
从上面的代码我们会发现这时候的opensnoop不依赖任何的库函数只有一个文件strip后的文件大小只有235KB启动运行的时候既不不需要读取外部的文件也不会做额外的编译。
## 重点小结
好了今天我们主要讲了eBPF的一个前端工具BCC我来给你总结一下。
在我看来对于把eBPF运用于Linux内核的性能分析和跟踪调试这个领域BCC是社区中最有影响力的一个项目。BCC项目提供了eBPF工具开发的Python/Lua/C++的接口以及上百个基于eBPF的工具。
对不熟悉eBPF的同学来说可以直接拿这些工具来调试Linux系统中的问题。而对于了解eBPF的同学也可以利用BCC提供的接口开发自己需要的eBPF工具。
BCC工具目前主要通过ptyhon+libbcc的模式在目标节点上运行但是这个模式需要节点有内核头文件以及内嵌在libbcc中的Clang/LLVM编译器每次程序启动的时候还需要再做一次编译。
为了弥补这个缺点BCC工具开始向libbpf+BPF CO-RE的模式转变。用这种新模式编译出来的BCC工具程序只需要很少的系统资源就可以在目标节点上运行并且不受内核版本的限制。
除了BCC之外你还可以看一下[bpftrace](https://github.com/iovisor/bpftrace)、[ebpf-exporter](https://github.com/cloudflare/ebpf_exporter)等eBPF的前端工具。
bpftrace提供了类似awk和C语言混合的一种语言在使用时也很类似awk可以用一两行的命令来完成一次eBPF的调用它能做一些简单的内核事件的跟踪。当然它也可以编写比较复杂的eBPF程序。
ebpf-exporter可以把eBPF程序收集到的metrics以[Prometheus](https://prometheus.io/docs/introduction/overview/)的格式对外输出,然后通过[Grafana](https://grafana.com/)的dashboard可以对内核事件做长期的以及更加直观的监控。
总之,前面提到的这些工具,你都可以好好研究一下,它们可以帮助你对容器云平台上的节点做内核级别的监控与诊断。
## 思考题
这一讲的最后,我给你留一道思考题吧。
你可以动手操作一下尝试编译和运行BCC项目中[libbpf-tools](https://github.com/iovisor/bcc/tree/master/libbpf-tools)目录下的工具。
欢迎你在留言区记录你的心得或者疑问。如果这一讲对你有帮助,也欢迎分享给你的同事、朋友,和他一起学习进步。