☰
containerd容器生命周期管理:状态流转、工具选择与生产排障要点
2026/10/5 3:14:57 网站建设 项目流程

刚接触 containerd 的那阵子,我把 containerd 理解成“一个轻量一点的 Docker”,直到在线上排查一个容器反复退出的问题,才意识到 containerd 的容器生命周期管理跟 Docker 脑中的那套模型根本不是一回事。容器对象、任务、快照、内容存储、事件流,这些拆开之后,你能准确说出一个问题到底发生在哪一层。这篇就来拆一下 containerd 的容器生命周期管理:状态怎么流转、命令怎么用、资源怎么回收、生产中哪些坑最常见。适合两种人看:一种是想摆脱 Docker 使用习惯、直接和 containerd 对话的运维;另一种是每天在 Kubernetes 上看到容器状态,却不知道它和 runc 之间到底发生了什么。

1. 先厘清 containerd 眼中的生命周期:容器对象和 Task 不是一个东西

第一次用ctr查容器的时候,我很容易被“容器还在不在”这个问题困住。因为在 Docker 里,docker ps直接告诉你运行状态,而在 containerd 里,你至少要看两个层面的东西:容器对象(container)和任务(task)。这两个不是同一件事,生命周期管理里一半的混乱都来源于没分清它们。

1.1 容器对象层:一份元数据,不代表进程在跑

ctr container create会创建一个容器对象,但这时候没有任何进程被拉起。它更像往数据库里登记了一条记录:这个容器的名字是什么、用哪个镜像、挂载哪些目录、环境变量是什么、网络配置是什么。真正的进程、cgroup、namespace 都还没创建。

ctr containers create docker.io/library/nginx:1.27-alpine nginx-demo

执行完这行命令,如果你马上去看进程列表,找不到 nginx 的 master/worker 进程。但ctr containers ls里已经能看到它。这个设计是为了让上层编排系统可以“先登记、后启动”,比如 Kubernetes 集群在节点上准备沙箱、预创建一堆对象,再按调度时机启动。

用生活类比:容器对象像身份证,task 像这个身份证名下那个真正在呼吸的人。身份证办了,不代表人一定站在你面前。

1.2 Task 层:真正被 runc 拉起的运行体

task 才是 containerd 里真正“活着”的那层。它由 containerd 调用低层 runtime(默认是 runc)创建出来,负责管理进程、cgroup、文件挂载和退出码。启动一个 task 用的是:

ctr tasks start nginx-demo

你会在输出里看到类似nginx-demo已经启动的信息,之后再去ps就能看到 nginx 进程了。这里有个容易被忽略的细节:task 创建后,containerd 会拉起一个containerd-shim进程来托管这个 task。shim 的存在非常重要,它让 containerd 主进程和 runc 进程之间有了一个“持久化中间层”,即使 containerd daemon 重启,已运行的容器也不会立刻被杀掉,只是 daemon 对它们的监控会短暂中断。

所以生产环境里那句“别随便重启 containerd”背后,就是这个原因:容器不一定会死,但你的可观测性会断档。

1.3 一张状态表看懂生命周期

不同工具对状态的叫法略有不同,但 containerd 底层 task 的状态基本是这几个:

状态含义你能做什么
CREATEDtask 已创建但未启动可以启动,也可以删除
RUNNING主进程正在运行,cgroup 已挂上可以 exec、pause、kill
PAUSED进程被冻结,CPU 不再执行可以 resume,也可以 kill
STOPPED主进程退出或被杀,退出码已记录可以删除 task,或读取退出信息

看状态要分清工具。ctr containers ls看的是对象元数据,不能直接反映运行状态;ctr tasks ls看的是当前所有 namespace 下的 task 状态。Kubernetes 节点上更常用的是crictl ps -a,它走 CRI 插件把容器和 sandbox 的关系也带了出来,后面的章节会展开。

2. 工具链怎么选:ctr、crictl、nerdctl 各自负责哪一段生命周期

