一、背景
在KV存储中使用eBPF做实时主从同步的旁路转发,作为直推式网络转发的替代方案,本博客重点谈论eBPF实现时踩的坑。
eBPF的优势
较小侵入:无需修改,或者只需要少量修改目标程序源码,即可动态插入观测与控制逻辑。
减少拷贝:利用 eBPF 在内核态完成数据采集与初步过滤,减少拷贝
旁路设计:将发送和编码的逻辑旁路,从而降低开销。
二、eBPF实现
2.1 设计概述
我们通过KProbe挂载到hook函数kvs_eBPF_propagation_hook,捕获客户端发送给主机并解析后的cmd、key、value等字段,然后用户态程序进行编码,再通过send转发给Slave。
[Client] → (RESP数据) → [Master] ↓ hook函数 (KProbe) ↓ eBPF内核态处理 ↓ Ring Buffer推送 ↓ 用户态转发模块 → [Slave]设计方案参考这篇博客。
2.2 三大模块划分
eBPF分为三个模块:
| 模块 | 位置 | 职责 |
|---|---|---|
| 共享头文件 | 内核态+用户态 | 定义Ring Buffer、Map等数据结构,保证双方数据契约一致 |
| 内核态模块 | 内核eBPF虚拟机 | 挂载KProbe、捕获数据、推送到Ring Buffer |
| 用户态模块 | 用户空间进程 | 从Ring Buffer读取数据、执行实际转发逻辑 |
这种划分保证了内核态逻辑足够轻量(只做捕获和推送),复杂逻辑(如转发、重试、配置管理)全部放在用户态,降低Verifier拒绝的风险。
三、踩坑记录
踩坑1:——迷信零入侵,不肯修改任何源码
尽管eBPF的零入侵确实是一大优势,可这一般是相对于探测和性能分析而言的,我们要做用户态转发,被选作hook点的函数要有一定的讲究:需要显式声明为非内联和非优化,否则无法确定是否能稳定捕捉数据。
__attribute__((noinline))voidkvs_eBPF_propagation_hook(constchar*cmd,constchar*key,size_tklen,constchar*value,size_tvlen){(void)cmd;(void)key;(void)klen;(void)value;(void)vlen;asmvolatile("");}踩坑2:——不理解Vertifier对512字节栈上空间的限制
eBPF虚拟机栈的大小被严格控制为512字节,然而,这种处于安全性的考虑给内核态实现复杂功能带来了不方便。并且即便是一些简单功能,比如一次性捕获多于512字节(不算是很大的值)的数据会发生意想不到的截断。
此外,eBPF Verifier 对代码安全性要求极高,涉及复杂的指针操作、数据访问边界、函数调用等,容易导致加载失败。比如编程时避免*buf这种缓冲区,vertifier会其长度无法确定而给出报错。
建议:内核态代码逻辑一定要保持精简,突出一个“够用就行”,即便要做优化,做复杂逻辑,也不要内核态去做,能下放到用户态就下放到用户态。
踩坑3:——迷信零拷贝,Hook点选择过早
hook点的选取应该紧密贴合功能需求,而不是为了追求零拷贝而将hook点选择在过早的位置
比如如果讲hook点选在从主机的recv系统调用入口和出口或者网络层recv_callback的出入口,那么tcp半包问题还需要主动处理,关于数据包的顺序性也会带来复杂度,更重要的是,栈上512字节的限制会再tcp分包的基础上进一步引入复杂的捕获数据时的分块问题
踩坑4:-——超前优化、过度设计
数据通路一定要保持简单干净,功能优先考虑落地能用,再考虑兼容性,和进一步迭代。
因为我之前想做传统主从同步,就是feedslave和ebpf路径兼容,可以做一个退化策略,就是说,ebpf不支持的情况下退化到传统路径。所以就用so_mark打算做个标记然后tc egress丢弃。避免ebpf二次转发问题。
总结
内核态极简主义:内核态只做数据捕获和入 Ring Buffer,不做过滤、标记或丢包等逻辑。
恰当的hook点:选择越早越容易将问题复杂化。
功能优先:先实现“能工作”的同步方案,再考虑退化策略、兼容性等。
紧密贴合需求,先落地,再优化,eBPF编程的本质复杂度本身就很高