eBPF 从入门到生产落地:以自研 KV 存储旁路增量复制为例(二)

发布时间:2026/8/18 19:26:48
eBPF 从入门到生产落地:以自研 KV 存储旁路增量复制为例(二) 第 6 章内核探针逐行拆解——fd 跟踪与字节搬运6.1 探针的三块职责整个探针bpf/kvs_repl_probe.bpf.c只做三件事对应三个区块声明 Map、跟踪 fd 的出生/迁移/死亡、盯住 recvfrom/read 搬运字节。内核里不解析 RESP、不做任何业务判断——那是用户态的事第 7 章。6.2 六张 Map 各司其职struct { __uint(type, BPF_MAP_TYPE_ARRAY); __uint(max_entries, 1); ... } probe_cfg SEC(.maps); struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 4096); ... } tracked_fds SEC(.maps); struct { __uint(type, BPF_MAP_TYPE_RINGBUF);__uint(max_entries, 1 22); } events SEC(.maps); struct { ... keypid_tgid, valuepending_rd } pending_rd SEC(.maps); // fd 用户缓冲指针 struct { ... keypid_tgid, value__u8 } pending_accept SEC(.maps); // 打正在 accept标记 struct { ... keypid_tgid, valuepending_dup } pending_dup SEC(.maps); // dup 暂存probe_cfgARRAY写死master_pid。每个探针函数第一步都调is_master_pid()过滤——不是主库进程的事件一律当场返回。这是全局开关。tracked_fdsHASHfd→1 字节这套探针的心脏。只有 key 在里面的 fd探针才采集数据。它回答这条连接我要不要偷听。eventsRINGBUF4MiB唯一输出通道字节流从这里流到用户态。三张pending_*HASHkey 都是 pid_tgid都是enter/exit 接力棒——enter 存参数exit 读结果靠 pid_tgid 唯一标识同一次系统调用的两个事件。为什么要 pid_tgid 而不是进程号因为协程/多线程下同一时刻可能有多个线程同时在 recv用 PID 会串台必须用进程号线程号合成的一个值。6.3 第一条流fd 的出生、迁移与死亡出生。客户端连接是被accept出来的。探针在sys_enter_accept4往pending_accept打个标记在sys_exit_accept4读返回值新 fd 号把它塞进tracked_fdsSEC(tracepoint/syscalls/sys_exit_accept4) int tp_exit_accept4(struct tp_sys_exit *ctx) { // 若此线程确实调过 acceptpending_accept 有标记且 ret0 bpf_map_update_elem(tracked_fds, (u32)ctx-ret, one, BPF_ANY); bpf_map_delete_elem(pending_accept, id); // 用完清掉 }迁移。主库握手会把 accept 出的 fddup成控制面 fd 再 close 原 fd。探针在sys_enter_dup/dup2/dup3里判断旧 fd 在 tracked_fds 里吗在的话把新 fd 也登记进去。否则控制面上后续的SYNC FINISHED信号会完全听不到第 4 章提过。死亡。sys_enter_close做两件事把 fd 从tracked_fds删掉同时往 ringbuf 发一个len0的特殊事件if (tr) { // 被跟踪的 fd 正在被 close e bpf_ringbuf_reserve(events, sizeof(*e), 0); e-fd ctx-fd; e-len 0; // len0 语义该 fd 已关闭 bpf_ringbuf_submit(e, 0); } bpf_map_delete_elem(tracked_fds, fdk);这个len0事件意义很大fd 号会复用。如果用户态不清掉这个 fd 的半包缓冲下次一个完全不相干的连接复用同一个 fd 号会拼上上一段脏半包组帧全乱。close 通知就是你该把这个 fd 的组帧状态清掉了。6.4 第二条流recvfrom/read 的字节搬运entersys_enter_recvfrom拿到 fd 和用户缓冲区指针ubuf但此刻还不知道会读到多少字节。它把两者存进pending_rd且跳过MSG_PEEK第 4 章讲过。exitsys_exit_recvfrom的返回值ret就是实际读到的字节数。emit_on_exit做四步ret0才继续按 pid_tgid 取出pending_rd里暂存的指针确认这个 fd 还在tracked_fds里然后按 512B 一片最多 32 片地把用户缓冲区拷进 ringbuftotal ret; #pragma unroll for (i 0; i 32; i) { if (off total) break; copy_len min(total - off, 512); e bpf_ringbuf_reserve(events, sizeof(*e), 0); if (!e) break; // ringbuf 满放弃本次不阻塞 e-fd p-fd; e-len copy_len; bpf_probe_read_user(e-data, 512, (void *)(p-ubuf off)); // 安全拷用户内存 bpf_ringbuf_submit(e, 0); off copy_len; }为什么是32 片 × 512B 16KiB因为主库的读缓冲上限就是 16KiB一次 recv/read 最多读到这么多32 片正好全覆盖。#pragma unroll让验证器能静态证明循环 32 次必然结束第 2 章验证器约束的直接体现。bpf_probe_read_user是唯一能碰用户态内存的安全 helper指针越界由它兜底——哪怕用户缓冲非法也只是拷 0 字节不会让内核崩。6.5 探针行为直接决定上层业务正确性这个32 片分片曾经是一个线上事故的根因。早期版本emit_on_exit只拷 ringbuf 里的第一片 512B一次 recv 若返回好几 KB比如 LLM 生成的长答案通过 VSET 写入后面的字节全被丢弃用户态组帧拿到残缺命令 → 从库收不到向量 → 近似语义检索永不命中。修复就是改成完整 32 片和 io_uring 路径的emit_user_chunks保持同一上限第 8 章。这个教训值得单独记住探针在内核里丢一个字节用户态没有任何办法察觉。探针的采集逻辑必须严格等于用户态需要什么多丢一片就是业务事故。这也是为什么把组帧、判断、投递全部放用户态更稳——至少出错时还能打日志、能 debug而内核里的 bug 是盲区。第 7 章用户态旁路进程——组帧、VSET 语义化、会话与容错7.1 从 ringbuf 字节流到业务命令探针把主库入站字节按 512B 分片推进 ringbuf。用户态旁路进程kvs_repl_ebpf的主循环就是poll三路 fdringbuf 的 epoll fd客户端写、控制套接字主库控制面消息、增量 TCP 监听口从库来连。每次唤醒先把 ringbuf 抽干再处理别的——这个顺序是刻意的SYNC FINISHED若是抢先触发 drain→LIVE而 ringbuf 里还堵着未投递的写命令就会丢数据。所以代码里是抽干 ringbuf → 处理 TCP accept → 处理控制面 → 再抽一轮 ringbuf → 统一 LIVE。ringbuf 回调拿到的是某个 fd 的一段字节先按 fd 拼进对应的半包缓冲g_frames[fd]再进入组帧循环。7.2 组帧半包、粘包与软重置探针采到的是字节流不是命令边界。用户态要把它还原成一条条 RESP 命令于是手写了一个 RESP Array 解析器resp_array_span从缓冲开头找*、解析数组长度、逐个解析$bulk直到确认整条命令完整到达返回该命令占的字节数span。三种情况缓冲区不够一条完整命令 → 返回半包等更多字节拼进来解析出完整命令 → 按 span 消费掉中间出现非法字节 → 不整段清空而是软重置从偏移 1 开始找下一个*跳到下一条可能的命令起点。这防止一条脏字节毁掉后面整段写命令。还有一个特殊信号len0的 ringbuf 事件代表某个 fd 被 close第 6 章。用户态收到它就把该 fd 的组帧缓冲整个清掉——fd 号会复用不清就会把旧半包拼给新连接。7.3 白名单不是所有命令都复制组帧出完整命令后先看命令名是否命中复制白名单cmd_is_replicated_writeSET/RSET/HSET/ZSET、VSET、MOD/RMOD/HMOD/ZMOD、DEL/RDEL/HDEL/ZDEL、PEXPIRE/RPEXPIRE/HPEXPIRE/ZPEXPIRE。命中的才投递给从库GET 族、PING、ECHO、SAVE、FLUSHALL 一律不复制。这个名单不是拍脑袋定的它严格对齐主库dispatch里会进持久化复制通道的写命令——凡是从库需要一致的写操作才值得复制。而REPL/SYNC这类控制命令不走业务通道会被直接移出tracked_fdsframe_drop_fd不再当业务连接偷听。7.4 VSET 语义化从观测到业务这是本系统最有意思的部分eBPF 不只是观测还参与业务转换。主库收到VSET 问题 答案后普通复制路径只发明文给从库。但从库的职责是向量→答案它需要的不是明文VSET而是把问题 embed 成向量、再带着答案存进去。于是旁路进程在识别出VSET时调用handle_vset_semantic调 embedding 服务:35303把问题转成 1024 维 dense 向量 可选的 ColBERT 向量用floats_to_vec_key把浮点向量打包成 4096 字节的二进制 key用vector_codec_encode_value把问题答案[ColBERT]编码成 v0x02 复合 value组一条SET 4096字节向量key 复合value投递给从库——绝不把明文 VSET 转发过去。如果 embedding 失败先重试再退化为纯 dense 向量 只含问题和答案的 value最后仍失败就放弃本次语义转发明文已在主库落库稍后全量同步会补向量快照。整个函数末尾有一句自检日志打印长度公式expect14q4a[4tc*4096]——编码长度必须在投递前算清楚错了就丢掉。这条路径把探针偷字节 → 用户态解析 → 外部服务调用 → 格式转换 → 再投递串成了闭环是 eBPF 应用里难得的旁路即中间件案例。7.5 会话管理从 HOLDING 到 LIVE从库不是一开始就能实时收增量的。它先做主从全量同步走独立的全量通道这段时间如果旁路把增量发过去会被从库丢弃或打乱。所以旁路给每个从库维护一个状态机HOLDING积压主库通过控制套接字发SLAVE_BEGIN带从库 id/IP/端口旁路登记会话、只把命令攒进队列不发FINISHED分界主库发SLAVE_FINISHED表示全量同步已到某个序号 M此前的增量都在积压队列里此后的才要实时发。旁路只打一个want_live标记不立刻切LIVE实时把积压队列 drain 干净状态切到 LIVE之后新命令直接send。为什么 FINISHED 不立刻切 LIVE因为SYNC FINISHED信号探针辅通道 Unix 控制面双通道幂等去重可能比 ringbuf 里仍在排队的写命令先到若此时 drain随后入队的写会丢。所以统一等 ringbuf 抽干后再 drain→LIVE。从库的 TCP 增量连接靠IP 匹配绑定到会话SLAVE_BEGIN 登记了从库 IP增量口 accept 时取对端 IP 找同 IP 且未绑定的会话还没 BEGIN 就来的连接先进 pending等 BEGIN 到了再 FIFO 绑定。多从库场景下这套匹配防止把增量发给 id/IP 不匹配的从库。7.6 容错崩了怎么办旁路进程由主库的一个监督线程管着repl_ebpf_proc.c主库 fork 出旁路等它的控制套接字出现才算就绪超时默认 3s就杀掉重来监督线程用waitpid(WNOHANG)盯着旁路发现它退出先立即把增量回退成 tcp不让业务停摆再尝试重启最多 3 次间隔 1s重启次数耗尽后固定走 tcp 直到主库重启不再折腾。单从库缓冲满也只断该从库的 eBPF 连接不影响其它从库、不阻塞主库写。这套软降级 监督重启的容错矩阵是项目自己定义的降级策略文档里叫 §8 降级矩阵任何一步失败系统的底线是退回传统的 tcp 增量。这也回答了一个核心设计问题——eBPF 是加速器、是优化项但绝不是唯一路径主从同步的正确性永远有一份 tcp 兜底。这是生产系统用 eBPF 的正确姿势永远留退路。第 8 章io_uring 的坑与 uprobe 方案8.1 问题io_uring 不走经典系统调用第 6 章探针依赖sys_enter_recvfrom/sys_enter_read这些 tracepoint——它们在内核处理经典系统调用时触发。但主库有个后端叫 io_uring它把读写请求提交到内核队列由内核异步完成根本不经过recvfrom/read的系统调用路径。于是 tracepoint 完全听不到主库读了什么字节旁路增量直接失效。这暴露了 eBPF 探针的一个通用局限探针能看到的是目标程序触发了你监听的事件目标程序如果换了路径探针就瞎了。io_uring 就是换了路径。8.2 解法主库埋 noinline 钩子 uprobe既然 tracepoint 听不到就换一种程序类型——uprobe第 2 章提过挂到用户态程序的某个函数入口。但 eBPF 无法知道主库的 io_uring 代码里哪个函数是读入站字节的。项目的做法是在主库里主动埋两个空钩子__attribute__((noinline, used)) void kvs_repl_probe_hook_accept(int fd) {} __attribute__((noinline, used)) void kvs_repl_probe_hook_inbound(int fd, const void *buf, size_t n) {}noinline保证钩子真的有独立函数体、有符号表项uprobe 才能挂上去used防止编译器把没人调用的它优化掉。主库在 io_uring 的 accept 完成、读到入站字节时显式调用这两个钩子把 fd、缓冲区指针、长度传进去——钩子本身是空函数什么都不做只是给 uprobe 一个下钩点。这样分工很清晰钩子函数体在主库里是主库代码的一部分要随NETuring编译才有但真正读数据的逻辑在旁路的 uprobe 程序里旁路从函数入口的参数寄存器里取 fd/指针/长度。8.3 uprobe 如何拿到参数手搓寄存器布局uprobe 程序触发时参数在 CPU 寄存器里x86_64 下前几个参数在di/si/dx。探针为此自备了一个struct kvs_pt_regs结构体字段顺序严格对齐 x86_64 的pt_regsstruct kvs_pt_regs { __u64 r15, r14, r13, r12, bp, bx; __u64 r11, r10, r9, r8; __u64 ax, cx, dx, si, di; // 用户态 ABIparm1di, parm2si, parm3dx ... };两个 uprobe 程序分别对应两个钩子uprobe_hook_accept从di拿新 fd 号登记进tracked_fdsuprobe_hook_inbound从di/si/dx拿 fd、缓冲指针、长度直接走emit_user_chunks分片拷进 ringbuf。核心采集逻辑和 tracepoint 路径完全共用只是入口不一样。8.4 attach 细节先解析 ELF 再挂uprobe 要指定挂到哪个函数的哪个地址。旁路进程先读主库的/proc/pid/exe得到可执行文件路径再用 libelf 遍历符号表找到kvs_repl_probe_hook_accept/_inbound这两个函数的符号偏移然后lk bpf_program__attach_uprobe(prog, false, master_pid, exe, (size_t)off);进程号 可执行文件 函数偏移三要素齐了才能把 uprobe 精确挂到主库进程里那个钩子上。如果主库不是用NETuring编译的符号不存在attach 直接失败——旁路进程 exit≠0主库软降级回 tcp。所以这也是一道隐形的编译期一致性校验uprobe 方案天然要求主库二进制和旁路探针的预期匹配。8.5 三模式对比与降级矩阵至此三种网络后端的旁路方案就齐了coro / epoll走经典系统调用tracepoint 采集recvfrom/read/accept纯探针方案uring异步队列tracepoint 只能采 close/dup 这类仍走 syscall 的事件入站字节靠主库空钩子 uprobe。而配置层面始终有三个档位repl_incr_accelebpf强制 eBPF失败软降级 tcp、auto能用就用失败静默回 tcp、tcp根本不拉起旁路。不论哪个后端、哪档配置失败的最后归宿都是 tcp。这个永远有 tcp 兜底的设计在 io_uring 这种最特殊的场景下是最后的保险。8.6 教训io_uring 案例浓缩成一句话tracepoint 只覆盖经典系统调用路径异步 IO、用户态库、非标准 IO 都可能绕过它。遇到这种场景与其在内核里找更多 hook不如回到目标程序自己最懂自己的原则——在主库里埋一个极简的 noinline 空钩子把数据到了这个事实暴露出来再用 uprobe 在钩子入口接住。钩子没有业务逻辑、不影响性能空函数、随编译开关可选这是程序可观测性接口的一种轻量做法也是 eBPF 用户态探针的标准姿势。第 9 章构建、验收、排错与展望9.1 构建策略缺依赖不炸主工程项目对 eBPF 相关依赖采取缺了也能编过的构建策略无 clang跳过编译kvs_repl_probe.bpf.o主程序、旁路进程、测试照常编无 libbpf旁路进程仍编出来不带-DKVS_HAVE_LIBBPF运行时加载那一步失败主库自动降级 tcp无 root / CAP_BPF探针 attach 失败同样降级 tcp。所以eBPF 没装上绝不会导致整个服务起不来——它只是让旁路功能不可用。这套策略的核心是eBPF 在项目里是可选加速不是硬依赖。9.2 验收怎么确认旁路真的在工作项目自带一个降级验收脚本scripts/check_repl_ebpf_degrade_07b.sh人工触发无权限、端口占用、缓冲满、旁路口断等至少 3 项确认每一项都按预期软降级。常规联调时主要看日志关键字主库启动打印增量ebpf——旁路真正生效而不是悄悄退回了 tcp旁路打印VSET→向量 SET v0x02 …——VSET 语义化路径在走想验证从库真的收到了向量对从库发一次改写问句的VGET命中即为整条链路打通。排错辅助工具bpftool prog list/bpftool map dump看探针和 Map 是否在内核报错一般直接出现在旁路 stderr如EPERM、EADDRINUSE主库侧只有一句回退 tcp。9.3 常见坑速查全部出自本项目真实踩坑权限加载需要 root/CAP_BPFsetcap在 nosuid 挂载上无效本机仓库就这样改用repl_ebpf_sudo1让主库 sudo 拉起旁路。tracepoint 参数布局__syscall_nr在第 8 字节真参数从第 16 字节起——别把系统调用号当 fd。MSG_PEEK 重复采集peek 和真实 recv 读到同一份字节会重复组帧前缀探针要显式跳过。dup 后 fd 迁移主库握手 dup 出新 fd 并 close 原 fd探针要跟 dup/dup2/dup3 才能继续监听控制面。大包分片一次 recv 可达 16KiB只拷第一片 512B 会导致长答案组帧残缺、语义永不命中——必须完整 32 片。fd 复用close 后要清用户态组帧缓冲否则新连接拼上旧半包。io_uringtracepoint 听不到异步读写需要主库 noinline 钩子 uprobe。ringbuf 顺序先抽干 ringbuf 再处理控制面 FINISHED否则可能丢写命令。这些坑几乎每一个都对应一次线上事故或联调失败。eBPF 教程不会教你这些因为它们藏在真实系统的交互细节里——这正是一篇以真实项目为教材的博客最有价值的地方。9.4 展望这套旁路方案还有几个值得继续深挖的方向CO-RE / BTF 迁移当前探针手搓结构体布局tracepoint 参数、pt_regs是硬编码了当前内核版本用 BTF CO-RE 可以做到探针跨内核版本免重编译代价是引入 CO-RE 的 libbpf 依赖。从观察到介入当前方案只在外面看入站字节属于可观测性。将来若想在内核里直接做响应式处理比如按命令类型分流可以借鉴 sockmap 的思路但要吸取旧方案在内核里做太多的教训。把旁路即中间件的模式推广VSET 语义化已经证明旁路进程可以对接外部服务embedding、做格式转换再反向投递。这套探针观察 用户态中间件的模式完全可以复用到数据脱敏、审计、流控等场景。结语回头看这条博客从 eBPF 的理论底座出发验证器怎么保证安全、程序类型怎么选、Map 怎么传数据经过一段程序从源码到运行的全流程clang 编译、libbpf 加载、attach、ringbuf最终落回到一套真实跑在生产上的旁路复制系统探针偷听主库入站字节 → 用户态组帧、白名单过滤 → VSET 语义化对接 embedding → 会话状态机投递从库 → 监督重启与 tcp 兜底外加 io_uring 这种探针听不到场景的 uprobe 解法。这条链路上几乎每一行都踩过真实的坑验证器的循环展开约束、tracepoint 的偏移布局、MSG_PEEK 的重复采集、dup 的 fd 迁移、512B 分片的长答案截断、fd 复用污染、nosuid 下 setcap 失效、io_uring 绕过系统调用……eBPF 的知识从来不在 API 表里而在这些为什么和怎么绕里面。最后留一句我认为最重要的设计心得生产环境用 eBPF一定要留一条不走 eBPF 的退路。本项目里这条退路就是 tcp 增量——旁路崩了、权限没了、内核不兼容了主从同步依旧正确只是慢一点。eBPF 是优化项正确性永远有兜底这既是工程的务实也是对新技术的敬畏。