☰
Docker与Containerd架构关系及命令对比:容器运行时的底层逻辑
2026/10/9 6:08:59 网站建设 项目流程

前两天有同事跑来问我一个问题:我们在服务器上明明装了 Docker,为什么生产环境里看到容器进程却是放在 containerd 目录下的?还有人拿docker run跑起来的容器,用crictl ps居然看不到,反过来也一样。这个问题其实困扰过不少刚接触云原生的人。Docker 和 Containerd,名字相似、功能重叠,但又不是一个东西,命令对不上、架构对不上、日志路径也对不上,确实容易把人绕晕。

这篇文章打算把两件事彻底讲清楚:第一,Containerd 和 Docker 在架构层面到底是什么关系;第二,日常运维里各自的命令该怎么用,哪些坑是真实存在的。如果你是做容器平台、K8s 集群维护的,或者只是刚入门想弄明白容器底层的运行逻辑,这篇文章应该能省下你不少排查时间。

1. 架构演进:先搞清楚 Containerd 在“食物链”里的位置

1.1 从 Docker 1.x 那会儿说起

很多人以为 Docker 天生就是这么重,其实不是。在 Docker 早期的 1.x 时代,整个容器运行时就是一个大二进制,直接管理镜像、容器、网络、存储,还自带一套 API。那时候没有 containerd 这个说法,Docker 自己把底层那套活全包了。

后来容器生态开始分化,Kubernetes 打算同时支持多种运行时,就不能让每个容器引擎都自己去管底层那套东西,得定一套通用接口。于是 Docker 在 1.11 版本开始把底层逻辑拆出来,捐献给了 CNCF,这就是 containerd 的来历。拆完之后,Docker 自己变成了一个用户友好的“壳”,真正的容器创建、运行、销毁,其实都是 containerd 在干。

这段历史对理解当前架构特别重要,因为它解释了为什么你装了 Docker,机器上却会多出一个 containerd 进程。这不是什么残留,是 Docker 本来就依赖它。

1.2 现在的架构长啥样:shim、runc、containerd 各司其职

以 Docker 为入口的场景,完整链路是这样的:

docker 命令 → dockerd → containerd → containerd-shim → runc → 容器进程
  • docker:客户端,就负责把人的操作翻译成 API 请求。
  • dockerd:Docker 的服务端,负责解析镜像、管理网络、管理卷,以及把容器创建请求转给 containerd。
  • containerd:真正的容器生命周期管理引擎,负责镜像解包、容器进程的启停。
  • containerd-shim:每个容器旁边挂一个轻量进程,作用是让底层 runc 退出后,容器还能继续跑,同时负责收集容器状态和 IO。
  • runc:真正创建容器用的工具,基于 Linux 内核的 namespace、cgroups 等特性拉起进程。

你可以把 containerd 想象成一个厨房,runc 是最底下的灶台,shim 是每个灶台旁边站着的小工,而 Docker 就是前面接待客人的服务员。你点菜(打命令)时接触的是服务员,但真正炒菜的是厨房。

在纯 K8s 环境里,架构就更简单了,Kubelet 直接通过 CRI 接口对接 containerd,中间没有 dockerd 这个环节。这也是为什么 K8s 在 1.24 版本以后宣布弃用 Docker,因为 Docker 那层封装对 K8s 来说纯粹是多余的路由。

1.3 为什么 K8s 移除了 Docker 却离不开 Containerd

当年 K8s 宣布废弃 dockershim 的时候,很多人以为 K8s 以后不支持 Docker 了。准确说法是:K8s 不再用 dockerd 这个进程来拉取镜像、管理容器,而是直接用 containerd 通过 CRI 接口管理容器。你构建镜像时依然可以用 Dockerfile、依然可以推送到 registry,但运行时不再经过 Docker。

这里有个关键点:containerd 本身并不提供构建镜像的能力。你用docker build构建,用ctr或crictl跑容器,一个负责生产工件,一个负责消耗工件。生产环境里最常见的组合其实是:开发时用 Docker 构建镜像,推到镜像仓库后,由 K8s 节点上的 containerd 直接拉取并运行。你只有在需要本地调试、或者往开发环境部署时才真正用 Docker 命令。

理解了这条链路,之后看命令就好办了。因为你遇到的问题大多不是命令不会写,而是不知道命令在跟谁说话。

2. 命令体系大对比:docker、ctr、crictl 三个 CLI 的真实关系

2.1 三兄弟的出身完全不同

前面说过,Docker、containerd、K8s 三者属于不同的体系。对应到命令行工具,就是三个“方言不一样”的东西:

