容器运行时从Docker到containerd的迁移复盘:兼容性验证、性能对比与零宕机切换方案
一、项目背景与业务挑战
Kubernetes 1.24 正式移除了 dockershim,这意味着 K8s 不再内置 Docker 支持。我们的 3 个生产集群(共 1200+ Node)全部使用 Docker 作为容器运行时,必须在升级到 K8s 1.26 之前完成 Docker → containerd 的迁移。
迁移面临的三个核心挑战:
我们做了全面的评估和测试,最终用 4 周时间完成了 1200+ Node 的全量迁移,期间零服务中断。
二、核心方案:兼容性验证 + 性能基准 + 逐节点滚动迁移
2.1 Docker vs containerd 兼容性差异梳理
迁移前必须识别所有 Docker 依赖点。我们扫描了集群中所有 Pod 的 YAML 配置,识别了三类兼容性问题:
import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
class CompatibilityIssueType(Enum):
"""兼容性问题类型"""
DOCKER_SOCKET_MOUNT = "docker_socket_mount" # Docker socket挂载
DOCKER_LOGGING_DRIVER = "docker_logging_driver" # Docker特有日志驱动
DOCKER_COMPOSE_DEPENDENCY = "docker_compose_dep" # docker-compose依赖
DOCKER_NETWORK_PLUGIN = "docker_network_plugin" # Docker网络插件
DOCKER_VOLUME_DRIVER = "docker_volume_driver" # Docker存储驱动
@dataclass
class CompatibilityIssue:
"""兼容性问题记录"""
pod_name: str # Pod名称
namespace: str # Namespace
issue_type: CompatibilityIssueType # 问题类型
description: str # 问题描述
remediation: str # 修复方案
priority: str = "medium" # 优先级:high/medium/low
resolved: bool = False # 是否已修复
class CompatibilityScanner:
"""兼容性扫描器:识别Docker特有依赖"""
# Docker socket 挂载检测规则
DOCKER_SOCKET_PATHS: List[str] = [
"/var/run/docker.sock",
"/run/docker.sock",
"/var/lib/docker",
]
# Docker 特有日志驱动列表
DOCKER_LOGGING_DRIVERS: List[str] = [
"json-file", # Docker默认日志驱动(containerd使用CRI日志)
"journald", # Docker支持的journald驱动
"splunk", # Docker Splunk日志驱动
"gelf", # Docker GELF日志驱动
]
# Docker Compose 特征标记
COMPOSE_INDICATORS: List[str] = [
"docker-compose",
"compose file",
"depends_on",
"docker-compose.yml",
]
def scan_cluster(self, pod_list: List[dict]) -> List[CompatibilityIssue]:
"""扫描集群中所有Pod的Docker依赖
Args:
pod_list: Pod配置列表
Returns:
兼容性问题列表
"""
issues = []
try:
for pod in pod_list:
pod_name = pod.get("metadata", {}).get("name", "")
namespace = pod.get("metadata", {}).get("namespace", "")
# 检查Docker socket挂载
for volume in pod.get("spec", {}).get("volumes", []):
host_path = volume.get("hostPath", {}).get("path", "")
if host_path in self.DOCKER_SOCKET_PATHS:
issues.append(CompatibilityIssue(
pod_name=pod_name, namespace=namespace,
issue_type=CompatibilityIssueType.DOCKER_SOCKET_MOUNT,
description=f"Pod挂载Docker socket: {host_path}",
remediation="改用Kubernetes原生API替代Docker CLI",
priority="high"
))
# 检查容器内的Docker socket挂载
for container in pod.get("spec", {}).get("containers", []):
for mount in container.get("volumeMounts", []):
if mount.get("name", "") in [v.get("name", "") for v in pod.get("spec", {}).get("volumes", [])]:
vol = next((v for v in pod.get("spec", {}).get("volumes", [])
if v.get("name") == mount.get("name")), {})
host_path = vol.get("hostPath", {}).get("path", "")
if host_path in self.DOCKER_SOCKET_PATHS:
logger.warning(f"Docker socket挂载: {pod_name}/{namespace}")
# 检查Docker特有日志驱动(通过Annotations检测)
annotations = pod.get("metadata", {}).get("annotations", {})
for key, value in annotations.items():
if "docker" in key.lower() or "logging" in key.lower():
for driver in self.DOCKER_LOGGING_DRIVERS:
if driver in value and driver != "json-file":
issues.append(CompatibilityIssue(
pod_name=pod_name, namespace=namespace,
issue_type=CompatibilityIssueType.DOCKER_LOGGING_DRIVER,
description=f"Pod使用Docker日志驱动: {driver}",
remediation="切换到Kubernetes CRI标准日志收集(Fluentd/Fluent Bit)",
priority="medium"
))
logger.info(f"兼容性扫描完成,发现 {len(issues)} 个问题")
return issues
except Exception as e:
logger.error(f"兼容性扫描异常: {e}", exc_info=True)
return []
兼容性扫描结果:在 5000+ Pod 中发现 47 个兼容性问题,其中 23 个高风险(Docker socket 挂载)、15 个中风险(非标准日志驱动)、9 个低风险(Docker Compose 依赖)。
2.2 性能基准测试
迁移前我们搭建了两个对照集群(各 50 Node),分别运行 Docker 和 containerd,执行标准化基准测试:
基准测试覆盖四个维度:
| 镜像拉取延迟(100MB) | 12.3s | 8.1s | ↓34% | containerd更快 |
| 容器启动延迟 | 2.8s | 1.9s | ↓32% | containerd更快 |
| Pod调度延迟 | 5.2s | 4.1s | ↓21% | containerd更快 |
| Node CPU开销(运行时) | 3.5% | 1.8% | ↓49% | containerd更轻 |
| 日志收集吞吐 | 15MB/s | 18MB/s | ↑20% | containerd更快 |
| 构建排队时间 | 45s | 38s | ↓16% | containerd更快 |
结论:containerd 在所有测试维度上均优于 Docker,性能差异范围 16%-49%,迁移不会带来性能回退风险。
2.3 逐节点滚动迁移方案
核心迁移流程是"逐节点滚动切换"——每次选择一批 Node(batch size = 5%),执行以下步骤:
import logging
from typing import List, Optional
logger = logging.getLogger(__name__)
class RuntimeMigrationExecutor:
"""容器运行时迁移执行器:逐节点滚动切换"""
# 迁移批次大小(每批迁移5%的Node)
BATCH_PERCENTAGE = 0.05
# Pod驱逐优雅期
GRACE_PERIOD_SECONDS = 300 # 5分钟
def __init__(self, k8s_client, node_selector=None):
self.k8s_client = k8s_client
self.node_selector = node_selector # Node选择器(可按区域/角色分组)
def execute_migration(self, total_nodes: int) -> dict:
"""执行全量滚动迁移
Args:
total_nodes: 总Node数量
Returns:
迁移结果统计
"""
batch_size = max(1, int(total_nodes * self.BATCH_PERCENTAGE))
total_batches = total_nodes // batch_size + 1
results = {
"total_nodes": total_nodes,
"batch_size": batch_size,
"total_batches": total_batches,
"migrated": 0,
"failed": 0,
"skipped": 0,
}
try:
for batch_num in range(1, total_batches + 1):
logger.info(f"开始迁移第 {batch_num}/{total_batches} 批")
# 选择当前批次的Node列表
batch_nodes = self._select_batch_nodes(batch_num, batch_size)
logger.info(f"本批次Node: {batch_nodes}")
for node in batch_nodes:
success = self._migrate_single_node(node)
if success:
results["migrated"] += 1
logger.info(f"Node {node} 迁移成功")
else:
results["failed"] += 1
logger.error(f"Node {node} 迁移失败,标记跳过")
# 每批次迁移后暂停10分钟,观察稳定性
logger.info(f"第 {batch_num} 批完成,暂停10分钟观察")
# 实际实现:等待10分钟 + 监控健康检查
logger.info(f"迁移完成: 成功 {results['migrated']}, 失败 {results['failed']}")
return results
except Exception as e:
logger.error(f"迁移执行异常: {e}", exc_info=True)
return results
def _migrate_single_node(self, node_name: str) -> bool:
"""迁移单个Node的容器运行时
Args:
node_name: Node名称
Returns:
迁移是否成功
"""
try:
# 第1步:标记Node为不可调度(cordon)
logger.info(f"[{node_name}] Step 1: cordon节点")
self.k8s_client.cordon_node(node_name)
# 第2步:优雅驱逐所有Pod(drain)
logger.info(f"[{node_name}] Step 2: drain节点,优雅期 {self.GRACE_PERIOD_SECONDS}s")
self.k8s_client.drain_node(
node_name,
grace_period=self.GRACE_PERIOD_SECONDS,
ignore_daemonsets=True, # DaemonSet Pod不驱逐
delete_emptydir_data=True # 清理emptyDir数据
)
# 第3步:停止Docker服务
logger.info(f"[{node_name}] Step 3: 停止Docker服务")
# SSH执行: systemctl stop docker docker.socket
# 第4步:安装并启动containerd
logger.info(f"[{node_name}] Step 4: 安装containerd")
# SSH执行: apt install containerd && systemctl start containerd
# 第5步:配置kubelet指向containerd
logger.info(f"[{node_name}] Step 5: 配置kubelet运行时为containerd")
# 修改kubelet配置: –container-runtime=remote –container-runtime-endpoint=/run/containerd/containerd.sock
# 第6步:重启kubelet
logger.info(f"[{node_name}] Step 6: 重启kubelet")
# systemctl restart kubelet
# 第7步:验证Node健康
logger.info(f"[{node_name}] Step 7: 验证Node健康状态")
if not self._verify_node_health(node_name):
logger.error(f"[{node_name}] 健康检查失败,回退到Docker")
self._rollback_node(node_name)
return False
# 第8步:恢复Node调度(uncordon)
logger.info(f"[{node_name}] Step 8: uncordon节点,恢复调度")
self.k8s_client.uncordon_node(node_name)
return True
except Exception as e:
logger.error(f"[{node_name}] 迁移异常: {e}", exc_info=True)
self._rollback_node(node_name)
return False
def _verify_node_health(self, node_name: str) -> bool:
"""验证Node迁移后的健康状态
检查项:kubelet Ready、containerd运行、Pod可调度
"""
try:
node_status = self.k8s_client.get_node_status(node_name)
# 检查kubelet是否Ready
conditions = node_status.get("conditions", [])
ready = any(c.get("type") == "Ready" and c.get("status") == "True"
for c in conditions)
if not ready:
logger.error(f"Node {node_name} kubelet未Ready")
return False
# 检查containerd进程是否运行
# SSH执行: systemctl is-active containerd
return True
except Exception as e:
logger.error(f"健康检查异常: {e}")
return False
def _rollback_node(self, node_name: str) -> bool:
"""回退Node到Docker运行时"""
try:
logger.warning(f"回退Node {node_name} 到Docker")
# 停止containerd,恢复Docker配置,重启kubelet
# systemctl stop containerd
# 修改kubelet配置恢复Docker参数
# systemctl restart kubelet docker
return True
except Exception as e:
logger.error(f"回退异常: {e}")
return False
三、实践落地:迁移过程与效果数据
3.1 47 个兼容性问题的修复
23 个高风险 Docker socket 挂载 Pod 的修复方式分类:
| 改用K8s Client API | 15 | 原通过Docker CLI操作容器,改为调用K8s API |
| 改用BuildKit | 5 | 原通过Docker socket构建镜像,改为远程BuildKit |
| 移除挂载 | 3 | CI/CD Pipeline不再需要Docker CLI,直接移除 |
15 个中风险日志驱动 Pod 全部切换到 Fluent Bit(K8s CRI 标准日志收集)。
9 个低风险 Docker Compose 依赖通过迁移到 K8s Manifest 解决。
3.2 迁移过程数据
| 总Node数 | 1200 |
| 每批Node数 | 60(5%) |
| 总批次 | 20 |
| 迁移总时长 | 4周 |
| 成功迁移Node | 1197 |
| 失败回退Node | 3 |
| Pod驱逐成功率 | 99.8% |
| 服务中断次数 | 0 |
3 个失败回退的 Node 原因分析:
- 2 个 Node:containerd 安装后镜像缓存损坏,回退后重新迁移成功
- 1 个 Node:kubelet 配置修改后启动失败,回退后排查为配置语法错误
3.3 迁移后性能对比数据(生产环境)
| 镜像拉取延迟 | 15.2s | 9.3s | ↓39% |
| 容器启动延迟 | 3.1s | 2.0s | ↓35% |
| Node CPU开销 | 3.8% | 2.1% | ↓45% |
| Node内存开销 | 280MB | 165MB | ↓41% |
| 日志收集吞吐 | 14MB/s | 17MB/s | ↑21% |
| Pod调度延迟 | 5.8s | 4.3s | ↓26% |
| 镜像存储占用 | 45GB/Node | 38GB/Node | ↓16% |
3.4 迁移后的运维变化
迁移后最显著的变化是运维复杂度的降低:
- 不再需要维护 Docker Engine:containerd 是 K8s 原生运行时,配置简单,升级与 K8s 版本对齐
- 日志收集统一化:从 Docker json-file + Fluentd 改为 CRI 标准日志 + Fluent Bit,配置更统一
- 镜像管理简化:containerd 使用 CRI image manager,无需额外维护 Docker image prune 等清理任务
四、关键挑战与应对策略
4.1 Docker socket 挂载的深度依赖
23 个 Pod 挂载 Docker socket 的用途各异:CI/CD 构建镜像、运维调试工具、安全扫描容器。不能简单移除挂载,必须找到替代方案。
应对策略:
- CI/CD场景:迁移到 Tekton + BuildKit,不再依赖 Docker socket
- 运维调试:改用 kubectl exec + crictl 替代 Docker CLI
- 安全扫描:改用 Trivy 的远程扫描模式,不需要在容器内运行 Docker CLI
4.2 大规模滚动迁移的调度压力
每批 60 个 Node 同时驱逐 Pod,可能导致其他 Node 的调度压力骤增。
应对策略:
- 分区域滚动:按可用区(AZ)分批迁移,同一AZ内最多迁移 5% Node,跨AZ分散压力
- PDB保护:关键服务设置了 PodDisruptionBudget,确保驱逐时最低可用实例数不低于阈值
- 扩容缓冲:迁移期间临时扩容 10% Node,为驱逐的 Pod 提供额外调度空间
4.3 回退机制的可靠性
万一 containerd 运行出现系统性问题,需要在短时间内回退到 Docker。
应对策略:
- 双运行时共存期:迁移初期每个 Node 保留 Docker Engine 的安装包,5分钟内可回退
- 渐进式清理:全部 Node 迁移完成后,保留 Docker Engine 30 天冷备期,再统一卸载
4.4 containerd 的运维工具差异
运维团队习惯了 Docker CLI(docker ps、docker logs、docker exec),containerd 使用 crictl,命令和输出格式完全不同。
应对策略:
- 迁移培训:制作 Docker → crictl 命令对照表,2周集中培训
- 工具适配:开发运维脚本包装层,将 docker 命令自动翻译为 crictl 命令
- 监控适配:Prometheus 指标从 docker_* 改为 containerd_*,Grafana Dashboard 全部更新
五、总结
从 Docker 到 containerd 的迁移,本质是从"外部依赖"回归"K8s原生"的架构精简。Docker 作为容器运行时的时代正在终结,containerd 作为 CNCF 标准运行时是 K8s 的长期方向。
三个关键经验:
下一步计划:基于 containerd 的 CRI 排错能力增强——开发基于 crictl 的智能排障脚本,替代传统 Docker 排障流程;同时探索 containerd 的镜像懒拉取(lazy pulling)功能,进一步优化镜像拉取延迟。





