1. 为什么要在 containerd 里聊 NRI 这件事
containerd 这个容器运行时,做过 Kubernetes 运维的人基本都绕不开它。早期大家用 Docker 跑容器,后来 Kubernetes 把容器运行时接口抽象成 CRI,containerd 就慢慢站到了舞台中央。但真正把 containerd 用深之后你会发现,它默认的插件机制虽然够用,却有个明显的短板:想在容器生命周期的关键节点上做点“额外动作”,比如调整容器的资源限制、注入环境变量、挂载额外设备、修改 OCI spec,传统做法要么改 containerd 源码,要么在 CRI 层外面再包一层,维护成本高得离谱。
NRI,全称 Node Resource Interface,就是在这个背景下被提出来的。它本质上是 containerd 提供的一套节点级插件接口,允许外部进程以插件的形式介入容器的创建、更新、删除等事件,在容器真正被拉起之前对 OCI spec 做修改。你可以把它理解成 containerd 在容器生命周期里预留的一排“插座”,插件插上去就能在特定时刻接管控制权,做资源调整、设备注入、策略校验这类事情。
这个机制解决的问题很具体:过去做节点级资源管理,比如给容器分配特定的 CPU 核、注入 GPU 设备、按策略限制内存,往往要靠设备插件加 CRI 改造,链路长、耦合重。NRI 把这些能力收敛到 containerd 内部的一个标准接口上,插件开发者只需要实现一套 gRPC 服务,就能在容器生命周期的各个钩子上做文章,不用碰 containerd 的核心代码。
适合谁来读这篇内容?如果你是在做容器平台、节点资源调度、GPU 或高性能设备管理,或者单纯想把 containerd 的插件体系吃透,那 NRI 是绕不过去的一环。哪怕你只是好奇“容器启动前到底还能插多少手”,这篇也会把机制、实操、坑点一次讲清楚。下面我按设计思路、核心细节、实操落地、问题排查四个层面展开,尽量把每个“为什么这么设计”都说明白。
2. NRI 的整体设计与思路拆解
2.1 从“改源码”到“插插件”的演进逻辑
要理解 NRI 为什么长这样,得先看它替代的是什么。在 NRI 出现之前,想在节点上对容器做精细化干预,常见路子有三条。第一条是直接改 containerd 源码,在创建容器的流程里塞逻辑,问题是升级困难、和上游分叉、每次版本迭代都要重新合并。第二条是在 CRI 实现层做文章,比如自己写一个 CRI shim,把请求拦截下来改一改再转发,问题是链路变长、调试复杂、和运行时版本强绑定。第三条是靠 OCI hook,在容器启动前后执行脚本,问题是 hook 能拿到的上下文有限,改 OCI spec 的能力弱,而且多个 hook 之间没有统一协调。
NRI 的设计思路是把“干预点”标准化、进程化。containerd 在容器生命周期的关键节点上主动通知注册的 NRI 插件,插件通过 gRPC 接收事件、返回修改后的 spec。这样插件是独立进程,可以单独编译、单独部署、单独升级,containerd 只负责在正确的时机把事件推出去、把插件的修改合并回来。这个解耦带来的好处很直接:插件挂了不影响 containerd 主流程(有超时和降级策略),插件可以用任意语言写(只要实现 gRPC 协议),多个插件可以按顺序链式处理同一个容器。
我个人的理解是,NRI 把“节点级容器干预”从一种侵入式的改造,变成了一种声明式的扩展。你不再需要关心 containerd 内部怎么走流程,只需要关心“在哪个事件上、对 spec 做什么修改”。这个抽象层次的提升,是它相比 OCI hook 和源码改造最核心的优势。
2.2 NRI 在 containerd 架构中的位置
containerd 内部有一套插件体系,NRI 本身也是以插件形式存在的,但它比较特殊,属于“框架型插件”。它在 containerd 启动时被加载,负责维护和外部 NRI 插件进程之间的连接。外部插件通过一个 Unix socket 和 containerd 通信,这个 socket 的路径由 containerd 配置决定,默认在/var/run/nri/下面。
从数据流上看,当一个容器要被创建时,containerd 的 CRI 插件收到请求,生成初始的 OCI spec,然后在真正调用 runtime(比如 runc)之前,把 spec 交给 NRI 框架。NRI 框架按注册顺序依次调用各个外部插件,每个插件拿到 spec 的副本,可以修改它,返回后框架把修改合并,再交给下一个插件。全部处理完后,最终的 spec 才被送去执行。更新和删除事件也是类似的链路,只是携带的上下文不同。
这里有个设计细节值得说:NRI 传递的 spec 是经过序列化的,插件拿到的是完整 OCI spec 的表示,而不是某种简化模型。这意味着插件的能力边界几乎和直接改 spec 一样大,能改的东西非常全。但反过来,这也要求插件开发者对 OCI spec 的结构足够熟悉,改错了地方可能导致容器起不来。这个取舍我认为是合理的,因为 NRI 的定位就是给“懂行的人”用的底层接口,不需要为了易用性牺牲表达力。
2.3 事件模型与插件生命周期
NRI 的事件模型围绕容器的生命周期展开,核心事件包括容器创建前的 spec 调整、容器创建后的通知、容器更新时的资源调整、容器删除时的清理。每个事件都携带容器 ID、Pod 信息(在 Kubernetes 场景下)、命名空间等上下文,插件据此决定要不要处理、怎么处理。
插件的生命周期管理也有一套约定。containerd 启动时会扫描配置的插件目录,发现可执行文件就尝试拉起并建立连接。插件进程需要实现一个 gRPC 服务,暴露注册、配置、事件处理等接口。连接建立后,插件先注册自己关心的事件类型和能力,containerd 据此决定哪些事件推给它。如果插件进程崩溃,containerd 会尝试重连,重连期间相关事件走降级逻辑,不会阻塞容器创建。
这个设计里有个容易被忽略的点:插件是“按需注册事件”的,不是所有事件都无脑推给所有插件。这样做的好处是减少不必要的 RPC 调用,尤其在节点上容器数量多、插件数量也多的时候,能明显降低开销。但代价是插件必须在注册阶段就声明清楚自己要处理什么,动态调整能力比较麻烦。实际做插件时,我建议一开始就把事件类型声明全,避免后期改注册逻辑带来的重启成本。
2.4 为什么选择 gRPC 而不是别的通信方式
NRI 用 gRPC 作为插件和 containerd 之间的通信协议,这个选择背后有几层考虑。第一,gRPC 天然支持流式通信和双向调用,适合事件推送这种模式。第二,protobuf 定义的接口契约清晰,跨语言支持好,插件用 Go、Rust、C++ 写都行。第三,gRPC 的超时、重试、连接管理机制成熟,不用自己造轮子。
对比一下其他选项:如果用 HTTP REST,事件推送得靠轮询或者长连接,效率低;如果用 Unix socket 裸传 JSON,接口契约靠文档约定,容易出错;如果用共享内存,跨语言和生命周期管理都复杂。gRPC 在这几个维度上是最平衡的。当然它也有代价,就是插件进程得带一个 gRPC 运行时,二进制体积会大一些,对于资源敏感的节点来说需要考虑。不过实测下来,一个简单的 NRI 插件常驻内存也就几十 MB,对现代节点来说可以接受。
3. 核心细节解析与实操要点
3.1 OCI spec 修改的关键位置
NRI 插件最核心的能力就是改 OCI spec,但 spec 里字段那么多,改哪里、怎么改,直接决定插件能不能正常工作。先说资源相关的部分。CPU 和内存的限制在linux.resources下面,cpu.shares、cpu.quota、cpu.period控制 CPU 分配,memory.limit控制内存上限。如果你要做节点级的资源策略,比如按 Pod 优先级调整 CPU 权重,就是在这里动手。
设备注入是另一个高频场景。GPU、FPGA 这类设备,需要在linux.devices里追加设备节点,同时在linux.resources.devices里放行对应的 cgroup 规则。这里有个坑:只加linux.devices不加 cgroup 放行,容器里能看到设备文件但访问会被拒绝,报错信息还不直观。我踩过这个坑,排查了半天才发现是 cgroup 规则没同步。
环境变量和挂载点也是常改的地方。process.env追加环境变量,mounts追加挂载。要注意挂载的顺序和传播属性,尤其是需要双向传播的挂载,rbind和rprivate这些选项配错了会导致挂载在容器里不可见。还有annotations字段,虽然不影响容器运行,但可以用来在插件之间传递元数据,做多插件协作时很有用。
提示:修改 spec 时尽量做“增量修改”,不要整体替换。整体替换容易丢掉 containerd 或其他插件已经设置好的字段,导致难以定位的问题。
3.2 插件注册与事件订阅的实操细节
写一个 NRI 插件,第一步是把 gRPC 服务跑起来,监听 containerd 配置的 socket 路径。插件启动后要做的第一件事是注册,告诉 containerd“我是谁、我关心哪些事件、我有什么能力”。注册信息里有个events字段,列出要订阅的事件类型,比如RUN_POD_SANDBOX、CREATE_CONTAINER、UPDATE_CONTAINER、REMOVE_CONTAINER等。
这里有个实操要点:事件类型要按实际需求精确声明,不要图省事全订阅。全订阅虽然省事,但每个容器生命周期里会多出好几次不必要的 RPC,节点上容器一多,累积开销不小。我做过一个粗略对比,一个只订阅创建事件的插件,相比全订阅,在每秒创建十几个容器的压力下,CPU 占用能低三成左右。
注册完成后,插件进入事件循环,收到事件后根据类型分发处理。处理函数里要注意两点:一是处理要快,因为 containerd 对插件调用有超时限制,超时后事件会被跳过,插件相当于“失联”;二是处理要幂等,同一个容器的事件可能因为重试被推多次,重复处理不能出问题。超时时间可以在 containerd 配置里调,但我不建议调太大,因为插件慢会拖慢整个容器创建链路,宁可让插件内部做异步预处理,也别让同步处理变慢。
3.3 多插件链式处理的顺序问题
节点上往往不止一个 NRI 插件,比如一个管资源、一个管设备、一个管安全策略。多个插件处理同一个容器时,顺序就变得很重要。containerd 按插件注册的顺序依次调用,先注册的先处理。这个顺序在配置里可以控制,但很多人没意识到它的影响。
举个具体例子:假设插件 A 负责给容器加内存限制,插件 B 负责根据内存限制调整 CPU 配额。如果 B 先于 A 执行,B 读到的内存限制还是初始值,算出来的 CPU 配额就是错的。所以这类有依赖关系的插件,注册顺序必须和依赖方向一致。我的经验是把“基础资源设置”类插件放前面,“派生策略”类插件放后面,形成清晰的流水线。
还有一个细节:每个插件拿到的是前一个插件修改后的 spec,所以插件之间可以通过 spec 字段传递信息。但这种方式比较隐晦,容易出问题。更稳妥的做法是用annotations显式传递,约定好 key 的命名空间,避免冲突。多插件协作时,我一般会在文档里明确写清楚每个插件读写哪些 annotation,减少联调时的扯皮。
3.4 配置管理与插件参数传递
NRI 插件通常需要一些配置,比如资源策略的阈值、设备白名单、日志级别。这些配置怎么传给插件,有几种方式。一种是插件自己读配置文件,路径在启动参数里指定;一种是通过 containerd 的配置传递,containerd 在拉起插件时把配置作为参数或环境变量给它;还有一种是插件启动后通过 NRI 的配置接口动态获取。
我倾向于第一种和第二种结合:静态配置走配置文件,动态调整走 NRI 配置接口。原因是配置文件便于版本管理和审计,动态接口适合运行时的策略微调。要注意的是,NRI 的配置接口在不同 containerd 版本上行为可能有差异,升级 containerd 时得回归测试配置下发逻辑。我遇到过升级后配置字段被忽略的情况,查了半天是版本兼容问题,所以版本升级前一定要看 changelog 里 NRI 相关的变更。
配置的校验也很重要。插件启动时应该校验配置的合法性,比如阈值是否在合理范围、白名单格式是否正确,校验失败直接退出并打清晰日志,别让插件带着错误配置跑起来,那样出的问题更难查。
4. 实操过程与核心环节实现
4.1 环境准备与 containerd 配置
动手之前先把环境理清楚。containerd 版本要支持 NRI,较新的稳定版都内置了 NRI 框架,但默认可能是关闭的,需要在配置文件里显式打开。配置文件通常在/etc/containerd/config.toml,找到 NRI 相关的段落,把启用开关打开,并指定插件目录和 socket 路径。
配置里几个关键项:插件目录决定 containerd 去哪里找插件可执行文件,socket 路径决定通信端点,超时时间决定插件调用的容忍度。插件目录建议单独建一个,别和 containerd 其他文件混在一起,方便权限管理和清理。socket 路径的目录要保证 containerd 和插件进程都有读写权限,权限配错是新手最常见的启动失败原因。
配置改完后重启 containerd,然后看日志确认 NRI 框架是否正常初始化。日志里会有插件扫描和连接的记录,如果看到连接失败,先查 socket 路径和权限,再查插件进程是否真的起来了。这一步看着简单,但实际排查时信息往往不够,建议把 containerd 日志级别临时调高,拿到更详细的连接过程记录。
4.2 编写一个最小可用的 NRI 插件
从零写插件,建议先用官方提供的 SDK 起一个骨架,把注册和事件循环跑通,再往里填业务逻辑。骨架的核心是几块:建立 gRPC 连接、实现服务接口、注册事件、处理事件。连接建立部分要处理重连,因为 containerd 重启或插件重启都会导致连接断开,没有重连逻辑的插件在节点维护后就成了“僵尸”。
事件处理部分,先实现创建事件的 spec 修改,这是最直观的验证方式。比如在创建事件里给容器追加一个环境变量,然后起一个容器看变量是否生效。这个最小闭环跑通,说明整条链路是通的,后面再加复杂逻辑就有底了。
代码结构上,我建议把“事件分发”和“业务处理”分开。分发层只负责根据事件类型调用对应处理函数,业务层专注 spec 修改逻辑。这样业务逻辑可以单独写单元测试,不用每次都起 containerd 联调。spec 修改函数最好是纯函数,输入 spec 输出修改后的 spec,便于测试和复用。
// 事件处理骨架示意 func (p *plugin) OnCreateContainer(ctx context.Context, req *nri.CreateContainerRequest) (*nri.CreateContainerResponse, error) { spec := req.GetPodSandbox().GetLinux().GetResources() // 业务逻辑:按策略调整资源 adjustResources(spec) return &nri.CreateContainerResponse{}, nil }上面这段只是示意结构,实际 SDK 的接口签名以所用版本为准。重点是理解“拿到 spec、改 spec、返回”这个模式,所有事件处理都是这个套路。
4.3 资源调整的参数计算与验证
资源调整是 NRI 插件最典型的应用。假设场景是:节点上跑着不同优先级的负载,高优先级容器要保证 CPU 配额,低优先级容器在资源紧张时让路。实现上,插件在创建事件里读容器的优先级标注,据此设置cpu.shares和cpu.quota。
参数怎么算?cpu.shares是相对权重,默认 1024,高优先级可以设 2048 甚至 4096,低优先级设 512。cpu.quota是绝对上限,单位是微秒每周期,周期默认 100000 微秒。比如限制 2 核,quota 就是 200000。计算时要注意 quota 和 period 的配合,只改 quota 不改 period 也能生效,但理解两者的关系有助于排查“限制没生效”的问题。
验证环节很关键。改完 spec 后,容器起来进到里面看 cgroup 文件,/sys/fs/cgroup/cpu/下面能看到实际的 shares 和 quota 值,和预期对比。如果值不对,先确认插件是否真的被调用(看插件日志),再确认修改的字段路径是否正确。我见过改错字段层级导致修改被忽略的情况,spec 结构嵌套深,路径写错很常见,建议改完后打印修改前后的 spec 做 diff。
4.4 设备注入的完整链路
设备注入比资源调整复杂,因为涉及设备节点和 cgroup 规则两处修改。以注入一个字符设备为例,插件要在linux.devices里追加设备条目,指定路径、类型、主次设备号,同时在linux.resources.devices里追加一条 allow 规则,放行对应的访问。
主次设备号从哪来?通常从宿主机上设备文件的信息里读,或者由设备管理组件提前告知。插件里可以读宿主机/dev下对应设备的信息拿到主次号,也可以从容器标注里读预置的值。我倾向于后者,因为插件不该假设宿主机设备布局,让上层把信息传下来更可控。
注入完成后验证:进容器ls /dev看设备文件在不在,然后实际读写设备确认权限没问题。只验证文件存在不够,cgroup 规则没配对的话,文件在但操作会报权限错误。这个验证步骤别省,设备类问题在容器里表现往往很隐晦。
4.5 更新与删除事件的清理逻辑
创建事件之外,更新和删除事件也要处理好。更新事件在容器资源被调整时触发,插件要在这里同步自己的状态,比如更新内部记录的容器资源视图。删除事件是清理时机,插件在这里释放为该容器分配的资源、清理临时文件、更新统计。
删除事件的清理容易被忽视,导致资源泄漏。比如插件为容器分配了某种独占资源,容器删了但插件没释放,下次分配就冲突。我的做法是在插件内部维护一个容器 ID 到资源分配的映射,删除事件里按 ID 清理,同时加一个定期对账机制,扫描实际存在的容器和映射做比对,发现孤儿记录就清理。这个对账机制在插件异常重启后特别有用,能兜住状态不一致的问题。
5. 常见问题与排查技巧实录
5.1 插件连不上 containerd 的排查路径
插件启动后连不上 containerd,是最常见的入门问题。排查顺序我一般这样走:先看 socket 文件在不在,路径是否和 containerd 配置一致;再看权限,containerd 进程和插件进程的用户对 socket 目录是否有读写权限;然后看 containerd 日志里有没有插件扫描和连接尝试的记录;最后看插件自己的日志,连接失败的具体错误是什么。
这四步能覆盖绝大多数情况。我遇到过的典型原因包括:配置里插件目录写错导致 containerd 根本没扫描到插件、socket 目录权限是 root 独享导致非 root 插件连不上、插件进程启动后立刻崩溃导致连接被拒。还有一种隐蔽情况是 containerd 和插件对协议版本理解不一致,连接建立了但握手失败,这种要看两边的版本兼容性说明。
注意:排查连接问题时,先把 containerd 日志级别调高,默认级别下很多连接细节不打印,容易误判。
5.2 spec 修改不生效的几种可能
插件日志显示处理了事件,但容器里看不到修改效果,这种问题排查起来比较绕。可能原因我整理成一张表,按出现频率排序。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 修改完全没生效 | 事件没订阅或订阅类型不对 | 查插件注册日志里的事件列表 |
| 部分字段生效部分不生效 | 修改的 spec 路径写错 | 打印修改前后 spec 做 diff |
| 创建时生效更新时丢失 | 更新事件没处理或覆盖了修改 | 查更新事件处理逻辑 |
| 多插件场景下被覆盖 | 后续插件覆盖了前面的修改 | 调整插件顺序或改用 annotation 传递 |
| 容器起不来 | 修改导致 spec 非法 | 校验修改后的 spec 合法性 |
其中“更新时丢失”这个情况特别值得说。容器运行中如果发生资源更新,containerd 会推更新事件,如果插件没处理更新事件,或者处理时用了初始 spec 而不是当前 spec,之前的修改就可能被冲掉。我的做法是更新事件里基于当前 spec 做增量修改,并且把关键修改记录在插件状态里,更新时重新应用。
5.3 性能问题的定位与优化
NRI 插件在容器创建链路上是同步调用的,插件慢会直接拖慢容器创建。性能问题通常表现为容器创建耗时变长,或者 containerd 日志里出现插件调用超时。定位时先看插件处理单个事件的耗时,在插件里打点记录,超过阈值就告警。
优化方向有几个。一是减少不必要的 RPC,事件订阅精确化,别全订阅。二是处理逻辑里避免阻塞操作,比如同步读大文件、同步网络请求,这些挪到异步预处理或者缓存起来。三是 spec 修改尽量做增量,别整体序列化反序列化大对象。四是多插件场景下审视顺序,把快的插件放前面,慢的放后面,至少让快插件的效果先落地。
我做过一个优化案例:某插件在创建事件里同步查一个外部服务拿配置,单次耗时几十毫秒,容器创建高峰期直接把创建耗时拉高了一截。改成启动时预拉配置加定期刷新后,创建链路上的耗时降到毫秒级。这个思路值得借鉴:能在插件启动阶段做的准备,别放到事件处理里做。
5.4 版本升级带来的兼容性坑
containerd 升级时,NRI 相关的接口和行为可能有变化。我遇到过升级后插件注册失败的情况,原因是事件枚举值调整了,插件里硬编码的旧值不被识别。还有一次是配置字段改名,旧配置被静默忽略,插件用了默认值,行为悄悄变了。
应对办法:升级前读 changelog 里 NRI 相关段落,重点看接口定义和配置项的变化;升级后在测试环境跑一遍插件的完整链路,包括创建、更新、删除;插件里对关键配置做启动校验,配置没生效就报错退出,别静默用默认值。插件和 containerd 的版本兼容矩阵最好在文档里维护一份,节点批量升级时按矩阵来,别混着版本上。
5.5 实操心得与避坑清单
最后整理几条从实际项目里攒下来的经验,都是文档里不太会写的东西。
第一,插件一定要有“旁路开关”。出问题时能快速禁用插件而不影响容器创建,这个开关在事故处理时价值极高。实现上可以是配置项,也可以是环境变量,禁用后插件不处理事件直接返回。
第二,日志要带容器 ID 和事件类型。排查问题时按容器 ID 过滤日志,能快速还原单个容器的完整处理链路。日志级别分档,正常运行时 info 级别只打关键节点,排查时调 debug 打详细 spec。
第三,插件状态要可观测。暴露一个 metrics 端点,记录处理事件数、耗时分布、错误数,接入监控后能提前发现异常。没有可观测性的插件,出问题时只能靠猜。
第四,联调时先用最小容器验证。别一上来就用复杂业务容器测,最小容器(比如只跑 sleep 的)能把插件问题和业务问题隔离开,定位效率高很多。
第五,多插件协作先定契约。annotation 的 key 命名、读写职责、处理顺序,这些在开发前就约定清楚,比事后联调扯皮省事得多。
这套机制用下来,我的体会是 NRI 的价值在于把节点级干预标准化了,但标准化的代价是插件开发者要自己对 spec 和生命周期有足够理解。它不是那种“配一下就能用”的东西,而是给愿意深入底层的人准备的工具。把上面这些细节吃透,再动手写插件,踩坑的概率会低很多。后续如果要做更复杂的场景,比如按拓扑分配设备、跨插件协调资源,可以在这套基础上继续扩展,核心思路还是那一条:在正确的事件上,对 spec 做正确的增量修改。