命令服务端面向对象设计目标
dockerdockerd人类用户镜像构建、多容器编排、开发者友好
ctrcontainerdcontainerd 本身底层调试、排查 containerd 状态
crictlcontainerd(通过 CRI)K8s 节点管理员模拟 Kubelet 视角,调试 Pod 和容器

用docker的时候,你的命令首先交给 dockerd,dockerd 做完自己的处理后,再把请求转给 containerd。而ctr是 containerd 的亲儿子,直接和 containerd 通信,绕过了 dockerd 的全部逻辑。crictl则是通过 CRI 标准接口走过去,所以它面对的是 K8s 的抽象。

正因为对接的接口不同,三个工具看到的“容器”概念也不完全一样。docker ps显示的单位是“容器”;crictl ps显示的单位是“Pod 里的容器”,而且它会把同一个 Pod 里的 pause 容器也算进去;ctr更底层,它甚至能看到 containerd 为每个容器创建的 task 状态。

2.2 镜像管理命令对比

对初学者来说,最容易踩坑的是镜像相关操作。因为三个工具都能拉镜像,但命令完全不通用。

docker拉镜像:

docker pull nginx:latest

ctr拉镜像:

ctr image pull docker.io/library/nginx:latest

注意,ctr 拉镜像要写全地址,nginx:latest这种简写它不认,必须带上docker.io/library/前缀。

crictl拉镜像:

crictl pull nginx:latest

crictl 倒是能识别简写,但它的实现是帮你补全前缀然后再操作。

镜像列表也一样:

docker images ctr image ls crictl images

看起来功能相同,但结果往往对不上。最典型的场景是:你docker pull了一个镜像,然后到/var/lib/docker/containerd下去找,发现目录里根本没有按镜像名命名的东西,其实它被解包成了层文件。反过来,你用ctr image import导入的镜像,用docker images也可能是看不到的,因为 dockerd 只认自己的元数据,而 containerd 只认自己 content store 里的内容。

2.3 容器生命周期命令对比

跑容器的差异也很大。拿启动一个 nginx 容器举例:

# docker 正常启动 docker run -d --name web nginx:latest # crictl 启动(必须指定 Pod 沙箱) crictl run nginx:latest # ctr 启动(通常不直接用,太底层) ctr run docker.io/library/nginx:latest nginx

这里面容易混淆的是 crictl。因为 K8s 里一个容器必须属于某个 Pod,而 Pod 对应的底层是一个沙箱(sandbox),也就是 pause 容器。crictl 严格遵循 CRI 的抽象,所以它跑容器必须基于一个已经存在的 Pod 沙箱。直接裸敲crictl run,不少版本会提示你缺少 sandbox。

日常 K8s 节点上,我们很少直接用 crictl 去启动一个独立容器,更多的是用它查看状态、拉日志、执行命令。比如:

# 查看 Pod 列表(crictl 会把 Pod 也列出来) crictl pods # 查看容器列表 crictl ps -a # 进入容器执行命令 crictl exec -it <container-id> /bin/sh # 查看容器日志 crictl logs <container-id>

ctr就更直接,它对“Pod”和“命名空间”的理解完全是另一套逻辑。比如它是按 namespace 来隔离容器资源的,默认 namespace 叫default,而 K8s 创建的容器会放在一个叫k8s.io的 namespace 里。如果你ctr -n k8s.io container ls能看到 K8s 的容器,但不加-n参数,什么都看不到。

2.4 namespace:很多人栽跟头的地方

这是最有意思、也是最容易忽略的一个点。containerd 自身有一个 namespace 的概念,用来隔离不同的使用者,比如 Docker 会使用名为moby的 namespace,K8s 会使用k8s.io的 namespace,而ctr默认则使用default。

这个机制直接导致了一个常见困惑:你在服务器上明明跑了 K8s 集群,容器进程一抓一大把,但执行ctr container ls却空空如也。原因就是你没有指定-n k8s.io。

# 看到所有 namespace ctr namespace ls # 查看 k8s.io 下的容器 ctr -n k8s.io container ls

而 crictl 不存在这个问题,因为它通过 CRI 接口,会自动绑定到它该用的命名空间,不需要你手动指定。

所以如果你哪天用 ctr 排查完容器,记得区分自己当前在什么 namespace 下操作,否则你会觉得“进程存在、但工具看不到很好笑”。这不是 bug,是设计。

3. 实操演示:用 ctr 和 crictl 完成日常容器运维

3.1 配置 containerd,让它能用起来

如果你在 K8s 节点上工作,containerd 一般已经作为系统服务装好了。但我们经常需要改几个关键配置,否则后面排查会处处碰壁。

containerd 的主配置文件在/etc/containerd/config.toml。装完 containerd 后,如果你直接启动,默认配置其实是有缺失的。建议先初始化一份完整配置:

