想象你是城市的交通管理局工程师。城市的主干道就像操作系统的内核,每辆车都是一块被分配的内存。过去,你要查“哪辆车只进不出”(内存泄漏),必须把路面挖开,埋设地感线圈——那相当于修改内核源码,风险极大,一不留神整个城市的交通都会瘫痪。现在,eBPF 出现了。它像一套“智能交通摄像头”,不挖路、不封道,只需在路旁架起可编程的观察哨所,就能实时记录每一辆车的进出,精准找出那些只占用车位却从不离开的“僵尸车”。这套系统不仅看得见,还能分析得明明白白,甚至能在爆满(OOM)之前提前预警。从交通指挥的角度,你将理解 eBPF 如何工作、它手中的数据台账(Map)、安置在路口的各类摄像头(kprobe/uprobe),以及 BCC 与 bpftrace 这两套指挥平台,最终掌握在真实场景中定位内存泄漏的完整方法论。

岗位关联

掌握 eBPF 内存监控,你将拥有在生产环境“不动刀”地透视内核内存行为的能力,从被动背锅变成主动定位,这是内核开发、SRE 运维与性能调优从业者的核心竞争力所在。

目录
  1. 可视化演示
  2. eBPF沙箱原理
  3. Map与辅助函数
  4. kprobe探针
  5. uprobe探针
  6. 内存监控方案
  7. BCC工具实战
  8. bpftrace分析
  9. OOM排查实战
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

动态演示eBPF追踪内存监控实战的核心机制,通过输入/处理/存储/输出四种模式展示关键概念与交互流程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

eBPF追踪内存监控实战:事件驱动 事件驱动架构的核心原理与事件定 观察者模式的同步事件通知机制 核心机制可视化 eBPF追踪内存监控实战整体架构与关键组件

eBPF沙箱原理

城市交通管理局过去想观察车流,只能选择挖开路面埋地感线圈,相当于修改内核源码——风险高、周期长,还可能引发交通事故。现在,他们在路旁架起了一套“智能观察哨所”:外形小巧、无需破土动工,却能精准捕捉每一辆经过的车。这套哨所就是 eBPF。它是嵌入在 Linux 内核中的可编程沙箱,专为安全观测而设计。你只需要编写一段小代码,加载进内核,它就会在指定事件发生时自动执行,全程不碰内核源码一根汗毛。

为什么敢让第三方代码跑在内核里?因为 eBPF 有一道严格的“安检门”——验证器机制。在程序加载阶段,验证器会从头到尾检查这段代码:是否存在死循环、是否越界访问内存、是否调用了非法函数。任何有安全隐患的程序都会被当场拦住,内核绝不会让一颗“定时炸弹”进入交通指挥中心。

通过验证的 eBPF 程序采用事件驱动模式运行:平时它安静地挂在指定钩子上不消耗资源,只有当目标事件(如系统调用、内核函数调用、网络包到达)发生时,它才被唤醒执行。这种设计让 eBPF 的观测开销极低,如同摄像头待机时不耗电,有人经过才点亮。

交通管理局终于拥有了不挖路就能看车流的利器:eBPF 沙箱让观测变得安全而轻盈,从此“透视内核”不再需要伤筋动骨。

Ctotal=Cbase+ϵobserveC_{total} = C_{base} + \epsilon_{observe}
总运行开销等于系统自身开销加上观测开销,而 eBPF 的观测开销非常小,小到可以忽略不计。
  1. 总运行开销系统的整体 CPU/内存消耗,是评价监控工具的重要指标。
    CtotalC_{total}
  2. 系统基础开销未安装任何观测工具时,系统正常运行本身的消耗。
    CbaseC_{base}
  3. 观测附加开销eBPF 探针被触发后执行处理逻辑所额外增加的消耗。
    ϵobserve\epsilon_{observe}
  4. 综合结论eBPF 的设计目标就是让观测附加项趋向于零。
    Ctotal=Cbase+ϵobserveC_{total} = C_{base} + \epsilon_{observe}

Map与辅助函数

