通过记忆化技术将eBPF CPU开销降低约90%(非AI生成)
摘要
本文描述了作者如何在他们的安全代理中通过实现记忆化来缓存基于inode的策略,从而将eBPF CPU成本降低约90%,避免了文件访问检查时的重复路径遍历。
暂无内容
查看缓存全文
缓存时间: 2026/09/15 02:50
# 通过备忘录技术将eBPF的CPU开销降低约90%(非AI生成)
来源:https://nathannaveen.dev/posts/dropping-ebpf-cpu-cost-by-90/
我和兄弟花了很多时间从底层设计我们的eBPF安全代理,使其能极速运行,但最近我们发现可以通过备忘录技术让它快得多!
几周前,我分析了eBPF代码,发现防护体系中最昂贵的部分其实不是执行策略(允许/拒绝),而是确定哪个策略适用于特定的文件打开操作。
我们的策略基于路径,因此eBPF会利用在文件打开时触发的LSM钩子。随后我们重建路径,向上遍历父目录项,并检查该文件或任何祖先目录是否有匹配的策略。虽然这方法可行,但效率低下,而且我们会为已经处理过的文件重复大量工作(例如数据库反复访问相同文件路径的情况)。
于是我们为每个inode缓存了对应的策略映射。这使内核CPU开销降低了约90%。
另外,我们最近开源了代码仓库,本文提到的所有内容都能在以下地址找到:https://github.com/bomfather/agent
## 问题所在
缓存启用之前,每次文件打开都会遍历完整路径。整个流程如下:
1. 获取文件路径。
2. 通过目录项向上遍历文件路径。
3. 在每一级检查该路径是否存在策略。
4. 合并结果得到最终策略,以此决定允许或拒绝。
这方法可行,但如果同一文件被多次打开,或同一子树下的多个文件被打开,我们就不得不为每个文件重复这些步骤。
例如:在Postgres中,如果我们只希望Postgres能访问`/var/lib/postgres`,可以采用如下策略示例:
``
policies:
- executable: "filepath = /usr/lib/postgresql/16/bin/postgres"
can_access_dirs:
- "/var/lib/postgres:read"
``
当Postgres从`var/lib/postgres/data/base/123`、`var/lib/postgres/data/base/234`和`var/lib/postgres/data/base/345`检索文件时,我们必须为每次文件访问遍历完整的目录项路径,这非常低效。
在本文剩余部分中,我将称这种低效的路径遍历为"慢速路径"。
## 缓存内容是什么?
我们的解决方案是使用缓存。但必须确保缓存不会过于庞大,且能安全复用缓存条目。
我们曾考虑使用目录项,但目录项是指针,而指针无法存储在eBPF映射中。如果想使用目录项,我们可以将目录项内容存储在结构体中,并将该结构体作为映射键,但这样结构体会非常庞大。
因此我们决定使用基于`inode`的缓存。缓存键包含三个字段:挂载命名空间ID、挂载ID和inode编号。
不能单独缓存`inode`,因为inode编号在特定挂载树内是唯一的(因此如果策略覆盖多个挂载树,inode可能会重叠)。挂载ID帮助我们识别观察文件时通过的挂载树。挂载命名空间ID则防止在不同命名空间中使用缓存条目。
缓存值包含两部分:`access_index`和缓存状态。我们以位掩码形式存储策略以节省空间,`access_index`是路径策略的位位置(https://nathannaveen.dev/posts/optimizing-ebpf-policies-for-speed-and-space/)。
因此,我们的缓存连同键值大致如下所示:
``
#define INODE_POLICY_CACHE_NO_POLICY 0
#define INODE_POLICY_CACHE_ACCESS_INDEX 1
#define INODE_POLICY_CACHE_GLOBAL_READ_ONLY 2
#define INODE_POLICY_CACHE_ACCESS_INDEX_AND_GLOBAL_RO 3
struct inode_cache_key {
u64 mntns_id;
u64 mount_id;
u64 inode;
};
struct inode_policy_cache_value {
u32 access_index;
u8 state;
};
struct {
__uint(type, BPF_MAP_TYPE_LRU_HASH);
__uint(max_entries, 10000);
__type(key, struct inode_cache_key);
__type(value, struct inode_policy_cache_value);
} bomfather_inode_policy_cache SEC(".maps");
```
引入缓存后,我们的流程变为:
1. 构建缓存键。
2. 在LRU哈希映射中查找键。
3. 若命中,根据缓存结果直接执行文件打开的允许/拒绝判定。
4. 若未命中,执行慢速路径并将结果存入缓存。
缓存命中时,文件打开操作构建inode缓存键后直接进行允许或拒绝判定。未命中时,则遍历父目录项、合并策略、存储结果,然后进行判定。
## 性能变化
在基准测试中,我们对同一文件执行了20万次打开操作以分析性能;缓存将内核周期从280亿降至30.3亿。未使用缓存时,`tail_call_security_check`出现在堆栈中的比例为89.2%,`is_restricted_filepath`为81.9%,`path_check_callback`为63.7%。
在下面的火焰图中可以看到,使用缓存后,首次查找后的路径遍历开销基本消失。例如`is_restricted_filepath`和`path_check_callback`各自缩减至约0.02%,小到在图中几乎不可见。
**缓存使用前:**
未使用inode缓存的内核火焰图,tail_call_security_check、is_restricted_filepath和path_check_callback占据堆栈主要部分。
**缓存使用后:**
使用inode缓存的内核火焰图,首次查找后路径遍历开销基本消失。
我们使用`perf`工具的`cycles:k`事件分析了内核CPU占用情况,该指标衡量文件打开期间的内核侧CPU开销。
## 边缘案例
使用此缓存时需考虑的一个问题是:多个路径可能共享单个inode。硬链接是最典型的例子——两个不同路径可以共享相同inode。这问题很关键,因为结果准确性比缓存性能更重要。
我们的解决方案更像是权宜之计而非根本解决方案。inode包含链接计数(`i_nlink`),它表示有多少路径指向该inode;我们可以读取该值,若大于1则不使用该缓存条目,回退到慢速路径。
``
if (BPF_CORE_READ_INTO(&nlink, inode, i_nlink)) {
return false;
}
if (nlink != 1) {
inode_cache_stats_inc(INODE_CACHE_STATS_SKIPS_NLINK);
return false;
}
``
这是个折中方案——我们牺牲了部分缓存覆盖率,但我认为问题不大,毕竟保持缓存准确性最为重要。
## 总结
这确实是个有趣的项目,因为在我确定最终方案前,必须尝试多种不同的缓存设计思路。
另外我很高兴缓存完全内置于代理内部,用户无需修改策略就能享受性能提升!
相似文章
我们对20个生产代理场景进行了MCP与文件系统访问的基准测试。文件系统设置将LLM成本降低了27%,延迟降低了32%
一项比较MCP和文件系统访问在20个生产场景中针对AI代理的基准研究发现,文件系统访问将LLM成本降低了27%,延迟降低了32%,同时提高了答案质量。
如何对eBPF代码进行性能分析?
本文演示了如何通过创建一个简单的C测试框架来衡量文件打开延迟,从而对eBPF代码性能进行分析,使开发者能够比较附加eBPF钩子前后的开销。
调试BPF中基于类型的别名分析优化
文章描述了如何将BPF程序切换到使用BTF生成的vmlinux.h,由于Clang的基于类型的别名分析优化,导致IP校验和不正确,并通过汇编检查详细说明了调试过程。
给3个研究智能体加了共享记忆以降成本,结果一分没省!!
一位实践者对三个并行研究智能体进行了成本基准测试,它们共享页面摘要记忆并使用前缀缓存,结果发现几乎没有节省(1.11美元 vs 1.09美元),因为并发启动导致记忆召回为空、前缀缓存未命中,且摘要增加了输出 token 的开销。
@bcherny: 这样的生活小改进累积起来。更多改进即将到来
Claude Code CLI 已通过更新 Bun 的垃圾回收器,使其仅在进程空闲时运行,从而优化了 CPU 使用率,在 p99 上降低了 2 倍。