mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-11 12:07:13 +08:00
del
This commit is contained in:
176
极客时间专栏/geek/容器实战高手课/专题加餐/加餐01 | 案例分析:怎么解决海量IPVS规则带来的网络延时抖动问题?.md
Normal file
176
极客时间专栏/geek/容器实战高手课/专题加餐/加餐01 | 案例分析:怎么解决海量IPVS规则带来的网络延时抖动问题?.md
Normal 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,然后再通过路由从宿主机的obr0(openvswitch)接口和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上的si(softirq)的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工具,你还有什么方法来找到这个问题的突破口呢?
|
||||
|
||||
欢迎你在留言区和我交流讨论。如果这一讲的内容对你有帮助的话,也欢迎转发给你的朋友、同事,和他一起学习进步。
|
||||
295
极客时间专栏/geek/容器实战高手课/专题加餐/加餐02 | 理解perf:怎么用perf聚焦热点函数?.md
Normal file
295
极客时间专栏/geek/容器实战高手课/专题加餐/加餐02 | 理解perf:怎么用perf聚焦热点函数?.md
Normal 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来自处理器中的一个PMU(Performance Monitoring Unit),这些event数目不多,都是底层处理器相关的行为,perf中会命名几个通用的事件,比如cpu-cycles,执行完成的instructions,Cache相关的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(&current->mm->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的标准接口了,ftrace,ebpf等工具都会用到它,后面我们还会再详细介绍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
|
||||
|
||||
```
|
||||
|
||||
我这里给了一个带静态链接perf(kernel 5.4)的container image[例子](https://github.com/chengyli/training/tree/master/perf),你可以运行 `make image` 来生成这个image。
|
||||
|
||||
在容器中运行perf,还要注意一个权限的问题,有两点注意事项需要你留意。
|
||||
|
||||
第一点,Perf 通过系统调用perf_event_open()来完成对perf event的计数或者采样。不过Docker使用seccomp(seccomp是一种技术,它通过控制系统调用的方式来保障Linux安全)会默认禁止perf_event_open()。
|
||||
|
||||
所以想要让Docker启动的容器可以运行perf,我们要怎么处理呢?
|
||||
|
||||
其实这个也不难,在用Docker启动容器的时候,我们需要在seccomp的profile里,允许perf_event_open()这个系统调用在容器中使用。在我们的例子中,启动container的命令里,已经加了这个参数允许了,参数是"--security-opt seccomp=unconfined"。
|
||||
|
||||
第二点,需要允许容器在没有SYS_ADMIN这个capability(Linux capability我们在[第19讲](https://time.geekbang.org/column/article/326253)说过)的情况下,也可以让perf访问这些event。那么现在我们需要做的就是,在宿主机上设置出 `echo -1 > /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 > out.perf
|
||||
# git clone --depth 1 https://github.com/brendangregg/FlameGraph.git
|
||||
# FlameGraph/stackcollapse-perf.pl out.perf > out.folded
|
||||
# FlameGraph/flamegraph.pl out.folded > 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并且生成火焰图,看看开销最大的函数是哪一个。
|
||||
|
||||
欢迎在留言区分享你的疑惑和见解。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
@@ -0,0 +1,351 @@
|
||||
<audio id="audio" title="加餐03 | 理解ftrace(1):怎么应用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
|
||||
#
|
||||
# _-----=> irqs-off
|
||||
# / _----=> need-resched
|
||||
# | / _---=> hardirq/softirq
|
||||
# || / _--=> preempt-depth
|
||||
# ||| / delay
|
||||
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
|
||||
# | | | |||| | |
|
||||
|
||||
```
|
||||
|
||||
下面,我们可以执行 `echo function > 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 > current_tracer
|
||||
# cat current_tracer
|
||||
function
|
||||
|
||||
```
|
||||
|
||||
在启动了function tracer之后,我们再查看一下trace的输出。这时候我们就会看到大量的输出,每一行的输出就是当前内核中被调用到的内核函数,具体的格式你可以参考trace头部的说明。
|
||||
|
||||
```
|
||||
# cat trace | more
|
||||
# tracer: function
|
||||
#
|
||||
# entries-in-buffer/entries-written: 615132/134693727 #P:12
|
||||
#
|
||||
# _-----=> irqs-off
|
||||
# / _----=> need-resched
|
||||
# | / _---=> hardirq/softirq
|
||||
# || / _--=> preempt-depth
|
||||
# ||| / delay
|
||||
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
|
||||
# | | | |||| | |
|
||||
systemd-udevd-20472 [011] .... 2148512.735026: lock_page_memcg <-page_remove_rmap
|
||||
systemd-udevd-20472 [011] .... 2148512.735026: PageHuge <-page_remove_rmap
|
||||
systemd-udevd-20472 [011] .... 2148512.735026: unlock_page_memcg <-page_remove_rmap
|
||||
systemd-udevd-20472 [011] .... 2148512.735026: __unlock_page_memcg <-unlock_page_memcg
|
||||
systemd-udevd-20472 [011] .... 2148512.735026: __tlb_remove_page_size <-unmap_page_range
|
||||
systemd-udevd-20472 [011] .... 2148512.735027: vm_normal_page <-unmap_page_range
|
||||
systemd-udevd-20472 [011] .... 2148512.735027: mark_page_accessed <-unmap_page_range
|
||||
systemd-udevd-20472 [011] .... 2148512.735027: page_remove_rmap <-unmap_page_range
|
||||
systemd-udevd-20472 [011] .... 2148512.735027: lock_page_memcg <-page_remove_rmap
|
||||
…
|
||||
|
||||
```
|
||||
|
||||
看到这个trace输出,你肯定会觉得输出的函数太多了,查看起来太困难了。别担心,下面我给你说个技巧,来解决输出函数太多的问题。
|
||||
|
||||
其实在实际使用的时候,我们可以利用ftrace里的filter参数做筛选,比如我们可以通过set_ftrace_filter只列出想看到的内核函数,或者通过set_ftrace_pid只列出想看到的进程。
|
||||
|
||||
为了让你加深理解,我给你举个例子,比如说,如果我们只是想看do_mount这个内核函数有没有被调用到,那我们就可以这么操作:
|
||||
|
||||
```
|
||||
# echo nop > current_tracer
|
||||
# echo do_mount > set_ftrace_filter
|
||||
# echo function > current_tracer
|
||||
|
||||
```
|
||||
|
||||
在执行了mount命令之后,我们查看一下trace。
|
||||
|
||||
这时候,我们就只会看到一条do_mount()函数调用的记录,我们一起来看看,输出结果里的几个关键参数都是什么意思。
|
||||
|
||||
输出里"do_mount <- 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
|
||||
#
|
||||
# _-----=> irqs-off
|
||||
# / _----=> need-resched
|
||||
# | / _---=> hardirq/softirq
|
||||
# || / _--=> preempt-depth
|
||||
# ||| / delay
|
||||
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
|
||||
# | | | |||| | |
|
||||
mount-20889 [005] .... 2159455.499195: do_mount <-ksys_mount
|
||||
|
||||
```
|
||||
|
||||
这里我们只能判断出,ksys mount()调用了do mount()这个函数,这只是一层调用关系,如果我们想要看更加完整的函数调用栈,可以打开ftrace中的func_stack_trace选项:
|
||||
|
||||
```
|
||||
# echo 1 > 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
|
||||
#
|
||||
# _-----=> irqs-off
|
||||
# / _----=> need-resched
|
||||
# | / _---=> hardirq/softirq
|
||||
# || / _--=> preempt-depth
|
||||
# ||| / delay
|
||||
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
|
||||
# | | | |||| | |
|
||||
mount-20889 [005] .... 2159455.499195: do_mount <-ksys_mount
|
||||
mount-21048 [000] .... 2162013.660835: do_mount <-ksys_mount
|
||||
mount-21048 [000] .... 2162013.660841: <stack trace>
|
||||
=> do_mount
|
||||
=> ksys_mount
|
||||
=> __x64_sys_mount
|
||||
=> do_syscall_64
|
||||
=> 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 > 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 ' >> set_ftrace_filter ### 先把之前的do_mount filter给去掉。
|
||||
# echo kfree_skb > set_graph_function ### 设置kfree_skb()
|
||||
# echo nop > current_tracer ### 暂时把current_tracer设置为nop, 这样可以清空trace
|
||||
# echo function_graph > 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 <**fentry**>"。
|
||||
|
||||
你如果编译过内核,那么你可以用"objdump -D vmlinux"来查看一下内核函数的汇编,比如do_mount()函数的开头几条汇编就是这样的:
|
||||
|
||||
```
|
||||
ffffffff81309550 <do_mount>:
|
||||
ffffffff81309550: e8 fb 83 8f 00 callq ffffffff81c01950 <__fentry__>
|
||||
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 <**fentry**>"指令的地址。这样我们很容易就能找到每个函数的这个入口点。
|
||||
|
||||
为了方便你理解,我画了一张示意图,我们编译出来的vmlinux就像图里展示的这样:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9f/49/9f62b0951b764fa61b7e5fe9b2d05449.jpeg" alt="">
|
||||
|
||||
不过你需要注意的是,**尽管通过编译的方式,我们可以给每个函数都加上一个额外的hook点,但是这个额外"<strong>fentry**"函数调用的开销是很大的。</strong>
|
||||
|
||||
即使"**fentry**"函数中只是一个retq指令,也会使内核性能下降13%,这对于Linux内核来说显然是不可以被接受的。那我们应该怎么办呢?
|
||||
|
||||
ftrace在内核启动的时候做了一件事,就是把内核每个函数里的第一条指令"callq <**fentry**>"(5个字节),替换成了"nop"指令(0F 1F 44 00 00),也就是一条空指令,表示什么都不做。
|
||||
|
||||
虽然是空指令,不过在内核的代码段里,这相当于给每个函数预留了5个字节。这样在需要的时候,内核可以再把这5个字节替换成callq指令,call的函数就可以指定成我们需要的函数了。
|
||||
|
||||
同时,内核的mcount_loc段里,虽然已经记录了每个函数"callq <**fentry**>"的地址,不过对于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 > 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追踪到吗?
|
||||
|
||||
欢迎你在留言区跟我分享你的思考与疑问,如果这一讲对你有启发,也欢迎转发给你的同事、朋友,跟他一起交流学习。
|
||||
@@ -0,0 +1,360 @@
|
||||
<audio id="audio" title="加餐04 | 理解ftrace(2):怎么理解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的应用,这样我们后面做详细的原理介绍时,你也会更容易理解。
|
||||
|
||||
首先看看tracepoint,tracepoint其实就是在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, &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 >= 0) {
|
||||
struct file *f = do_filp_open(dfd, tmp, &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->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 > events/fs/do_sys_open/enable
|
||||
|
||||
# cat trace
|
||||
# tracer: nop
|
||||
#
|
||||
# _-----=> irqs-off
|
||||
# / _----=> need-resched
|
||||
# | / _---=> hardirq/softirq
|
||||
# || / _--=> 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' > /sys/kernel/debug/tracing/kprobe_event
|
||||
|
||||
```
|
||||
|
||||
完成上面的写入之后,我们再enable这个新建的kprobe event。这样在trace中,我们就可以看到每次do_filp_open()被调用时前两个参数的值了。
|
||||
|
||||
```
|
||||
# echo 1 > /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(&__tracepoint_##name.key)) \
|
||||
__DO_TRACE(&__tracepoint_##name, \
|
||||
TP_PROTO(data_proto), \
|
||||
TP_ARGS(data_args), \
|
||||
TP_CONDITION(cond), 0); \
|
||||
if (IS_ENABLED(CONFIG_LOCKDEP) && (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)->funcs); \
|
||||
\
|
||||
if (it_func_ptr) { \
|
||||
do { \
|
||||
it_func = (it_func_ptr)->func; \
|
||||
__data = (it_func_ptr)->data; \
|
||||
((void(*)(proto))(it_func))(args); \
|
||||
} while ((++it_func_ptr)->func); \
|
||||
}
|
||||
…
|
||||
|
||||
…
|
||||
|
||||
```
|
||||
|
||||
而probe函数的注册,它可以通过宏定义的“register_trace_##name”函数完成。
|
||||
|
||||
```
|
||||
static inline int \
|
||||
register_trace_##name(void (*probe)(data_proto), void *data) \
|
||||
{ \
|
||||
return tracepoint_probe_register(&__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(&kp);
|
||||
if (ret < 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] <_do_fork> pre_handler: p->addr = 0x00000000d301008e, ip = ffffffffb1e8c9d1, flags = 0x246
|
||||
[8446287.087643] <_do_fork> post_handler: p->addr = 0x00000000d301008e, flags = 0x246
|
||||
[8446288.019731] <_do_fork> pre_handler: p->addr = 0x00000000d301008e, ip = ffffffffb1e8c9d1, flags = 0x246
|
||||
[8446288.019733] <_do_fork> post_handler: p->addr = 0x00000000d301008e, flags = 0x246
|
||||
[8446288.022091] <_do_fork> pre_handler: p->addr = 0x00000000d301008e, ip = ffffffffb1e8c9d1, flags = 0x246
|
||||
[8446288.022093] <_do_fork> post_handler: p->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(&kp);
|
||||
if (ret < 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 <__fentry__>”的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之后,怎样能看到对应内核函数的第一条指令被替换了呢?
|
||||
|
||||
欢迎你在留言区记录你的思考或者疑问。如果这一讲对你有帮助,也欢迎你转发给同事、朋友,跟他们一起交流、进步。
|
||||
338
极客时间专栏/geek/容器实战高手课/专题加餐/加餐05 | eBPF:怎么更加深入地查看内核中的函数?.md
Normal file
338
极客时间专栏/geek/容器实战高手课/专题加餐/加餐05 | eBPF:怎么更加深入地查看内核中的函数?.md
Normal 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操作系统中的BPF(Berkeley Packet Filter),1992伯克利实验室的一篇论文 [“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进行很严格的指令检查。检查通过之后,再通过JIT(Just 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 && make && 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 <linux/bpf.h>
|
||||
#include <stdlib.h>
|
||||
#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->sig != 9) return 0;
|
||||
|
||||
// We can call glibc functions in eBPF
|
||||
long key = labs(ctx->pid);
|
||||
int val = 1;
|
||||
|
||||
// Mark the PID as killed in the map
|
||||
bpf_map_update_elem(&kill_map, &key, &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 <bpf_prog>:
|
||||
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 <LBB0_2>
|
||||
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>>= 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 <unistd.h>
|
||||
#include <stdio.h>
|
||||
|
||||
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, &prev_key, &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 <pid>`,那么在内核中的eBPF代码就可以截获这个操作,然后通过eBPF Maps把信息传递给用户态进程,并且把这个信息打印出来了。
|
||||
|
||||
```
|
||||
# LD_LIBRARY_PATH=lib64/:$LD_LIBRARY_PATH ./src/ebpf-kill-example &
|
||||
[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 Types,eBPF 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()函数的进程所对应的进程号打印出来。
|
||||
|
||||
欢迎你在留言区记录你的思考或疑问。如果这一讲让你有所收获,也欢迎转发给你的朋友,同事,跟他一起学习进步。
|
||||
353
极客时间专栏/geek/容器实战高手课/专题加餐/加餐06 | BCC:入门eBPF的前端工具.md
Normal file
353
极客时间专栏/geek/容器实战高手课/专题加餐/加餐06 | BCC:入门eBPF的前端工具.md
Normal 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执行时间在0~1us时间区间里的有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 -> 1 : 2 |******************** |
|
||||
2 -> 3 : 3 |****************************** |
|
||||
4 -> 7 : 2 |******************** |
|
||||
8 -> 15 : 4 |****************************************|
|
||||
|
||||
softirq = rcu
|
||||
usecs : count distribution
|
||||
0 -> 1 : 189 |****************************************|
|
||||
2 -> 3 : 52 |*********** |
|
||||
4 -> 7 : 21 |**** |
|
||||
8 -> 15 : 5 |* |
|
||||
16 -> 31 : 1 | |
|
||||
|
||||
softirq = net_rx
|
||||
usecs : count distribution
|
||||
0 -> 1 : 1 |******************** |
|
||||
2 -> 3 : 0 | |
|
||||
4 -> 7 : 2 |****************************************|
|
||||
8 -> 15 : 0 | |
|
||||
16 -> 31 : 2 |****************************************|
|
||||
|
||||
softirq = timer
|
||||
usecs : count distribution
|
||||
0 -> 1 : 16 |************* |
|
||||
2 -> 3 : 49 |****************************************|
|
||||
4 -> 7 : 43 |*********************************** |
|
||||
8 -> 15 : 5 |**** |
|
||||
16 -> 31 : 13 |********** |
|
||||
32 -> 63 : 13 |********** |
|
||||
|
||||
softirq = sched
|
||||
usecs : count distribution
|
||||
0 -> 1 : 18 |****** |
|
||||
2 -> 3 : 107 |****************************************|
|
||||
4 -> 7 : 20 |******* |
|
||||
8 -> 15 : 1 | |
|
||||
16 -> 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 <uapi/linux/ptrace.h>
|
||||
#include <uapi/linux/limits.h>
|
||||
#include <linux/sched.h>
|
||||
|
||||
…
|
||||
|
||||
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 < 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.debug,cflags_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 loader(libbpf库)以及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 && \
|
||||
llvm-strip -g .output/opensnoop.bpf.o
|
||||
bin/bpftool gen skeleton .output/opensnoop.bpf.o > .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 <bpf/bpf_helpers.h>
|
||||
#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 > .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)目录下的工具。
|
||||
|
||||
欢迎你在留言区记录你的心得或者疑问。如果这一讲对你有帮助,也欢迎分享给你的同事、朋友,和他一起学习进步。
|
||||
Reference in New Issue
Block a user