☰
containerd进阶拆解:架构、配置与生产排错实战
2026/10/26 13:33:02 网站建设 项目流程

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 shim,shim 再去调runc这类底层工具。每一层之间通过明确定义的接口通信,某一层出问题不会直接拖垮整个 containerd 主进程。这个思路和 Unix 的“小工具组合”哲学一脉相承,也是它能在生产环境站稳脚跟的根本原因。

我个人的理解是:containerd 更像一个“容器操作系统的内核”,它提供系统调用(gRPC API),具体的文件系统、进程管理、网络配置由各个“驱动”实现。你在配置里选 snapshotter、选 runtime,本质上就是在给这个内核挑驱动。

1.2 主进程、shim 与 runc 的三层关系

这是最容易混淆的地方,必须掰清楚。当你执行ctr run或者 kubelet 发起一个创建容器的请求时,实际发生的链路是这样的:

  1. containerd 主进程(containerd)收到 gRPC 请求,做参数校验、元数据记录、准备镜像和快照。
  2. 主进程 fork 出一个containerd-shim-runc-v2进程,把“启动并守护这个容器”的任务交给它。
  3. shim 进程调用runc二进制,runc根据 OCI runtime spec 创建 namespace、cgroup,exec 容器主进程,然后runc自己退出。
  4. 容器主进程被 shim 收养(re-parent),shim 成为它的父进程,负责收集退出码、转发信号、上报状态。

这个设计的关键在于: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 = "podruntime"

2.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 内部大致经历这些步骤:

  1. 解析引用:把registry.example.com/app:v1解析成 manifest 的 digest。这一步要访问仓库的 manifest 接口。
  2. 拉取 manifest:拿到 manifest 后,里面列出了 config 和各个 layer 的 digest。
  3. 并发拉取 layer:containerd 会并发下载所有 layer,每个 layer 下载完先校验 digest,再写入 content store。content store 是按 digest 寻址的,同样的内容只存一份,这就是为什么多个镜像共享基础层时不会重复占空间。
  4. 解压并生成快照:layer 是压缩的 tar 包,snapshotter 负责把它解压并 prepare 成快照。overlayfs 下,每个 layer 对应一个只读目录,最终合并挂载。
  5. 创建容器:基于快照创建可写层,生成 OCI spec,交给 shim 和 runc 启动。

这个链路里,content store 和 snapshotter 是两套存储。content store 存的是压缩的原始 blob(按 digest),snapshotter 存的是解压后的文件系统层。所以你会看到磁盘上既有/var/lib/containerd/io.containerd.content.v1.content又有/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs,两者加起来才是完整占用。清理镜像时如果只删了一边,另一边会变成孤儿数据,白占空间。

提示:ctr content ls能看到所有 content blob,ctr 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 日志,先按这个顺序走:

  1. 看 Pod 事件:kubectl describe pod里的事件往往直接告诉你卡在哪一步,是拉镜像、挂卷还是启动容器。
  2. 看 kubelet 日志:kubelet 会记录它和 CRI 的交互,能看出请求发出去没有、containerd 返回了什么。
  3. 看 containerd 日志:journalctl -u containerd里找对应时间点的错误。常见的有镜像拉取超时、snapshot 创建失败、runtime 启动失败。
  4. 看 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。如果设成false,containerd 直接写 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 ctrk='ctr -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 在特定负载下的性能差异,那些是更细的优化空间了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询