资讯动态

containerd进阶拆解:架构、配置与生产排错实战

发布时间:2026/10/26 13:33:15 来源:尧图企业网站定制
1. 从“能跑起来”到“跑得明白”containerd 进阶拆解很多人第一次接触 containerd 是在装 Kubernetes 的时候kubelet 启动日志里蹦出一行Container runtime ... containerd://1.x.x然后就没再多看一眼。等到容器起不来、镜像拉不下来、Pod 卡在ContainerCreating才开始回头翻文档发现 containerd 的配置文件藏在/etc/containerd/config.toml里一堆 TOML 字段看得头皮发麻。这篇内容就是写给已经用过 containerd、但还没真正把它“拆开看过”的人——我会把 containerd 的内部结构、核心组件协作方式、配置项背后的逻辑、以及实际运维中踩过的坑一条一条摊开讲。读完你应该能做到看到一个 containerd 相关的报错能大致判断问题出在哪个环节需要调优或改配置时知道每个参数动了会影响什么遇到镜像、快照、运行时插件的问题有清晰的排查路径。这不是入门安装教程而是把 containerd 当成一个需要被理解、被调教的系统组件来对待。containerd 本质上是一个容器运行时管理器注意“管理”两个字。它自己不直接创建容器进程而是把“拉镜像、解压层、挂快照、调底层运行时、管生命周期”这些活儿拆给不同的子模块去干自己当调度中枢。这个设计决定了它的架构一切皆插件一切通过 gRPC 通信。理解了这一点后面所有的配置和排错都有了落脚点。1.1 为什么 containerd 要拆成这么多组件先想一个问题如果让你设计一个容器运行时你会怎么做最直觉的做法是一个大进程收到“起容器”的请求就拉镜像、解压、调 namespace 和 cgroup、exec 进程一条龙干完。早期的一些运行时确实这么干但问题很快暴露镜像格式在变、底层隔离技术在变、存储驱动在变任何一处改动都可能牵动整个进程稳定性没法保证。containerd 的选择是分层解耦。它把职责切成几块镜像和内容管理交给content store和metadata store文件系统层交给snapshotter真正跑进程的活儿交给runtime shimshim 再去调runc这类底层工具。每一层之间通过明确定义的接口通信某一层出问题不会直接拖垮整个 containerd 主进程。这个思路和 Unix 的“小工具组合”哲学一脉相承也是它能在生产环境站稳脚跟的根本原因。我个人的理解是containerd 更像一个“容器操作系统的内核”它提供系统调用gRPC API具体的文件系统、进程管理、网络配置由各个“驱动”实现。你在配置里选 snapshotter、选 runtime本质上就是在给这个内核挑驱动。1.2 主进程、shim 与 runc 的三层关系这是最容易混淆的地方必须掰清楚。当你执行ctr run或者 kubelet 发起一个创建容器的请求时实际发生的链路是这样的containerd 主进程containerd收到 gRPC 请求做参数校验、元数据记录、准备镜像和快照。主进程 fork 出一个containerd-shim-runc-v2进程把“启动并守护这个容器”的任务交给它。shim 进程调用runc二进制runc根据 OCI runtime spec 创建 namespace、cgroupexec 容器主进程然后runc自己退出。容器主进程被 shim 收养re-parentshim 成为它的父进程负责收集退出码、转发信号、上报状态。这个设计的关键在于containerd 主进程重启不会杀掉正在运行的容器。因为容器的父进程是 shim不是 containerd。主进程挂了shim 还在容器照跑主进程恢复后通过 shim 的 socket 重新接管。这是生产环境里 containerd 能平滑升级的底层保障。很多人不知道这一点升级 containerd 时战战兢兢其实只要 shim 版本兼容运行中的容器基本不受影响。注意shim 和 containerd 主进程的版本要匹配。跨大版本升级时老 shim 可能不被新主进程识别这时候运行中的容器会变成“孤儿”虽然还在跑但 containerd 管不到它了。升级前务必确认版本兼容矩阵。2. 配置文件里的每一个字段都不是摆设/etc/containerd/config.toml这个文件很多人是复制粘贴来的改个sandbox_image就完事。但真正出问题的时候往往就是某个没注意的字段在作祟。这一章我把几个高频、高影响的配置块拆开讲说清楚每个字段动了会发生什么。2.1 version 与插件配置的整体结构新版 containerd 的配置采用version 2的格式和老的 v1 结构差异很大。v2 的核心变化是插件化配置每个插件有自己的配置段通过[plugins.io.containerd.xxx.v2]这样的表来声明。这个命名不是随便起的io.containerd.xxx是插件的类型标识v2是插件版本。你改配置时如果表名写错一个字符containerd 启动时不会报错而是默默用默认值——这是最坑的地方。我建议每次改完配置用containerd config dump把最终生效的配置打印出来核对一遍。这个命令会把所有默认值和你的覆盖值合并后输出能直接看出你的修改有没有被识别。实测下来至少三成的“配置不生效”问题都是表名拼错或者层级放错导致的。2.2 snapshotter 选型overlayfs 之外还有什么[plugins.io.containerd.snapshotter.v1.overlayfs]是绝大多数场景的默认选择因为 overlayfs 在主流内核上性能好、实现成熟。但它不是唯一选项理解 snapshotter 的作用才能选对snapshotter适用场景关键特点overlayfs通用场景内核支持 overlay多层合并写时复制性能好native调试、不支持 overlay 的环境每层完整拷贝占空间大但行为最直观btrfs底层用 btrfs 文件系统利用子卷和快照节省空间devmapper需要块设备隔离的场景基于 thin pool隔离性强配置复杂选 overlayfs 的理由很直接容器镜像天然是分层的overlayfs 的“下层只读、上层可写、合并挂载”模型和镜像层结构一一对应启动容器时不需要拷贝整个镜像只挂一个可写层秒级启动。而 native snapshotter 每层都完整解压一份一个 1GB 的镜像起十个容器就是十份拷贝磁盘直接爆掉。但 overlayfs 有个经典限制它要求下层目录不能被修改。所以当你在容器里改一个属于镜像层的文件时overlayfs 会触发 copy-up把文件先复制到可写层再改。这个 copy-up 对大文件是有开销的第一次修改一个几百 MB 的文件会明显卡顿。知道这个原理你就明白为什么有些场景比如频繁改大配置文件的容器会感觉“第一次写特别慢”。2.3 runtime 配置与 runc 参数调优[plugins.io.containerd.grpc.v1.cri.containerd.runtimes.runc]这一段是 CRI 场景下的运行时配置runtime_type一般写io.containerd.runc.v2。这里有几个值得关注的字段SystemdCgroup这个必须重点说。设为true时containerd 用 systemd 来管理 cgroup设为false时自己直接写 cgroupfs。在 systemd 作为 init 的系统上强烈建议设为 true。原因是 systemd 和直接写 cgroupfs 两套机制并存时会出现 cgroup 被两边同时管理、互相覆盖的诡异问题表现为容器资源限制不生效或者容器莫名被杀。这个坑我踩过不止一次日志里看不出明显错误就是行为不对。NoPivotRoot默认 false表示用 pivot_root 切换根文件系统。某些老内核或特殊文件系统上 pivot_root 会失败这时才考虑设 true 改用 chroot但安全性会下降非必要不动。ShimCgroup把 shim 进程放进独立的 cgroup便于资源隔离和统计。生产环境建议配置避免 shim 进程和业务容器抢资源。配置示例这是常见实践的合理组合不是唯一答案[plugins.io.containerd.grpc.v1.cri.containerd.runtimes.runc] runtime_type io.containerd.runc.v2 [plugins.io.containerd.grpc.v1.cri.containerd.runtimes.runc.options] SystemdCgroup true ShimCgroup podruntime2.4 镜像仓库与拉取策略配置[plugins.io.containerd.grpc.v1.cri.registry]这一块管镜像仓库的认证、镜像加速和 TLS 配置。实际运维中最高频的需求是给某个私有仓库配认证或者给公共仓库配镜像源。配置结构是registry.configs.仓库地址.auth和.tls。这里有个容易忽略的点仓库地址的匹配是精确匹配加路径前缀匹配。你给registry.example.com配了认证对registry.example.com/team/app是生效的但如果你配的是registry.example.com/team那registry.example.com/other就不生效。配置时地址要写全别想当然。另外config_path字段指向一个目录里面按仓库地址分目录存放hosts.toml这是更灵活的配置方式适合仓库多、配置复杂的场景。两种方式可以共存优先级上config_path里的配置会覆盖主配置里的同名项。3. 镜像、快照与内容存储的实操链路理解了配置接下来看数据是怎么流动的。containerd 里“镜像”不是一个实体文件而是一组元数据加上一堆内容块content blob。这个设计初看绕但它是镜像分层复用和并发拉取的基础。3.1 从 pull 到 run一次完整的镜像旅程执行一次镜像拉取containerd 内部大致经历这些步骤解析引用把registry.example.com/app:v1解析成 manifest 的 digest。这一步要访问仓库的 manifest 接口。拉取 manifest拿到 manifest 后里面列出了 config 和各个 layer 的 digest。并发拉取 layercontainerd 会并发下载所有 layer每个 layer 下载完先校验 digest再写入 content store。content store 是按 digest 寻址的同样的内容只存一份这就是为什么多个镜像共享基础层时不会重复占空间。解压并生成快照layer 是压缩的 tar 包snapshotter 负责把它解压并 prepare 成快照。overlayfs 下每个 layer 对应一个只读目录最终合并挂载。创建容器基于快照创建可写层生成 OCI spec交给 shim 和 runc 启动。这个链路里content store 和 snapshotter 是两套存储。content store 存的是压缩的原始 blob按 digestsnapshotter 存的是解压后的文件系统层。所以你会看到磁盘上既有/var/lib/containerd/io.containerd.content.v1.content又有/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs两者加起来才是完整占用。清理镜像时如果只删了一边另一边会变成孤儿数据白占空间。提示ctr content ls能看到所有 content blobctr snapshots ls能看到所有快照。排查“磁盘被谁吃了”时这两个命令比docker images之类的上层工具更接近真相。3.2 快照的生命周期与垃圾回收快照有三种状态View只读视图、Active可写正在被容器使用、Committed已提交作为新层的基础。容器运行时用的是 Active 快照容器删除后快照会被标记删除但实际磁盘回收依赖 GC。containerd 的 GC 是基于引用计数的每个对象content、snapshot、image、container都有引用关系GC 扫描时把没有引用的对象清理掉。默认 GC 是定时触发的也可以手动ctr leases相关操作干预。这里有个实操经验删了镜像但磁盘没释放多半是还有容器或快照引用着它。用ctr images ls看镜像还在不在用ctr snapshots ls看有没有残留的 Active 快照顺着引用链找基本都能定位。还有一种情况拉取镜像中途失败会留下不完整的 content blob。这些 blob 没有 manifest 引用正常 GC 会清掉但如果 GC 被禁用或频率太低就会堆积。生产环境建议保持默认 GC 开启别为了“性能”把它关了省下的那点 CPU 远不够填磁盘的坑。3.3 命名空间隔离为什么 ctr 看不到 k8s 的容器第一次用ctr的人都会懵明明 kubelet 起了那么多容器ctr containers ls却是空的。原因是 containerd 有命名空间namespace概念kubelet 用的是k8s.io命名空间ctr默认用default。加-n k8s.io就能看到了ctr -n k8s.io containers ls ctr -n k8s.io images ls ctr -n k8s.io tasks ls这个设计是为了多租户隔离不同命名空间的对象互相不可见避免误操作。理解这一点很多“命令查不到东西”的困惑就解开了。我习惯在排查时先ctr namespace ls确认有哪些命名空间再进对应的空间操作比瞎猜高效得多。4. 生产环境排错实录与避坑清单前面讲的是“应该怎样”这一章讲“实际会怎样”。下面这些场景都是真实运维中反复出现的我把排查思路和解决方法整理成可复用的路径。4.1 容器卡在 ContainerCreating 的排查顺序这是最高频的问题。别急着看 containerd 日志先按这个顺序走看 Pod 事件kubectl describe pod里的事件往往直接告诉你卡在哪一步是拉镜像、挂卷还是启动容器。看 kubelet 日志kubelet 会记录它和 CRI 的交互能看出请求发出去没有、containerd 返回了什么。看 containerd 日志journalctl -u containerd里找对应时间点的错误。常见的有镜像拉取超时、snapshot 创建失败、runtime 启动失败。看 shim 日志如果容器进程起来了但很快退出问题在 shim 或 runc 层日志里会有 OCI runtime 相关的错误。这个顺序的逻辑是从外到内先确认是调度/卷的问题还是运行时的问题再逐层深入。跳过前面直接看 containerd 日志经常会被大量无关信息淹没。4.2 镜像拉取失败的几类典型原因现象可能原因排查动作x509: certificate signed by unknown authority私有仓库证书不被信任检查registry.configs里的ca_file配置401 Unauthorized认证信息缺失或错误核对auth配置确认用户名密码/token 正确context deadline exceeded网络不通或仓库响应慢从节点上直接 curl 仓库地址测试连通性manifest unknown镜像 tag 不存在或仓库路径写错用ctr image pull手动拉一次看详细报错no space left on device磁盘满检查 content 和 snapshotter 目录占用清理无用镜像这张表我建议贴在运维手册里。实际排查时报错信息的第一行往往就对应了原因关键是别被后面的堆栈吓到抓住第一行。4.3 资源限制不生效的隐藏原因给容器配了 CPU/内存限制但docker stats或者 cgroup 里看到的数值不对甚至容器能超用。这种情况八成是SystemdCgroup配置和系统环境不匹配。前面说过systemd 系统上应该设true。如果设成falsecontainerd 直接写 cgroupfs而 systemd 可能同时在管理同一个 cgroup 路径两边打架限制就被覆盖了。验证方法很直接进到容器的 cgroup 目录看cpu.max、memory.max的实际值和配置对比。如果对不上先查SystemdCgroup再查 kubelet 的cgroupDriver是否和 containerd 一致。这两个必须统一一个用 systemd 一个用 cgroupfs必然出问题。4.4 磁盘占用异常的定位技巧containerd 的磁盘占用分三块content压缩 blob、snapshotter解压层、metadata元数据库。异常增长时按这个顺序查du -sh /var/lib/containerd/io.containerd.content.v1.content看 content 大小。du -sh /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs看快照大小。如果 content 大但镜像不多可能是拉取失败的残留 blob手动触发 GC 或ctr content prune。如果 snapshotter 大看是不是有大量 Active 快照没释放对应容器可能已经异常退出但没清理干净。我遇到过一次 snapshotter 目录涨到几百 GB最后发现是某个容器反复重启每次重启都留下一个没提交的 Active 快照日积月累堆起来的。后来加了监控对 Active 快照数量做告警这类问题就能提前发现。4.5 升级 containerd 时的注意事项升级前必须确认三件事shim 版本兼容性、配置文件格式是否变化、运行中容器的状态。跨大版本时配置文件结构可能变直接拿老配置启动新版本会报错或者静默用默认值。稳妥的做法是先在新环境用新版本跑一遍containerd config default生成基准配置再把老配置里的自定义项逐条迁移过去迁移完用config dump核对。升级过程中运行中的容器因为 shim 还在通常不受影响但新建容器会短暂失败因为主进程在重启。生产环境建议在维护窗口做或者用滚动方式逐节点升级别一次性全升。5. 我个人的几条实操心得最后分享几个文档里不太会写、但实际用起来很省事的经验。第一养成用ctr -n k8s.io的习惯。排查 k8s 相关问题时所有 ctr 命令都带上这个命名空间能省掉大量“怎么查不到”的困惑。可以设个 alias比如alias ctrkctr -n k8s.io效率提升明显。第二改配置前先备份改完必 dump。cp config.toml config.toml.bak是基本操作改完containerd config dump /tmp/effective.toml核对生效值。这个习惯帮我避免过好几次“以为改了其实没生效”的尴尬。第三GC 别乱关但可以调频率。默认 GC 间隔对大多数场景够用如果确实觉得 GC 影响性能调大间隔而不是关掉。关掉 GC 的代价是磁盘迟早出问题得不偿失。第四日志级别按需调别长期开 debug。[debug] level debug能打出详细信息但日志量巨大长期开着既占磁盘又影响性能。排查时临时开查完改回info。containerd 这个组件用熟了会发现它的设计其实很克制每个模块只做一件事通过清晰的接口组合起来。理解了这层结构配置和排错就不再是背文档而是顺着数据流和调用链去推理。后续如果还想深入可以看看 CRI 插件和 kubelet 的交互细节以及不同 snapshotter 在特定负载下的性能差异那些是更细的优化空间了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