摄像头拍到车辆信息后,数据不能直接丢在马路上——你需要一个记录本,把每辆车的进出情况记下来。eBPF 里这个“记录本”叫做 Map 数据结构。它是内核态与用户态共享的一块键值存储区域,就像交警手上那本“车流台账”。eBPF 程序在内核态把数据写入台账,用户态的监控程序则从台账中读取统计数据。Map 有哈希表、数组、LRU 等多种形态,应付不同场景:记录进程分配总量用 hash,记录固定长度的统计信息用 array。

但是,在内核里写台账不能随便动手,必须使用官方发放的专用工具——helper 函数。这些函数是内核预先定义好的安全接口,eBPF 程序只能调用这一小撮“持证工具”。比如 map_update_elem() 是“盖章工具”,map_lookup_elem() 是“翻阅工具”。验证器会确保 eBPF 程序不会绕过这些工具去直接操作任意内核内存,从根本上杜绝了“乱写乱画”的可能。

正是这种“台账本 + 专用工具”的组合,打通了内核与用户态之间的数据共享通道。内核负责探测、记录,用户态负责整理、展示,各司其职,数据流转井井有条。Map 还会自动处理并发访问,多个 CPU 同时更新数据也不会打架。

Map 和 helper 让交通指挥中心有了可靠的数据基础:摄像头负责捕捉,台账本负责记录,工具包负责安全。数据有了“家”,监控才有底气。

ρ=nNcap\rho = \frac{n}{N_{cap}}
负载因子表示台账本被占用了多少比例,这个值越接近 1,说明 Map 越拥挤,数据丢失风险越高。
  1. 负载因子符号它标识了数据结构的拥挤程度,数值越高越危险。
    ρ\rho
  2. 条目数量当前台账中已经登记了多少条记录。
    nn
  3. 容量上限台账本最多能够容纳的总记录条数。
    NcapN_{cap}
  4. 比率关系不要让你的监控数据占据超过容量的 70%,否则就要扩容。
    ρ=nNcap\rho = \frac{n}{N_{cap}}

kprobe探针

回到交通管理现场:你想观察车辆进入高速是否拥堵,最直接的办法是在高速匝道口安装摄像头——每当一辆车驶入匝道,摄像头就自动拍下车牌与货物量。对应到内核世界,这就是 kprobe 探针。kprobe 是 eBPF 家族中负责内核动态追踪的装置,它把你关心的内核函数(比如 kmalloc 内存分配函数)设为一个“观察点”,当系统中任何进程调用该函数时,探针立即触发。

kprobe 的实现原理很巧妙,它并不修改内核源码,而是在运行时把目标函数入口处的第一条指令临时替换为断点指令。这就像在匝道上放一个“感应器”,车压过感应带时触发拍照。这种函数入口插桩方式不需要重新编译内核,甚至可以针对正在运行的生产系统动态挂载,真正做到“装摄像头不用封路”。

在内核内存监控中,kprobe 最典型的应用就是分配路径观测。当进程执行 kmalloc 请求分配一块内存时,kprobe 探针会被触发,此刻 eBPF 程序可以从寄存器中读取本次分配的大小、进程 PID、用户态调用栈等信息,并把这些数据写入 Map 台账。每一次“车辆入场”都被完整登记在案。

kprobe 把高速匝道上的每一辆“内存车”都拍了下来,但它在内核主干道上工作。如果你把目光转向用户态程序,还需要另一套摄像头。

Ralloc=NeventsΔTR_{alloc} = \frac{N_{events}}{\Delta T}
分配速率等于观察窗口内捕获到的分配事件总数除以时间窗长度,用来衡量内存分配的压力有多大。
  1. 分配速率它能够反映系统内存分配活动的频繁程度。
    RallocR_{alloc}
  2. 分配事件总数kprobe 探针在窗口内捕获到的所有分配事件数量。
    NeventsN_{events}
  3. 观察时间窗这次观测所持续的时间跨度,窗口越长数据越稳定。
    ΔT\Delta T
  4. 速率公式速率高意味着系统可能在频繁申请内存,需要重点关注。
    Ralloc=NeventsΔTR_{alloc} = \frac{N_{events}}{\Delta T}

