1.2 内核模块这条路为什么走不通


1.2 内核模块这条路为什么走不通

本节摘要:内核模块能读到结构体、能挂任意符号,可见性接近满分。它走不通,是因为权限模型是“全有”:没有验证器事先证明内存安全,一次空指针就等于整机崩溃。生产要的是可回滚的观测,不是把发布节奏绑死在内核构建上。

你能学到什么

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

  1. 对比内核模块与 eBPF 在权限、失败模式、发布节奏上的差异
  2. 解释为什么“我们写得很小心”不能当作生产准入条件
  3. 说出符号版本、许可证、调试难度如何把模块方案拖垮
  4. 判断哪些需求仍然必须写模块,哪些其实该退回 eBPF

有经验的人碰到 1.1 节那种盲区,第一反应常常是:那就写个内核模块。模块确实能解决问题——kprobe 最初就是模块生态长出来的。问题不在“能不能看见”,而在“看见的代价是不是生产肯付”。

一、全权通行证,没有安检口

内核模块加载之后,几乎就是内核自己。它可以解引用任意指针,可以改调度器,可以在中断上下文里睡觉(然后整机锁死)。没有一道加载前的证明,说“这段代码不会越界、不会死循环、不会把页表写坏”。

这和用户态程序完全相反。用户态程序踩空,内核把进程杀掉,机器还在。模块踩空,轻则 oops,重则 panic,节点从集群里消失。云上一个 DaemonSet 如果以模块方式铺开,一次坏指针就是一片节点同时重启。

有人会说:代码审查、测试、灰度就能管住。审查管不住并发下的生命周期:你拿到的 task_struct 在你读完字段之前可能已经退出;你在软中断里调了会睡眠的函数,测试环境从没打到这条路径。测试更管不住发行版之间的结构体布局变化。灰度能缩小爆炸半径,但不能把“全权”变成“有限权”。

海关安检和外交豁免是两种模型。模块是外交豁免:证件一亮,仓库随便进。eBPF 是安检:行李 X 光机先照,违禁品直接拦在门外,放行的东西仍可能重,但不会是炸药。

维度 内核模块 eBPF 程序
权限 等同内核 受验证器与 Helper 白名单约束
失败模式 节点崩溃或静默内存破坏 加载失败或程序被拒绝
卸载 依赖引用计数,可能卸不掉 通常可立即卸挂载
发布物 针对特定内核构建的二进制 字节码加 BTF,CO-RE 可跨版本
调试 内核调试器、崩溃转储 验证日志、跟踪打印、BTF 符号
适合 驱动、必须改内核行为的补丁 观测、过滤、策略、有限干预

⚠️ 常见坑:把“模块在测试机跑了三天没崩”当成安全证明。没崩只说明你没打到那条路径,不说明路径不存在。
💡 关键直觉:模块方案的风险函数是非线性的。多一个指针解引用,不是多一分风险,而是多一种可以把整机打穿的方式。

二、发布节奏被内核绑死

即便你接受崩溃风险,模块还有第二道墙:它必须跟着内核版本走。结构体布局一变,偏移错了就是读脏数据。发行版内核、云厂商定制内核、安全补丁热更新,都会让你的模块二进制过期。

于是工程变成:为 4.19、5.4、5.10、5.15、6.1 各打一份,CI 矩阵膨胀,热修复还要等内核团队的构建管道。观测需求往往以小时计,模块发布以周计。两者合不上拍时,团队会退回用户态猜——第 1.1 节的迟到又回来了。

许可证与签名把墙加厚。不少发行版要求模块许可证字符串与内核兼容,安全启动要求签名。这些本身合理,但它们让“临时挂一个计数器看看重传”变成法务和密钥管理问题。eBPF 也有权限与签名(详见第 8 章),粒度却是程序类型和能力位,不是“给你一把内核钥匙”。

调试体验同样不对称。模块出问题,你面对的是调用栈里一堆内联函数,以及“是否开启了调试符号”这种运维现实。生产内核常常裁掉调试信息。eBPF 验证失败至少给你一条“哪条指令、哪种状态不合法”的拒绝理由——难读,但比 panic 后的沉默强。

概念上,模块开发的最小闭环是:

写驱动式 C → 针对某内核头文件编译 → insmod → 观察 dmesg → 崩了就重启;没崩也不知道并发路径

观测类需求不该走这套闭环。它需要的是:编译一次、在验证失败时立刻改、在生产上能秒级卸载。

三、哪些事仍然该写模块

说模块走不通,不是说模块该死。驱动必须模块化,因为你要注册中断、操作 MMIO、实现设备生命周期。某些调度或文件系统行为,现有 eBPF 程序类型覆盖不到,也只能改内核或写模块。

我用来划线的问题是:你是否必须拥有任意内核内存的写权限?

  • 只要读字段、聚个数、有条件地丢包或拒绝系统调用,优先 eBPF。
  • 要新增一种设备抽象、要实现内核线程、要在任意地址写,才回到模块。
  • 介于中间的“改一下某个内核函数的返回值”,eBPF 在部分挂载点能做有限覆盖,但语义受限,不能当通用 hook。

