containerd实战:容器运行时原理、命令与K8s排障指南
搞容器的人大概都有过这种经历开发同事挺喜欢用 Docker 命令感觉一切都很简单。但等到我们把集群管理起来、把 Kubernetes 节点铺开以后才发现真正干活的含金量组件其实是 containerd。这个项目从 Docker 里拆出来之后逐渐变成了生产环境容器运行时的中坚力量不过网上的中文资料往往只讲概念、不讲操作或者堆一堆 ctr 命令却不解释为什么。这篇就把我实际使用 containerd 的定位理解、核心原理、日常操作命令、生产配置和排障经验一并说清楚适合容器平台开发者、K8s 集群管理员也适合所有想知道容器底层到底怎么运转的读者。1. 先认清一个问题容器运行时到底管了什么很多人第一次听到“容器运行时”这个概念脑子里会默认把它等同于 Docker。这里面的误解比你想象的大得多如果不纠正过来后面看 containerd 的命令和配置会始终有一层窗户纸。我先把这层纸捅破。1.1 Docker 并不是一个“运行时”从用户视角看Docker 确实干了从拉镜像、起容器、做网络、挂存储、打日志到端口映射的所有事所以大家习惯叫它“容器引擎”。但在实现层面Docker 是一个很典型的“分层组合”系统用户通过 docker CLI 发指令dockerd 常驻进程负责 API 管理和编排逻辑再往下才是真正的容器生命周期执行者。这个执行者早期可以直接调用 runc 之类的 OCI runtime后来 Docker 自己也把容器的核心管理能力收敛到了 containerd 中。我常打一个比方Docker 命令是外卖 App 的“用户下单页”dockerd 像调度中心containerd 是那个真正跑到店家里取餐、把餐品送到门卫的履约系统而 runc 则是最后骑手敲门那一下。用户感知到的只是外卖到了但整个链条中承担实际动作的是后面两层。这也解释了为什么 Docker 大版本升级时containerd 版本也会跟着变。Docker 不是独立造了一套容器管理器而是和 containerd 形成上下游关系。Kubernetes 想管理容器时理论上可以让 kubelet 直接调 containerd 的接口也可以让 kubelet 先找 Docker、再让 Docker 去找 containerd。后者明显多了一层代理Kubernetes 过去为了兼容 Docker 还专门维护过 dockershim后来这个中间层被废掉正好说明 containerd 这类独立运行时才是更合理的底座。1.2 containerd 的出身与定位containerd 最早来自 Docker 内部的容器管理模块2016 年被拆成一个独立项目随后捐献给了 CNCF。它从 Docker 里解耦出来这件事本身就很有深意Docker 希望把重心放在开发者体验和镜像构建上而容器核心的管理能力需要对外提供标准化接口不能只被 Docker 自己攥着。经过几年演进containerd 在 2019 年正式从 CNCF 毕业。它实现了 OCI 相关的镜像分发、容器运行、快照、网络等规范并且把 Kubernetes 需要的 CRI 功能以插件方式内置进来。现在提到 containerd我们说的是一个高稳定性、轻量级、面向“平台化”场景的容器运行时组件。如果只是用 Docker 跑几个测试容器你完全可以一辈子不和 containerd 直接打交道。但一旦把容器规模做到几十台节点、管理方变成了 Kubernetes 或者自研容器平台多一层 Docker 就多一层延迟、多一个潜在故障点也消耗更多系统资源。直接让 kubelet 通过 CRI 调用 containerd链路更短排障时能少看一层日志。1.3 裸用 containerd 到底解决了什么问题我认为最核心的收益有三点第一是降低了系统占用Docker 常驻架构需要 dockerd 进程、containerd 进程以及各种辅助模块都活在那里而直接部署 containerd 时宿主资源更干净尤其在几百台节点的规模下节省的内存和进程数非常可观。第二是减少版本耦合Docker 的发布节奏和 Kubernetes 的兼容性存在历史包袱containerd 作为 CRI 原生运行时和 kubelet 的版本兼容关系更直观。第三是排障模型更精确容器起不来就查 containerd镜像拉不到就查 containerd 配置不用再分不清问题到底出在 docker CLI、dockerd 还是底层。当然裸用 containerd 也有代价。它的原生客户端 ctr 功能粗糙语法更像底层调试工具不适合直接给普通开发使用。这个问题后面我会讲怎么用 nerdctl 和 crictl 补齐体验。2. 拆解 containerd 的核心架构看懂关键概念理解了定位之后再去看架构就比较顺了。containerd 不算复杂但它有几个抽象概念特别容易混淆namespace、content store、snapshotter、shim、sandbox。我把这些一个一个拆开讲因为它们直接决定你在排障时看到目录和日志会不会一头雾水。2.1 进程模型与 shim 的妙处先看进程模型。containerd 主进程不直接持有用户容器的进程而是为每个需要运行的容器启动一个名为 containerd-shim 的辅助进程。这个 shim 的套路是containerd 接收请求后创建出一个任务shim 调用 runc 去真正 fork 出容器进程等 runc 完成初始化和创建操作后退出shim 就作为容器进程的父进程常驻下来负责转发标准输入输出信号、维护容器退出状态码。为什么要夹一个 shim最早的设计动机是为了升级 containerd 时不打断正在运行的容器。如果容器进程直接挂在 containerd 主进程下面主进程一重启容器就会跟着断掉或变成孤儿。有了 shim 的隔离哪怕 containerd 二进制更新、套接字被重建正在跑的容器业务也能继续。我在测试环境实际做过滚动升级升级 containerd 包、重启 containerd 服务运行中的 Pod 没有受影响业务连接也没有中断。这就是 shim 模型最直观的价值。想验证这个设计可以在节点上执行 ps -ef | grep shim你会看到一堆 containerd-shim 进程每个容器一个它们不是异常状态而是正常架构。2.2 镜像管理content store、snapshotter 和 image storecontanerd 对镜像的处理比 Docker 更细它把存储分成了几个层次第一层是 content store负责保存镜像分层layer的原始 blob 数据目录一般在 /var/lib/containerd/io.containerd.content.v1.content。这部分有点像后端仓库里的货物只做内容寻址存储。第二层是 snapshotter负责把这些 layer 挂载、解压、合并成可用的 rootfs。默认的 overlayfs 快照器会把镜像层做成只读层在最上面创建一层可读写层。容器写文件时通过 overlay 文件系统的 copy-up 机制落到可写层这也解释了为什么多个容器共用同一个镜像时磁盘占用比较小。第三层是 image store负责保存镜像的元数据比如 tag、索引、平台架构等。它不直接保存 blob而是引用 content store 里的具体 data真正完成的是“货物清单 仓库定位”的工作。用生活场景来理解content store 是商场仓库snapshotter 是理货员把货物按展台要求摆好image store 是那本记录货物位置的台账。三者缺一不可。2.3 containerd 自身 namespace 的隔离含义containerd 有自己的 namespace 概念它和 Linux 内核的 kernel namespaces 不是一回事。containerd 的 namespace 更像一套数据隔离的键比如默认 namespace 叫 defaultKubernetes 通过 CRI 插件操作时会把数据放在一个叫 k8s.io 的 namespace 下面。平时用 ctr 命令时不带 -n 参数操作的就是 default 空间里的镜像和容器看不到 k8s.io 里正在运行的那些容器。于是出现了一个典型困惑节点上明明有容器在跑ctr containers ls 却看不到。因为 K8s 的容器在另一个逻辑空间里。这就是 namespace 隔离带来的最直接副作用排查问题前先确认 ctr 或 crictl 的视角对不对。如果想切到 k8s.io就执行ctr -n k8s.io containers ls。在跑 K8s 相关的排查时这一步几乎必用。2.4 CRI 插件与 PodSandboxKubernetes 的 CRI 接口定义了 kubelet 与运行时之间的协议其中有一个重要资源叫 PodSandbox它对应一个 Pod 的基础设施容器。containerd 的 CRI 插件里围绕 sandbox 做了生命周期管理先启动一个 sandbox 容器即 pause 容器它主要负责占住网络命名空间、作为同 Pod 内容器的“锚点”然后再在该 sandbox 中启动业务容器。在节点上执行 crictl ps -a 时你经常会看到如 pause 或一个未知名称的沙箱容器处于运行状态这不代表异常。相反如果 sandbox 起不来Pod 里的业务容器也一定起不来。排障时先看 sandbox 状态再往下查业务容器的退出码顺序不能搞反。3. 实操三种客户端命令的使用与取舍很多文章只介绍 ctr 一个工具但我觉得在实际工作中更合理的姿势是“三个工具都装上按场景挑选”。ctr 适合调试 containerd 本身nerdctl 适合开发自测和日常批量管理crictl 适合 Kubernetes 节点排障。三者的命令风格差异很大很多人不知道什么时候该用哪个。3.1 ctr原生的底层调试工具ctr 是 containerd 自带的 CLI直接和 containerd 的 gRPC API 对话相当于对着底层引擎说话。它没有任何 Docker 式的便利设施没有端口映射参数没有自动挂载卷甚至默认连标准输入输出都没有显式接好。但正因为底层它最能暴露 containerd 的状态。最常用的几组命令是# 查看命名空间 ctr namespace ls # 查看默认命名空间下的镜像 ctr images ls # 拉取镜像 ctr images pull docker.io/library/alpine:latest # 查看镜像内容比较接近 docker inspect ctr images inspect docker.io/library/alpine:latest # 查看容器对象 ctr containers ls # 查看正在运行的任务 ctr tasks ls # 运行一个容器任务 ctr run --rm docker.io/library/alpine:latest alpine-demo sh -c echo helloctr run 的参数顺序需要特别注意先写标志再写镜像引用然后写容器 ID最后写容器内要执行的命令。很多新手把顺序写反提示语法错误。这里的 --rm 和 Docker 的 --rm 类似任务退出后直接清理容器记录。想要指定命名空间时在命令最前面加 -n 参数。例如ctr -n k8s.io images ls ctr -n k8s.io containers lsctr 看不到端口映射、卷挂载这些“编排层”信息因为它只管容器核心生命周期。如果你试图用 ctr run 跑一个需要网络访问的服务会发现没有 docker run -p 那种东西必须自己处理 iptables 或 CNI。这说明 ctr 的定位更接近硬件诊断仪而不是日常驾驶仪表盘。3.2 nerdctl容器管理体验补齐的关键nerdctl 是 containerd 生态里我非常推荐的日常管理工具。它是一个与 Docker CLI 高度兼容的客户端支持 docker run、docker compose、docker build 等大多数常用子命令但后端直接连到 containerd不需要 dockerd 进程。安装方面的要求是 containerd 正常工作并且还有 CNI 插件因为它要负责为容器创建网络接口。装好后大部分命令可以无痛切换# 查看镜像 nerdctl images # 运行容器并映射端口 nerdctl run -d -p 8080:80 nginx:alpine # 进入容器 nerdctl exec -it 容器ID sh # 查看日志 nerdctl logs -f 容器ID # 执行清理 nerdctl system prune -anerdctl 最大的价值是给还在依赖 Docker 开发习惯的人一条平缓的上手路径同时不需要在宿主机上安装 Docker。生产集群节点上不需要装它但排障时装一个能省不少事。需要留意的是 nerdctl 和 docker CLI 的兼容性在逐步完善个别高级参数可能出现差异比如 docker build 构建缓存的行为、某些 compose 字段的支持程度动手前先看下版本文档会更稳妥。3.3 crictlKubernetes 节点上的第一排查工具如果你不关心 K8scrictl 可以先放一放但运维 K8s 集群的话crictl 是比 kubectl 更底层、比 ctr 更友好的存在。它专门面向 CRI 运行时暴露的一层 API能直接看到 Kubelet 视角下的 Pod、Sandbox、容器信息。常用命令如下# 查看所有容器包括退出的 crictl ps -a # 查看 PodSandbox crictl pods # 查看容器详细信息 crictl inspect 容器ID # 查看容器日志 crictl logs 容器ID # 进入容器执行命令 crictl exec -it 容器ID sh # 查看资源占用 crictl stats结合 K8s 排障场景kubectl logs 偶尔会拿不到容器日志这时候完全可以到节点上执行 crictl logs 直接拉。即使 kubectl 能工作crictl 也能帮你确认容器的 ExitCode、创建时间、镜像 ID 等细节信息粒度比 kubectl describe 更接近运行时本身。3.4 三个客户端的定位对比用一个表格把它们的差异列出来便于按场景选择工具主要管理对象是否理解 Pod 语义是否支持端口映射是否支持镜像构建典型使用场景ctr容器、任务、镜像、命名空间否否否底层调试、查看 containerd 原生状态nerdctl容器、镜像、网络、卷、Compose部分是支持日常管理、开发测试crictlPod、Sandbox、容器是否否K8s 节点排障、CRI 层诊断我个人的习惯是三件套都会留在工具清单里日常运维和自测用 nerdctl上 K8s 排查先用 crictl需要直接看 containerd 的元数据、内容存储时再拉 ctr。4. 生产实践配置 containerd 并接入 Kubernetes命令部分只能解决“怎么操作”的问题生产部署还需要理解配置文件、镜像加速、CRI 对接和监控。这一章我梳理一套从零配置到接入 K8s 的完整路径重点解释参数为什么这么写。4.1 安装与基础初始化以主流 Linux 发行版为例安装很简单yum install -y containerd.io # 或者 apt install -y containerd.io装完后先确认一下服务是否被 systemd 管理systemctl enable --now containerd systemctl status containerd接着生成一份可编辑的默认配置。containerd 支持在没有配置文件的情况下用内置默认值启动不过生产环境我会先导出来改几个关键项mkdir -p /etc/containerd containerd config default /etc/containerd/config.toml systemctl restart containerd生成出来的配置很长不用急着全看懂重点检查几个段落Cri 插件配置、快照器驱动、registry 镜像配置。containerd 默认使用 overlayfs 快照器这在内核支持 overlay 的现代服务器上没有问题。如果用的一些旧内核或特殊文件系统不兼容 overlay可以在配置中把 snapshotter 改为 native只是镜像部署时占用的磁盘空间会明显变大。4.2 镜像加速与仓库配置国内环境下拉取 Docker Hub 镜像经常不稳定生产环境必须把加速器或镜像仓库代理配好。在 config.toml 中新版配置路径通常是[plugins.io.containerd.grpc.v1.cri.registry.mirrors] [plugins.io.containerd.grpc.v1.cri.registry.mirrors.docker.io] endpoint [https://docker.m.daocloud.io]注意这种配置的含义当 containerd 尝试拉取 docker.io 下的镜像时它会优先访问 endpoint 列表里的地址。可以配多个 endpointcontainerd 会按顺序尝试。如果内网有自建的 Harbor 或代理缓存就把内网地址放到最前面这样回源时不会导到公网速度和稳定性都好很多。改完配置文件后需要执行 systemctl restart containerd 才能生效。这里有个很容易踩的坑如果配置里的插件名引号写法和你当前 containerd 版本不匹配会导致 containerd 启动失败或者镜像配置不生效。遇到这种情况先看一下 containerd config dump 输出的实际路径格式再按对应格式修改不要照抄网上的旧配置。4.3 与 Kubernetes 对接Kubelet 通过 CRI 协议和运行时通信默认的 socket 路径是 unix:///run/containerd/containerd.sock。用 kubeadm 初始化集群时可以在 kubeadm init 或 kubelet 配置里直接指定kubeadm init --cri-socketunix:///run/containerd/containerd.sock如果集群本来用的是 Docker想迁移到 containerd那是一个比较大的变更需要先 drain 节点、停止 kubelet然后卸载 Docker、安装 containerd、再启动 kubelet最后 uncordon 节点。整个过程中最重要的验证手段是 crictl ps如果 crictl 能连上 sokcet 并列出 Pod 容器说明 Kubelet 和 containerd 的对接已经成功。有一个关于 CRI 版本的问题值得注意老版本 kubelet 可能会要求运行时启用某个 CRI API 版本containerd 一般会在启动时自动协商。当你发现 kubelet 日志一直提示连不上 containerd 时优先检查 socket 是否存在、权限是否够而不是急着改集群版本。4.4 日志与监控containerd 自身的日志由 systemd 管理查看方式journalctl -u containerd -f如果日志量大在 journalctl 命令后面加 -n 100 可以只看最近的 100 行。需要采集运行时指标时containerd 暴露了一个 Prometheus 风格的指标端点通常默认监听在 1338 端口。Kubernetes 节点上常有自带的监控组件直接在 prometheus 的 scrape_config 里加一个 containerd 的 job就可以把容器运行时层面的指标接进来比如容器数量、快照器使用情况、正在运行的 task 数。5. 常见问题与排查技巧实录到了这道环节才是真正拉开经验差距的地方。containerd 表面稳定但一旦出问题现象往往让人摸不着头脑。我把过去实际踩过、帮别人排过的几类高频问题汇总在这里顺便给出立即能落地的处理思路。5.1 镜像拉取慢或直接失败现象是 kubelet 里报 ErrImagePull到节点上用 ctr images pull 手动跑一遍能看到网络层的具体报错。常见原因有三个镜像加速没配置、DNS 解析异常、镜像仓库证书校验不通过。解决方法按顺序做先确认配置文件中 mirror 段落有没有生效用ctr images pull docker.io/library/nginx:latest测试一次再检查 /etc/resolv.conf 和节点 DNS部分内网环境需要把自建仓库解析到内部 IP最后如果连的是 HTTPS 但证书过期或者自签名可以在 registry 配置里加 tls 相关参数测试环境可以临时跳过校验生产环境不该这么干。如果拉取的是私有仓库镜像记得先把账号密码写入配置文件containerd 没有像 docker login 那样把凭证存到本地它需要你在 root 环境下直接配置 registry 的 auth 字段。这个细节很容易被从 Docker 迁移过来的同学忽略结果每次拉取都卡在 unauthorized。5.2 容器反复异常退出K8s 里表现为 CrashLoopBackOff到节点上执行 crictl ps -a 能看到容器状态是 ExitedExitCode 非 0。先别急着看程序日志用 crictl inspect 拿容器完整状态再通过 crictl logs 看业务输出。如果日志为空那就很可能是容器还没把业务跑起来就崩了此时把镜像拉下来在 ctr run 环境里前台执行一遍是特别快的验证手段。还有一种情况是启动时 shim 无法创建报错信息里带有 permission denied多半是节点安全模块或 seccomp 配置问题。查看 containerd 日志时如果出现类似的 exec format error还要看一下镜像平台是否和宿主机架构一致比如在 arm64 节点上拉了 amd64 镜像。5.3 overlayfs 或 runc 相关的启动失败有段时间我遇到一个批次节点只要启动容器就报 unknown runtime spec members 错误节点上一查内核的 overlay 模块加载异常。解决方式是确认内核版本是否满足 overlayfs 要求必要时重新配置模块或换回 native snapshotter。这类问题通常不会全平台出现而是一批特定内核的节点中招排查时多对比不同节点的系统版本会很有帮助。runc 相关的另一类故障是容器内部文件系统挂载失败报 read-only file system。先确认磁盘是否写满再看快照器对应的挂载目录权限。df -h 和 df -i 两个命令都要看inode 占满也会造成创建文件失败但磁盘剩余空间可能显示还有不少。5.4 containerd 升级或重启时容器会不会挂基于 shim 机制只重启 containerd 服务一般不会中断正在运行的容器。但升级 containerd 包时要考虑到 shim 二进制版本和 containerd 主程序匹配的问题。比较稳妥的顺序是先 drain 节点再升级 containerd启动服务后用 crictl ps 验证最后 uncordon 节点。实际操作中我踩过一次坑升级只更新了 containerd 包但没有同步更新 shim 相关文件结果节点上存量容器在新版本 containerd 重启后出现状态不一致。后续我都会把升级动作安排在维护窗口并且升级完立刻验证几种常见容器状态不能只看 containerd 的 systemd 状态是 active 就认为万事大吉。5.5 磁盘空间被旧镜像占用如果只用 ctr 或 crictl清理无用镜像不像 docker prune 那么顺手。containerd 原生提供的是 content 层的 GCctr content ls ctr content gc不过 content gc 只清理没有被引用的内容不会自动理解“哪些 tag 已废弃”。如果日常装了 nerdctl用它的 prune 命令更方便nerdctl system prune -a这里要特别提醒直接删除 /var/lib/containerd 下的目录几乎等于自杀因为里面除了原始层数据还有镜像元数据和容器状态删掉后通过任何命令都很难完整恢复运行中的容器和镜像。要清理就按命令来不要直接在文件系统上动刀。5.6 快速排查速查表我把高频问题整理成一张速查表方便遇到现象时直接定位现象优先排查命令常见原因镜像拉取超时ctr images pull加速器未生效、DNS 异常镜像拉取 403检查 config.toml mirrorregistry 连接串配置错误Container 退出码非 0crictl logs业务崩溃、镜像平台不符CrashLoopBackOffcrictl inspectsandbox 异常、资源不足无法启动容器journalctl -u containerdoverlayfs、runc、磁盘写满Kubelet 报告连不上运行时ls /run/containerd/containerd.socksocket 路径不对、权限不够容器看不到ctr -n k8s.io containers ls命名空间视角不对镜像列表很大但无 tagctr content lscontent store 残留等 GC5.7 一处容易忽视的坑服务启动顺序额外补充一个容易被无视的细节containerd 和 kubelet 都通过 systemd 管理但如果节点重启时 containerd 没有设置开机自启kubelet 会先连不上运行时然后不断重试表现为 Pod 一直 Pending。排查时先执行 systemctl is-enabled containerd确保它是 enabled。还有一个看起来很低级但实际频率不低的坑磁盘被日志文件打满containerd 日志文件或容器日志文件把 /var 占满后新的容器无法创建老容器也开始读写失败。监控节点时别只盯 CPU 和内存磁盘空间和 inode 永远都要看。这些年把 containerd 当主力运行时用下来我最大的体会是先接受它的“朴素”不要总拿 Docker 的习惯去套。理解 namespace、shim、content store、snapshotter 这些抽象之后再去看命令和配置就顺理成章了。日常管理如果没有 Docker 的依赖完全可以只用 nerdctl 和 crictl只有真的需要诊断 containerd 自身状态时再去动用 ctr。这套组合我用了很久稳。