很多人在 containerd 上栽跟头,不是不会用某个命令,而是不知道什么场景该用哪个客户端。containerd 本身只暴露一套 gRPC API,市面上常见的命令都是它的客户端,但视角完全不同。

2.1 ctr:离 containerd 内部世界最近的命令

ctr是 containerd 自带的命令行客户端,安装 containerd 二进制后一般跟着就有了。它不经过 CRI 插件,直接操作 containerd 的底层 API。这意味着它能看到镜像内容、快照、内容存储、租约这些“更底层”的东西,适合做实验、排查存储问题、理解 containerd 内部机制。

我常用它看三层:

ctr images ls ctr containers ls ctr tasks ls

但它不适合给不懂 containerd 的同学当日常工具,因为命令风格很原始,很多地方要手动拼参数。比如ctr run不是 Docker 的docker run,它不支持一整套 restart policy,也不会像 Docker 那样帮你管理网络。用ctr手动创建容器时,如果你的目标只是“跑个 nginx 看看”,完全没问题;但如果你想复刻docker-compose那样的体验,ctr会很快让你抓狂。

2.2 crictl:Kubernetes 节点上的首选

crictl是 CRI(Container Runtime Interface)的调试工具。它和 kubelet 走的是同一条 CRI 通道,所以你在 Kubernetes 节点上排查容器,应该第一时间想到它,而不是用ctr去翻底层状态。

crictl的视角和 K8s 的世界是对齐的:它先看 Pod(在 CRI 里叫 sandbox),再看容器。K8s 使用 containerd 时,业务容器通常跑在一个叫pause的沙箱容器里,每个 Pod 有一个 sandbox,Pod 里的业务容器都挂在 sandbox 下。所以你会经常看到节点上有大量pause容器,它们是沙箱基础设施,不是失控的僵尸容器。

常用命令:

crictl ps -a crictl logs -f <container-id> crictl exec -it <container-id> /bin/sh crictl stop <container-id> crictl rm -f <container-id>

这里有个核心认知:crictl的生命周期动作最终也要调到 containerd,但它操作的对象是 CRI 会话里的容器,和 containerd 原生 API 里的容器对象不是一对一对应关系。K8s 里“容器退出了,但 kubelet 还在按照 restart policy 拉起新的实例”,这些逻辑你从crictl里能看到现象,但重启动作的决策在 kubelet,不在 containerd。

2.3 nerdctl:给想用 Docker 习惯的人

如果你确实是从 Docker 迁过来的,直接上ctr可能有点反人类。nerdctl是 containerd 社区维护的一个类 Docker 命令行工具,支持nerdctl run、nerdctl stop、nerdctl ps、甚至nerdctl compose up。它本质上还是走 containerd API,但把很多细节包装好了。

它背后也会用到 CRI 插件或 containerd 原生 API,取决于你配置的 endpoint。对我个人来说,单机测试和离线部署脚本用nerdctl最舒服,线上 K8s 节点排障则用crictl,看 containerd 内部存储和垃圾回收时再切ctr。三个工具不是竞争关系,而是不同层次的翻译器。

3. 亲手跑一遍:用 containerd 命令完成容器从创建到销毁的全过程

光说概念没用,我把一条完整的生命周期链路走一遍。这里不涉及 Kubernetes,就用 containerd 原生 API 来操作一个 nginx 容器,从拉镜像一直走到清理。

3.1 准备镜像和容器对象

第一步拉镜像:

ctr images pull docker.io/library/nginx:1.27-alpine

拉取完成后,ctr images ls里能看到这个镜像。注意这里要写全镜像地址,如果你只写nginx:1.27-alpine,部分版本的 ctr 可能无法正确识别默认仓库地址。

第二步创建容器对象:

ctr containers create docker.io/library/nginx:1.27-alpine nginx-demo

创建时不指定网络、环境变量,就先得到一个“登记在案”的容器。如果想带上环境变量或挂载,可以在 create 时加参数:

