1. 为什么需要关注 containerd 的插件扩展模式
containerd 作为容器运行时的事实标准之一,很多人日常用 Docker、Kubernetes 时其实已经在跟它打交道了,但真正动手去改它、扩展它的人并不多。我最初接触 containerd 的插件机制,是因为一个很具体的需求:公司内部有一套自研的镜像加速与审计逻辑,需要在容器创建前后插入自定义处理,同时又不想去改 containerd 的核心代码,毕竟一改核心,后续版本升级就是噩梦。当时翻了不少资料,发现讲 containerd 插件扩展的中文内容要么太浅只提了个概念,要么直接跳到某个具体插件的源码,缺少一个从“为什么这么设计”到“怎么落地”的完整视角。所以这篇就把我自己踩过的路、读过的源码、写过的插件整理出来,尽量把 containerd 的几种插件扩展模式讲透。
containerd 的插件体系不是随便设计的,它背后有一套很清晰的取舍逻辑。你要理解它,得先明白 containerd 的定位:它是一个纯粹的容器运行时守护进程,负责镜像拉取、容器生命周期管理、存储、网络命名空间等底层能力,但它刻意把很多“可替换”的部分抽成了插件。这样做的好处是核心足够稳定,外围能力可以按需拼装。打个比方,containerd 就像一台主机的机箱和主板,插件就是插在上面的各种板卡,你想换网卡就换网卡,想加采集卡就加采集卡,主板本身不用动。
这套插件机制主要解决三类人的问题。第一类是运行时开发者,需要替换或增强某个底层能力,比如换一种快照存储实现、加一种运行时 shim;第二类是平台工程师,需要在容器生命周期里插入自定义逻辑,比如审计、安全策略注入、镜像签名校验;第三类是运维和 SRE,想搞清楚 containerd 启动时到底加载了哪些插件、某个功能为什么没生效、配置改了为什么不生效。不管你是哪一类,理解插件扩展模式都是绕不开的基本功。
containerd 的插件扩展模式,从大类上可以分成几种:基于内置插件注册表的静态插件、基于 gRPC 的代理插件(proxy plugin)、基于运行时 shim 的扩展、以及通过配置和事件机制做的轻量扩展。这几种模式的适用场景、实现成本、稳定性要求都不一样,选错了模式,后面维护起来会非常痛苦。接下来我会逐一拆开讲,每种模式都尽量说清楚它解决什么问题、内部怎么运作、实际怎么落地、有哪些坑。
2. containerd 插件体系的整体设计与思路拆解
2.1 插件注册表:一切扩展的起点
containerd 内部有一个插件注册表的概念,所有内置插件在编译期就通过plugin.Register这类方式注册到一个全局的注册表里。这个注册表按插件类型(plugin type)分类,比如runtime、snapshotter、content、metadata、gc、service等等。每种类型下面可以有多个具体实现,containerd 启动时根据配置决定加载哪些、初始化顺序如何。
这个设计的关键点在于“类型 + 实现”的二维结构。类型定义了插件要满足的接口契约,实现则是具体的功能提供者。比如snapshotter类型定义了快照的 Prepare、Commit、Mounts 等接口,overlayfs、native、btrfs 都是这个类型下的不同实现。你在配置里指定用哪个实现,containerd 就去注册表里找对应的插件初始化。
为什么这么设计?核心是为了解耦和可替换。如果 containerd 把所有功能写死,那它就是一个巨大的单体,任何一个小功能的变更都要动核心。而通过注册表 + 接口的方式,核心只依赖接口,具体实现可以随时替换。这跟很多框架的依赖注入思路是一致的,只不过 containerd 用的是编译期注册 + 运行时选择的方式,兼顾了性能和灵活性。
这里有个容易被忽略的细节:内置插件的注册是编译期完成的,也就是说你如果要用一个自定义的内置插件,必须重新编译 containerd,或者把它作为一个独立的二进制通过 proxy 方式接入。这一点直接决定了你选哪种扩展模式——如果你不想碰 containerd 的编译,那内置插件这条路基本就走不通,得考虑 proxy plugin。
2.2 静态插件与动态插件的取舍
containerd 的插件从加载时机上分,有静态和动态两类。静态插件就是编译进 containerd 二进制、启动时由注册表统一初始化的那些,绝大多数内置插件都是这一类。动态插件则是在运行时通过外部进程接入的,典型代表就是 proxy plugin。
静态插件的优势是性能好、调用链路短、没有跨进程开销,因为插件和 containerd 在同一个进程里,直接函数调用。缺点是灵活性差,加插件要重新编译,插件出问题可能拖垮整个 containerd 进程。动态插件的优势正好相反,独立进程、独立生命周期、可以单独升级和重启,出问题隔离性好,但代价是跨进程通信的开销和复杂度。
我个人的经验是:底层高频调用的能力,比如 snapshotter、content store,优先用静态插件,因为性能敏感;而偏业务逻辑、调用频率不高的扩展,比如审计、策略校验,用 proxy plugin 更合适,隔离性好、迭代快。这个取舍不是绝对的,但大方向是这样。
2.3 事件机制:轻量扩展的入口
除了插件本身,containerd 还有一套事件机制,通过eventsservice 对外发布容器生命周期事件,比如容器创建、启动、退出、删除,镜像拉取完成等等。这套机制严格来说不算“插件”,但它是一种非常重要的轻量扩展手段。你不需要写插件、不需要改 containerd,只要订阅事件流,就能在容器状态变化时做自定义处理。
事件机制的价值在于“旁路”。很多需求其实不需要侵入 containerd 的调用链,只需要知道“发生了什么”,然后在旁边做自己的事。比如你想在容器启动后自动打一个审计日志、或者在镜像拉取后触发一次扫描,订阅事件就够了。这种方式的侵入性最低,稳定性最好,因为即使你的处理逻辑挂了,也不影响 containerd 主流程。缺点是它只能“观察”,不能“干预”——你没法在事件处理里阻止一个容器的创建,只能事后补救。
所以选型的时候要先问自己一个问题:我是要“改变” containerd 的行为,还是只要“感知”它的行为?前者需要插件,后者事件机制往往就够了。这个判断能帮你省掉大量不必要的开发工作。
3. 内置插件模式:最原生也最重的扩展方式
3.1 内置插件的工作原理与注册流程
内置插件的核心是plugin包里的注册表。containerd 启动时,会遍历注册表里所有已注册的插件,按照依赖关系和配置进行初始化。每个插件在注册时会声明自己的类型、ID、依赖的其他插件、初始化函数以及配置解析逻辑。初始化函数负责根据配置构造出插件实例,然后注册到对应的服务里。
这个流程里有个很重要的概念叫“插件依赖”。比如 metadata 插件依赖 content store 和 snapshotter,它初始化时必须等这些依赖先就绪。containerd 的插件初始化器会做依赖排序,保证初始化顺序正确。如果你自己写内置插件,声明依赖这一步不能省,否则可能出现依赖还没初始化就被调用的情况,表现为空指针或者服务不可用。
注册的代码大致长这样(以 Go 为例,这是基于常见实践的示意,不是逐字源码):
func init() { plugin.Register(&plugin.Registration{ Type: plugin.SnapshotPlugin, ID: "my-snapshotter", Requires: []plugin.Type{ plugin.MetadataPlugin, }, InitFn: func(ic *plugin.InitContext) (interface{}, error) { // 从 ic.Config 读取配置 // 构造并返回插件实例 return newMySnapshotter(ic) }, }) }这段代码的关键在于Type和ID的组合唯一标识一个插件,Requires声明依赖,InitFn是初始化入口。你把这个文件放进 containerd 的源码树里编译,插件就生效了。
3.2 什么场景适合写内置插件
内置插件适合那些“必须深度嵌入 containerd 调用链、性能敏感、且相对稳定”的能力。典型场景包括:自定义的 snapshotter(比如对接某种分布式存储)、自定义的 content store(比如对接对象存储做镜像层存储)、自定义的 runtime shim 管理逻辑、以及需要在核心路径上做拦截的过滤器。
我见过一个比较典型的案例:某团队需要把镜像层直接存到自研的分布式文件系统上,读写的频率非常高,如果走 proxy plugin 跨进程通信,延迟会明显上升。他们最终选择写一个内置的 content store 插件,直接在主进程里完成读写,性能满足要求。代价是每次 containerd 升级都要重新合入代码、重新编译,维护成本不低,但因为这块逻辑本身很稳定,几年下来改动很少,所以这个取舍是划算的。
反过来说,如果你的扩展逻辑经常变、或者团队里没人愿意维护 containerd 的编译环境,那内置插件就是给自己挖坑。我见过有团队为了加一个简单的镜像标签校验,硬是写了个内置插件,结果每次升级 containerd 都要花两天处理编译和兼容问题,后来改成 proxy plugin,半天就搞定了。所以选型一定要看“变更频率”这个维度。
3.3 内置插件的实操要点与注意事项
写内置插件有几个实操上的坑,我按重要性排一下。
第一,配置解析要健壮。containerd 的配置是 TOML 格式,插件配置挂在对应的类型下面。你的InitFn里读配置时,一定要处理配置缺失的情况,给出合理的默认值或者明确的报错。我见过插件因为配置项没写就 panic,导致整个 containerd 起不来,这种问题在生产环境是灾难性的。
第二,初始化失败的处理要谨慎。内置插件初始化失败,默认行为可能导致 containerd 启动失败。你要想清楚:这个插件是“必须成功”还是“可以降级”?如果是可选能力,初始化失败时应该记录日志并返回一个可用的降级实现,而不是直接让进程退出。
第三,注意并发安全。内置插件和 containerd 主进程共享内存空间,你的插件方法会被多个 goroutine 并发调用。共享状态一定要加锁或者用 channel 保护,别想当然地认为调用是串行的。这块出问题的表现往往是偶发的数据竞争,很难复现,排查成本极高。
第四,日志要打对地方。containerd 用的是结构化的日志框架,你的插件日志要接入这套框架,带上插件 ID 等字段,方便排查。别用裸的fmt.Println,那样日志会散落在标准输出里,跟 containerd 的日志体系对不上。
提示:内置插件的调试建议在开发环境用
containerd --log-level debug启动,观察插件初始化顺序和依赖解析过程,很多依赖问题在这一步就能暴露。
4. Proxy Plugin 模式:跨进程扩展的主力方案
4.1 Proxy Plugin 的通信模型
Proxy plugin 是 containerd 提供的一种跨进程插件机制。它的基本模型是:containerd 作为客户端,通过 gRPC 连接一个外部插件进程提供的服务端,把原本内部的调用转发到这个外部进程。插件进程独立运行、独立生命周期,containerd 通过配置里的地址找到它。
这个模型的关键在于“协议对齐”。containerd 为每种插件类型定义了对应的 gRPC 服务接口,比如 snapshotter 有Snapshotsservice,content store 有Contentservice。你的外部插件进程只要实现了这套 gRPC 接口,containerd 就能把它当成一个正常的插件来用。从 containerd 的视角看,proxy plugin 和内置插件在调用层面几乎没有区别,都是接口调用,只不过底层从函数调用变成了网络调用。
为什么用 gRPC 而不是别的?因为 containerd 整个对外 API 就是 gRPC 的,内部复用这套基础设施成本最低。而且 gRPC 有成熟的代码生成、流式传输、超时控制、拦截器机制,这些对插件场景都很有用。比如你可以用拦截器统一做鉴权、限流、日志,不用在每个方法里重复写。
4.2 一个 Proxy Plugin 的完整落地过程
我拿一个相对简单的场景来演示:写一个 proxy snapshotter,把快照操作转发到自研的存储后端。整个过程分几步。
第一步,拿到 containerd 的 proto 定义。containerd 的插件接口 proto 文件在源码的api/services目录下,snapshotter 对应的是snapshots相关定义。你需要用 protoc 生成 Go 代码,或者直接引用 containerd 提供的 Go 包。
第二步,实现 gRPC 服务端。以 snapshotter 为例,你需要实现Prepare、Commit、Mounts、Stat、List、Remove、Usage等方法。每个方法对应快照生命周期的一个环节。这里要注意,Mounts返回的是挂载信息,containerd 拿到后会自己去执行挂载,你的插件不负责实际挂载动作,只负责告诉它“该怎么挂”。
第三步,启动 gRPC 服务并监听一个地址。这个地址可以是 unix socket,也可以是 tcp,生产环境建议用 unix socket,避免网络暴露和额外的安全配置。
第四步,在 containerd 配置里声明这个 proxy plugin。配置大概长这样:
[proxy_plugins] [proxy_plugins.my-snapshotter] type = "snapshot" address = "/run/containerd/my-snapshotter.sock"type对应插件类型,address是插件进程的监听地址。containerd 启动时会读取这段配置,尝试连接对应地址,连接成功就把这个插件纳入可用列表。
第五步,验证。用ctr plugin ls可以看到插件列表,确认你的插件状态是 ok。然后用ctr指定 snapshotter 创建容器,观察调用是否正常。
4.3 Proxy Plugin 的稳定性设计与常见坑
Proxy plugin 最大的风险点是“跨进程”带来的不确定性。插件进程挂了、网络断了、响应超时了,这些在内置插件里不存在的问题,在 proxy 场景下都是常态。所以稳定性设计是重中之重。
首先是超时控制。containerd 调用 proxy plugin 时有默认超时,但不同操作的合理超时不一样。比如Stat这种轻量查询可以短超时,Prepare涉及存储分配可能耗时较长。你要在插件实现里对耗时操作做好异步化或者分段处理,避免单次调用超时。我踩过一个坑:某个 snapshotter 的Prepare在存储后端慢的时候会卡住几十秒,导致 containerd 侧超时重试,重试又加重后端负担,形成雪崩。后来把Prepare拆成“快速占位 + 异步准备”两段,问题才解决。
其次是连接管理。containerd 到插件进程的连接是长连接,插件进程重启后,containerd 需要能重连。这块 containerd 本身有重连逻辑,但你的插件进程要保证重启后监听地址不变、服务能快速就绪。建议插件进程用 systemd 或者类似的进程管理工具托管,配置自动重启。
第三是错误语义要对齐。gRPC 的错误码和 containerd 期望的错误语义要匹配。比如“快照不存在”应该返回NotFound,而不是笼统的Internal。错误码不对,containerd 侧的重试和降级逻辑就会走错分支。这块建议对照 containerd 源码里对各类错误的处理逻辑来设计。
第四是版本兼容。containerd 的插件接口 proto 会随版本演进,字段可能增加。你的插件实现要能容忍未知字段,gRPC 的 proto3 默认是向后兼容的,但如果你用了Any或者自定义序列化,就要格外小心。升级 containerd 前,先在测试环境验证插件兼容性。
注意:proxy plugin 的地址配置在 containerd 重启后才会重新读取,改了配置记得重启 containerd,别指望热加载。
5. Runtime Shim 扩展:容器运行时的定制入口
5.1 Shim 的职责与扩展点
Runtime shim 是 containerd 体系里一个特殊的存在。它介于 containerd 和实际容器进程之间,负责容器的创建、启动、监控、信号转发、退出状态收集等。containerd 本身不直接 fork 容器进程,而是启动一个 shim 进程,由 shim 去管理容器。这个设计的目的是让容器进程的生命周期和 containerd 解耦——containerd 重启不影响运行中的容器,因为 shim 还在。
Shim 的扩展点在于:你可以实现自己的 shim,来支持特殊的运行时需求。比如对接某种轻量级虚拟机运行时、实现特殊的资源隔离策略、或者在容器启动前后插入自定义逻辑。containerd 官方提供了runtime/v2的 shim 框架,基于这个框架实现自定义 shim 是相对成熟的路径。
Shim 和 containerd 之间通过 ttrpc(一种轻量级 RPC)通信,协议定义在runtime/v2的 proto 里。你的 shim 要实现Create、Start、Delete、State、Kill、Exec等方法。相比 proxy plugin 的 gRPC,ttrpc 更轻量,适合这种高频、低延迟的场景。
5.2 自定义 Shim 的适用场景与实现路径
自定义 shim 不是随便就该上的。它的实现复杂度比 proxy plugin 高不少,涉及进程管理、命名空间、cgroup、信号处理等底层细节。适合的场景通常是:你需要一种 containerd 原生不支持的运行时形态,或者需要在容器进程管理层面做深度定制。
举个典型例子:某团队需要一种“容器进程退出后保留现场一段时间用于取证”的能力。标准 shim 在容器退出后很快清理资源,他们基于 v2 shim 框架改了一个版本,在容器退出后把关键状态 dump 到指定目录,再走正常清理流程。这种需求用 proxy plugin 做不了,因为 proxy plugin 拿不到容器进程的底层控制权,只能做外围服务。
实现路径上,建议直接从 containerd 官方的runtime/v2/runc或者runtime/v2/shim代码出发,改而不是从零写。官方 shim 已经把进程管理、ttrpc 服务、日志转发这些脏活累活处理好了,你只需要在关键钩子处插入自己的逻辑。从零写一个 shim,光是调试 ttrpc 协议对齐就能耗掉一周。
5.3 Shim 开发中的高频问题
Shim 开发最容易出问题的地方是进程和文件描述符管理。容器进程、shim 进程、containerd 进程三者的关系要理清楚,尤其是文件描述符的继承和关闭。我见过 shim 因为没关好继承的 fd,导致容器退出后 fd 泄漏,时间长了把系统 fd 耗尽。
另一个高频问题是信号处理。容器里的进程收到 SIGTERM 后,shim 要正确转发并等待退出,超时后再 SIGKILL。这块逻辑如果写错,表现是容器“停不下来”或者“退出状态不对”。建议直接复用官方 shim 的信号处理逻辑,别自己造轮子。
还有日志和 stdio 的转发。容器的标准输出、标准错误要通过 shim 转发到 containerd 的日志管道,这块涉及 fifo 或者 pipe 的管理,路径和权限都要对。调试时可以用ctr run加详细日志观察转发链路。
6. 事件与配置扩展:低侵入的补充手段
6.1 事件订阅的实操方式
containerd 的 events service 通过 gRPC 流式接口对外发布事件。你可以写一个独立的客户端程序,订阅/tasks/create、/tasks/start、/tasks/exit、/images/create等 topic,收到事件后做自定义处理。这个客户端跟 containerd 是完全解耦的,用官方 Go 客户端库几行代码就能跑起来。
订阅的关键是 topic 过滤和事件解析。containerd 的事件有 namespace 概念,你订阅时要指定 namespace,否则可能收不到或者收到不该收的。事件体里包含容器 ID、进程 ID、退出码等信息,解析时注意字段可能随版本变化,做好容错。
这种方式的典型用途:审计日志、监控指标采集、镜像拉取后的安全扫描触发、容器异常退出的告警。它的最大优势是零侵入,containerd 完全不知道你的存在,你的程序挂了也不影响它。缺点是只能事后响应,没法阻止操作。
6.2 配置层面的扩展技巧
containerd 的配置文件本身也提供了一些扩展能力,虽然不算严格意义上的插件,但实际很有用。比如通过[plugins."io.containerd.grpc.v1.cri"]下面的配置调整 CRI 行为,通过[plugins."io.containerd.internal.v1.opt"]配置内部优化参数,通过 registry 配置做镜像仓库的认证和镜像加速。
这些配置项的价值在于“不改代码就能调整行为”。很多运维层面的需求,其实翻翻配置文档就能解决,不需要写插件。我见过有团队为了改镜像拉取的并发数,差点去改源码,其实配置里就有对应参数。所以动手写扩展之前,先把配置项过一遍,能省很多事。
配置扩展的注意事项是:不同 containerd 版本的配置项可能有增删,升级时要对照 release notes 检查。另外配置写错可能导致 containerd 启动失败,改配置前备份、改完用containerd config dump验证,是基本操作。
7. 几种扩展模式的对比与选型建议
7.1 横向对比表
把前面讲的几种模式放在一起对比,选型时会更清晰。
| 扩展模式 | 侵入性 | 性能开销 | 实现复杂度 | 隔离性 | 适用场景 |
|---|---|---|---|---|---|
| 内置插件 | 高(需改源码重编译) | 最低(同进程调用) | 中 | 低(同进程) | 高频、稳定、深度嵌入的核心能力 |
| Proxy Plugin | 低(独立进程) | 中(跨进程 RPC) | 中高 | 高(独立进程) | 业务逻辑扩展、迭代频繁的能力 |
| Runtime Shim | 中(独立进程但协议特殊) | 低到中 | 高 | 高 | 运行时形态定制、进程级控制 |
| 事件订阅 | 最低(完全旁路) | 无(不影响主流程) | 低 | 最高 | 审计、监控、事后触发 |
| 配置扩展 | 无 | 无 | 最低 | 不适用 | 行为微调、参数调整 |
7.2 选型决策的实操思路
选型时我一般按这个顺序问自己几个问题。
第一个问题:这个需求是“改变行为”还是“感知行为”?如果只是感知,直接上事件订阅,成本最低。很多需求到这一步就解决了。
第二个问题:如果必须改变行为,这个逻辑的变更频率高不高?高的话优先 proxy plugin,因为独立进程迭代快、升级不影响 containerd。低的话可以考虑内置插件,换取性能和调用链的简洁。
第三个问题:这个逻辑是不是在容器进程管理层面?如果是,那基本只能走 shim,因为其他模式拿不到那层控制权。
第四个问题:性能是不是硬约束?如果是高频调用的核心路径,内置插件的同进程调用优势明显,proxy 的跨进程开销可能成为瓶颈。这时候要算一笔账:单次调用多几毫秒,乘以每秒调用次数,是否可接受。
这套决策逻辑不是教条,实际项目里往往要综合权衡。比如一个需求变更频率中等、性能要求中等,那 proxy plugin 通常是稳妥选择,因为它两边都不极端,容错空间大。
7.3 混合使用的实践
实际项目里,几种模式经常是混用的。比如一个镜像安全平台,可能用事件订阅做镜像拉取后的扫描触发,用 proxy plugin 做镜像签名校验的拦截,用配置扩展调整拉取并发。每种模式各司其职,整体架构反而清晰。
混用的关键是边界要清楚。每个扩展点负责什么、失败时怎么降级、相互之间有没有依赖,这些要在设计阶段就想明白。我见过一个项目,事件订阅和 proxy plugin 都去处理同一个容器创建事件,结果逻辑重复执行,排查了半天才发现是两个扩展点都在干活。所以混用不是堆功能,而是明确分工。
8. 实操中的问题排查与经验沉淀
8.1 常见问题速查
下面这张表是我这些年遇到的高频问题,按现象、可能原因、排查方向整理,方便对照。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 插件列表里看不到自定义插件 | 配置未生效、地址不通、类型写错 | 检查 containerd 配置、插件进程是否监听、ctr plugin ls输出 |
| 插件状态显示 error | 初始化失败、依赖缺失、协议不匹配 | 看 containerd 日志、插件进程日志、proto 版本 |
| 容器创建卡住 | proxy plugin 响应超时、shim 启动失败 | 抓插件调用耗时、看 shim 日志、检查存储后端 |
| 容器退出状态不对 | shim 信号处理问题、退出码转发错误 | 对比 shim 日志和容器内进程退出码 |
| 事件订阅收不到消息 | namespace 不匹配、topic 写错 | 检查订阅参数、用ctr events对照 |
| 升级 containerd 后插件失效 | 接口 proto 变更、配置项废弃 | 对照 release notes、重新生成代码、验证配置 |
8.2 独家避坑经验
说几个文档里不会写、但实际很要命的点。
第一,proxy plugin 的 unix socket 路径权限。containerd 以 root 运行,你的插件进程如果以非 root 运行,socket 文件的权限可能导致 containerd 连不上。表现是插件状态一直 error,日志里是 permission denied。解决办法是 socket 文件权限设成 0660 并把 containerd 和插件进程放到同一个组,或者干脆都用 root(生产环境要评估)。
第二,内置插件升级时的接口兼容。containerd 的内部插件接口不像对外 API 那样有严格的兼容承诺,小版本升级也可能改接口。你写的内置插件在升级时一定要重新编译并跑一遍集成测试,别想当然地认为能直接用。我吃过这个亏,一次小版本升级后插件编译报错,排查发现是某个接口方法签名变了。
第三,事件订阅的背压处理。如果你的事件处理逻辑比较慢,而事件产生速度很快,客户端侧会积压。containerd 的事件流有缓冲,但缓冲满了可能丢事件或者阻塞。处理逻辑要做成异步队列,消费不过来时要有丢弃策略和告警,别让事件处理拖垮整个订阅连接。
第四,shim 的僵尸进程清理。自定义 shim 如果没处理好子进程回收,容器退出后会留下僵尸进程。这块要用好wait和信号处理,确保每个 fork 出来的进程都有对应的回收逻辑。排查时用ps aux | grep defunct能看到僵尸进程,结合 shim 日志定位是哪个环节漏了回收。
第五,配置热加载的误区。containerd 的大部分配置不支持热加载,改了要重启。但重启 containerd 不影响运行中的容器(因为 shim 独立),所以很多人以为可以随便重启。实际上重启期间新的容器操作会短暂不可用,生产环境要选低峰期操作,并做好健康检查。
8.3 调试工具与手段
调试 containerd 插件问题,几个工具是必备的。ctr是最直接的,ctr plugin ls看插件状态,ctr namespace ls看命名空间,ctr events看事件流。containerd --log-level debug启动能看到详细的插件初始化和调用日志。对于 proxy plugin,可以用grpcurl直接调插件进程的 gRPC 接口,绕过 containerd 验证插件本身是否正常。对于 shim,ctr task ls和ctr task ps能看任务和进程状态。
还有一个技巧:在插件实现里加一个 health check 接口,containerd 侧虽然不一定调用,但你自己可以用它快速判断插件进程是否健康。这个接口返回插件内部的关键状态,比如连接数、队列长度、最近错误,排查时非常有用。
9. 我个人的几点体会
containerd 的插件扩展,表面上看是几种技术模式的选择,实际上考验的是对“边界”的理解。哪些逻辑该放进 containerd 主进程,哪些该独立出去,哪些干脆旁路观察,这个判断做对了,后面的开发和维护都会顺很多。我见过太多项目,一开始图省事把所有逻辑塞进内置插件,结果半年后升级一次 containerd 就要全员加班。也见过过度设计,一个简单的审计需求搞了一套 proxy plugin 加消息队列,最后发现事件订阅十行代码就能搞定。
我的经验是:能用旁路解决的不用插件,能用 proxy 解决的不用内置,能用配置解决的不用代码。这个顺序不是绝对的,但它能帮你避开大部分过度设计的坑。真正需要写内置插件或者自定义 shim 的场景,其实比想象中少,一旦确定要写,就说明这个能力确实是核心且稳定的,那就值得投入。
另外一点是,containerd 的版本演进比较快,插件相关的接口和配置在不同版本间有变化。做扩展开发时,一定要锁定一个版本基线,把兼容性测试纳入 CI,别等到升级时才手忙脚乱。这块的投入,在项目初期看起来是额外成本,但长期看是省大钱的。
最后分享一个小技巧:如果你不确定某个扩展需求该用哪种模式,先用事件订阅做一个最小原型,跑通“感知”这一层,再评估是否需要升级到“干预”层。这个渐进式的路径,能让你在需求还不完全清晰的时候,用最低成本验证方向,避免一上来就选错模式、推倒重来。