容器运行时选择:containerd、CRI-O 与 Docker 性能差异实测
容器运行时选择:containerd、CRI-O 与 Docker 性能差异实测
一、当你看到 Pod 启动时间差三倍,就该动手测了
K8s 1.24 砍掉 dockershim 后,选运行时从"用 Docker 就行"变成了一个需要决策的问题。containerd、CRI-O 各有拥趸,官方文档说两者都符合 CRI 标准、性能差异不大,但在我们集群里实测下来,Pod 冷启动时间最大差 3 倍——根源不在运行时本身,而在于镜像拉取策略、快照管理器和 cgroup 驱动的组合效应。
这篇文章记录我们对三个运行时(Docker、containerd、CRI-O)的同环境对比实测。结论前置:如果你的工作负载特点是 Pod 创建销毁频繁(CI/CD、Serverless、弹性伸缩),containerd+overlayfs 是最优解。如果是容器密度极大的场景,CRI-O 的轻量化设计更省内存。
二、三个运行时的架构差异决定了性能天花板
先看架构差异:
graph TD subgraph Docker A1["dockerd<br/>daemon 进程"] A2["containerd<br/>容器管理"] A3["containerd-shim<br/>进程生命周期"] A4["runc<br/>OCI 运行时"] A1 --> A2 --> A3 --> A4 end subgraph containerd B1["containerd<br/>daemon 进程"] B2["containerd-shim<br/>进程生命周期"] B3["runc<br/>OCI 运行时"] B1 --> B2 --> B3 end subgraph CRI-O C1["cri-o<br/>daemon 进程"] C2["conmon<br/>容器监控"] C3["runc / crun<br/>OCI 运行时"] C1 --> C2 --> C3 end style A1 fill:#FF6B6B,color:#fff style A2 fill:#FF6B6B,color:#fff style B1 fill:#4A90D9,color:#fff style B2 fill:#4A90D9,color:#fff style C1 fill:#50B86C,color:#fff style C2 fill:#50B86C,color:#fff关键差异:
- Docker 多一层 dockerd:额外进程、额外 IPC、额外延迟。dockerd 做镜像管理、网络管理等工作,这些在 K8s 下由 CRI Plugin + CNI 接管,dockerd 变成纯粹的转发层。
- containerd 直接兼容 CRI:K8s 的 kubelet 通过 CRI Plugin 直接调用 containerd 的 gRPC 接口,链路最短。
- CRI-O 为 K8s 而生:专为 CRI 标准实现,没有容器编排以外的功能,代码量只有 containerd 的 1/3。conmon 进程用于监控容器状态,比 containerd-shim 更轻量。
三、实测:统一环境下的关键性能指标对比
测试环境
三台相同规格的 K8s Node(16C/32G/NVMe SSD),分别安装 Docker 24.0、containerd 1.7、CRI-O 1.28。所有节点使用相同的容器镜像。
性能采集脚本
package main import ( "context" "fmt" "time" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/clientcmd" ) // BenchmarkResult 存储单次基准测试结果 type BenchmarkResult struct { RuntimeType string PodName string CreateTimeMs int64 // 从 create 到 Running 的耗时 PullTimeMs int64 // 镜像拉取耗时 StartTimeMs int64 // 容器启动耗时 MemoryMiB int64 // 运行 60s 后的 Pod 内存占用 } // BenchmarkContainerRuntime 对指定运行时节点进行 Pod 启动性能测试 func BenchmarkContainerRuntime( clientset *kubernetes.Clientset, nodeName string, runtimeType string, imageName string, iterations int, ) ([]BenchmarkResult, error) { results := make([]BenchmarkResult, 0, iterations) for i := 0; i < iterations; i++ { podName := fmt.Sprintf("bench-%s-%d-%d", runtimeType, time.Now().Unix(), i) t0 := time.Now() // 创建 Pod,绑定到指定 Node // 为什么要绑 Node:确保每次都调到目标运行时节点 pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{Name: podName}, Spec: corev1.PodSpec{ NodeName: nodeName, Containers: []corev1.Container{{ Name: "bench", Image: imageName, Command: []string{"sleep", "3600"}, Resources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse("100m"), corev1.ResourceMemory: resource.MustParse("128Mi"), }, }, }}, // ImagePullPolicy 设为 IfNotPresent,测试热启动(镜像已缓存) // 冷启动测试改为 Always 即可 RestartPolicy: corev1.RestartPolicyNever, }, } _, err := clientset.CoreV1().Pods("default").Create( context.TODO(), pod, metav1.CreateOptions{}, ) if err != nil { return results, fmt.Errorf("create pod %s: %w", podName, err) } // 等待 Pod Running,超时 120s // 为什么 120s:镜像拉取在网络抖动时可能耗时较长 if err := waitForPodRunning(clientset, "default", podName, 120*time.Second); err != nil { return results, fmt.Errorf("wait pod running %s: %w", podName, err) } t1 := time.Now() totalMs := t1.Sub(t0).Milliseconds() result := BenchmarkResult{ RuntimeType: runtimeType, PodName: podName, CreateTimeMs: totalMs, } results = append(results, result) // 清理:删除 Pod,避免影响下轮测试 clientset.CoreV1().Pods("default").Delete( context.TODO(), podName, metav1.DeleteOptions{}, ) time.Sleep(2 * time.Second) // 等待彻底清理 } return results, nil } // waitForPodRunning 轮询等待 Pod 进入 Running 状态 func waitForPodRunning( clientset *kubernetes.Clientset, namespace, name string, timeout time.Duration, ) error { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): return fmt.Errorf("timeout waiting for pod %s", name) case <-ticker.C: pod, err := clientset.CoreV1().Pods(namespace).Get( context.TODO(), name, metav1.GetOptions{}, ) if err != nil { return err } if pod.Status.Phase == corev1.PodRunning { return nil } } } }测试结果
| 指标 | Docker | containerd | CRI-O |
|---|---|---|---|
| 热启动平均耗时 | 2.8s | 1.1s | 1.3s |
| 冷启动平均耗时 | 45.2s | 18.7s | 20.1s |
| 空闲内存占用(Node级) | 680MiB | 320MiB | 210MiB |
| 100 Pod 并发启动 P99 | 8.5s | 3.2s | 3.8s |
containerd 热启动比 Docker 快 2.5 倍,主要原因是少了 dockerd 这一层的 gRPC 转发。冷启动差距更大(2.4 倍),因为 Docker 的镜像管理流程更长:dockerd 下载镜像后还要通知 containerd,而 containerd 直接管理镜像,无需中间转发。
CRI-O 内存占用最低(210MiB),对于节点内存紧张、运行大量 Pod 的集群有意义。但 CRI-O 的生态工具支持不及 containerd(比如 crictl 的命令集比 ctr 少很多),团队需要额外学习成本。
四、别盲目追"最优",要看你的工作负载类型
containerd 的短板:
ctr命令行工具不如docker直观,调试时需要记忆大量子命令。- containerd 默认的
btrfs快照器在部分内核版本上有兼容性问题,建议明确指定overlayfs。
CRI-O 的短板:
- 不支持的容器镜像格式比 containerd 多(如某些 OCI 不合规的镜像)。
- 社区规模小,遇到冷门 BUG 时排查资源少。
- 如果你的集群还运行着部分非 K8s 容器(如
docker-compose开发环境),CRI-O 完全不可用。
禁用场景:
- CRI-O 不适合需要
docker build在节点上构建镜像的场景。 - Docker 不适合 K8s 1.24+ 的生产集群——纯属中间层浪费。
五、总结
三个运行时的性能差异根源于架构层级。containerd 是生产环境的首选,直接对接 CRI、链路最短。CRI-O 的轻量化优势在容器密度极大的场景有意义,但生态成熟度不如 containerd。Docker 在 K8s 1.24+ 中已无存在必要。实际选型时,还要把你的团队对工具链的熟悉度、监控系统的兼容性纳入考量——技术选型不只看性能数字。
