containerd 镜像拉取与容器启动全链路解析:content store、snapshotter、shim 与 CRI 协作原理
1. 从一次镜像拉取超时说起containerd 到底在背后做了什么上周帮一位做后端的朋友排查问题他那边一个边缘节点上的容器起不来了报错信息只有一行failed to pull image: context deadline exceeded。他第一反应是网络问题换了几个镜像仓库地址、调大了超时时间折腾一下午没解决。我让他把crictl info和systemctl status containerd的输出发过来一眼就看到 containerd 的日志里反复出现content store相关的写入失败——磁盘 inode 满了。这件事让我意识到很多人天天敲docker run、天天写 Kubernetes 的 YAML却对中间那个真正干活的 containerd 缺乏基本认知一旦出问题就只能靠猜。这篇是系列的第二篇第一篇我们聊了 containerd 的架构分层和它跟 Docker、CRI 之间的关系算是把它是什么、站在哪讲清楚了。这一篇要往下钻讲它怎么把一件事真正做成镜像从远端拉下来之后怎么落地、content store 和 snapshotter 怎么配合、容器进程怎么被创建和托管、CRI 插件怎么把 Kubernetes 的请求翻译成 containerd 的原生调用。这些内容不是概念科普而是你排查问题、做性能调优、甚至自己写工具对接 containerd 时必须掌握的底层逻辑。适合谁来读如果你已经能熟练使用容器但遇到snapshot does not exist、content digest mismatch、task not found这类报错时只能重启大法那这篇就是给你准备的。如果你正在做容器运行时相关的开发或者要给内部平台做镜像分发、节点运维工具那这些原理更是绕不开的地基。我会尽量用这件事为什么这么设计的角度去讲而不是罗列 API 文档——文档你自己能查但设计意图和踩坑经验查不到。2. 镜像落地的全链路content store 与 snapshotter 的分工2.1 为什么镜像不能直接解压到磁盘先抛一个很多人没想过的问题为什么 containerd 不把拉下来的镜像直接解压成一个目录就完事早期的一些简易容器工具确实是这么干的但很快就撞墙了。原因有三层。第一层是复用。同一个基础镜像比如某个精简的运行时基础层会被几十上百个业务镜像共享如果每个镜像都完整解压一份磁盘会被瞬间吃光。第二层是一致性校验。镜像在传输过程中可能损坏必须有一个按内容寻址的机制保证我拿到的这一层就是我想要的那一层。第三层是并发与原子性。多个容器同时启动、同时拉同一个镜像时不能让它们互相踩踏也不能出现解压到一半被另一个进程读到的中间状态。containerd 的解法是把存内容和组织文件系统这两件事彻底拆开分别交给content store和snapshotter。这个拆分是整个镜像体系的设计基石理解了它后面所有报错你都能对上号。2.2 content store按摘要寻址的只读仓库content store 的本质是一个内容寻址存储Content Addressable Storage。你可以把它想象成一个巨大的、只增不改的仓库每个物件都有一个由内容本身算出来的身份证号——也就是 digest形如sha256:abc123...。你取东西的时候不报名字只报身份证号仓库按号取货取出来的内容必然和你期望的完全一致。这个设计带来几个直接好处。写入时containerd 先算 digest 再落盘落盘路径就是按 digest 组织的所以同一个 blob 无论被引用多少次物理上只存一份。读取时调用方拿着 digest 来要containerd 可以顺手再校验一遍杜绝了文件被意外篡改却没人发现的情况。删除时也有讲究——content store 里一个 blob 可能被多个镜像的 manifest 引用所以不能简单地删镜像就删 blob得靠引用计数或者垃圾回收GC来判断。实际目录结构大致是这样的路径因配置而异这里给的是常见默认布局/var/lib/containerd/io.containerd.content.v1.content/ ├── blobs/ │ └── sha256/ │ ├── 3f4a... # 某一层的压缩包 │ ├── 8b2c... # 某个 config 文件 │ └── c1d9... # 某个 manifest └── ingest/ └── ... # 正在写入、尚未提交的临时数据注意那个ingest目录这是很多人忽略的细节。镜像拉取不是下载完直接扔进 blobs而是先进ingest做流式写入和校验校验通过、写完整了才原子性地提交到blobs。这就解释了为什么拉取中断后不会留下半个损坏的 blob——没提交的东西根本不算数。我那位朋友遇到的 inode 满很大概率就是ingest里堆积了大量未完成或未清理的临时文件。提示排查 content store 相关问题时先看ingest目录的占用。异常中断的拉取任务如果没被正确清理临时文件会一直占着 inode表现为磁盘明明还有空间却写不进去。2.3 snapshotter把只读层叠成可写文件系统content store 存的是压缩的、只读的 blob但容器运行需要一个可读写的根文件系统。这中间的转换就靠 snapshotter。它的核心概念叫snapshot可以理解成某一时刻文件系统状态的一份快照。snapshotter 的工作模式是分层叠加。镜像的每一层对应一个只读 snapshot容器启动时在最上面再叠一个可写 snapshot所有写操作都落在最上层读操作则从最上层往下逐层找。这就是所谓写时复制Copy-on-Write的文件系统语义。不同的 snapshotter 实现overlayfs、btrfs、zfs、devmapper 等底层机制不同但对外暴露的抽象是一致的Prepare准备一个可写层、Commit把可写层固化成只读层、Mounts拿到挂载参数、Remove清理。以最常用的 overlayfs 为例一个容器的挂载参数大致长这样lowerdir/var/lib/containerd/.../snapshots/1/fs, /var/lib/containerd/.../snapshots/2/fs, /var/lib/containerd/.../snapshots/3/fs upperdir/var/lib/containerd/.../snapshots/4/fs workdir/var/lib/containerd/.../snapshots/4/worklowerdir是镜像的各只读层顺序很重要越靠前优先级越高upperdir是容器的可写层workdir是 overlayfs 内部用的工作目录。这里有个关键点snapshot 是有父子关系的树状结构不是平铺的列表。容器删除时只删它自己那个可写 snapshot底下的镜像层 snapshot 因为还被别的容器或镜像引用着会保留下来。这也是为什么删了容器磁盘没释放是常见困惑——释放的是可写层镜像层还在。2.4 两者的协作时序一次拉取到底发生了什么把上面两块拼起来一次完整的镜像拉取和容器启动时序大致是这样的解析引用containerd 拿到registry/repo:tag先向 registry 请求 manifest拿到 manifest 的 digest 和其中各层的 digest 列表。拉取 blob 到 ingest对每个还没在 content store 里的 blob开一个 ingest 写入流边下边算 digest下完校验通过则提交到blobs。解压并生成 snapshot对每一层snapshotter 的Prepare创建一个临时可写 snapshot把 blob 解压进去然后Commit成只读 snapshot并记录父子关系。组装镜像元数据把 manifest、config、各层 snapshot 的对应关系写进 metadata store通常是 bolt 数据库形成一个镜像对象。创建容器容器创建时snapshotter 基于镜像最顶层的 snapshot 再Prepare一个可写层Mounts拿到挂载参数交给运行时去挂载。这条链路上任何一环出问题报错信息都能精确定位。比如content digest mismatch是第 2 步校验失败snapshot does not exist是第 3 或第 5 步的父子关系断了failed to mount则是第 5 步挂载参数或底层文件系统的问题。我习惯把这五步画在脑子里看到报错先对号入座比盲目重启高效得多。3. 容器进程的托管task、shim 与 runc 的三方配合3.1 为什么需要一个 shim 夹在中间containerd 作为常驻的守护进程如果直接去fork容器进程会有一个致命问题容器进程成了 containerd 的子进程。这意味着 containerd 重启、升级、崩溃都可能牵连到所有容器反过来容器进程的退出信号、标准输入输出、僵尸进程回收也全都压在 containerd 身上。这在生产环境是不可接受的——守护进程要能独立升级容器要能独立存活。shim 就是为了解耦而生的。它是一个极轻量的中间进程由 containerd 启动但启动后就和 containerd 脱钩通过setsid之类的手段脱离会话成为容器进程的直接父进程。containerd 通过 gRPC 跟 shim 通信shim 再通过 runc 把容器真正跑起来。这样一来containerd 重启时shim 和容器都不受影响容器退出时由 shim 负责回收不会给 containerd 留僵尸。这个设计还有个隐含好处shim 是按容器或按 pod粒度存在的一个 shim 挂了只影响它托管的容器不会波及全局。而且 shim 的实现是可替换的标准的是containerd-shim-runc-v2如果你用别的运行时比如基于虚拟化技术的就换成对应的 shim。这种守护进程 - shim - 运行时的三段式是 containerd 架构里最值得称道的一处解耦。3.2 task 的生命周期从 Created 到 Deleted在 containerd 的 API 里容器container和任务task是两个概念。container 是元数据层面的东西——名字、镜像、配置、snapshot 引用task 才是进程层面的东西——真正跑起来的那个进程。这个区分很关键因为你可以创建一个 container 但不启动它没有 task也可以让 task 退出但 container 元数据还在方便查日志、看退出码。task 的状态流转大致是Created进程已创建但还没跑→Running跑起来了→Stopped退出了但还没清理→Deleted彻底清理。每一步都对应一个 API 调用也对应一类常见故障。Created卡住通常是 runc 在准备命名空间、挂载根文件系统时出了问题比如 snapshot 挂载失败、cgroup 创建失败。Running之后突然Stopped要看退出码和 shim 日志OOM、信号、程序自身崩溃都可能。Stopped但删不掉往往是 shim 进程僵死或者文件系统还有引用没释放。我处理过一个案例容器删了但Deleted一直失败最后发现是容器内某个进程fork出的子进程逃逸到了宿主机命名空间还占着挂载点——这种就得先找到并清理逃逸进程。3.3 runc 的职责边界只做创建不做管理很多人误以为 runc 是容器运行时就该负责容器的整个生命周期。其实 runc 的定位非常窄它只负责根据 OCI 规范把容器进程创建出来创建完就退出。容器的后续管理暂停、恢复、执行命令、收信号全靠 shim 通过其他机制完成。这个边界划得很清楚也很有必要。runc 每次创建容器都是一次性的短命进程用完即走不驻留、不持有状态这样它本身的 bug 或崩溃不会长期影响系统。而 shim 是长期驻留的负责和容器进程打交道。理解了这个分工你就明白为什么升级 runc相对安全它不常驻而升级 shim要谨慎会影响在跑的容器。runc 创建容器时干的事本质上是把 OCI 的config.json翻译成一系列系统调用设置命名空间clone带各种CLONE_NEW*标志、配置 cgroup、挂载根文件系统、设置 capabilities、pivot_root切换根目录、最后execve执行用户指定的命令。这里面任何一步失败都会体现在 task 创建失败上。所以排查Created阶段的问题思路就是对照 config.json 逐项检查系统环境。3.4 实操用 ctr 和 crictl 观察 task 状态理论讲完上手看。containerd 自带一个命令行工具ctr虽然它定位是调试工具、不适合生产直接用但观察内部状态非常方便。下面这条命令能列出所有 task 及其状态ctr -n k8s.io task ls输出里STATUS列就是 task 状态PID是宿主机上容器主进程的 PID。想看某个 task 的详细信息包括它对应的 shim 进程、挂载信息ctr -n k8s.io task info task-id如果你在 Kubernetes 环境更常用的是crictl它走的是 CRI 接口crictl ps -a # 看所有容器含已退出 crictl inspect id # 看容器详情 crictl logs id # 看容器日志这里有个实操心得ctr和crictl看到的容器不完全是一回事。crictl走 CRI看到的是 Kubernetes 视角的 sandbox 和 containerctr -n k8s.io直接看 containerd 的 namespace能看到更底层的 container 和 task。排查问题时两个都看往往能发现CRI 层以为容器还在但底层 task 已经没了这类不一致。注意ctr默认 namespace 是default而 Kubernetes 用的是k8s.io。不加-n k8s.io你会以为容器都不见了其实是看错了命名空间。这个坑我见过太多次。4. CRI 插件Kubernetes 请求如何被翻译成 containerd 调用4.1 CRI 存在的意义与 containerd 的接入方式Kubernetes 早期直接依赖 Docker 的 API但 Docker 的接口是给人和脚本用的不是给编排系统用的语义对不上、耦合太深。于是有了CRIContainer Runtime Interface一套专门给 Kubernetes 用的 gRPC 接口把运行时抽象成两个服务RuntimeService管 pod 和容器生命周期和ImageService管镜像。containerd 内置了一个 CRI 插件io.containerd.grpc.v1.cri它作为 containerd 的一个 gRPC 服务端接收 kubelet 发来的 CRI 请求翻译成 containerd 原生的 container/task/image 操作。这个插件是 containerd 能在 Kubernetes 生态里站稳脚跟的关键——它让 containerd 不需要为 Kubernetes 做任何特殊改造只要启用这个插件就能直接当 CRI 运行时用。配置上这个插件在 containerd 的config.toml里有一段专门的[plugins.io.containerd.grpc.v1.cri]可以配镜像仓库、配 sandbox 镜像、配各种超时和限流。很多人调优 containerd 其实就是调这一段但如果不理解 CRI 的语义调参数就是瞎调。4.2 sandbox 与 containerpod 在 containerd 里长什么样Kubernetes 的 pod 在 CRI 里对应一个PodSandbox在 containerd 里则对应一个特殊的 container task通常跑的是那个极简的 pause 镜像。这个 sandbox 容器的作用是占住命名空间——pod 内容器的网络、IPC 等命名空间都挂在 sandbox 上这样容器之间能共享网络pod 重建时命名空间也能保持稳定。所以一个 pod 在 containerd 里的实际结构是一个 sandbox containerpause 进程 N 个业务 container它们共享 sandbox 创建的网络命名空间。这个结构解释了很多现象。比如为什么 pod 里所有容器看到的是同一个 IP——因为网络命名空间是 sandbox 的。为什么删 pod 时偶尔会卡住——因为 sandbox 的清理要等所有业务容器先清理完顺序错了就卡。排查 pod 级别的问题第一步永远是确认 sandbox 状态。crictl pods看 sandbox 列表crictl inspectp pod-id看详情。如果 sandbox 起不来业务容器根本没机会启动这时候去看业务容器的日志是白费功夫。4.3 镜像拉取策略在 CRI 层的落地Kubernetes 的imagePullPolicyAlways、IfNotPresent、Never最终是在 CRI 插件里执行的。Always意味着每次都要向 registry 确认 digest即使本地有缓存IfNotPresent则先查本地 content store有就直接用。这里有个容易被忽略的点本地有没有判断的是 digest 级别的存在性不是 tag。举个例子你本地有myapp:v1对应的 digest Aregistry 上myapp:v1已经被重新推送成了 digest B。IfNotPresent策略下kubelet 认为本地有 v1直接用 digest A不会去拉 B。这就是为什么明明推了新镜像节点上跑的还是旧的——不是缓存没清是策略决定的。要强制更新要么用Always要么换 tag要么用 digest 引用。这个行为经常被误解成containerd 缓存有问题其实是 CRI 语义如此。镜像拉取的并发控制也在这一层。CRI 插件有拉取限流配置默认同时拉取的镜像数量是有限的。节点上大量 pod 同时启动时如果限流配得太小会出现镜像拉取排队导致启动慢配得太大又可能打爆 registry 或本地磁盘 IO。这个参数没有万能值得根据节点规格和 registry 能力实测。4.4 一个完整的 CRI 调用链拆解拿启动一个 pod这件事把调用链完整走一遍你会对整篇的内容有个串联的理解kubelet 通过 CRI 的RunPodSandbox请求创建 sandbox。CRI 插件先确保 pause 镜像存在走第 2 章的拉取链路然后创建 sandbox container 和 task走第 3 章的 shim/runc 链路。sandbox 起来后kubelet 通过CreateContainer请求创建业务容器CRI 插件为每个容器准备 snapshot、组装 OCI spec。kubelet 通过StartContainer启动task 进入 Running。整个过程的状态通过 CRI 的PodSandboxStatus、ContainerStatus回报给 kubelet。这条链上第 2 步失败表现为pod 一直 ContainerCreating第 3 步失败表现为容器创建失败第 4 步失败表现为容器起不来。每一段的排查手段不同第 2 步看 sandbox 和网络插件第 3 步看 snapshot 和镜像第 4 步看容器自身和 runc 日志。把这条链记熟Kubernetes 里 80% 的容器启动问题你都能快速定位到具体环节。5. 常见故障的排查路径与实战速查5.1 镜像相关digest 不匹配与拉取卡死content digest mismatch这个报错字面意思是算出来的 digest 和期望的不一致。原因通常有三类传输过程中数据损坏网络中间设备、磁盘坏块、registry 返回的 manifest 和实际 blob 对不上镜像推送不完整、本地 content store 里已有同名 digest 但内容被污染。排查顺序是先删掉本地对应的 blob 重新拉排除本地污染还不行就换节点拉排除单机磁盘问题再不行就是 registry 侧的问题得让镜像的推送方重新推。拉取卡死没有报错就是一直不完成更常见。先看ingest目录有没有在增长增长说明在下载只是慢检查到 registry 的网络带宽和 registry 自身的限流不增长说明卡在某个环节看 containerd 日志里最后一条相关记录。还有一种隐蔽情况拉取任务在 CRI 层已经超时放弃了但底层的 ingest 写入没被取消临时文件一直挂着——这就是开头我朋友遇到的 inode 问题的成因之一。5.2 snapshot 相关父子关系断裂与挂载失败snapshot does not exist通常意味着 metadata store 里记录的 snapshot 在磁盘上找不到或者父子链断了。常见诱因是手动清理了 snapshot 目录、磁盘故障导致部分文件丢失、或者 containerd 异常退出导致元数据和实际状态不一致。这种问题不能靠删了重建糊弄得先搞清楚是哪个镜像/容器的 snapshot 受影响评估影响范围。failed to mount则多半是 overlayfs 层面的问题lowerdir里的某一层路径不存在、workdir和upperdir不在同一个文件系统、底层文件系统不支持 overlay比如某些网络文件系统。overlayfs 对底层文件系统有要求upperdir所在的分区必须是支持d_type的ext4、xfs 都行但某些老文件系统不行。这个限制在选存储方案时就得考虑进去。5.3 task 相关创建失败与清理不掉task 创建失败先看 runc 的报错。runc 的日志默认不详细可以在 containerd 配置里调高日志级别或者手动用runc命令复现。常见的创建失败原因cgroup 层级创建失败cgroup v1/v2 混用、权限不足、命名空间创建失败内核参数限制、根文件系统挂载失败snapshot 问题、capabilities 配置非法。task 清理不掉Deleted一直失败八成是有进程还占着资源。用ctr task info拿到 PIDps看这个进程树里还有没有残留子进程用lsof或fuser看挂载点还被谁占着。极端情况下需要手动umount再清理但一定要先确认没有别的容器在用同一批 snapshot否则会误伤。5.4 速查表症状、可能原因与首选动作症状可能原因首选排查动作content digest mismatch传输损坏、registry 数据不一致、本地污染删本地 blob 重拉换节点验证拉取无进展、无报错网络慢、registry 限流、ingest 卡住看 ingest 目录增长、看 containerd 日志尾部snapshot does not exist元数据与磁盘不一致、手动误删定位受影响镜像/容器检查 snapshot 目录failed to mountoverlayfs 路径缺失、文件系统不支持检查 lowerdir/upperdir/workdir 与底层 fs 类型task 创建失败cgroup/命名空间/挂载/capabilities提高日志级别用 runc 手动复现task 删不掉残留子进程、挂载点被占ctr task info拿 PID查进程树和挂载占用pod 一直 ContainerCreatingsandbox 起不来、镜像拉取失败crictl pods看 sandbox再看镜像事件推了新镜像节点仍跑旧的IfNotPresent 按 digest 判断改策略、换 tag 或用 digest 引用5.5 几条踩坑换来的经验第一条别在生产节点上随手ctr删东西。ctr是调试工具它绕过 CRI 直接操作 containerd删了之后 kubelet 那边的状态不会同步容易出现kubelet 以为容器在、实际没了的诡异局面。要清理就走crictl或者让 kubelet 自己处理。第二条containerd 的日志级别要按需调别一直开着 debug。debug 级别日志量巨大高负载节点上能把磁盘写满反而制造新问题。排查时临时调高查完调回去。第三条升级 containerd 前先确认 shim 的兼容性。containerd 主进程升级相对平滑但 shim 版本和 containerd 版本之间有兼容矩阵跨大版本升级时在跑的容器可能受影响。稳妥做法是逐个节点排空drain后再升级别图省事批量操作。第四条磁盘规划要把 content store 和 snapshot 分开考虑。content store 是只读 blob增长相对可控snapshot 是解压后的文件系统体积可能是 blob 的好几倍而且容器的可写层会持续增长。很多磁盘莫名满了的案例最后都定位到 snapshot 目录而不是镜像本身。6. 把原理用起来几个可落地的调优方向理解了 content store、snapshotter、shim、CRI 这几层调优就有了抓手而不是对着配置文件瞎改。这里给几个我实际验证过有效的方向。镜像分发优化。如果节点多、镜像大可以考虑在集群内做镜像预热或者用 P2P 分发方案减少对中心 registry 的并发压力。containerd 侧能配合的是合理设置 CRI 的拉取并发数以及确保 content store 所在磁盘的 IO 能力跟得上。预热的核心思路就是提前把 blob 灌进 content store让真正启动时走本地命中。snapshotter 选型。overlayfs 是默认也是最通用的但如果你对快照、克隆有更高要求btrfs 或 zfs 的 snapshotter 在特定场景下性能更好尤其是大量相似容器的创建。代价是底层文件系统的运维复杂度上升。选型要看你的实际负载形态别为了先进而先进。shim 资源观察。shim 虽然轻量但容器数量多的时候几百个 shim 进程的内存和文件描述符占用也不容忽视。定期观察 shim 的数量和资源占用能提前发现泄漏类问题。正常情况下 shim 数量应该和运行中的容器数大致相当如果只增不减说明有 shim 没被正确回收。GC 策略。containerd 有定期的垃圾回收机制清理不再被引用的 content 和 snapshot。默认配置对大多数场景够用但在镜像更新极其频繁的环境里可以适当调整 GC 的触发条件和保留策略避免刚拉完就被回收或者该回收的迟迟不回收。这块要结合监控数据调没有通用答案。我个人在实际操作中的体会是containerd 这套东西的复杂度不在于单个组件多难而在于组件之间的边界和协作。content store 和 snapshotter 的边界、container 和 task 的边界、containerd 和 shim 的边界、CRI 和原生 API 的边界——每一条边界都是一次职责划分也都是一类故障的发生地。把这几条边界想清楚遇到问题就不会慌因为你知道该去哪个环节找答案。下一篇如果继续写我打算聊聊 containerd 的插件机制和自定义 snapshotter 的实现那部分对做平台开发的人会更有意思。