2.2 从加载到触发的完整路径


2.2 从加载到触发的完整路径

本节摘要:一次 eBPF 程序要经历编译、打开对象、创建 Map、加载、挂载、等待事件、通过环形缓冲区或 Map 回传。失败几乎都发生在加载和挂载两步:验证拒绝、程序类型与钩子不匹配、权限不足。把路径记熟,排错才不会在用户态日志里空转。

本节地图

阅读完本节,你应当能够:

  1. 按顺序列出从源码到第一次命中的步骤
  2. 区分加载失败与挂载失败
  3. 说明程序、Map、链接信息如何用文件描述符绑在一起
  4. 设计一条最小的“能证明已触发”的回传路径

架构图看懂了,仍会在第一次加载时卡住。这一节改走时间轴:你在用户态做了什么,内核在哪一拍说不,成功之后事件怎么回来。

一、时间轴:八步,两道闸

典型 libbpf 风格的路径可以压成八步。名字因框架而异,顺序不异。

  1. 编译:源码变成带 BTF 的对象文件。失败是编译器错误,还没进内核。
  2. 打开对象:用户态库解析段、Map 定义、重定位。CO-RE 在这一步准备字段偏移修正,详见第 7 章。
  3. 创建 Map:内核分配哈希表或数组。容量、键大小写错,后面程序会加载失败。
  4. 加载程序:字节码进入验证器。这是第一道闸。拒绝时内核保持原状,你拿到验证日志。
  5. 挂载:把已加载程序连到 kprobe、XDP、cgroup 等。这是第二道闸。程序类型与挂载点不一致会在这里爆。
  6. 固定或持有描述符:进程退出是否带走程序,取决于你是否 pin 到 bpf 文件系统,或是否有别的进程持有 fd。
  7. 事件发生:钩子触发,机器码执行,读写 Map,或向环形缓冲区提交记录。
  8. 用户态消费:读 Map 快照,或从环形缓冲区拉事件,再变成指标或日志。

两道闸的失败形态不同。验证拒绝:日志里是寄存器状态、未初始化读、无限循环嫌疑。挂载失败:常见是权限、接口不支持该程序类型、cgroup 路径不对、XDP 与网卡驱动能力不匹配。把两者混成一句“invalid argument”,你会空转很久。第 8 章给拆日志的顺序。

二、文件描述符是胶水,不是细节

内核把程序和 Map 都当成对象,用文件描述符交给用户态。加载器拿着 prog fd 去挂载,拿着 map fd 去更新策略。进程一退出,无人引用的对象就消失——这既是安全特性(崩溃的加载器不会把探针留在内核里),也是运维陷阱(守护进程被杀,观测一起没)。

所以生产加载器几乎总要做两件事里的一件:把自己做成常驻进程;或把对象 pin 到 bpf 文件系统,让后续进程能重新打开。常驻适合“控制面要持续读事件”,pin 适合“程序留下、加载器可以重启”。两者都能做,混用时一定要写清楚所有权,否则会出现“我以为卸掉了,钩子还在”或反过来。

概念上的所有权关系:

加载器进程 ├─ prog fd ──► 内核中的程序对象 ├─ map fd ──► 内核中的 Map 对象 └─ link fd ──► 挂载关系 pin 目录可选地再持有一份引用

没有 link 这个抽象时,老接口把挂载绑在 cgroup 文件或 netlink 上,卸载语义更散。现代代码优先显式 link:创建、销销毁,行为像普通资源。

步骤 成功时你看见 失败时优先查
编译 对象文件、BTF 段 头文件、内核版本、编译器插件
打开对象 Map 与程序列表 段名、许可证字符串、BTF 缺失
创建 Map map fd max_entries、key/value 大小
加载 prog fd 验证日志,路径数,未初始化
挂载 link fd 程序类型、权限、网卡/cgroup
回传 计数增加或事件流 程序其实没被触发;环形缓冲区溢出

⚠️ 常见坑:用“加载成功”当“已经在干活”。没挂载、挂错接口、或事件条件从未命中,Map 会一直是零。先做一条无条件计数,证明钩子活着,再加过滤。
💡 关键直觉:fd 和 pin 决定程序的寿命。排“探针怎么还在”或“探针怎么没了”,先查引用还在不在,再查代码。

三、最小回传:先证明活着,再证明正确

第 1.3 节的规格最后一步是“条件成立则输出”。工程上我建议拆成两次交付。

第一次:钩子上只做 count++ 到 per-CPU 数组。用户态每秒读一次。能涨,说明挂载点选对了、权限过了、没有被静默卸掉。

第二次:加条件、加调用栈、加环形缓冲区。环形缓冲区满了会丢事件,丢了你还以为故障没发生。所以缓冲区大小、批读取、丢失计数要一起设计。调试阶段用跟踪打印可以,生产热路径不要靠它——它慢,而且和内核跟踪缓冲区抢位置。

/* 概念性:先证明钩子活着 */ SEC("tracepoint/syscalls/sys_enter_connect") int on_connect(void *ctx) { /* 无条件给每 CPU 计数加一,用户态读到增长即证明已触发 */ increment_percpu_counter(0); return 0; }

不要在第一次交付里就解析深层结构体。CO-RE 重定位、空指针、变长路径,都会把“钩子没挂上”和“读字段失败”搅在一起。活着之后再加深,排错才能二分。

图:成功路径与两道闸

图:成功路径与两道闸

问题:加载器崩溃后探针还在不在?

看引用。只有进程 fd、没有 pin,通常会走。已 pin 或被别的进程打开,就会留下。不要凭感觉,查 bpf 文件系统和当前加载的程序列表。