uprobe探针

当你想知道某个小区住户的生活轨迹,最佳方式不是观察整条城市主干道,而是在小区门口装一套摄像头。与此对应,uprobe 探针专注于用户态追踪——它不盯内核函数,而是直接扎进应用程序的代码里。在 eBPF 监控体系中,kprobe 管内核要道,uprobe 管应用门口,两者配合才能覆盖从用户态到内核态的完整内存链路。

uprobe 的动态能力来自对程序二进制文件的符号解析。当加载一个可执行文件时,eBPF 会根据函数名(如 malloc)在 ELF 符号表中查找到对应地址,然后把该地址处的指令替换为触发探针的跳转指令。这让它可以观测应用行为的方方面面:应用调用了多少次 malloc、每次申请多少字节、调用来自哪个函数。对内存监控来说,uprobe 最亮眼的价值是追踪用户态分配——无论是 malloc 还是 free,每一次调用都无所遁形。

与 kprobe 侧重内核视角不同,uprobe 让你站在“应用门口”思考问题。比如一个 Java 服务频繁申请大块内存,uprobe 可以告诉你这些申请发生在哪些用户态函数中,从而把嫌疑范围从“内核”缩小到“某一段业务代码”。

uprobe 的“门口摄像头”让用户态应用的内存行为变得透明。但装了摄像头只是第一步,真正的挑战在于如何设计一套完整的监控方案——从记录、配对到判定。

Nactive=Nalloc−NfreeN_{active} = N_{alloc} - N_{free}
活跃内存对象数等于累计分配次数减去累计释放次数,如果这个数只增不减,就是典型的泄漏特征。
  1. 活跃对象数当前仍然存活在应用程序中的内存对象数量。
    NactiveN_{active}
  2. 累计分配次数uprobe 在追踪周期内捕获到的所有 malloc 调用总次数。
    NallocN_{alloc}
  3. 累计释放次数所有 free 调用成功释放的对象数量。
    NfreeN_{free}
  4. 泄漏判据活跃数持续上涨说明释放不彻底,需要借助堆栈定位泄漏点。
    Nactive=Nalloc−NfreeN_{active} = N_{alloc} - N_{free}

内存监控方案

有了摄像头(kprobe/uprobe)和台账本(Map),我们终于可以开启一套正式的“停车管理方案”:车辆进场要登记,出场要核销,定期盘点滞留车辆。这套方案在内存监控中的专业说法是分配统计。当 kmalloc 或 malloc 被调用时,eBPF 程序记录下指针地址、分配大小、进程 PID 和调用栈,并存入 Map。从此每一次内存分配都有了“入场登记”。

第二步是释放配对。当 kfree 或 free 被调用时,eBPF 程序取出指针,在 Map 中查找对应的分配记录,然后将其删除。这相当于“出场核销”——车辆离开停车场,登记册上就划掉这一条。整个查找过程依托 Map 的哈希索引,可以在亚微秒级别完成,不会对应用性能造成明显干扰。

有了进场登记和出场核销,“漏网之鱼”就无处遁形:泄漏判定的核心逻辑就是映射扫描。监控程序每隔一段时间遍历 Map,凡是长期残留的记录,都是“只进不出”的嫌疑车辆。再配合调用栈信息,你可以精确知道是哪一行代码分配了这些内存。若某个进程的残留记录数持续上涨,内存泄漏便已实锤。

这套“登记—核销—盘点”的监控方案,让内存泄漏从“凭空猜测”变成了“有据可查”。接下来需要工具把这些数据变成看得见的结果。