还有组织因素。能安全维护模块的人,通常是内核组。观测和安全策略的需求方是 SRE 和安全团队。把后者的迭代速度,压缩进前者的发布窗口,两边都会痛苦。eBPF 把“可验证的内核逻辑”变成一种可以按应用发布的资产,这才让 Cilium、bpftrace 这类东西能由平台组交付,而不是每次都开内核变更单。

问题:eBPF 是不是“更安全的模块”?

不完全是。模块能做的事,eBPF 大量做不了。更准确的说法:eBPF 是在故意缩小能力之后,换来可证明的安全加载。能力不够时,该回模块或该改内核,不要为了“全用 eBPF”去绕验证器。

问题:有了 eBPF 还要不要禁止生产加载模块?

多数互联网生产会收紧模块加载策略,只允许签名白名单。这与 eBPF 不冲突。禁止随意 insmod,同时允许受控加载 eBPF,正是把“全权”和“有限权”分开管理。

四、变更窗口里的真实成本

模块方案的成本很少写在设计文档里,却写在变更日历上。一次内核模块发布通常要经过:针对特定内核编译、在预发集群验证不会 oops、走安全启动签名、等一个更长的变更窗。观测需求如果来自正在进行的故障,这个日历根本对不上。于是出现两种畸变:要么故障结束了模块还在排队;要么有人在故障夜绕过流程直接 insmod,把稳定性承诺当场作废。

eBPF 不是取消变更管理,而是把变更物从“内核同权二进制”降成“可拒绝加载的字节码”。审批可以更快,是因为爆炸半径的上限被验证器写死了。审批仍应存在:拦截类程序、覆盖返回值、XDP 丢包,这些会改变业务对错,不是单纯多几条指标。

还有人力结构问题。能审查模块的人少,能写 bcc 脚本的人多。如果所有内核可见性都必须经过那少数审查者,平台组会变成瓶颈,业务组会回到猜。把可验证程序交给平台组以产品形式发布,审查者审框架和预算,而不是审每一行字段读取,产能才能匹配故障频率。

稳定性事件的事后也不同。模块 oops 的复盘焦点是“谁允许加载、符号对不对、有没有在中断里睡眠”。eBPF 复盘焦点是“验证器为什么没挡住这条路径——哦它挡住了,那是资源叠加或逻辑写错”。两种复盘培养的组织能力不一样。长期看,后者让更多工程师能安全地碰内核观测,而不把内核组变成单点。

若你所在环境确实必须用模块——例如驱动或无法用现有程序类型表达的补丁——把观测部分仍然拆出去用 eBPF。不要因为“反正已经加载模块了”就把计数器也写进同一份全权代码。权限应按最小需要授予,模块里多写的每一行观测都是额外的崩溃面。

现场笔记:预发没崩、生产第三天 oops

模块在预发跑了一周。生产第三天,一条很少走到的错误路径在中断上下文里调了可能睡眠的函数,节点重启。预发没有那种中断密度,测试通过是假的。审查也没抓住,因为代码在普通进程上下文看起来合法。全权模型把“没测到的路径”直接映射成整机风险。

若当时把观测部分用 eBPF 做,最坏是计数错误或加载失败,不会是重启。真正必须模块化的驱动逻辑可以留下,计数器不该享受外交豁免。事后把模块拆开,观测迁出,驱动留下,崩溃面明显下降。迁移成本低于再一次集体值班。

组织上也该改:模块审查名额留给驱动和必须改内核行为的补丁。观测需求走可验证程序的发布轨。两条轨的 SLA 不同,混在一起只会逼人在故障夜绕过流程。绕过一次成功,就会有第二次。流程要给观测一条合法的快轨,否则快轨会在流程外面自己长出来,而且没有验证器。

延伸讨论:爆炸半径必须写成数字

变更单只写加载观测模块,审批人无法判断风险。要写失败模式是 oops 还是拒绝加载,影响是单节点还是与内核同权,回滚是 rmmod 可能卸不掉还是毁 link 即可。把模块方案和 eBPF 方案的变更单并排,快轨更好通过。审批人怕的是说不清的半径。半径写不清,无论用什么技术都不应走快轨。拦截类还要单独写半径,禁止和观测混报。混报会养出反正都能回滚的空话。回滚时间差和成功率要有数字。没有数字的回滚是愿望。愿望在变更窗口里看起来像计划,在故障夜里像没计划。把数字写进模板,比把勇气写进群里更有用。勇气不能卸模块,数字能让人选择一开始就不要用模块去观测。

本节速览

  • 走不通的原因是权限模型:模块等同内核,失败模式是节点级事故。
  • 小心不能替代证明:并发生命周期和发行版布局差异,测试覆盖不住。
  • 发布被内核版本绑死:观测以小时计,模块构建以周计。
  • 调试不对称:panic 后的沉默,对比验证器的拒绝理由。
  • 划线问题:是否必须任意写内核内存;多数观测与策略答案是否。
  • 组织后果:把 SRE 的迭代塞进内核变更窗口,两边都慢。

下一节用四个真实故障把缺口钉死:延迟、连接、逃逸、调度,缺的都是可验证的内核瞬间。


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