1. 前言:别再傻傻分不清了!Containerd 和 Docker 到底有啥区别?
打开终端,敲下docker ps、docker run,然后用ctr -n k8s.io images list查看镜像,发现结果不一样;或者刚接触 K8s,想docker exec进入 Pod 里的容器,结果报错“not found”……很多搞容器的人都会卡在这一步:Containerd 和 Docker 到底谁是谁?
先说结论:Docker 是一套完整的容器管理解决方案,Containerd 是其中的核心容器运行时组件。你平时在终端敲的docker命令,本质上是调用了一个高级工具(Docker CLI + Docker Engine 中的 containerd),而 Containerd 本身是一个更底层的守护进程,负责真正“跑容器”的那部分脏活累活。理解这一点,是打通容器知识体系的关键,尤其当你从 Docker 迁移到 Kubernetes、或者排查节点问题时,分不清这两者会处处碰壁。
这篇文章我会从架构、命令、实际排障三个维度,把 Containerd 和 Docker 的关系彻底拆开揉碎,顺便把我自己踩过的坑也一并写出来。适合刚入门容器没多久、或者已经被 K8s 逼着从 Docker 切换到 Containerd 的同学。
2. 架构全拆解:Docker 到底是怎么“长”出来的?
2.1 从“上古时期”说起:Docker 的繁与简
2013 年前后 Docker 横空出世时,它其实是一个超级“大而全”的怪兽。那时候 Docker 不仅帮你管理容器,还自带网络、存储卷、镜像构建、容器编排功能。这个设计思路在当时解决了“怎么跑一个应用”的痛点,但也带来了一个严重副作用:组件耦合太深,每个人都想复用 Docker 的一部分,却拿不到。最典型的例子就是 K8s 想无缝管理 Docker 容器,但在 Docker 的“大礼包”里,K8s 只需要一个能按标准接口运行容器的组件——不需要 Docker 的镜像构建、不必依赖 Docker Compose,甚至不想被 Docker 的守护进程单点故障拖累。
于是 Docker 公司在 2017 年做了一个关键决定:把自己拆分开来。核心的容器执行部分被剥离成一个独立的开源项目,这就是containerd。Docker 引擎(dockerd)变成 containerd 的上层管理者,负责镜像构建、网络配置、数据卷、用户友好的 API 层。而 containerd 干的事情则更聚焦——拉取镜像、管理容器生命周期、管理网络命名空间和存储。
2.2 那一层神秘的“中间层”:shim 进程
如果你在运行 Docker 或 containerd 的节点上执行pstree,会发现一个有意思的现象:containerd 下面挂着很多containerd-shim进程,每个容器对应一个。这个 shim 是干嘛的?
一句话:让容器进程的爹不轻易死掉。早期 Docker 版本(使用 runc 直接启动)有个痛点:当 dockerd 重启或崩溃时,所有正在运行的容器也会跟着遭殃,因为它们成了孤儿进程,或者在 systemd 下被直接杀掉。而 containerd-shim 的存在,使得每个容器的运行状态与 containerd/dockerd 解耦——即使 containerd 崩了,shim 依然在维护容器进程,等 containerd 起来之后重新接管。
如果说容器是“装应用的盒子”,那 shim 就像盒子旁边那个专门负责报信的警卫,它管着你这个盒子是否还活着、要不要发信号通知给管理员。理解 shim,你就知道为什么 K8s 能放心地把 Pod 里的容器交给 containerd:因为 containerd 对每个容器都做了隔离管理,不会因为自身重启就拖累业务进程。
2.3 Docker 和 Containerd 的完整层级关系
为了便于理解,我把这套架构整理成了一张逻辑链:
docker CLI → docker API → dockerd ↓ containerd(守护进程) ↓ containerd-shim(每容器一个) ↓ runc(OCI runtime) ↓ Linux 内核的 namespaces + cgroups- docker CLI:你敲的命令工具,只负责把用户指令翻译成 HTTP API 请求。
- dockerd:Docker 守护进程。负责镜像构建、网络管理(CNM)、存储卷(volume)、Docker API 网关,以及对接 containerd 完成容器操作。
- containerd:容器运行时管理守护进程。对接 CRD(Container Runtime Interface 的 Containerd 实现就是 CRI plugin),维护镜像层,管理容器生命周期。
- containerd-shim:容器级隔离进程。
- runc:OCI 标准运行时,真正调用 Linux 内核能力创建 namespace、cgroup,启动/停止容器进程。
- Linux 内核:容器最终的资源隔离与限制执行者。
对比一下:如果你直接用 containerd 的原始接口(ctr 命令)管理容器,那么链路短了好几层:ctr → containerd → runc → kernel。这也是为什么越来越多的 K8s 用户直接使用 containerd,因为少了一层 dockerd 的转发,不论是性能还是故障点都更可控。
2.4 为什么 K8s 要“抛弃” Docker?(其实是抛弃 dockerd)
这里我要澄清一个常见的误区:K8s 从未抛弃 Docker 镜像,也没有抛弃 containerd 本身,K8s 抛弃的是dockerd。在 K8s 1.20 之前,kubelet 通过 dockershim 组件和 dockerd 打交道,为的是让 Docker 也能作为 K8s 的运行时。但 dockerd 这个“大盘子”太重了,它带来的额外中间层在稳定性、资源占用上都是问题。因此社区推动 K8s 直接使用 containerd,绕开 dockerd,这就是“K8s 不再支持 Docker”说法的来源。
你如果还在用docker build构建镜像,那一点问题没有,镜像格式依然是 OCI 标准;但如果你在 K8s 节点上还指望docker ps查看容器,在默认 containerd 运行时的节点上是看不到的,因为容器由 containerd 直接管理。这一点我放到第 3 章细说。
3. 命令大对比:日常工作到底该用哪个?
3.1 最常用的十个操作:Docker vs Containerd(ctr vs crictl)
很多人一开始最直观的困惑就是命令不一样。docker 有docker ps,containerd 却有两个客户端:ctr和crictl。这两个名字都会出现在和 containerd 打交道的场景里,但它们的定位完全不同:
- ctr:containerd 自带的原生命令行客户端,偏向调试和底层操作,不提供太多优雅的错误提示。它是 containerd 项目的一部分,主要用于开发、调试、测试。
- crictl:是 Kubernetes 社区提供的 CRI(Container Runtime Interface)命令行工具,专门面向 K8s 场景。它能操作 containerd 的 CRI plugin,功能更贴近 K8s 用户的视角(Pod、容器命名空间等)。
我把两者和 docker 命令做了一张对照表,几乎覆盖了我日常用得最频繁的操作:
| 操作场景 | docker | ctr(containerd 原生) | crictl(K8s 场景) |
|---|---|---|---|
| 查看容器列表 | docker ps -a | ctr c list或ctr -n k8s.io c list | crictl ps -a |
| 查看正在运行的 Pod | docker ps | 没有 Pod 概念 | crictl pods |
| 查看镜像列表 | docker images | ctr -n k8s.io images list | crictl images |
| 拉取镜像 | docker pull nginx:latest | ctr -n k8s.io images pull | crictl pull nginx:latest |
| 运行容器 | docker run -it nginx bash | ctr -n k8s.io run --with-namespace -t | crictl run(需要 sandbox 配置) |
| 进入容器 | docker exec -it <ID> bash | ctr -n k8s.io tasks exec -t --exec-id <ID> | crictl exec -it <ID> bash |
| 查看日志 | docker logs <ID> | 不支持直接查看,需配合 shim 或日志重定向 | crictl logs <ID> |
| 停止容器 | docker stop <ID> | ctr -n k8s.io c rm <ID>(强制移除) | crictl stop <ID> |
| 删除镜像 | docker rmi <NAME> | ctr -n k8s.io images rm <NAME> | crictl rmi <NAME> |
| 查看资源统计 | docker stats | 不支持 | crictl stats |
| 查看事件日志 | docker events | 通过 containerd 日志 | crictl events |
看到区别了吗?ctr 的命令相对“裸”,很多高级功能要么缺失,要么看起来很不友好;而 crictl 更贴近 Docker 的使用习惯,K8s 用户迁移成本会低很多。如果是在 K8s 节点上,我强烈建议优先使用 crictl,而不是 ctr。
3.2docker ps能看到 containerd 起的容器吗?——不能
一个很典型的现场:你在一台 K8s 节点上docker ps,面对空空的列表陷入沉思;但在crictl ps里明明一大堆 Pod 正在跑。这正常吗?太正常了。
因为我前面讲过,K8s 节点如果使用 containerd 作为 CRI 运行时,kubelet 创建的容器直接由 containerd 管理,dockerd 根本不知情。dockerd 只能看到“自己建的那部分容器”。你再怎么docker ps都只能看到宿主机上自己手动跑的那几个容器。如果你偏要用 docker 命令去操作 K8s 的容器,只会得到“not found”“unable to find”之类的报错。
解决方案:在 K8s 节点上,习惯用crictl。要查日志就看crictl logs,要进入容器就crictl exec。网上很多老教程还在用docker exec进入 Pod 容器,那是以前 dockershim 时代的做法,现在完全行不通了。
3.3 containerd 的 namespace 机制,比你想的重要
使用ctr时,镜像和容器默认是放在default命名空间下的。而 K8s 通过 containerd 的 CRI plugin 启动容器时,会把所有镜像、容器放到k8s.io命名空间。所以在 K8s 节点上你如果乱用ctr images list,很可能看不到任何镜像,因为你在 default 空间里查。加上-n k8s.io再查一遍就能看到了。
这个 namespace 不是 Linux 内核的 namespace,而是 containerd 自身做的一层逻辑隔离。它让同一个 containerd 进程可以服务多类工作负载而互不干扰。K8s 用 k8s.io,Docker 用 moby(那是 Docker 对 containerd 的逻辑分区),平时自己调试用它默认的 default。如果你拿 ctr 去操作 k8s.io 下的容器,请务必加上-n k8s.io,否则大概率操作的是空气。
3.4 实际操作示例:用 crictl 拉镜像并启动一个容器
很多读者可能还是第一次接触 crictl,我这里给一个相对完整的落地示例:在 K8s 节点上手动用 crictl 拉取镜像并操作。
# 查看当前 containerd 的 CRI 配置是否正确 crictl version # 拉取一个官方 nginx 镜像 crictl pull docker.io/library/nginx:latest # 查看镜像是否就绪 crictl images # 查看节点上所有 Pod crictl pods # 查看所有容器(包括 Running 和 Exited) crictl ps -a注意一点:crictl run并不是像docker run那样一条命令就能把容器跑起来。CRI 标准要求先创建 Pod sandbox(相当于一个隔离边界,通常就是 Infra 容器),然后在 sandbox 里创建业务容器。手动用 crictl 启动容器比较繁琐,得写 JSON 配置,不适合日常使用。K8s 正常业务中你也不用手动起容器,只需要掌握crictl ps、crictl logs、crictl exec这几个查看排查命令就足够了。
4. 常见问题与排查技巧实录:我踩过的坑
4.1 问题一:crictl ps报unable to connect或连接超时
这是我碰到最多的坑之一。明明 containerd 是运行着的,但crictl ps提示无法连接。原因往往是crictl 的 endpoint 配置不对,或者 containerd 的 CRI socket 路径不对。
containerd 的 CRI 监听地址常见有两种:
/var/run/containerd/containerd.sock/run/containerd/containerd.sock
而 crictl 默认可能去连/var/run/dockershim.sock(从 Docker 时代留下的习惯),自然连不上。解决方法是查看 crictl 配置:
cat /etc/crictl.yaml里面应该长这样:
runtime-endpoint: unix:///var/run/containerd/containerd.sock image-endpoint: unix:///var/run/containerd/containerd.sock timeout: 10 debug: false如果/etc/crictl.yaml不存在或配置不对,你可以临时用参数指定:
crictl --runtime-endpoint unix:///var/run/containerd/containerd.sock pscrictl --runtime-endpoint unix:///run/containerd/containerd.sock ps建议直接把配置文件写对,毕竟后面你还会经常用。
4.2 问题二:ctr images pull很慢,怎么加速?
用 Docker 的时候,docker pull是在 daemon 层面对的 registry,你可以配置/etc/docker/daemon.json里的registry-mirrors。但ctr 不读 Docker 的 daemon.json,它有自己的配置。containerd 的 config 通常在/etc/containerd/config.toml。
如果你在 K8s 节点上用ctr images pull,发现慢得感人,又想配置镜像加速,可以修改/etc/containerd/config.toml中类似这段的配置:
[plugins."io.containerd.grpc.v1.cri".registry.mirrors] [plugins."io.containerd.grpc.v1.cri".registry.mirrors."docker.io"] endpoint = ["https://docker.mirrors.example.com"]修改后重启 containerd:
systemctl restart containerd这里我不给具体镜像加速地址,因为不同环境不一样。你只需要理解:containerd 有独立的 registry mirror 配置,别再去改 Docker 的配置文件了。
4.3 问题三:docker exec进不了 K8s Pod 里的容器
第一次切换到 containerd 运行时,最容易习惯性使用docker exec -it <containerID> bash去进 Pod 容器,结果得到:
Error response from daemon: No such container: xxx或者干脆找不到容器。原因上面已经说透了:dockerd 根本不认识 containerd 管理的容器。到 K8s 节点上就先切换心态,用crictl exec -it <containerID> bash。
这里我多说一句:如果容器里没有/bin/bash,可以试试sh:
crictl exec -it <containerID> sh4.4 问题四:如何查看容器日志?ctr怎么没有logs命令
用ctr的用户很容易抓狂:ctr c list能看到容器,但是怎么查看容器里的标准输出日志?ctr 确实没有类似docker logs的功能。因为 containerd 原生不处理日志聚合这一层,它只是把容器的 stdout/stderr 重定向到某个位置。容器的日志通常由 kubelet 负责收集,存放在/var/log/pods/或/var/log/containers/下。
所以在 K8s 节点上,如果不想用kubectl logs,可以直接看日志文件:
tail -f /var/log/pods/kube-system_xxx_xxx/xxx/0.log或者用 crictl:
crictl logs <containerID>4.5 问题五:明明有镜像,ctr images list却查不到
之前提过 namespace 的原因,这里再展开一下。你docker images能看到的是 Docker 视角的镜像,而 containerd 的镜像和 Docker 镜像其实在底层存储上也不是完全共通的(虽然镜像格式一样,但 containerd 有自己的内容存储和元数据管理)。如果你用ctr images list默认在 default 命名空间下查询,自然看不到 k8s.io 空间下的镜像。
ctr -n k8s.io images list如果ctr images list某些时候看不到某个镜像,可以直接去看 containerd 的 meta.db:
ctr -n k8s.io images ls(注意ls和list是等价的)但我建议直接养成-n k8s.io的习惯。
4.6 问题六:crictl pod和crictl ps看到的容器状态对不上
这个问题经常让新手困惑:K8s 里明明kubectl get pod显示 Running,crictl pods里也有这个 Pod,但crictl ps -a里业务容器状态却是 Exited。
遇到这种情况别慌,通常是因为容器曾经退出了,但 K8s 正在根据重启策略拉它起来,或者在等待下一次重启。排查的关键是看日志和事件状态:
crictl logs <containerID>如果日志为空或总是 Immediately 退出,多半是启动命令不对、镜像入口有问题,或者资源限制被拉满。进一步可以看容器详情:
crictl inspect <containerID>这里能看到容器的 exit code、last state、资源限制、挂载情况等,信息量比docker inspect更 CRI 化,但对排查来说反而更清晰。
4.7 问题七:关闭 Docker 后,Linux 上的 containerd 服务起不来
有时候你在迁移过程中会先把 Docker 停掉,再重启 containerd,却发现 containerd 启动失败。这往往不是 containerd 本身坏了,而是它依赖的一些网络或存储配置被 Docker 管理着。比如 Docker 创建了docker0网桥,或者里面的 iptables 规则已经被清理了,而 containerd 本身并不需要 docker0,但有些用户自定义 CNI 配置会依赖 Docker 的网桥。
更常见的原因:你同时装了 docker-ce 和 containerd.io,但 Docker 自带的 containerd 和你手动安装的 containerd 版本冲突。这种情况建议直接查看 systemd 状态:
systemctl status containerd如果是端口冲突或 socket 冲突(比如/var/run/docker.sock和/run/containerd/containerd.sock互抢),那就需要重新梳理服务依赖。很多人不知道,docker-ce 自带一个containerd.io包,提供的是 containerd 二进制;而你在管理系统 store 里可能又装了一个独立 containerd。两者版本不一致时,很容易出现“启动 Docker 挺好,单独启动 containerd 就报错”。
我的经验是:同一台机器上别同时混装两个不同来源的 containerd。如果要彻底转向 containerd,就把 docker 的 containerd 依赖剥离开,或者干脆在干净环境装一套 containerd。
5. 实际场景中的工作流建议:什么时候用 Docker,什么时候用 Containerd?
5.1 单机开发环境:继续用 Docker 是最优解
如果你只是开发环境跑一个 MySQL、写个 Demo、搭个环境,强烈建议继续使用 Docker Desktop 或 docker 引擎。理由很简单:Docker 的开发者体验是完整闭环的,镜像构建、多阶段构建、Compose 编排、热更新、插件生态,这些都不是 containerd 原生能替代的。
比如你本地开发一个项目,用docker compose up -d一键拉起 MySQL + Redis + 后端服务,体验非常顺滑。containerd 生态整合得再好,也没有给你提供一套人类友好的 compose 方案。K8s 是生产环境的问题,本地开发没必要自讨苦吃。
5.2 K8s 生产节点:直接用 containerd 作为 CRI 运行时
如果你搭 K8s 集群(无论是 kubeadm 还是托管云 K8s),生产节点的运行时建议直接上 containerd。优势在于:
- 少一层 dockerd 转发,故障面更小,启动更快,内存占用更低。
- kubelet 直连 containerd 的 CRI 接口,日志、健康检查、状态上报更标准。
- 避免了 dockershim 时代的版本兼容问题。
在这种节点上,你就把 docker 命令抛到脑后。镜像构建可以放在 CI 阶段完成(用 Docker 构建),产物推到镜像仓库,K8s 节点再用 containerd 拉取运行。
5.3 调试场景:crictl + ctr 搭配使用
我自己的调试习惯是:在 K8s 节点上,优先用crictl查看、进入容器、看日志;遇到crictl没有的命令,再用ctr -n k8s.io补位。比如有时候要直接操作“镜像层”或“快照层”数据,crictl做不了,就得靠ctr snapshots。再者,当你怀疑 containerd 的镜像存储有问题,ctr content list可以查看内容布式存储的数据块信息,这对排查镜像损坏或磁盘泄漏很有用。
5.4 终极建议:构建和运行分离
经过这几年在容器相关项目中的实操,我最深的体会是——把“构建容器镜像”和“运行容器”这两件事彻底分开。
- 构建:用 Docker(BuildKit 能力足够成熟,多阶段构建、缓存策略都很完善)。
- 运行:交给 containerd(K8s 场景)或者 docker(本地简单运行)。
每当我接到“为什么 K8s 里镜像拉不下来”的工单时,我都会先问一句:这个镜像对应镜像仓库的认证配置好没有?因为 containerd 访问私有仓库的认证配置和 Docker 完全不同,如果只配置了docker login,而 containerd 的 registry auth 没有配,那 K8s 节点上crictl pull一定失败。
登录私有镜像仓库时,用 docker 会在~/.docker/config.json里生成 auth,这个文件 containerd 的 CRI 插件理论上也可以读,但实际配置里更常见的是把镜像仓库的认证信息写进/etc/containerd/config.toml对应的 registry 配置片段。这块配置繁琐,建议使用 K8s 的 imagePullSecret 方案,让 kubelet 直接处理镜像拉取认证,不要人工去干预 containerd 的登录状态。
6. 我实践下来的几个核心体会
别把 containerd 和 runc 混为一谈。runc 是 OCI 运行时的一个具体实现,containerd 是管理运行时生命周期的守护进程。有些教程会把
runc说成“容器运行时”,但在 K8s 语境里,containerd 才是那个 CRI 运行时。理解这一点,看 CRI-O、runsc、Kata Containers 这些不同运行时方案时也不会混淆。命令和架构的差异背后,是职责边界的变化。docker 走的是“全家桶”路线,containerd 走的是“单一职责”路线。当你了解 containerd 为什么拆出来、kubelet 为什么直接对接它,就不会再纠结命令是否好用。命令只是表象,架构设计才是答案。
遇到容器问题,先确认自己手里的工具是哪个视角。我曾经在一个客户现场排查半天,发现对方一直用
docker ps看 K8s Pod 容器,结果结论自然全都是错的。切换到crictl ps -a和crictl logs之后,问题不到十分钟定位。工具定位错了,再怎么排查都是白费功夫。
如果你现在正处于 Docker 向 Containerd 迁移的过渡期,我的经验就是:先掌握crictl ps、crictl logs、crictl exec这三个命令,保证能救火;再慢慢看crictl pods、crictl inspect这些状态和详情功能;最后有空再翻ctr去理解镜像和快照层的存储机制。这套路径走下来,你不仅能分清 Containerd 和 Docker,还能真正理解现代容器基础设施为什么长这样。