Mleak=∑i=1Nai−∑j=1MfjM_{leak} = \sum_{i=1}^{N} a_i - \sum_{j=1}^{M} f_j
泄漏量等于累计分配的内存总量减去累计释放的内存总量,差值逐周期递增,就意味着内存正在被偷偷消耗。
  1. 泄漏总量它是衡量内存泄漏严重程度的最直接指标。
    MleakM_{leak}
  2. 单次分配大小第 i 次分配请求所申请的字节数。
    aia_i
  3. 单次释放大小第 j 次释放操作所归还的字节数。
    fjf_j
  4. 累计求和关系分配总和减去释放总和得到的正值,就是还没归还的部分。
    ∑i=1Nai−∑j=1Mfj\sum_{i=1}^{N} a_i - \sum_{j=1}^{M} f_j

BCC工具实战

执法记录仪收集了海量数据,但交通管理局需要一个“数据大屏”把统计信息可视化。BCC 就是 eBPF 生态中那套功能完备的指挥中心平台。它的全称是 BPF Compiler Collection,最大特色是提供了一整套 Python 接口——你可以在 Python 脚本中直接书写并加载 eBPF 程序,而无需单独编写 C 代码并手动编译。这好比用一台“预制机柜”连接了所有摄像头:插上电源(加载内核代码),数据就自动流到你的终端。

BCC 的工作方式非常符合工程直觉。在 Python 脚本里,你用字符串嵌入一段 C 语言编写的 eBPF 程序,BCC 会自动帮你完成编译、验证和加载。以内存追踪为例,脚本示例通常包含这几步:定义 BPF 程序文本、调用 attach_kprobe 将探针挂到 kmalloc 上、然后通过 perf_buffer_poll 或直接读取 Map 来获取数据。整个过程不需要写内核模块、不需要重启系统,几十行代码就能完成一次完整的内存探察。

当数据量较大时,直方图是最直观的呈现方式。直方图输出把内存块的大小分布按区间统计,比如“1-4KB 分配了 1000 次、4-16KB 分配了 200 次”,一眼就能看出系统的分配倾向。BCC 还支持对 Map 内容排序,轻松找出分配总量最高的 Top 进程。

BCC 这套“指挥大屏”适合深度分析,但当你凌晨三点被电话叫醒,需要快速判断“谁在泄漏”,还有更轻便的选择。

H[k]=∑i=1N1[bucket(xi)=k]H[k] = \sum_{i=1}^{N} \mathbb{1}[\text{bucket}(x_i) = k]
直方图第 k 个桶的计数,就是统计所有落入该大小区间的样本数,形成直观的分布视图。
  1. 直方图桶计数第 k 个大小区间内累积的样本数量。
    H[k]H[k]
  2. 指示函数当样本属于该区间时取 1,否则取 0,用于选择性累加。
    1[bucket(xi)=k]\mathbb{1}[\text{bucket}(x_i) = k]
  3. 全样本求和把所有样本逐一判定,最终得到该桶的总数。
    ∑i=1N\sum_{i=1}^{N}

bpftrace分析

深夜值班的运维工程师需要的是“手持执法仪”:一台便携设备,对着小区门口扫一下,立即显示进出的车流统计。bpftrace 就是 eBPF 生态中的那台便携执法仪。它采用单行命令的语法设计,让你用短短一行脚本就能完成采样、过滤、统计和输出。一改 BCC 需要编写完整 Python 工程的厚重感,bpftrace 把常用操作压缩成极简表达方式,上手门槛几乎为零。

虽然 bpftrace 小巧,但它的脚本语法五脏俱全。它以探针点作为命令行参数(比如 kprobe:kmalloc),配合大括号内的动作块(比如 { @[comm] = count(); })完成统计。@ 符号代表 Map 变量,count() 是内置的计数函数,kstack/ustack 可以获取内核或用户态调用栈。这套语法遵循“你要什么就写什么”的哲学,没有繁琐的工程样板。

在故障现场,bpftrace 最擅长现场分析。执行一条 bpftrace -e 'kprobe:kmalloc { @[kstack] = count(); }',运行十秒钟,它就能输出哪个内核调用栈触发了最多的内存分配。这个 Top 栈就是你的第一个破案线索。同样的思路也可以对 malloc/free 做差值统计,快速找到“分配了却不释放”的用户态函数。

