
我第一次被问“Containerd到底是什么”是在帮朋友排查一个Kubernetes节点反复NotReady的问题。他盯着kubelet日志里的containerd字样一脸茫然我不是用的Docker吗怎么冒出来一个Containerd这个问题其实特别典型——很多人天天用Docker却未必知道Docker底层早就换了血。Containerd是一个容器运行时而且是当前Kubernetes和云原生场景下默认的容器运行时。这篇文章我打算把它从架构原理、安装配置、常用命令到排错经验完整拆一遍适合刚开始接触Containerd的读者也适合正在从Docker迁移到Containerd的运维同学参考。1. 先分清Docker、containerd、runc容器运行时到底有几层1.1 内核之上的三层容器工具链Linux容器能跑起来基础是内核提供的cgroups资源限制和namespace隔离能力再加上一套跟宿主机隔离的rootfs。但光有内核特性你没法像敲docker ps那样直观地管理容器——中间需要软件把内核能力封装成“容器”这个用户可感知的东西。这里就引出容器运行时这个概念而它其实又分好几层。我们可以把容器技术栈粗略分成三层最底层是OCI Runtime代表就是runc。它直接跟内核打交道根据一份容器配置OCI spec把rootfs变成真正运行起来的进程处理进程的启停、挂载、环境变量等。可以把它理解为“真正动手创建容器进程的那一层”。中间层是High-level Runtime代表就是containerd。它负责更上层的管理工作拉镜像、管理镜像层、维护容器元数据、管理容器生命周期、对接存储快照等。它自己不直接创建容器进程而是把任务下发给runc。最上层是用户产品层比如Docker Engine、Kubernetes的kubelet。用户通过Docker CLI或kubelet发出指令这一层负责解析用户意图再调用中间层完成工作。这几个层级的关系可以用下面这种链式结构表达用户命令docker CLI / kubelet / nerdctl ↓ containerdCRI插件或API层 ↓ containerd-shim每容器一个垫片进程 ↓ runcOCI Runtime真正创建进程 ↓ Linux内核cgroups/namespace/overlayfs很多人在Docker环境里做排查时ps -ef能看到containerd-shim和runc这样的进程那就是Docker把任务传递下去之后留下的痕迹。所以可以这么说你用Docker表面上是Docker在管容器实际替你干活的中间环节就是containerd和runc。1.2 一句话说清三个组件我用一句话分别总结它们的分工方便记忆runc只知道“如何从零把一个进程变成容器”是容器引擎的最终执行者普通用户几乎不会直接操作它。containerd知道“如何管理镜像、快照、生命周期和事件”是一个运行在后台的守护进程通过gRPC接口对外提供能力。Docker Engine知道“如何面向人提供友好的操作体验”封装了CLI、BuildKit构建、网络模型、编排等一大堆东西内部调用containerd来管理容器。还有一个容易被忽略的角色是containerd-shim。它为每个容器单独启动一个垫片进程作用是即使containerd主进程崩了或重启了容器进程本身还能继续运行同时它也负责转发信号、维护容器的标准输入输出。这个设计直接影响容器在生产环境里的稳定性后面排错时我们还会再碰到它。2. 为什么Kubernetes选择了containerd2.1 从一个开源项目的出生聊起containerd的诞生离不开Docker。早期Docker是一整套封闭的容器管理方案内部自己管理镜像、容器、网络等。后来随着容器规模化、尤其是Kubernetes生态崛起业内越来越需要一个更轻、更标准、可以独立复用的容器管理中间层。2016年12月Docker公司宣布把containerd从Docker Engine中拆出来作为一个独立的开源项目捐赠给CNCF云原生计算基金会。2017年3月CNCF正式接受containerd2019年2月containerd 1.0发布成为CNCF毕业项目。同一时期Kubernetes在容器运行时接入方面遇到了一个结构性问题kubelet原本是通过一个叫dockershim的适配层去对接Docker daemon再由Docker daemon调用containerd和runc。链路长、维护成本高、还要额外承担Docker自身API膨胀带来的兼容压力。于是Kubernetes社区定义了CRIContainer Runtime Interface用这个标准接口统一对接各种容器运行时不再要求必须走Docker。containerd因为天然定位在中间管理层又提供了CRI插件最终成了社区默认选择。2.2 dockershim移除后链路到底少了什么Kubernetes 1.20宣布弃用dockershim1.24正式移除。在那之后一个Pod的启动路径从原来的kubelet - dockershim - docker daemon - containerd - runc变成了kubelet - containerd(内置CRI插件) - containerd-shim - runc减少的不只是中间转发跳数。dockershim和docker daemon这两层被拿掉之后节点上的内存占用、启动延迟、故障排查面都明显下降。Kubelet直接跟containerd通信containerd本身就实现了CRI规范不再需要额外适配。但这里有个容易误解的点Docker并没有被“淘汰”。你完全可以在构建镜像时继续用Docker把镜像推到镜像仓库然后让Kubernetes节点上的containerd去拉取并运行——这是当前大量团队的实际部署组合。Docker作为一种镜像构建和本地调试工具仍然有效只是不再是Kubernetes运行时链路里的必经环节。另外containerd也不只服务于Kubernetes。它的Go API设计得很干净很多内部平台、边缘计算项目会直接在自己程序里内嵌containerd用来管理容器生命周期。也就是说理解containerd的价值不光是“会看K8s节点日志”而是让你对整个容器生态的工具定位有更清晰的认识。3. 动手装containerd默认配置里必须注意三个大坑3.1 安装方式和初始化配置不同发行版安装containerd的方式大同小异。以Ubuntu系为例直接apt update apt install -y containerd systemctl enable --now containerd某些环境里可能安装包版本较旧或者需要跟随Kubernetes版本走可以从GitHub releases下载官方二进制包解压到/usr/local同时准备好配套的runc和CNI插件。二进制方式的好处是版本可控、不依赖发行版仓库的更新节奏。安装完成后第一件事不是直接拉镜像而是生成配置文件。containerd默认是可以无配置启动的但生产环境里默认配置有几处几乎必改。用下面的命令生成默认配置mkdir -p /etc/containerd containerd config default /etc/containerd/config.toml然后修改配置文件再重启containerd加载systemctl restart containerd验证是否起来ctr version能打印出containerd版本信息说明守护进程已经正常工作了。3.2 必改项SystemdCgroup、sandbox_image、registry配置拿到默认生成的config.toml之后不要直接拿去生产用。我习惯先检查三件事。第一件事是把CRI插件的SystemdCgroup改成true。现在几乎主流Linux发行版都用systemd作为init进程cgroup也由systemd统一管理。如果containerd默认按cgroupfs方式直接操作cgroup而kubelet用的是systemd驱动两者就会打架。典型后果是节点状态异常、Pod一直创建不出来。改成[plugins.io.containerd.grpc.v1.cri.containerd.runtimes.runc.options] SystemdCgroup true第二件事是确认sandbox_image。这个镜像对应Kubernetes里的pause容器负责为Pod提供网络和命名空间基础。默认生成的地址往往是registry.k8s.io/pause:3.x如果你的生产网络访问不了这个地址Pod启动会一直卡在SandBox阶段。可以根据实际网络环境改成可达的镜像地址例如[plugins.io.containerd.grpc.v1.cri] sandbox_image registry.aliyuncs.com/google_containers/pause:3.10第三件事是Registry Mirror配置。containerd遵循OCI分发规范默认从docker.io拉镜像但很多网络环境下直接访问docker.io体验不好。containerd 1.x版本常见做法是在CRI插件里配registry.mirrors[plugins.io.containerd.grpc.v1.cri.registry.mirrors] [plugins.io.containerd.grpc.v1.cri.registry.mirrors.docker.io] endpoint [https://docker.m.daocloud.io, https://docker.1panel.live]如果你的containerd已经是2.x版本配置方式有变化官方更推荐config_path的方式把每个registry的配置放到独立的hosts.toml文件里。例如[plugins.io.containerd.grpc.v1.cri.registry] config_path /etc/containerd/certs.d然后在/etc/containerd/certs.d/docker.io/hosts.toml里写mirror地址。这种方式的好处是不同registry的配置互不干扰也方便用配置管理工具统一分发。3.3 namespace与容器状态containerd里两个容易混淆的概念第一次用ctr命令的人经常被namespace搞懵。containerd自身支持多namespace用来做逻辑隔离。默认命名空间叫defaultKubernetes通过CRI插件创建的对象放在k8s.io命名空间下Docker自己的containerd则使用moby命名空间。所以你在一个节点上执行ctr namespace ls会看到不止一个命名空间不同命名空间下的镜像、容器互不可见。这就是为什么ctr images ls看不到Docker环境里的镜像也看不到crictl拉取的镜像——它们各自落在不同的namespace里。关于这个我后面还会展开说排错案例。另外containerd把容器的生命周期拆成两个对象container和task。container是容器元数据表示“这个容器存在”task是实际运行中的进程。你可以用ctr containers ls看到容器元数据用ctr tasks list看到正在运行的进程。很多刚上手的人只查了container列表发现容器“在”却在进程列表里看不到就以为出了bug——其实是这两个概念没对上。4. ctr命令详解containerd自带调试工具怎么用4.1 镜像操作完整镜像名很重要ctr是containerd自带的命令行工具定位偏向调试和底层操作。它跟docker CLI的习惯差别不小最明显的一点是镜像名必须写完整。Docker里你写nginx:latest它会自动补全成docker.io/library/nginx:latest但ctr不会那么“贴心”你需要把仓库地址、命名空间、镜像名和tag都写上。先看已有的镜像ctr images ls拉一个nginx镜像ctr images pull docker.io/library/nginx:latest给镜像打tagctr images tag docker.io/library/nginx:latest docker.io/library/nginx:v1导出和导入镜像时也用ctr images export和ctr images importctr images export nginx.tar docker.io/library/nginx:latest ctr images import nginx.tar注意ctr images export默认导出的tar包格式偏向OCI layout跟docker save打出来的包不一定完全兼容。如果要在Docker和containerd之间迁移镜像后面我会说更顺手的方案。4.2 容器与任务run、exec、kill、delete的完整用法用ctr跑一个容器ctr run -d --env NAMEnginx --net-host --rm docker.io/library/nginx:latest nginx-demo几个常用flag解释一下-d后台运行。--net-host使用宿主机网络。ctr默认不会自动给容器挂CNI网络调试时用主机网络最方便。--env设置环境变量。--rm容器生命周期结束后自动清理建议调试时带上否则会留下大量残留对象。查看当前运行中的taskctr tasks list查看容器元数据ctr containers ls进入正在运行的容器ctr tasks exec -t --exec-id my-exec-1 nginx-demo /bin/bash这里有个跟docker exec很不一样的点ctr强制要求你指定一个--exec-id相当于给这次exec会话起个名字否则会直接报错。这个id在同一个容器内必须唯一调试时随手写一个不重复的字符串就行。终止容器里的主进程ctr tasks kill -s SIGTERM nginx-demo然后在ctr里删除的顺序是先删task再删containerctr tasks delete nginx-demo ctr containers delete nginx-demo如果从一开始就带了--rmtask退出后ctr会自动清理不用你手动删两步。我给小白做演示时通常会强制要求每次ctr run都带--rm和唯一容器名最大限度避免残留。4.3 ctr、crictl、nerdctl三个工具如何选一台机器上可能同时装了好几个容器命令行工具很多人不知道用哪个。我的习惯是这样分的ctrcontainerd原生CLI功能最贴近底层适合调试containerd本身、确认镜像是否真的存在于存储层、排查daemon状态。不适合日常高频操作因为它不支持buildflag习惯也和Docker差异大。crictlCRI接口的调试工具专门查看Kubernetes通过containerd创建的Pod和容器。在K8s节点上查问题优先用crictl ps、crictl logs、crictl exec不要用ctr去乱看。nerdctlcontainerd官方维护的一个类Docker CLI工具兼容docker命令习惯支持run、build、compose等。如果你用containerd做单机容器管理或者正在从Docker迁过来遇到不熟悉的错误提醒用nerdctl最快缓解。三个工具命令对比如下操作dockernerdctlctrcrictl查看镜像docker imagesnerdctl imagesctr images lscrictl images跑容器docker runnerdctl runctr runcrictl run需配合sandbox查看运行容器docker psnerdctl psctr tasks listcrictl ps进入容器docker execnerdctl execctr tasks execcrictl exec查看日志docker logsnerdctl logs不支持crictl logs在K8s节点上排查问题时我的建议是业务容器状态用crictl ps看节点级containerd状态用ctr看两者结合视野才完整。5. 从Docker迁移到containerd镜像、命令、集群三个维度5.1 镜像迁移的可行方案假设你有一批存量环境用的Docker现在要切换到containerd。镜像迁移是最先要解决的问题。如果你有私有镜像仓库最省事的方案是直接把镜像推到仓库里在containerd节点重新pull。没有仓库的情况下可以用导出导入的方式在Docker节点上docker save -o nginx.tar nginx:latest把tar包拷贝到containerd节点然后用nerdctl load导入nerdctl load -i nginx.tar为什么用nerdctl load而不是直接ctr images import因为docker save打出来的包在文件头标注的是docker-archive格式旧版本的ctr images import对它的兼容性不够好。nerdctl load内部会做一层兼容处理导入体验更顺滑。如果连nerdctl都不想装也可以用skopeo这类镜像转换工具在Docker daemon和containerd存储之间做格式拷贝。总的来说工具链尽量统一不要混合使用导出导入否则很容易在格式兼容性上踩坑。5.2 日常操作替代nerdctl几乎让你忘了在换运行时从Docker迁到containerd最舒服的一点是有nerdctl这个兼容层存在。它支持绝大多数人已经习惯的docker命令nerdctl run -d -p 8080:80 --name web nginx:latest nerdctl ps -a nerdctl exec -it web bash nerdctl logs -f web nerdctl images nerdctl rmi nginx:latest如果你的项目用了Docker Composenerdctl compose也支持读取compose文件并拉起整套服务。把一台跑docker compose的机器切到containerd环境只要装了nerdctl-full包含CNI插件和buildkit命令基本无损迁移。比较典型的迁移路径是这样的先在测试机装好containerd和nerdctl把compose项目文件拿过来直接执行nerdctl compose up -d等服务起来了比对端口和日志。我见过很多团队就这样把单机容器服务从Docker切到了containerd整个过程基本不会让开发感知到变化。当然日常使用中发现最大差异还是在build能力上——nerdctl build需要额外部署buildkitd跟docker build的构建缓存和网络模式存在些差异构建逻辑复杂的话建议保留一台Docker机器专门做构建运行时节点用containerd。5.3 K8s节点接入CRI的配置要点如果是把整个Kubernetes集群切到containerd核心是把kubelet的运行时端点指到containerd的socket。通常这样配置kubelet--container-runtime-endpointunix:///run/containerd/containerd.sock在Kubernetes较新版本里--container-runtimeremote实际上已经变成了默认行为。集群初始化时只要kubelet能连通containerd的CRI插件节点就能正常注册。验证CRI插件是否工作可以用crictl infocrictl info输出的runtimeName字段应该显示containerd。这里还有一个容易踩的细节配置好containerd之后不要在K8s节点上通过ctr -n k8s.io随意创建容器。Kubernetes对Pod的容器数量和状态是有自己一套管理的拿ctr强行往k8s.io命名空间里塞容器会影响节点状态判断排查问题时非常痛苦。K8s节点上的日常观测用crictl底层验证再用ctr这个边界要守住。6. 用containerd时踩过的坑四个典型问题的完整排查链路6.1 kubelet报cgroup driver不一致节点疯狂NotReady现象很典型新装完containerd节点加入集群后状态反复NotReadykubelet日志里刷类似“cgroup driver cgroupfs ! kubelet cgroup driver systemd”的报错。第一次遇到时容易去翻网络插件、检查镜像但实际上问题出在配置一致性上。排查链路是先看kubelet的启动参数确认cgroupDriversystemd再看containerd配置里SystemdCgroup是不是还是默认的false。两者不一致时kubelet认为cgroup管理方式对不上节点健康检查自然过不了。处理办法就是打开SystemdCgroup并重启[plugins.io.containerd.grpc.v1.cri.containerd.runtimes.runc.options] SystemdCgroup truesystemctl restart containerd systemctl restart kubelet这个坑几乎每个裸机部署K8s的运维都会遇到一次建议装完containerd先改这个配置再初始化集群。6.2 ctr images ls看不到Docker环境里的镜像有人在Docker机器上执行docker images能看到一堆镜像切到containerd之后执行ctr images ls发现是空的第一反应是镜像丢了。其实不是丢了而是两个环境的存储和数据组织方式完全不同。Docker自己有独立的镜像存储目录通常情况下是/var/lib/docker/containerd里面运行着一套独立containerd加上自己的存储路径而系统独立安装的containerd数据目录默认是/var/lib/containerd。两者虽然都叫containerd但并不是同一个实例。再加上namespace不同Docker里的镜像落在moby命名空间独立containerd默认看default双方互相不可见。所以我平时都会提醒ctr images ls为空不代表没有镜像先想清楚当前环境用的是哪条containerd链路。如果确实要从Docker环境导入镜像参考5.1说的方案用nerdctl load或skopeo处理。6.3 task退出但container残留在列表里用ctr run跑了个容器容器进程一退出ctr tasks list确实空了但ctr containers ls里还能看到一条记录。这在第一次用ctr时非常容易造成困惑误以为容器卡死了。根源还是前面说的container和task分离。ctr默认在task退出后不自动删除container元数据除非你运行的时候带了--rm。所以排查思路是确认task是否真的不存在如果确认task已经没了直接清理元数据就行ctr tasks delete nginx-demo ctr containers delete nginx-demo如果task还显示running但容器里进程确实退出了那多半是shim和进程之间的状态同步出问题需要进一步看systemd日志。不过大多数情况都是没带--rm的清理问题养成脚本和调试命令都带--rm的习惯能少很多麻烦。6.4 私有镜像仓库自签证书导致x509报错拉取私有仓库镜像时经常看到类似“x509: certificate signed by unknown authority”的报错。原因是containerd默认按标准TLS校验遇到自签证书的私服仓库需要在containerd配置里单独指定CA证书和校验策略。比较通用的做法是配置config_path。先确保config.toml里指向了配置目录[plugins.io.containerd.grpc.v1.cri.registry] config_path /etc/containerd/certs.d然后在/etc/containerd/certs.d/private.registry.com/hosts.toml里写入server https://private.registry.com [host.https://private.registry.com] ca /etc/containerd/certs.d/private.registry.com/ca.crt skip_verify false把CA证书放到对应目录后重启containerdsystemctl restart containerd如果是containerd 1.x且用了老的registry.mirrors写法也可以在那个基础上增加registry.configs配置TLS但2.x之后官方明显更倾向config_path这种按域名拆分配置的方式。建议新项目一步到位全用config_path后面维护证书、mirror列表都会清晰很多。我在实际运维里最深的体会是containerd并不难难点在于用户心智从Docker那一套切换过来时要经历不少“为什么不认识、为什么看不到、为什么命令不一样”的摩擦。把这些摩擦逐条拆掉容器运行时这层迷雾也就散了。最终你会发现理解containerd的架构之后Docker也好、crictl也好不过是同一个底层之上不同的操作界面而已。如果你刚上手拿到一台新机器第一件事先看/etc/containerd/config.toml里的SystemdCgroup和sandbox_image这两个地方对了K8s节点至少能避开一半的坑。