问题:为什么挂载成功但计数为负或乱跳?

per-CPU 数组在用户态要按 CPU 求和。只读了 CPU0,或把带符号数当无符号展示,都会像“乱跳”。先排除消费端,再怀疑程序逻辑。

四、一次失败加载的现场记录该长什么样

很多团队的事故单只写“bpf load failed”。下一回的人无法复用经验。一份合格记录至少有:内核版本与 BTF 是否存在、程序类型、挂载目标、验证日志尾 30 行、errno、当时 memlock、是否与平台程序冲突、无条件计数在失败前是否存在过。这些字段够你判断该找验证器、额度还是挂钩点。

自动化可以把记录做进加载器。加载失败不要只打一行 invalid argument,把验证日志存到崩溃报告目录(注意:这里说的是你们自己的日志系统,不要在教程里写具体路径当保存指令)。成功时也要打版本和 link id,方便幽灵探针对账。加载器是控制面,它的可观测性决定内核程序的可观测性。

并发加载值得单独说。两个控制器同时加载同一份策略,可能一个 pin、一个销毁,表现为闪烁。所有权要唯一:要么平台调和循环是唯一加载者,要么人工应急工具在清单里登记为互斥。故障夜两人同时“帮忙”,最容易留下卸不掉的 link。

还有时钟。程序里用的时间戳 Helper 和用户态指标的时间源可能不是同一个。事件对不齐时,先对时源,再怀疑逻辑。启动后的单调时钟适合测耗时,墙上时钟适合和日志对齐,混用会让 8 毫秒的延迟看起来像时间穿越。把时间源写进规格,和第 1.3 节的瞬间一样,属于必须写明的字段。

最后,把“第一次触发”做成发布门禁。灰度节点上若 5 分钟无条件计数仍为零,不是“暂时没流量”,是挂错了。支付节点凌晨可能真没流量,那就要用合成流量把钩子点亮一次。没点亮就扩开,等于把盲飞当成灰度。

现场笔记:加载器被杀之后的幽灵计数

一次发布回滚只杀了加载器进程,程序 pin 还在。指标管道以为探针没了,内核还在计数。一周后有人发现 Map 内存没回去。清单对不上,因为清单跟发布系统走,不跟内核对象走。修复是巡检改为以内核对象为准,发布系统来对账,对不上就告警,而不是以发布系统为准去“相信已经回滚”。

引用计数是寿命的真相。进程 fd、pin、另一个调试器打开的 fd,任何一个还在,对象就在。回滚剧本必须点名这三种引用。只点名进程,是在对内核撒谎。撒谎一时爽,内存和 CPU 会在下周的账单里揭穿。

合成流量点亮钩子,也应写进回滚验证:卸完之后心跳必须停。心跳还在,回滚失败,即使进程树看起来很干净。干净的进程树和干净的挂钩点是两件事。值班只看前者时,后者会变成文化里的幽灵故事,直到有人第一次把 bpf 对象列表加进巡检。

延伸讨论:所有权以内核对象为准

发布系统说回滚了,内核对象可能还在。巡检必须列出程序、Map、pin、link,用这份列表去对发布记录,对不上告警。不要用发布记录去想象内核。想象会养幽灵。三种引用都要点名:进程 fd、pin、别人打开的调试 fd。回滚验证包含心跳停止。心跳不停,回滚失败,即使进程树干净。干净的进程树不是干净的挂钩点。灰度门禁里加上第一次触发:五分钟计数仍零且不是零流量时段,禁止扩开。零流量时段用合成流量点亮。没点亮就扩开是盲飞。盲飞偶尔走运,走运不能当流程。流程要假设不走运。不走运时,清单和心跳是仅有的手电筒。手电筒要在白天充电,也就是日常巡检,而不是等停电再找电池。

对照清单

  1. 加载成功不等于在干活,没挂载或过滤过狠时 Map 会一直是零,先点亮无条件计数。
  2. 进程退出是否带走程序取决于 pin 和其他 fd,回滚只杀进程会留下幽灵。
  3. 验证拒绝和挂载失败都可能叫 invalid argument,必须把日志和 errno 一起保存。
  4. 环形缓冲满了会丢事件,丢失计数是一等公民,丢了还以为故障没发生。
  5. 并发两个控制器抢同一 pin,表现为闪烁,所有权必须唯一。
  6. 时间戳 Helper 与用户态指标时源不同会像时间穿越,耗时用单调时钟,对齐日志用墙上时钟。
  7. 灰度五分钟计数仍零且不是零流量,禁止扩开,零流量用合成流量点亮。
  8. 第一次触发做成发布门禁,没点亮的成功是盲飞。
  9. 加载器要把版本和 link id 打进日志,方便和内核对象列表对账。
  10. 调试 fd 也是引用,排幽灵时不要漏掉有人开着观察窗口。
  11. 由浅入深:计数、条件、深字段,否则无法二分是挂钩问题还是解析问题。
  12. 记录内核版本、BTF 是否存在、程序类型、挂载目标,下一回才能复用经验。

温故知新

  • 八步两条闸:加载验证、挂载匹配,失败形态不同。
  • fd 是寿命:进程退出、pin、link 决定探针还在不在。
  • 加载成功不等于在干活:先无条件计数证明钩子活着。
  • 回传要设计丢失:环形缓冲区会丢,丢失计数是一等公民。
  • 由浅入深:先计数,再条件,再深字段,排错才能二分。
  • 所有权写清楚:常驻控制面还是 pin 后可重启,混用必出幽灵探针。

下一章回答程序在钩子里能把状态放哪、能调用哪些内核入口:Map 与 Helper。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U