bpftrace 让我们可以“三十秒巡一次街”。但无论工具多么顺手,真正的考验在于当 OOM 真的发生时,你是否能在那几分钟内找到真凶。

TopK=arg⁡max⁡s∈Scount(s)\text{TopK} = \arg\max_{s \in \mathcal{S}} \text{count}(s)
TopK 指出现频次最高的调用栈,它反映的是哪条代码路径在集中地分配内存,优先排查这里准没错。
  1. 目标调用栈我们最终要锁定的那个嫌疑堆栈。
    TopK\text{TopK}
  2. 最大参数函数它能够找出使某个表达式取得最大值的自变量值。
    arg⁡max⁡\arg\max
  3. 堆栈计数每种调用栈在所有采样事件中出现的总次数。
    count(s)\text{count}(s)
  4. 排序取优逐栈比较计数,排名第一的栈就是监控的热点所在。
    TopK=arg⁡max⁡s∈Scount(s)\text{TopK} = \arg\max_{s \in \mathcal{S}} \text{count}(s)

OOM排查实战

当一个城市的停车场彻底爆满,交管部门不得不启动“强制拖车”程序——内核里的 OOM Killer 会挑选一个占用内存最多的进程直接杀掉。虽然 OOM Killer 能暂时“缓解堵车”,但如果你不找到“只进不出”的车队,悲剧迟早重演。借助 eBPF,我们可以完成一次漂亮的事后根因定位。

首先,eBPF 的 Map 台账让增长曲线得以回放。记录每个进程在 OOM 前一段时间内的内存分配速率和总量,绘制出“内存增长曲线”,你会清楚看到哪个进程像“失控的仓库管理员”一样疯狂进货。通常 OOM 前 30 分钟是黄金排查窗口,通过历史记录就能锁定高增长的嫌疑进程。

其次,结合 kprobe/uprobe 记录的调用栈,定位到具体代码路径。比如某个 PID 的 malloc 事件集中在 login_service_alloc 函数中,基本可以断定是会话缓存忘记释放。这时你甚至可以把 eBPF 监控设为长期运行,配合阈值触发自动转储调用栈,形成监控告警体系:当某个进程的残留分配量超过阈值时,eBPF 自动把全量调用栈写入日志,相当于给系统装上了“事故黑匣子”。

eBPF 的“黑匣子”能力让 OOM 不再是一场盲目猜谜。从安装摄像头、建立台账、登记核销,到巡检与告警——这套完整的交通管理系统,终究能让你在城市的汹涌车流中抓住那只真正的“僵尸车”。

scorep=RSSpRSStotal×1000score_p = \frac{RSS_p}{RSS_{total}} \times 1000
OOM 分数等于进程常驻内存占总内存的比例乘以 1000,得分最高的进程往往成为 OOM Killer 的首要目标。
  1. 进程 OOM 分数它决定了进程被内核判处“死刑”的优先级。
    scorepscore_p
  2. 进程常驻内存该进程实际占用物理内存的字节数。
    RSSpRSS_p
  3. 系统总内存机器可用的全部物理内存容量。
    RSStotalRSS_{total}
  4. 计算得分占比越高的进程得分越高,被 OOM Killer 选中的概率越大。
    scorep=RSSpRSStotal×1000score_p = \frac{RSS_p}{RSS_{total}} \times 1000

AI 应用场景

SRE 运维:线上服务 OOM 根因排查

阶段:故障诊断与数据采集

解决的问题:传统排查 OOM 只能翻看系统日志和 dmesg,信息碎片化,难以回答“是谁在什么代码路径下不断分配内存”。eBPF 主动采集每个进程的分配/释放事件并记录调用栈,OOM 发生后可以利用历史监控数据还原现场。

具体案例:某电商平台凌晨促销出现频繁 OOM,SRE 通过 BCC 脚本追踪 malloc/free,发现某个订单模块的缓存对象在用户会话结束后未释放,残留对象以每分钟数十万个的速度累积。借助调用栈信息,开发团队十行代码修复,OOM 彻底消失。

业务可观测性:内核态内存热力分析