containerd config default > /etc/containerd/config.toml

然后重点检查几个部分。首先是disabled_plugins,某些发行版的 containerd 默认禁用了 CRI 插件。如果这个列表里包含"cri",那crictl连上去就会提示“CRI is disabled”,根本没法用。需要把"cri"从列表里删掉,或者注释掉对应行。

其次是镜像仓库的 mirror 配置。国内网络环境拉公共镜像经常超时,我一般会在plugins."io.containerd.grpc.v1.cri".registry.mirrors下配置加速器,或者在plugins."io.containerd.grpc.v1.cri".registry.configs下配置私有仓库认证。低版本 containerd 的配置字段是registry.mirrors,高版本在config_path = "/etc/containerd/certs.d"下按目录加载,千万要看清楚自己的版本。

改完配置一定要重启:

systemctl restart containerd

接着验证 CRI 插件是不是起来了:

crictl version

如果能看到版本号,说明 CRI 已经就绪,后面kubelet也能正常连上。

3.2 拉镜像、跑容器、看日志

配置好后,先试试用 crictl 拉一个镜像,并查看列表:

crictl pull nginx:1.25 crictl images

操作过程你会发现,crictl 的一些参数跟 docker 很像,比如-a看全部、-q只要 ID。但它在部分输出格式上又不一样,比如crictl ps不会自动加容器端口映射,这是因为它只关心 CRI 层面的东西,网络那套是 CNI 的职责。

如果想对比验证 docker 与 ctr 的区别,可以在同一台机器上执行docker ps和ctr -n moby container ls,你会发现它们其实指向同一批容器。这就是前面说的“服务员”和“厨房”的关系:Docker 把容器放到 containerd 的mobynamespace 里管理,所以ctr -n moby container ls能看到,用ctr container ls看不到。

在实际生产环境里,kubelet 拉完镜像后,可以用ctr -n k8s.io image ls查看节点上已经被 K8s 拉取的镜像列表。crictl images也能完成同样的事,只是内部最后还是走 CRI 把请求转给了 containerd。

日志这块要提一个重点:ctr本身没有专门打印容器日志的命令,它只能看 task 的状态。你要看标准输出日志,要么用crictl logs,要么直接去/var/log/containers目录下找 kubelet 重定向生成的日志文件。很多从 Docker 转过来的人在这里会卡住,拿出ctr logs一敲,发现根本没有这个子命令。

3.3 容器和沙箱的区别

用crictl ps -a的时候,你会看到容器列表里出现过一些名字很怪、状态为CONTAINER_RUNNING的 pause 容器。这种容器就是沙箱容器,也就是每个 Pod 最先被创建的那个“占位”容器,它负责持有 Pod 级的网络命名空间,其他业务容器共享它的网络栈。

这也是 crictl 与 docker 的一个本质差异:docker 只管理单容器,而 crictl 必须同时理解 Pod 和容器两级抽象。你输入crictl run的时候它要求你先有沙箱,而crictl runp这个命令专门用来启动一个 Pod 沙箱。平时写 K8s yaml 不太会直接碰沙箱,但当你排查网络问题的时候,去看 pause 容器的状态会比看业务容器的状态更有用,因为它活着,说明网络还在;它挂了,业务容器再怎么折腾都连不通外网。

我用一个实际案例来说明:有一次客户报“Pod 起来后一直 CrashLoopBackOff”,但kubectl describe pod看到的报错不明确,半天没定位。后来上节点用crictl ps -a发现 pause 容器是Exited状态,等于 Pod 的命根子都没了,后面业务容器肯定反复重启。查到最后是节点上磁盘空间写满,containerd 没法为沙箱创建新目录。如果不理解 crictl 输出的两级结构,这个排查要多绕好几个小时。

4. 常见问题排查与避坑实录

4.1 用 ctr 拉下来的镜像,docker pull 时又拉一遍

这种情况特别容易出现在混合部署的服务器上。同一个镜像,你在 K8s 节点上用crictl pull拉过了,然后想用 docker 快速跑一个调试容器,发现它还在那里转圈,仿佛觉得本地没有一样。

原因是 dockerd 的镜像缓存和 containerd 的 content store 是两套数据。哪怕 containerd 里已经有nginx:1.25的镜像层,dockerd 也不知道,它得用自己的逻辑重新拉取一遍。

解决办法其实也简单,就是别混用。生产环境桥接调试时,你部署到 K8s 就用 crictl 管理容器;开发环境需要快速交互就用 docker。不要在一台机器上交叉使用两个体系的命令,否则既浪费带宽,还容易给审计造成误解。

4.2 crictl 报错 “unable to connect to containerd”

我在不少新装的节点上踩过这个坑,原因通常是 containerd 服务根本没起来,或者 CRI 插件没启用。先做一次基础三步排查:

