【K8S 运维实战】40-Docker迁移Containerd

📅 2026/8/6 23:33:33
【K8S 运维实战】40-Docker迁移Containerd
案例:从 Docker 迁移到 Containerd一句话定位:K8s 1.24 移除 Dockershim,500 节点从 Docker 迁移到 Containerd,这不是一次升级,而是一次涉及镜像、构建、日志、监控、CRI 兼容性的系统性工程——复盘迁移全过程与那些文档里不会写的坑。写在前面2022 年 K8s 1.24 正式移除 Dockershim,这件事在社区吵了很久,但真正落到生产,很多团队是 2023-2024 年才动手。原因是 Dockershim 移除不是换个运行时那么简单,它牵扯到镜像构建、日志采集、监控指标、私有仓库认证、特殊挂载等一整套链路。我们公司 2024 年 Q1 启动迁移,500 节点、3 个集群、200 业务,前后花了 6 周完成全量迁移,中间踩了 7 个有意义的坑。这篇文章复盘整个迁移过程。迁移这类基础设施替换工程,最大的难点不是技术,而是兼容性盲区——你以为都兼容,实际有 N 个边角场景不兼容,而且往往在灰度到生产才暴露。我把我们遇到的所有盲区都列出来,希望大家迁移时能提前规避。迁移的核心方法论是:先评估兼容性,再灰度迁移,每一步都有回滚预案,绝不一把梭。案例概览维度内容集群规模3 个生产集群,共 500 节点K8s 版本1.20(迁移前) → 1.24(迁移后)容器运行时Docker 20.10 → Containerd 1.7业务规模200 业务,4000 Pod时间跨度2024/02/15 启动 → 2024/03/30 完成迁移方式节点排空 → 替换运行时 → 验证 → 恢复调度关键挑战私有仓库认证、日志路径、监控指标、镜像构建、特殊配置最终效果零业务中断,资源占用降 15%,Pod 启动快 20%迁移时间线:02-1802-2503-0303-1003-1703-2403-31影响评估兼容性测试灰度迁移10节点灰度迁移50节点全量迁移集群1全量迁移集群2全量迁移集群3遗留问题修复复盘评估灰度全量收尾Docker → Containerd 迁移时间线一、背景与挑战1.1 迁移动机K8s 1.24 强制:K8s 1.24 移除 Dockershim,继续用 Docker 需要装 cri-dockerd 适配层,增加复杂度。资源节省:Docker 多了 dockerd containerd 两层,containerd 单层,内存占用少 30%。性能更好:Pod 启动更快,镜像 pull 更快(containerd 并发拉取)。社区方向:K8s 社区主推 containerd,新特性优先支持。1.2 影响评估迁移前先做影响评估,梳理所有受影响的链路:链路Docker 方式Containerd 方式影响程度镜像构建docker buildbuildkit / kaniko高镜像拉取docker pullcrictl pull / ctr中私有仓库认证~/.docker/config.json/etc/containerd/certs.d高日志采集/var/lib/docker/containers/var/log/pods高监控指标docker metricscontainerd metrics中运维命令docker ps/imagescrictl ps/images中特殊配置docker daemon.jsoncontainerd config.toml高CI/CDdocker pushdocker push(buildkit 兼容)低1.3 主要挑战私有镜像仓库认证:Harbor 私有仓库,Docker 用~/.docker/config.json,containerd 配置完全不同。日志采集路径变化:Filebeat 原本采集/var/lib/docker/containers/*/*.log,迁移后路径变了。监控指标变化:cAdvisor 的 docker 指标消失,需切换到 containerd 指标。镜像构建链路:开发本地用 docker build,迁移后需切 buildkit。特殊挂载:有业务用了docker.sock,containerd 没有containerd.sock的等价权限场景。二、方案设计2.1 迁移策略:节点级灰度不做集群级切换,而是节点级灰度:每个节点单独排空、替换运行时、验证、恢复调度。这样业务零中断,出问题只影响单节点。迁移单节点流程: ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ 1.排空 │──▶│ 2.卸载 │──▶│ 3.安装 │──▶│ 4.验证 │──▶│ 5.恢复 │ │ cordon │ │ Docker │ │Containerd│ │ Pod │ │ 调度 │ │ drain │ │ │ │ │ │ │ │ uncordon │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ ▼ ┌─────────────────┐ │ 出问题 → 回滚 │ │ 重装 Docker │ └─────────────────┘2.2 灰度节奏第1批:10 节点(非核心业务节点),观察 5 天第2批:50 节点(含部分核心业务),观察 5 天第3批:全量剩余节点,分集群推进2.3 回滚预案每个节点迁移前打快照(虚拟机场景),出问题 5 分钟回滚:# 回滚脚本核心逻辑kubectl uncordonnodesystemctl stop containerd yum remove-ycontainerd.io yuminstall-ydocker-ce systemctlenable--nowdocker# 重启 kubelet(切回 docker)sed-is/containerd/docker//var/lib/kubelet/kubeadm-flags.env systemctl restart kubelet三、实施过程3.1 第一阶段:兼容性测试(2/15 - 2/27)3.1.1 镜像兼容性验证containerd 和 Docker 都支持 OCI 镜像格式,理论兼容。但实际有边角问题:# 测试所有核心镜像在 containerd 下能正常跑# 重点验证:# 1. 多阶段构建的镜像# 2. 带 --platform 的多架构镜像# 3. 老镜像(Docker 1.x 时代构建的,v2 schema 1)# 4. 带特殊 LABEL/ANNOTATION 的镜像# 用 containerd 拉取并运行测试forimgin$(catcore_images.txt);doecho测试镜像:$imgctr-nk8s.io images pull$img||echoFAIL:$imgctr-nk8s.io run--rm$imgtest-$(date%s)echoOK||echoRUN FAIL:$imgdone踩坑1:发现 2 个老镜像是 v2 schema 1,containerd 拒绝拉取。需要用docker pull后docker push转成 v2 schema 2。# 转换 schema 1 镜像dockerpull old-registry.example.com/legacy-app:v1dockertag old-registry.example.com/legacy-app:v1 new-registry.example.com/legacy-app:v2dockerpush new-registry.example.com/legacy-app:v23.1.2 私有镜像仓库认证Docker 用~/.docker/config.json,containerd 用config.toml配置 certs.d:# /etc/containerd/config.toml version 2 [plugins.io.containerd.grpc.v1.cri.registry] config_path /etc/containerd/certs.d # 每个仓库一个目录 # /etc/containerd/certs.d/harbor.example.com/hosts.toml # /etc/containerd/certs.d/docker.io/hosts.toml# /etc/containerd/certs.d/harbor.example.com/hosts.toml server https://harbor.example.com [host.https://harbor.example.com] capabilities [pull, resolve] # 认证:base64(user:password) # 但更推荐用 ImagePullSecrets,不在节点配明文 # 这里只配 CA(如果是自签证书) ca /etc/containerd/certs.d/harbor.example.com/ca.crt踩坑2:Harbor 用的是自签证书,containerd 默认不信任。必须在hosts.toml配ca,或者skip_verify true(不推荐生产用)。# 把 Harbor CA 复制到所有节点scpharbor-ca.crt node-xxx:/etc/containerd/certs.d/harbor.example.com/ca.crt3.1.3 日志采集路径变化Docker 日志在/var/lib/docker/containers/id/id-json.log,containerd 日志在/var/log/pods/ns_pod_uid/container/0.log。Filebeat 配置必须改:# Filebeat 新配置(containerd)filebeat.autodiscover:providers:-type:kubernetesnode:${NODE_NAME}hints.enabled:truetemplates:-condition:contains:kubernetes.labels.app:config:-type:containerpaths:-/var/log/containers/*${data.kubernetes.container.id}.log# containerd 软链到 /var/log/pods/...processors:-add_kubernetes_metadata:host:${NODE_NAME}-add_fields:target:fields:runtime:containerd踩坑3:containerd 日志格式是 CRI 格式,不是 Docker JSON 格式,解析规则要改。Docker 格式: {log:...,stream:stdout,time:...} CRI 格式: 2024-02-20T10:00:00.123456789Z stdout F log contentFilebeat 的json解析器要换成cri解析器。3.2 第二阶段:迁移脚本与配置准备(2/28 - 3/5)3.2.1 containerd 配置模板# /etc/containerd/config.toml - 生产配置模板 version 2 root /var/lib/containerd state /run/containerd oom_score -999 [grpc] address /run/containerd/containerd.sock uid 0 gid 0 max_recv_message_size 16777216 max_send_message_size 16777216 [debug] address uid 0 gid 0 level [metrics] address 127.0.0.1:1338 grpc_histogram false [plugins.io.containerd.grpc.v1.cri] # sandbox_image 是 pause 镜像 sandbox_image registry.k8s.io/pause:3.9 max_container_log_line_size 16384 [plugins.io.containerd.grpc.v1.cri.containerd] default_runtime_name runc snapshotter overlayfs [plugins.io.containerd.grpc.v1.cri.containerd.runtimes.runc] runtime_type io.containerd.runc.v2 [plugins.io.containerd.grpc.v1.cri.containerd.runtimes.runc.options] SystemdCgroup true # ← 必须和 kubelet 一致 [plugins.io.containerd.grpc.v1.cri.registry] config_path /etc/containerd/certs.d # 镜像垃圾回收 [plugins.io.containerd.gc.v1.scheduler] deletion_threshold 200 stale_threshold 4h3.2.2 kubelet 配置切换kubelet 的容器运行时参数要从 docker 切到 containerd:# /var/lib/kubelet/kubeadm-flags.env# 原来(Docker):# KUBELET_KUBEADM_ARGS--container-runtimeremote --container-runtime-endpointunix:///var/run/dockershim.sock# 改为(Containerd):KUBELET_KUBEADM_ARGS--container-runtimeremote --container-runtime-endpointunix:///run/containerd/containerd.sock3.2.3 迁移脚本#!/bin/bash# migrate_to_containerd.sh - 单节点迁移脚本set-euopipefailNODE$1LOG(){echo[$(date%F %T)] [$NODE]$*;}# 1. 排空节点LOGcordon 节点kubectl cordon$NODELOGdrain 节点kubectl drain$NODE--ignore-daemonsets --delete-emptydir-data--timeout5m# 2. 停止 DockerLOG停止 Dockerssh$NODEsystemctl stop kubelet docker# 3. 卸载 Docker(保留配置备份)LOG卸载 Dockerssh$NODEtar czf /root/docker-backup-$(date %s).tgz /etc/docker /var/lib/docker 2/dev/null || truessh$NODEyum remove -y docker-ce docker-ce-cli containerd.io || true# 4. 安装 containerdLOG安装 containerdssh$NODEyum install -y containerd.io-1.7.13# 5. 下发配置LOG下发 containerd 配置scp/etc/containerd/config.toml$NODE:/etc/containerd/config.tomlscp-r/etc/containerd/certs.d$NODE:/etc/containerd/ssh$NODEsystemctl enable containerdssh$NODEsystemctl start containerd# 6. 修改 kubelet 配置LOG切换 kubelet 运行时ssh$NODEsed -i s|dockershim.sock|containerd/containerd.sock| /var/lib/kubelet/kubeadm-flags.env# 7. 启动 kubeletLOG启动 kubeletssh$NODEsystemctl start kubeletsleep10# 8. 验证节点 ReadyLOG验证节点状态foriin{1..6};doSTATUS$(kubectl getnode$NODE-ojsonpath{.status.conditions[?(.typeReady)].status})RUNTIME$(kubectl getnode$NODE-ojsonpath{.status.nodeInfo.containerRuntimeVersion})if[$STATUSTrue]echo$RUNTIME|grep-qcontainerd;thenLOG节点正常,运行时:$RUNTIMEkubectl uncordon$NODELOG迁移完成exit0fisleep10done# 9. 失败 → 回滚LOG迁移失败,开始回滚ssh$NODEsystemctl stop kubelet containerdssh$NODEyum remove -y containerd.iossh$NODEyum install -y docker-ce docker-ce-clissh$NODEtar xzf /root/docker-backup-*.tgz -C /ssh$NODEsed -i s|containerd/containerd.sock|dockershim.sock| /var/lib/kubelet/kubeadm-flags.envssh$NODEsystemctl enable --now dockerssh$NODEsystemctl start kubeletsleep10kubectl uncordon$NODELOG回滚完成,人工介入排查exit13.3 第三阶段:灰度迁移(3/6 - 3/15)3.3.1 第一批 10 节点选了 10 个非核心业务节点(测试环境、内部工具),脚本批量执行:# 批量迁移(并发 2,避免同时影响太多)fornodein$(catbatch1_nodes.txt);do./migrate_to_containerd.sh$node[$(jobs-r-p|wc-l)-ge2]wait-ndonewait踩坑4:灰度第 3 天,有 Pod 报ImagePullBackOff,排查发现是 containerd 的镜像缓存策略和 Docker 不同。Docker 会缓存所有 pull 过的镜像,containerd 的 GC 会定期清理。修复:调整 containerd GC 配置,核心镜像用imagePullPolicy: IfNotPresent DaemonSet 预热。# 调整 GC,保留更久 [plugins.io.containerd.gc.v1.scheduler] deletion_threshold 500 stale_threshold 24h3.3.2 第二批 50 节点第二批包含 5 个核心业务节点。迁移时发现 2 个新问题:踩坑5:监控指标缺失。原来用 cAdvisor 的container_cpu_usage_seconds_total等 Docker 相关指标,Prometheus 采集配置里写了 Docker API 地址,迁移后采不到。修复:切换到 kubelet 内置 cAdvisor(端口 10250),指标名一致,但采集端点变了:# Prometheus 采集配置-job_name:kubernetes-nodes-cadvisorkubernetes_sd_configs:-role:nodescheme:httpstls_config:ca_file:/var/run/secrets/kubernetes.io/serviceaccount/ca.crtbearer_token_file:/var/run/secrets/kubernetes.io/serviceaccount/tokenrelabel_configs:-action:labelmapregex:__meta_kubernetes_node_label_(.)-target_label:__address__replacement:kubernetes.default.svc:443-source_labels:[__meta_kubernetes_node_name]regex:(.)target_label:__metrics_path__replacement:/api/v1/nodes/${1}/proxy/metrics/cadvisor踩坑6:有业务 Pod 挂载了/var/run/docker.sock,迁移后 Pod 启动失败。这个是最棘手的。有 3 个 Java 业务用 docker.sock 做服务发现(通过 docker API 查容器列表),迁移后 socket 不存在。解决方案分两步:短期:在节点上创建一个软链/var/run/docker.sock - /run/containerd/containerd.sock(不完美,API 不完全兼容)长期:推动业务改造,用 K8s API 做服务发现3.4 第四阶段:全量迁移(3/16 - 3/30)3.4.1 全量迁移节奏每个集群分批,每批 20-30 节点,夜间低峰执行:# 全量迁移编排脚本#!/bin/bashCLUSTER$1BATCH_SIZE20SLEEP_BETWEEN300# 批次间隔 5 分钟whilereadnode;doecho迁移:$node./migrate_to_containerd.sh$nodemigrate.log21# 控制并发while[$(jobs-r-p|wc-l)-ge$BATCH_SIZE];dosleep5done# 每批之间 sleepif[$(($(wc-lmigrate.log)%$BATCH_SIZE))-eq0];thensleep$SLEEP_BETWEENfidone${CLUSTER}_nodes.txtwait# 最终验证echo 迁移完成,验证 kubectl get nodes-ojsonpath{range .items[*]}{.metadata.name}{ }{.status.nodeInfo.containerRuntimeVersion}{\n}{end}3.4.2 镜像构建链路改造开发环境从 docker build 切到 buildkit:# 方式1:buildkit CLI(推荐)buildctl build\--frontenddockerfile.v0\--localcontext.\--localdockerfile.\--outputtypeimage,nameharbor.example.com/app:v1,pushtrue# 方式2:docker buildx(兼容 docker build 语法)dockerbuildx build--push-tharbor.example.com/app:v1.# 方式3:Kaniko(在 K8s 里构建,无 Docker daemon)kubectl apply-f-EOF apiVersion: v1 kind: Pod metadata: name: kaniko-builder spec: containers: - name: kaniko image: gcr.io/kaniko-project/executor:latest args: [--dockerfileDockerfile, --contextgit://github.com/example/repo.git, --destinationharbor.example.com/app:v1] volumeMounts: - name: docker-config mountPath: /kaniko/.docker volumes: - name: docker-config secret: secretName: harbor-cred EOF踩坑7:CI/CD 流水线里大量用了docker run做集成测试,迁移后失效。改造方案:用podman替代(命令兼容),或者改造为 K8s Job。# podman 替代 docker run(命令几乎一致)podmanrun--rm-itharbor.example.com/test-runner:v1 ./run-tests.sh3.5 第五阶段:遗留问题处理(3/25 - 3/30)3.5.1 日志采集双写过渡迁移期间部分节点 Docker、部分 containerd,Filebeat 采两套路径:filebeat.inputs:# Docker 节点-type:containerpaths:-/var/lib/docker/containers/*/*.logprocessors:-add_fields:target:fields:runtime:docker# Containerd 节点-type:containerpaths:-/var/log/containers/*.logprocessors:-add_fields:target:fields:runtime:containerd全量迁移后删除 Docker 路径配置。3.5.2 docker.sock 依赖业务改造推动 3 个业务从 docker.sock 切到 K8s API:// 原来:通过 docker.sock 查容器// DockerClient docker new DockerClient(unix:///var/run/docker.sock);// ListContainer containers docker.listContainers();// 改造后:用 K8s APIKubernetesClientk8snewKubernetesClient();PodListpodsk8s.pods().inNamespace(prod).withLabel(appxxx).list();四、踩坑与应急4.1 应急:灰度节点 Pod 大量 ImagePullBackOff现象:灰度第 1 天,迁移后的节点上 Pod 大量 ImagePullBackOff。定位:containerd 配置的 Harbor CA 路径不对,导致 HTTPS 校验失败。修复:# 临时修复sshnode-xxxmkdir -p /etc/containerd/certs.d/harbor.example.comscpharbor-ca.crt node-xxx:/etc/containerd/certs.d/harbor.example.com/ca.crtsshnode-xxxsystemctl restart containerd脚本修正 CA 下发逻辑后,问题不再出现。4.2 应急:节点 NotReady,kubelet 报 CRI 连接失败现象:迁移后某节点 NotReady,kubelet 日志报 “Failed to connect to CRI”。定位:containerd 的 socket 权限不对,kubelet 无权限访问。修复:# containerd 配置 socket 权限[grpc]address/run/containerd/containerd.sockuid0gid0# 关键:确保 kubelet 能访问并检查/run/containerd/containerd.sock权限是srw-rw---- root root。五、复盘与改进5.1 迁移效果指标DockerContainerd提升节点内存占用(运行时)380 MB210 MB-45%Pod 启动时间(平均)12 秒9.5 秒-21%镜像拉取时间(1GB)45 秒32 秒-29%日志采集延迟2 秒1 秒-50%5.2 经验教训兼容性测试要全面:镜像格式、仓库认证、日志路径、监控指标、特殊挂载,一个都不能漏。灰度要分批:10 → 50 → 全量,每批观察足够时间,别贪快。回滚预案要演练:回滚脚本不演练,真出事时一定卡壳。日志采集要双写:迁移期间两种路径共存,全量完成再切。docker.sock 依赖要早改:这是迁移最大障碍,提前半年推动业务改造。配置文件用配置管理:containerd config.toml 用 Ansible/Puppet 统一下发,别手动改。监控告警先切:迁移前先把监控指标切到 kubelet cAdvisor,避免迁移后盲区。5.3 长期改进镜像构建统一:全公司推行 Kaniko 在 K8s 内构建,淘汰本地 docker build镜像仓库治理:Harbor 升级,启用镜像签名与漏洞扫描运行时监控:建立 containerd 运行时专属监控大盘配置基线:containerd config.toml 纳入 GitOps 管理六、可复用产出6.1 containerd 迁移清单(检查表)阶段检查项状态评估镜像格式兼容性测试☐评估私有仓库认证方案☐评估日志采集路径方案☐评估监控指标切换方案☐评估docker.sock 依赖排查☐评估CI/CD 链路改造方案☐准备containerd 配置模板☐准备迁移脚本 回滚脚本☐准备监控告警切换☐灰度第一批 10 节点 观察 5 天☐灰度第二批 50 节点 观察 5 天☐全量集群 1/2/3 分批迁移☐收尾日志双写切单写☐收尾docker.sock 依赖业务改造☐收尾复盘报告☐6.2 docker vs containerd 命令对照表操作DockerContainerd(crictl)Containerd(ctr)查容器docker pscrictl psctr -n k8s.io c ls查镜像docker imagescrictl imagesctr -n k8s.io i ls拉镜像docker pullcrictl pullctr -n k8s.io i pull查日志docker logscrictl logsctr -n k8s.io c logs进入容器docker execcrictl execctr -n k8s.io c exec查容器详情docker inspectcrictl inspectctr -n k8s.io c info查运行时信息docker infocrictl infoctr version清理容器docker rmcrictl rmctr -n k8s.io c rm清理镜像docker rmicrictl rmictr -n k8s.io i rm查容器 statsdocker statscrictl statsctr -n k8s.io c metrics注意:crictl是 K8s 官方 CRI 调试工具,推荐用;ctr是 containerd 原生工具,功能更全但语法复杂。6.3 迁移脚本框架(见 3.2.3)6.4 回滚预案(见 2.3)思考题如果业务强依赖 docker.sock 且无法改造,你会如何在 containerd 集群中兼容?有哪些方案及其代价?containerd 的镜像 GC 策略和 Docker 有何不同?如何避免核心镜像被 GC?迁移过程中如何保证日志不丢?双写方案的代价是什么?延伸阅读K8s 官方:Dockershim 移除 FAQcontainerd 官方文档:https://containerd.io/docs/crictl 使用指南:https://github.com/kubernetes-sigs/cri-toolsBuildKit 官方文档:https://github.com/moby/buildkitHarbor 与 containerd 集成配置