阶段:数据可视化与洞察

解决的问题:仅凭用户态指标无法看清“内存到底被谁吃掉”。eBPF 的 kprobe 可以在内核分配函数处实时采样,区分页缓存、Slab、进程堆等不同消耗者。

具体案例:一家云厂商在压测中发现某客户虚拟机内存回收频繁导致性能暴跌。通过 bpftrace 挂载 shrink_slab 和 __alloc_pages_nodemask,快速定位到内核文件缓存增长过快,最终通过调整脏页比例参数解决了问题。

数据处理:容器内存泄漏自动化巡检

阶段:异常检测与告警

解决的问题:Kubernetes 集群中容器数量大、内存申请释放频繁,人工巡检效率极低。eBPF 监控脚本定时扫描 Map 中的残留分配记录,将异常进程的调用栈自动上报告警平台。

具体案例:某大数据团队每天运行上百个 Spark 任务,偶发节点内存耗尽。他们部署了一个基于 BCC 的巡检服务,每五分钟扫描一次容器维度的内存分配残留,配合告警,将问题定位时间从小时级降低到分钟级。

Python 演示

python52 行
#!/usr/bin/env python3# -*- coding: utf-8 -*-"""用 eBPF (BCC) 追踪 kmalloc 与 kfree,输出进程维度的内存分配统计""" from bcc import BPFfrom time import sleep # eBPF C 程序:挂载到内核内存分配与释放路径bpf_text = """#include <uapi/linux/ptrace.h>#include <linux/sched.h> BPF_HASH(alloc_bytes, u64, u64);BPF_HASH(free_count, u64, u64); int trace_kmalloc(struct pt_regs *ctx, size_t size) {    u64 pid = bpf_get_current_pid_tgid() >> 32;    u64 key = pid;    u64 *total = alloc_bytes.lookup(&key);    u64 new_total = (total ? *total : 0) + size;    alloc_bytes.update(&key, &new_total);    return 0;} int trace_kfree(struct pt_regs *ctx) {    u64 pid = bpf_get_current_pid_tgid() >> 32;    u64 key = pid;    u64 *cnt = free_count.lookup(&key);    free_count.update(&key, (cnt ? *cnt : 0) + 1);    return 0;}""" bpf = BPF(text=bpf_text)bpf.attach_kprobe(event="__kmalloc", fn_name="trace_kmalloc")bpf.attach_kprobe(event="kfree", fn_name="trace_kfree") print("采集 3 秒内的内核内存分配数据...")sleep(3) print("\n%-12s %-16s %-12s %s" % ("PID", "分配字节数", "释放次数", "平均每次大小"))rows = []for k, v in bpf["alloc_bytes"].items():    pid = k.value    alloc = v.value    cnt_val = bpf["free_count"].get(pid)    fcnt = cnt_val.value if cnt_val else 0    avg = alloc / fcnt if fcnt else 0    rows.append((pid, alloc, fcnt, avg)) for pid, alloc, fcnt, avg in sorted(rows, key=lambda r: r[1], reverse=True)[:10]:    print("%-12d %-16.2fKB %-12d %.2fKB" % (pid, alloc / 1024, fcnt, avg / 1024))
目的:使用 BCC 加载 eBPF 内核探针,统计系统各进程在采样窗口内的内核内存分配总量、释放次数与平均分配大小,用于初步定位内存分配热度的异常进程。
  1. BPF_HASH(alloc_bytes, u64, u64)定义一个以进程 PID 为键、分配字节数为值的 Map,用来记录每个进程的累计分配量。
  2. bpf_get_current_pid_tgid() >> 32从 eBPF 上下文中获取当前进程的 PID,右移 32 位是为了去掉线程 ID 只保留进程 ID。
  3. bpf["alloc_bytes"].items()遍历用户态读取 Map 的全部键值对,将内核中累积的统计结果拉到用户空间进行分析。
  4. sorted(rows, key=lambda r: r[1], reverse=True)按分配总字节数降序排序,让分配量最高的进程排在最前面,方便快速锁定热点。
  5. bpf.attach_kprobe(event="__kmalloc", ...)将探针挂载到内核 __kmalloc 函数入口,探测内存分配事件。