systemctl status containerd crictl logs cat /etc/containerd/config.toml | grep -i disabled_plugins

如果服务是启动的,但 CRI 插件被禁用,crictl 的连接就是直接失败的。修改配置里disabled_plugins列表,把"cri"移除,保存后重启 containerd。注意某些版本中配置文件里写的是plugins."io.containerd.grpc.v1.cri"注释段,并不是disabled_plugins,两个地方都要检查。

另外 crictl 的默认 endpoint 是unix:///run/containerd/containerd.sock,如果你改了 containerd 的监听地址,也要同步改/etc/crictl.yaml:

runtime-endpoint: unix:///run/containerd/containerd.sock image-endpoint: unix:///run/containerd/containerd.sock timeout: 5 debug: false

4.3 镜像仓库不走 HTTP,拉取失败

私有仓库场景下,常见报错是http: server gave HTTP response to HTTPS client。这是因为 containerd 默认要求仓库走 HTTPS,而你可能是内网自建仓库,只有 HTTP。

处理方式要看你用的 containerd 版本。低版本在/etc/containerd/config.toml里通过[plugins."io.containerd.grpc.v1.cri".registry.configs."your.registry".tls]段的insecure_skip_verify = true,或把scheme改成http。高版本推荐在/etc/containerd/certs.d/你的仓库地址/hosts.toml写一条:

server = "http://你的仓库地址" [host."http://你的仓库地址"] capabilities = ["pull", "resolve", "push"]

还要注意,crictl pull使用的镜像地址如果是简写,它会自动补docker.io前缀,私有仓库最好给完整的域名加路径,比如registry.internal.cn/team/app:v1,不要只写app:v1,否则匹配不到镜像。

4.4 containerd 日志文件位置和常见错误码

containerd 的日志在 systemd 环境下直接用journalctl看:

journalctl -u containerd -n 100 --no-pager

常见错误里有一个值得单独说明:failed to get sandbox image "registry.k8s.io/pause:3.9"。这是 node 上缺少 pause 镜像导致的,K8s 初始化沙箱时会找它,找不到就拉起任何容器。解决办法是预先手动拉取该镜像,并且确保版本与节点上的 containerd 兼容。

还有failed to create shim task: cgroup: fork failed,这种多是节点 PID 或 cgroup 限制导致,不是命令问题,得去调高节点上的内核参数,或者给 kubelet 的--system-reserved预留足够资源。

4.5 实测下来的工具选择建议

根据我自己维护多套集群的经验,给出几条很实际的选择原则:

  • 构建镜像、本地快速起容器,用docker没毛病,它交互体验最好,日志格式清晰。
  • K8s 节点上排查 Pod 状态,优先用crictl,因为它能看到 Pod 维度,输出格式与服务商监控对齐。
  • 在节点上排查 containerd 本身的数据、镜像层、content store 内容,才需要用ctr,并且要记住-n参数。
  • 如果你在维护一个纯 containerd 的裸环境(没有 K8s、没有 Docker),ctr就成了主力工具,但你需要额外手动管理 namespace、快照器和 CNI 插件,这远不如 docker 省心。

这套选型逻辑如果从理解架构的角度来看,其实就是一句话:上层用户选 Docker,K8s 运维选 crictl,底层研究选 ctr。它们服务于同一个对象,但视角完全不同。

5. 个人体会:这层关系想清楚以后,排查效率高了很多

我自己早期也犯过糊涂。那时刚接手集群维护,节点上装了 docker 又跑着 kubelet,执行ctr image ls发现啥都没有,一度以为是 containerd 没起来。后来才知道是 namespace 没对上。又过了几个月,碰到 K8s 拉镜像失败,我总是下意识跑docker images去确认,结果镜像明明在本地,kubelet 还是拉不下来,我才真正意识到“本地有没有”和“Kubelet 看到没有”是两码事。

后来我把所有节点统一改成 containerd 直连,不再安装 docker 命令,调试问题只保留 crictl 和 ctr。最初几天确实不习惯,比如crictl不支持--rm、没法直接 build 镜像,但适应之后,整个排查链路变得干净很多。因为 CRI 接口本身就是一个稳定边界,你能很清楚地判断问题出在镜像、容器还是沙箱层。

最后给一条建议:如果你准备学习容器底层,不要一上来就背命令,先把docker → dockerd → containerd → containerd-shim → runc这条链路的责任边界画出来。命令只是这个架构的外在表达,架构理解透了,命令其实不用背,用的时候--help就够。遇到“为什么这个工具看不到我创建的容器”这类问题,先回去想想它是在跟哪一层对话,答案往往自己就浮出来了。

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

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

立即咨询