ctr containers create \ --env "NGINX_PORT=8080" \ --mount type=bind,src=/data,dst=/data,options=rbind \ docker.io/library/nginx:1.27-alpine nginx-demo

这时候容器对象有了,但是 task 没有,nginx 进程更没有。

3.2 把容器跑起来:task start 与 run 的区别

启动 task:

ctr tasks start nginx-demo

然后看状态:

ctr tasks ls

输出里能看到nginx-demo对应的 PID 和状态,比如RUNNING。这个时候再用ps看系统进程,nginx 就在里面了。

如果你嫌两步太麻烦,可以用ctr run一步搞定:

ctr run -t docker.io/library/nginx:1.27-alpine nginx-demo

ctr run实际上是“创建容器对象 + 创建 task + 启动 task”的快捷方式。生产排障我反而不太推荐它,因为它把中间步骤藏起来了,一旦失败你很难判断是镜像问题、快照问题还是 task 启动问题。手动拆成 create 和 start,每一步都有明确报错。

3.3 进容器调试:exec 和 attach 的正确打开方式

容器跑起来后,经常需要进去看文件、看进程、临时改配置。ctr里对应的是exec,但语法和 Docker 不一样,它需要一个--exec-id:

ctr tasks exec --exec-id nginx-debug nginx-demo /bin/sh

不同版本对参数顺序可能有差异,拿不准时先执行ctr tasks exec --help确认。exec-id 可以理解成“这次进去调试的连接名”,同一个容器里可以同时开多个,每个都有独立 id。

如果你想看容器当前打到 stdout/stderr 的内容,ctr tasks attach可以把 I/O 接到当前终端,但注意它和 Docker 的docker logs不是一回事。containerd 本身默认不帮你持久化日志,日志落盘是 CRI 插件和上层编排器的事。所以 K8s 节点上你直接用crictl logs,底层 ctr 只适合临时 attach 看输出。

3.4 暂停、停止、删除:按顺序清理

生命周期管理里最容易被忽视的是“停止和删除不是一步到位”。

暂停和恢复:

ctr tasks pause nginx-demo ctr tasks resume nginx-demo

暂停时容器进程还在内存里,但 CPU 执行被冻结,状态显示为PAUSED。注意暂停并不是关闭,网络连接、文件句柄都还占着。

停止一个运行中的 task 通常先发信号:

ctr tasks kill --signal SIGTERM nginx-demo

等任务退出后,删除 task:

ctr tasks delete nginx-demo

最后删除容器对象:

ctr containers delete nginx-demo

为什么 task 和容器要分开删?因为含运行状态的 task 是一个运行体,删除它会带走进程、cgroup、shim;容器对象只是元数据,删不删它不影响进程本身。如果你先把容器对象删了,task 还残留,后续清理起来更别扭。我在生产里见过因为顺序不对导致 cgroup 残留的例子,所以建议流程固定为:kill -> delete task -> delete container。

4. 状态机里容易误判的几个环节:退出后的容器、重启策略、事件流

生命周期不只是“启动/停止”,还有各种中间态。我把几个最容易踩坑的环节单独拿出来讲,因为它们会直接影响你对“容器到底死没死”的判断。

4.1 进程退出不等于容器对象消失

容器里主进程退出了,task 会进入STOPPED状态,但容器对象只要没人删,它还是在的。这意味着你用ctr containers ls可能看到容器“还在”,但ctr tasks ls显示它是STOPPED。在 K8s 里对应现象就是crictl ps -a里容器显示Exited,但 Pod 可能还在,因为 kubelet 正在根据策略决定是否重新拉起。

很多人在这个环节翻车,是因为直接用ctr containers ls判断“容器是否在运行”。正确姿势是看 task 状态,或者看crictl ps不加-a时的输出——只有运行中的容器才会出现在不带-a的结果里。

4.2 重启是编排层的事:Docker、Kubernetes 与 containerd 的分工