常见误区

误区我一直以为要用 eBPF 就必须下载内核源码、编译安装新内核,否则内核不认这些探测代码。因为这个原因,我连试都不敢试。

正解eBPF 最大的魅力恰恰在于它是动态的。kprobe 和 uprobe 都是在运行时把已有函数的指令临时替换为断点,不需要重新编译内核。就像安装摄像头不需要把高速公路拆掉重建一样。只要内核开启了 CONFIG_BPF 相关选项(主流发行版默认开启),你就能直接使用 BCC 或 bpftrace 进行追踪。

为何常见:传统的内核模块开发确实需要匹配内核版本并编译,很多人把这条经验错误地迁移到了 eBPF 上。

误区既然 eBPF 这么强大,我是不是可以写一个复杂的循环来遍历所有进程内存?只要逻辑正确,内核应该允许我运行更长时间的代码。

正解验证器对 eBPF 程序有严格限制:循环必须有边界且每条路径都会终止,程序指令数不能超过 100 万条,栈空间也有限。超限的程序会在加载阶段被验证器拒绝,根本不会进入执行状态。这就像交通法规规定摄像头只能拍固定角度,不能自由转动去偷窥任何角落。

为何常见:很多人把 eBPF 和普通内核模块混为一谈,忽略了它“安全优先”的设计哲学。

误区我在网上看到别人都用 kprobe 追踪内存,所以只要用 kprobe 就能搞定所有问题。用户态程序是不是也会经过 kmalloc?那 kprobe 就够了呀。

正解内核态和用户态是两个不同的世界。kprobe 只能观测内核函数如 kmalloc,它看不到用户程序里的 malloc。如果应用通过 malloc 申请了内存,内核 kmalloc 未必会被调用(比如互斥锁、文件缓存也可能触发内核分配)。所以追踪用户态应用的内存行为,应该用 uprobe 挂在 malloc/free 上。两者配合才能覆盖全链路。

为何常见:新手往往从“内核”入手学习,容易忽略用户态探针的存在。

误区我打算把每次内存分配的调用栈都存到 Map 里,这样以后随时可以追溯。好像也没人提醒过我 Map 有大小限制。

正解Map 是内核中的有限资源,每个 Map 的条目数量在创建时就固定了(比如 10240 条)。如果存满后再插入新元素,默认行为是丢弃或覆盖旧数据,这会直接影响监控的准确性。因此实战中通常通过 max_entries 设置合理容量,并配合用户态定期清理旧记录,或者使用 LRU 类型的 Map 自动淘汰不活跃条目。

为何常见:用户态的字典/HashMap 通常支持动态扩容,大家天然认为 Map 也能无限增长。

面试题

Q1. eBPF 最核心的价值主张是什么?

单选题 · easy

  • A.加速内核网络包处理性能
  • B.不改内核源码即可安全观测← 正确答案
  • C.替代所有传统内核模块
  • D.自动修复内核安全漏洞

答案:B。eBPF 的立身之本是“可编程沙箱+安全观测”,让开发者在不动内核源码的前提下写代码探查系统。虽然它可以做网络加速(如 XDP),但那只是应用之一。C 过于绝对,D 完全不是 eBPF 的功能。

Q2. eBPF 验证器的主要职责是什么?

单选题 · easy

  • A.检查程序运行时的性能指标
  • B.编译并优化程序字节码
  • C.拒绝危险程序保障内核安全← 正确答案
  • D.自动生成探针挂载点

答案:C。验证器在程序加载阶段逐条评估指令,拒绝有死循环、越界访问、非法调用的程序,是 eBPF 安全模型的基石。B 是 JIT 编译器的职责,D 是 BCC 等工具链的功能。

Q3. kprobe 与 uprobe 的追踪对象有何区别?

