系统管理员如何使用 eBPF 追踪特定进程的 CPU 使用和内存分配?
为什么选择 eBPF?
eBPF 的工作原理
实战:追踪特定进程的 CPU 使用情况
实战:追踪特定进程的内存分配情况
总结
扩展思考
作为一名系统管理员,服务器性能监控绝对是日常工作的重中之重。面对日益复杂的应用环境,传统的监控工具往往显得力不从心,难以深入到内核层面进行细粒度的分析。这时,eBPF (extended Berkeley Packet Filter) 就如同一个瑞士军刀,能帮助我们洞察系统内部的运作机制,从而更好地优化性能、排查故障。今天,我就来分享一下如何利用 eBPF 追踪特定进程的 CPU 使用情况和内存分配,希望能给你带来一些启发。
为什么选择 eBPF?
在深入探讨具体实现之前,我们先来简单聊聊为什么选择 eBPF。传统的性能监控工具,例如 top
、vmstat
等,虽然能提供一些基本的系统信息,但它们往往存在以下局限性:
- 开销大:传统工具通常需要频繁地进行上下文切换,这会带来额外的 CPU 消耗,尤其是在高负载环境下,对系统性能的影响不容忽视。
- 信息有限:它们只能提供一些宏观的统计数据,难以深入到内核层面,了解特定进程的详细行为。
- 可定制性差:它们的功能相对固定,难以根据实际需求进行定制。
eBPF 的出现,彻底改变了这种局面。它具有以下显著优势:
- 高性能:eBPF 程序运行在内核态,避免了频繁的上下文切换,大大降低了开销。
- 细粒度:eBPF 可以挂载到内核的各种事件探针上,例如函数调用、系统调用等,从而实现对系统行为的精细化监控。
- 可编程性:eBPF 允许我们编写自定义的监控逻辑,根据实际需求进行灵活定制。
- 安全性:eBPF 程序在运行前会经过内核的验证器 (verifier) 检查,确保其安全性,避免对系统造成危害。
eBPF 的工作原理
要理解 eBPF 的强大之处,我们还需要简单了解一下它的工作原理。可以将 eBPF 想象成一个运行在内核态的虚拟机,它允许我们编写一些小程序 (eBPF 程序),并将这些程序挂载到内核的各种事件探针上。当事件发生时,eBPF 程序会被触发执行,收集相关数据,并将数据存储到用户态可以访问的映射 (map) 中。用户态程序可以通过读取这些映射,获取监控数据。
实战:追踪特定进程的 CPU 使用情况
接下来,我们就通过一个实际的例子,来演示如何使用 eBPF 追踪特定进程的 CPU 使用情况。这里我们使用 bcc
(BPF Compiler Collection) 工具包,它提供了一系列 Python 封装,使得编写 eBPF 程序更加方便。
安装 bcc
首先,我们需要安装
bcc
工具包。具体的安装步骤可以参考 bcc 的官方文档:https://github.com/iovisor/bcc编写 eBPF 程序
创建一个名为
cpu_usage.py
的 Python 文件,并添加以下代码:#!/usr/bin/env python from bcc import BPF import argparse import time # 定义命令行参数 parser = argparse.ArgumentParser(description="追踪特定进程的 CPU 使用情况") parser.add_argument("-p", "--pid", type=int, help="要追踪的进程 PID") args = parser.parse_args() if not args.pid: print("请指定要追踪的进程 PID,例如:./cpu_usage.py -p 1234") exit() # 定义 eBPF 程序 program = ''' #include <uapi/linux/ptrace.h> #include <linux/sched.h> // 定义一个 map,用于存储进程的 CPU 使用时间 BPF_HASH(start, u32, u64); // kprobe 函数,在进程执行时记录开始时间 int kprobe__sched_process_exec(struct pt_regs *ctx, struct task_struct *p) { u32 pid = p->pid; // 只追踪指定 PID 的进程 if (pid != PID) { return 0; } u64 ts = bpf_ktime_get_ns(); start.update(&pid, &ts); return 0; } // kprobe 函数,在进程切换时计算 CPU 使用时间 int kprobe__finish_task_switch(struct pt_regs *ctx, struct task_struct *prev) { u32 pid = prev->pid; // 只追踪指定 PID 的进程 if (pid != PID) { return 0; } u64 *tsp = start.lookup(&pid); if (tsp == 0) { return 0; } u64 delta = bpf_ktime_get_ns() - *tsp; // 输出 CPU 使用时间 (纳秒) bpf_trace_printk("PID %d: CPU 使用时间 = %llu ns\n", pid, delta); start.delete(&pid); return 0; } ''' # 替换 PID program = program.replace("PID", str(args.pid)) # 加载 eBPF 程序 b = BPF(text=program) # 打印输出信息 print("正在追踪 PID 为 %d 的进程... 按 Ctrl+C 停止" % args.pid) # 循环读取 eBPF 程序的输出 try: while True: time.sleep(0.1) except KeyboardInterrupt: pass 这段代码主要做了以下几件事:
- 定义了一个命令行参数
-p
,用于指定要追踪的进程 PID。 - 定义了一个 eBPF 程序,该程序包含两个 kprobe 函数:
kprobe__sched_process_exec
和kprobe__finish_task_switch
。kprobe__sched_process_exec
函数在进程执行时被调用,用于记录进程的开始时间。kprobe__finish_task_switch
函数在进程切换时被调用,用于计算进程的 CPU 使用时间。
- 使用
BPF_HASH
定义了一个 map,用于存储进程的开始时间。 - 使用
bpf_trace_printk
函数将 CPU 使用时间输出到 trace buffer 中。 - 使用
bcc.BPF
加载 eBPF 程序。 - 循环读取 eBPF 程序的输出,并打印到屏幕上。
- 定义了一个命令行参数
运行 eBPF 程序
保存
cpu_usage.py
文件,并使用以下命令运行:sudo ./cpu_usage.py -p <进程 PID>
将
<进程 PID>
替换为你要追踪的进程的实际 PID。例如,如果要追踪 PID 为 1234 的进程,则运行以下命令:sudo ./cpu_usage.py -p 1234
运行后,你将会看到类似以下的输出:
正在追踪 PID 为 1234 的进程... 按 Ctrl+C 停止 PID 1234: CPU 使用时间 = 1234567 ns PID 1234: CPU 使用时间 = 2345678 ns PID 1234: CPU 使用时间 = 3456789 ns ... 这表示 PID 为 1234 的进程在不断地使用 CPU,并且每次 CPU 使用的时间分别为 1234567 纳秒、2345678 纳秒、3456789 纳秒等。
停止 eBPF 程序
按下
Ctrl+C
即可停止 eBPF 程序的运行。
实战:追踪特定进程的内存分配情况
接下来,我们再通过一个例子,来演示如何使用 eBPF 追踪特定进程的内存分配情况。同样,我们使用 bcc
工具包。
编写 eBPF 程序
创建一个名为
memory_alloc.py
的 Python 文件,并添加以下代码:#!/usr/bin/env python from bcc import BPF import argparse import time # 定义命令行参数 parser = argparse.ArgumentParser(description="追踪特定进程的内存分配情况") parser.add_argument("-p", "--pid", type=int, help="要追踪的进程 PID") args = parser.parse_args() if not args.pid: print("请指定要追踪的进程 PID,例如:./memory_alloc.py -p 1234") exit() # 定义 eBPF 程序 program = ''' #include <uapi/linux/ptrace.h> #include <linux/sched.h> // 定义一个 map,用于存储进程的内存分配大小 BPF_HISTOGRAM(alloc_size); // kprobe 函数,在 malloc 函数被调用时记录内存分配大小 int kprobe__malloc(struct pt_regs *ctx, size_t size) { u32 pid = bpf_get_current_pid_tgid(); // 只追踪指定 PID 的进程 if (pid != PID) { return 0; } alloc_size.increment(bpf_log2l(size)); return 0; } // kprobe 函数,在 free 函数被调用时不做任何操作 int kprobe__free(struct pt_regs *ctx, void *addr) { return 0; } ''' # 替换 PID program = program.replace("PID", str(args.pid)) # 加载 eBPF 程序 b = BPF(text=program) # 打印输出信息 print("正在追踪 PID 为 %d 的进程... 按 Ctrl+C 停止" % args.pid) # 循环读取 eBPF 程序的输出 try: while True: time.sleep(1) print("\n----------------------------------------") alloc_size = b["alloc_size"] alloc_size.print_log2_hist("内存分配大小 (bytes)") alloc_size.clear() except KeyboardInterrupt: pass 这段代码主要做了以下几件事:
- 定义了一个命令行参数
-p
,用于指定要追踪的进程 PID。 - 定义了一个 eBPF 程序,该程序包含两个 kprobe 函数:
kprobe__malloc
和kprobe__free
。kprobe__malloc
函数在malloc
函数被调用时被触发,用于记录内存分配的大小。kprobe__free
函数在free
函数被调用时被触发,这里我们不做任何操作。
- 使用
BPF_HISTOGRAM
定义了一个 histogram,用于统计内存分配大小的分布情况。 - 使用
bpf_get_current_pid_tgid
函数获取当前进程的 PID。 - 使用
bpf_log2l
函数计算内存分配大小的以 2 为底的对数,用于 histogram 的统计。 - 使用
bcc.BPF
加载 eBPF 程序。 - 循环读取 histogram 的数据,并打印到屏幕上。
- 定义了一个命令行参数
运行 eBPF 程序
保存
memory_alloc.py
文件,并使用以下命令运行:sudo ./memory_alloc.py -p <进程 PID>
将
<进程 PID>
替换为你要追踪的进程的实际 PID。例如,如果要追踪 PID 为 1234 的进程,则运行以下命令:sudo ./memory_alloc.py -p 1234
运行后,你将会看到类似以下的输出:
正在追踪 PID 为 1234 的进程... 按 Ctrl+C 停止 ---------------------------------------- 内存分配大小 (bytes): value ------------- count ------------- 0 | 0 1 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ 100 2 |@@@@@@@@@@@@@@@@@@@@@@ 50 4 |@@@@@@@@@@ 25 8 |@@@@ 12 16 |@ 3 32 | 0 64 |@ 1 这表示 PID 为 1234 的进程在不断地进行内存分配,并且内存分配大小的分布情况如上所示。例如,分配大小为 1 字节的次数为 100 次,分配大小为 2 字节的次数为 50 次,等等。
停止 eBPF 程序
按下
Ctrl+C
即可停止 eBPF 程序的运行。
总结
通过以上两个例子,我们学习了如何使用 eBPF 追踪特定进程的 CPU 使用情况和内存分配情况。eBPF 的强大之处在于它的灵活性和可定制性,我们可以根据实际需求编写自定义的 eBPF 程序,从而实现对系统行为的精细化监控。希望这些内容能帮助你更好地理解和使用 eBPF,从而更好地管理和优化你的服务器。
扩展思考
- 更复杂的监控指标:除了 CPU 使用情况和内存分配情况,我们还可以使用 eBPF 追踪其他更复杂的监控指标,例如网络 I/O、磁盘 I/O、系统调用延迟等。
- 实时告警:我们可以将 eBPF 采集到的数据与预设的阈值进行比较,当超过阈值时,触发实时告警。
- 数据可视化:我们可以将 eBPF 采集到的数据进行可视化,例如使用 Grafana 等工具,从而更直观地了解系统的运行状态。
希望这些扩展思考能给你带来更多的灵感,让你更好地利用 eBPF 解决实际问题。