我用ctr用了很久之后才想明白一件事:containerd 自己不做“失败自动重启”。它更像一个精确执行指令的底层引擎,你让它启动它就启动,让它停止它就停止,进程退出了它就记录一个退出码,仅此而已。

Docker 里的restart: always是 Docker daemon 层的策略,不是 runc 或 containerd 的。Kubernetes 里严格的RestartPolicy和CrashLoopBackOff也是 kubelet 在控制。containerd 只负责把容器的创建、执行、销毁做对,至于要不要拉起一个新副本、等待多久再拉起,那是上层的大脑。

所以别指望在命令行手工ctr run一个服务就能拥有 Docker 一样的自动重启体验。如果你在 K8s 环境里想管理生命周期,应该通过kubectl或crictl看整体状态,而不是在节点上直接拿ctr去删容器。直接删 containerd 里的容器对象,kubelet 检测到后再重建,中间会有一段不可控的间隔。

4.3 订阅事件:生产里不用每秒轮询状态

生产环境监控容器生命周期,不要依赖轮询。containerd 对外提供事件流,容器创建、删除、task 退出、OOM、镜像拉取、快照提交等动作都会发出事件。命令行可以这样订阅:

ctr events

实际生产里更常见的是通过 containerd 客户端库订阅,然后把task.exit、task.oom之类的事件汇入监控系统。这样在容器异常退出时,你能拿到接近实时的信号,而且能结合事件里的容器 id、namespace、退出码做自动化补偿。

我自己做过一个小工具,专门监听现场节点上的 task exit 事件,把退出原因和当前 kubelet 的重启次数关联起来。这个思路比每 5 秒跑一次crictl ps高效得多,尤其在节点上容器数量大的时候,差一个量级。

5. 生命周期背后真正吃资源的部分:Content Store、Snapshot 和 GC

很多人只关注“容器起来/下去”,但生命周期管理绕不开存储。镜像层、可写层、快照、垃圾回收,这些才是生产环境里磁盘告警的真正来源。认识 containerd 的人体结构,必须知道它除了进程,还有一套内容存储系统。

5.1 生命周期第一步其实是内容存储和快照

ctr images pull拉下来的镜像,并不是简单解压到一个目录就完事。containerd 会把镜像的所有层拆成一个个 blob 存进 content store,然后由 snapshotter 把层组合成容器的 rootfs。

可以看这两层:

ctr content ls ctr snapshots ls

content存的是镜像内容,比如每一层 tar 包的压缩数据;snapshot则是运行时需要的 rootfs 视图。默认的 overlayfs snapshotter 会把镜像层作为只读 lowerdir,在容器创建时叠加一个可写 upperdir。所以同一个镜像启动多个容器,底层只读层可以被共享,磁盘占用不会成倍增加。

这就是为什么 containerd 的“容器生命周期”不是孤立的进程管理,它和镜像、快照是一根链条。容器创建时,containerd 需要根据容器对象引用的镜像,准备一套对应 snapshot;启动 task 时,再把这个 snapshot 作为 rootfs 挂到 runc 的配置里。

5.2 rootfs 的来源与 snapshotter 的职责

你可以把 snapshotter 理解成“给容器铺路”的人。它决定容器启动时看到的根文件系统长什么样,以及可写层落在哪里。默认路径一般在/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs下面。

查看某个容器使用的快照:

ctr snapshots ls | grep nginx-demo

创建容器时,containerd 会准备一个 active snapshot;task 运行期间,这个 snapshot 不能随便删。删除容器后,这个 active snapshot 失去了引用,才有资格被 GC 回收。

这也是很多“删除容器后磁盘没释放”问题的源头:如果你只是删了容器对象,但 task 还残留、快照还没被回收,磁盘空间占用会一直留在那里。

5.3 删除容器不释放磁盘?GC 与 Lease 才是关键

containerd 有后台 GC,会清理没有引用的 content 和 snapshot。但 GC 不是无脑把所有孤儿都扫掉,它有一套 lease(租约)机制。lease 可以理解成“我还在用这些东西,你别动”的锁。