单选题 · mid

  • A.前者追踪内核函数后者追踪用户态函数← 正确答案
  • B.前者追踪系统调用后者追踪库函数
  • C.前者只能挂载网络事件后者是内存事件
  • D.前者数量有限而后者可以无限挂载

答案:A。kprobe 插桩于内核函数(如 kmalloc),uprobe 插桩于用户态函数(如 malloc),这正是两种探针的分工核心。B 中系统调用只是内核函数的一类,不准确。C 和 D 的描述都不符合事实。

Q4. 内存监控中 Map 的典型用途是什么?

单选题 · mid

  • A.存储摄像头拍摄的图像帧
  • B.内核态与用户态共享统计信息← 正确答案
  • C.缓存内核函数的调用结果
  • D.保存用户态的日志文件内容

答案:B。Map 是 eBPF 程序与用户态程序之间的数据通道。eBPF 程序在内核侧把分配总量写入 Map,用户态脚本读取后展示,这是内存监控典型的数据流转方式。

Q5. 追踪应用层 malloc 调用应选择哪种挂载方式?

单选题 · mid

  • A.kprobe 挂载到 __kmalloc
  • B.uprobe 挂载到 malloc 符号← 正确答案
  • C.tracepoint 挂载到 page_fault
  • D.手动编写内核模块插桩

答案:B。malloc 是用户态 libc 提供的函数,必须用 uprobe 在用户进程内插桩。kprobe 只能看到内核态的 kmalloc,中间还隔着一层分配器,无法准确反映用户态申请行为。

Q6. 下列哪些是 eBPF 程序的安全约束?

多选题 · mid

  • A.不允许无限循环← 正确答案
  • B.只能调用内核提供的 helper 函数← 正确答案
  • C.访问内存必须在验证器确认的范围内← 正确答案
  • D.禁止使用 C 语言编写

答案:ABC。eBPF 通过三大约束保证安全:循环必须有限、只能调 helper、内存访问受到严格边界检查。C 语言是 eBPF 最主流的编写语言,完全可以用 C 写 eBPF 程序,因此 D 错误。

Q7. 定位内存泄漏的核心手段包括哪些?

多选题 · mid

  • A.分配时保留调用栈记录← 正确答案
  • B.释放时按指针配对删除← 正确答案
  • C.定期扫描残留记录← 正确答案
  • D.直接重启应用进程

答案:ABC。泄漏定位的“进场登记、出场核销、定期盘点”三步缺一不可。重启只是暂时缓解症状,没有解决根因,不属于定位手段。

Q8. 当一个进程 OOM 时,eBPF 能提供哪些线索?

多选题 · hard

  • A.该进程近期的内存增长曲线← 正确答案
  • B.触发大量分配的调用栈← 正确答案
  • C.各内存桶的分配热力分布← 正确答案
  • D.自动生成修复内存泄漏的补丁

答案:ABC。eBPF 实时采集增长曲线、调用栈和热力分布,这些数据帮助开发定位根因。但它只是观测工具,不负责生成修复补丁,那是编译器与人力的工作。

Q9. bpftrace 相比 BCC 的主要优势是什么?

单选题 · mid

  • A.支持更复杂的数据结构
  • B.单行命令快速现场分析← 正确答案
  • C.可以提供更低的追踪开销
  • D.内置更多内核补丁

答案:B。bpftrace 的设计目标就是“轻量巡检”,一行命令完成采样与统计。BCC 更适合需要写完整逻辑的复杂场景。二者底层都是 eBPF,追踪开销差异不大,C 不成立。

Q10. 关于观测开销描述正确的是?

单选题 · hard

  • A.eBPF 探针完全不消耗任何资源
  • B.事件高频时探针开销会显著增长← 正确答案
  • C.kprobe 比 uprobe 一定更快
  • D.Map 读写不会影响探针性能

答案:B。eBPF 开销虽低但并非为零,事件触发频率越高,处理逻辑的执行次数越多,开销自然上涨。开启 kprobe 探测 kmalloc 这种高频函数时要注意控制数据处理逻辑的复杂度。kprobe/uprobe 性能取决于上下文与场景,不存在绝对更快。