Kubernetes 的 CRI 插件在创建 sandbox、拉取镜像、创建容器时都会持有 lease,防止 GC 把正在使用的镜像层或快照删掉。所以你在节点上看到某些 blob 很久没释放,很可能是有 lease 挂着。

如果确实想清理,不要直接ctr content rm去删 blob,那是在拆发动机。安全做法是使用上层工具:

crictl rmi --prune nerdctl system prune

它们会从引用关系出发,清理掉不再被 container、image 和 lease 引用的对象。ctr content ls和ctr leases ls适合你排查“为什么磁盘没释放”,不适合当日常清理工具。

6. 生产环境管理容器生命周期的常见坑与应对

最后这部分全是实战经验。我尽量不写纸上谈兵的内容,每一条都是遇到过或者看别人踩过后印象深刻的。

6.1 命名空间用错:ctr 看不到 K8s 容器

这是最高频的坑。直接执行:

ctr containers ls

默认看到的是defaultnamespace 下的容器。而 Kubernetes 使用 containerd 时,容器几乎都在k8s.ionamespace 下。于是你经常看到有人对着空荡荡的列表说“节点上一个容器都没有”,实际上 K8s 里跑得正欢。

正确姿势:

ctr -n k8s.io containers ls ctr -n k8s.io tasks ls

crictl没有这个问题,因为它默认就通过 CRI 插件工作,namespace 已经帮你处理了。所以跨工具排查时,一定要先确认你操作的 namespace 对不对。containerd 的 namespace 和 Linux namespace 不是一回事,它更像命名空间里的“项目分区”,用来隔离不同租户或不同编排系统的对象。

6.2 OOM 之后的状态残留

容器因为内存超限被杀,task 会退出,但状态不会自动变成“清理完成”。你会在crictl ps -a里看到Exited,在 containerd task 里看到STOPPED。这是正常现象,不代表泄漏。但很多脚本会在容器退出后直接执行删除,如果删除时机不对,可能连带删掉一些还没写完的日志。

排查 OOM 要看几层证据。第一层看容器退出码,非 137 之外的退出码要结合日志判断;第二层看系统日志里的 OOM-kill 记录;第三层可以订阅 containerd 的task.oom事件。如果你发现容器反复 OOM,生命周期管理的重点不是反复重启,而是调整内存 limit 和优化进程占用。

6.3 容器退出了但磁盘没释放

这个问题的排查链路很有代表性。先看磁盘占用:

du -sh /var/lib/containerd/*

然后看是否有很多不再使用的镜像层:

ctr content ls

再看 snapshot 里是不是残留了大量 active 对象:

ctr snapshots ls | grep -E "active|prepared"

如果确认容器已经删了,但还有大量快照和 content,多半是 lease 没有释放或者 GC 还没跑。不要急着rm -rf /var/lib/containerd,那样会让正在运行的容器失去运行依据。正确做法是用crictl rmi --prune或者重启 kubelet 让它重新梳理引用,侵害面小得多。

6.4 暂停状态下的 kill 陷阱

遇到容器暂停后无法正常停止的情况,我心里第一反应是“先 resume”。因为PAUSED状态下进程被冻结,有些信号处理链路会变得不直接。你直接发 SIGKILL,可能发现进程没有立即消失,或者运行时要多花时间清理。

保险顺序是:

ctr tasks resume nginx-demo ctr tasks kill --signal SIGTERM nginx-demo

如果 SIGTERM 之后进程还没退出,再考虑 SIGKILL。这不算一个硬性报错,但在生产环境里能少等几秒钟,也少吓到旁边的人。

最后再分享一个小习惯:凡是涉及 containerd 底层生命周期的操作,我都默认先看 namespace,再确认 task 状态,最后才动删除键。这套动作看着慢,实际能省掉一半的不眠夜。

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

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

立即咨询