1. 项目概述:别再把K8s和Docker当“兄弟俩”了,它们其实是“施工队”和“标准砖块”的关系
刚入行那会儿,我也常听人说“K8s就是管Docker的”,甚至有面试官直接问“K8s和Docker哪个更厉害”。后来在某高校实验室带学生做容器化部署时,发现连写了三年运维脚本的老同事,也默认“装了Docker就等于能跑K8s”。这种认知偏差不是个例——它直接导致大量项目在容器化转型初期就卡在环境搭建、镜像构建、服务启停逻辑混乱这些基础环节上。K8s跟Docker的关系,本质上不是“谁管谁”,而是抽象层级与执行载体的分工协作:Docker是定义“单个应用怎么打包、怎么运行”的事实标准,而K8s是解决“成百上千个这样的应用,怎么协同、怎么容错、怎么弹性伸缩”的调度系统。就像盖一栋20层写字楼,Docker负责把每一块砖、每一扇窗、每一根钢筋都按统一规格预制好(镜像),而K8s则是那个拿着总平面图、协调吊车、安排工人、监控混凝土凝固时间、自动补漏的工程总控系统。你不用Docker也能用Podman或containerd启动容器,但K8s默认依赖的是符合OCI标准的运行时;你不用K8s也能用docker-compose跑三个服务,但一旦服务数超过15个、需要滚动更新不中断、跨机房灾备、自动扩缩容,Docker原生命令就彻底失能。这篇文章不讲概念堆砌,只拆解真实项目里我踩过的坑、调过的参数、画过的架构图——从一个镜像如何被K8s识别,到一个Pod为何突然重启,再到为什么删掉Docker daemon后K8s集群还能照常工作。适合正在写第一个Deployment.yaml的新手、被CI/CD流水线卡住的DevOps工程师、以及想搞懂云原生底层逻辑的架构师。全文所有操作均基于v1.28+稳定版实测,配置项全部标注来源和替代方案,拒绝“理论上可行”。
2. 核心设计逻辑:为什么K8s必须绕过Docker Engine?从CRI接口演进看技术分层本质
2.1 Docker Engine的“历史包袱”:从all-in-one到被解耦的必然性
2013年Docker刚发布时,它是个典型的“瑞士军刀”:集成镜像构建(docker build)、镜像分发(docker push/pull)、容器运行(docker run)、网络管理(docker network)、存储卷(docker volume)——所有能力打包在一个二进制里。这种设计对开发者极友好,但对K8s这类大规模编排系统却是灾难。举个真实案例:某金融客户在K8s集群升级到1.20后,所有节点突然出现大量ImagePullBackOff错误。排查三天才发现,是Docker Engine的containerd-shim进程在高并发拉取镜像时存在内存泄漏,而K8s的kubelet通过Docker Socket(/var/run/docker.sock)调用Docker API时,根本无法感知底层shim进程状态,只能被动等待超时。这暴露了第一层问题:K8s需要的是确定性的、可监控的、低延迟的容器生命周期控制,而不是依赖一个黑盒daemon的间接调用。
于是K8s在1.5版本引入CRI(Container Runtime Interface),这是一个gRPC协议定义的标准化接口。它的核心思想非常朴素:K8s只关心“启动一个容器”“停止一个容器”“获取容器状态”“拉取镜像”这四件事,至于具体由谁来执行、怎么执行,K8s不关心。这就为解耦埋下伏笔。我们来看CRI的关键方法定义:
service RuntimeService { // 启动一个容器(对应docker run) rpc RunPodSandbox(RunPodSandboxRequest) returns (RunPodSandboxResponse); // 在沙箱内创建容器(对应docker create) rpc CreateContainer(CreateContainerRequest) returns (CreateContainerResponse); // 启动已创建的容器(对应docker start) rpc StartContainer(StartContainerRequest) returns (StartContainerResponse); // 拉取镜像(对应docker pull) rpc PullImage(PullImageRequest) returns (PullImageResponse); }注意这里没有docker这个词。任何实现这套gRPC接口的程序,都能成为K8s的容器运行时。2017年,Docker公司把containerd(原Docker Engine的底层运行时)捐赠给CNCF,正是为了顺应这一趋势。containerd本身不包含镜像构建、CLI命令等上层功能,它专注做三件事:镜像分发、容器执行、存储管理。而K8s的kubelet通过CRI插件cri-containerd直接与containerd通信,跳过了Docker Engine这个中间层。这就是为什么2020年K8s官方宣布弃用Docker作为默认运行时——不是Docker不好,而是它太“重”了,K8s不需要它的构建能力,却要为它的稳定性买单。
2.2 现代K8s运行时栈:从镜像到Pod的七层穿透式解析
现在打开任意一个生产级K8s节点,执行ps aux | grep -E "(containerd|crio|kata)",你会发现Docker进程几乎不存在。真实的运行时栈是这样的(以containerd为例):
- 用户层:
kubectl apply -f deployment.yaml→ kubelet接收YAML - kubelet层:解析PodSpec,调用CRI接口
RunPodSandbox - CRI插件层:
cri-containerd将请求转换为containerd的TaskService.Start()调用 - containerd层:加载镜像rootfs,调用
runc创建容器进程 - runc层:Linux标准的OCI运行时,执行
clone()系统调用创建namespace隔离的进程 - 内核层:cgroups v2限制CPU/内存,namespaces提供PID/NET/UTS隔离
- 硬件层:CPU调度器分配时间片,MMU管理内存页表
整个链路里,Docker Engine只在第1步(开发者本地构建镜像)和第4步(早期通过dockerd调用runc)存在。而K8s集群内部,它全程缺席。我曾在某电商大促压测中验证过:手动停掉所有节点的dockerd服务,K8s集群的Pod状态、服务访问、健康检查全部不受影响——因为kubelet早已通过CRI直连containerd。这个实验让我彻底理解:Docker是开发者的交付工具,K8s是运维者的调度平台,二者在技术栈上天然错位。
2.3 镜像标准的统一:OCI规范如何让K8s摆脱Docker绑定
很多人以为“K8s不用Docker就跑不了镜像”,这是对镜像分发机制的根本误解。Docker Hub上的镜像,本质是符合OCI Image Specification的tar包,结构如下:
my-app:latest/ ├── manifest.json # 描述镜像层、配置文件、架构信息 ├── blobs/ # 实际二进制数据(压缩的rootfs层) │ ├── sha256-abc123... # layer 1 │ └── sha256-def456... # layer 2 └── oci-layout # 声明此目录为OCI布局K8s的kubelet通过CRI调用PullImage时,传递的是镜像URL(如registry.example.com/my-app:v1.2),底层运行时(containerd/cri-o)会:
- 解析URL获取registry地址、仓库名、tag
- 发送HTTP GET请求到
/v2/<repo>/manifests/<tag>获取manifest - 根据manifest中的digest列表,逐个拉取
/v2/<repo>/blobs/<digest> - 将blobs解压到本地存储(如
/var/lib/containerd/io.containerd.content.v1.content/blobs/)
整个过程完全不依赖Docker CLI或Docker daemon。你可以用skopeo copy docker://nginx:alpine oci:/tmp/nginx-oci把Docker Hub镜像转成纯OCI格式,再用ctr images import /tmp/nginx-oci导入containerd——全程无Docker参与。这也是为什么GitLab CI流水线里,我们常用buildah(无守护进程的镜像构建工具)替代docker build:它直接生成OCI镜像,省去docker push环节,与K8s运行时无缝对接。
提示:OCI规范的强制性体现在K8s的准入控制里。当你用
kubectl run nginx --image=docker.io/library/nginx时,kubelet会校验镜像是否含config.json(定义ENTRYPOINT/CMD)、manifest.json(定义layer顺序)。若镜像不符合OCI,kubelet会直接拒绝启动并报错failed to resolve image to digest。
3. 实操关键环节:从Dockerfile到K8s Pod的全链路拆解
3.1 Dockerfile不是为K8s写的:那些让运维崩溃的“本地开发陷阱”
新手最容易犯的错误,是把本地调试用的Dockerfile直接扔进K8s。我见过最典型的三个反模式:
反模式1:CMD ["sh", "-c", "while true; do sleep 1; done"]
本地测试时,用死循环保持容器不退出很常见。但在K8s里,这会导致Liveness Probe永远无法探测到应用端口,触发反复重启。正确做法是让主进程真正承载业务——比如用CMD ["python", "app.py"],并在app.py里启动Web服务器。
反模式2:EXPOSE 8080+docker run -p 8080:8080
Docker的EXPOSE只是文档声明,实际端口映射靠-p参数。而K8s的Service通过targetPort字段明确指定容器内端口,EXPOSE对此毫无作用。更糟的是,如果Dockerfile里EXPOSE写错端口(如写成8081),开发者可能误以为K8s必须配8081,导致Service配置错误。
反模式3:COPY . /app+RUN pip install -r requirements.txt
本地构建时,把整个代码目录COPY进去很爽。但在K8s里,这会导致镜像体积暴增(含.git/.idea等无用文件),且每次代码变更都要重build整个镜像。我们实测过:一个Python项目,用COPY .镜像大小1.2GB;改用多阶段构建COPY --from=builder /app/dist/*.whl /app/后,降至217MB,拉取速度提升4倍。
正确的Dockerfile应遵循K8s最佳实践:
# 第一阶段:构建环境(不进入最终镜像) FROM python:3.9-slim AS builder WORKDIR /app COPY requirements.txt . RUN pip wheel --no-deps --wheel-dir /wheels -r requirements.txt # 第二阶段:运行环境(最小化基础镜像) FROM python:3.9-slim-buster # 创建非root用户(K8s安全基线要求) RUN groupadd -g 1001 -f app && useradd -r -u 1001 -g app app USER app WORKDIR /app # 只拷贝wheel包,避免安装时网络失败 COPY --from=builder /wheels /wheels COPY --from=builder /app/requirements.txt . RUN pip install --no-deps --force-reinstall /wheels/*.whl # 拷贝应用代码(不含源码,只放编译后文件) COPY main.py . # 显式声明端口(虽不影响K8s,但增强可读性) EXPOSE 8000 # 主进程必须是前台运行 CMD ["gunicorn", "--bind", "0.0.0.0:8000", "main:app"]注意:
USER app这行至关重要。K8s默认禁止容器以root运行(SecurityContext.runAsNonRoot: true),如果Dockerfile没指定用户,kubelet会直接拒绝创建Pod,并报错container has runAsNonRoot and image will not run as non-root。
3.2 Deployment.yaml里的“隐形Docker”:镜像拉取策略与私有仓库认证
很多团队把镜像推到私有Harbor后,在Deployment里写image: harbor.example.com/project/app:v1.0,结果Pod卡在ImagePullBackOff。表面看是网络问题,实则常因两个Docker相关的细节被忽略:
细节1:imagePullPolicy的陷阱
K8s默认策略是IfNotPresent,即本地有同名镜像就直接用。这在CI/CD流水线中极其危险——当新版本镜像推送到Harbor,但节点本地缓存了旧版,K8s会静默使用旧镜像。必须显式声明:
spec: containers: - name: app image: harbor.example.com/project/app:v1.0 imagePullPolicy: Always # 强制每次拉取最新为什么Docker本地docker run没这问题?因为Docker CLI默认行为就是Always(除非加--pull=never),而K8s为性能考虑做了不同设计。
细节2:私有仓库认证的两种方式
Docker CLI用docker login生成~/.docker/config.json,但K8s节点不会读取这个文件。必须通过K8s Secret注入:
# 1. 创建docker-registry类型的Secret kubectl create secret docker-registry regcred \ --docker-server=harbor.example.com \ --docker-username=admin \ --docker-password=Harbor12345 \ --docker-email=dev@example.com# 2. 在Deployment中引用 spec: imagePullSecrets: - name: regcred # 这个name必须和Secret名一致 containers: - name: app image: harbor.example.com/project/app:v1.0这个Secret会被kubelet挂载到容器的/var/run/secrets/kubernetes.io/serviceaccount/路径下,containerd通过它获取Harbor的Bearer Token。如果你用kubectl get secret regcred -o yaml查看,会发现.dockerconfigjson字段的值,正是base64编码后的{"auths":{"harbor.example.com":{"auth":"YWRtaW46SGFyYm9yMTIzNDU="}}}——和Docker的config.json结构完全一致,只是K8s用Secret对象做了标准化封装。
3.3 容器运行时切换实战:从Docker Engine到containerd的平滑迁移
在某政务云项目中,我们需将运行3年的Docker-based K8s集群(v1.19)升级到v1.28,首要任务是替换运行时。以下是经过12个节点验证的迁移步骤:
步骤1:确认当前运行时
# 查看kubelet启动参数(关键!) ps aux | grep kubelet | grep -o 'container-runtime.*' # 若输出 container-runtime=docker,则需迁移 # 若已是 container-runtime=remote,则跳过步骤2:安装containerd(所有worker节点)
# Ubuntu 20.04+ curl -fsSL https://get.docker.com | sh # 先装Docker(含containerd) systemctl disable docker # 禁用Docker服务 systemctl stop docker # 修改containerd配置(关键!) sudo mkdir -p /etc/containerd containerd config default | sudo tee /etc/containerd/config.toml # 编辑config.toml,启用SystemdCgroup(K8s v1.22+必需) # [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc] # [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options] # SystemdCgroup = true sudo systemctl restart containerd步骤3:配置kubelet使用containerd
# 编辑kubelet配置(通常在/etc/default/kubelet或/var/lib/kubelet/kubeadm-flags.env) # 添加或修改以下参数: KUBELET_EXTRA_ARGS="--container-runtime=remote \ --container-runtime-endpoint=unix:///run/containerd/containerd.sock \ --runtime-request-timeout=15m" sudo systemctl restart kubelet步骤4:验证迁移效果
# 1. 检查kubelet日志是否有containerd连接成功 journalctl -u kubelet | grep "RemoteRuntime" | tail -5 # 应看到 "Remote runtime connected" # 2. 查看节点运行时信息 kubectl get nodes -o wide # STATUS列应显示 "Ready",AGE列正常增长 # 3. 创建测试Pod验证 kubectl run test-pod --image=nginx:alpine --restart=Never kubectl get pod test-pod -o wide # 如果STATUS为Completed,说明containerd已接管实操心得:迁移中最容易失败的是
SystemdCgroup配置。若未启用,K8s会报错cgroup driver: systemd does not match cgroup driver: cgroupfs。这是因为K8s v1.22+强制要求cgroup驱动与kubelet一致,而Ubuntu默认用systemd,containerd默认用cgroupfs。必须在containerd配置中显式开启SystemdCgroup,否则Pod会卡在ContainerCreating。
4. 常见问题与深度排查:那些文档里找不到的“幽灵故障”
4.1 “镜像拉取失败”背后的五层网络穿透分析
当kubectl describe pod xxx显示Failed to pull image "xxx": rpc error: code = Unknown desc = failed to pull and unpack image "xxx": failed to resolve reference "xxx": failed to authorize: failed to fetch anonymous token,别急着查Harbor日志。按以下五层顺序排查:
| 层级 | 检查点 | 命令/方法 | 典型原因 |
|---|---|---|---|
| L1:DNS解析 | 节点能否解析registry域名 | nslookup harbor.example.com | CoreDNS配置错误、节点DNS服务器不可达 |
| L2:TCP连通性 | 节点能否连通registry端口 | telnet harbor.example.com 443或curl -v https://harbor.example.com/v2/ | 安全组拦截、防火墙规则、registry证书过期 |
| L3:认证凭证 | Secret内容是否正确 | kubectl get secret regcred -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d | 用户密码错误、Token过期、Secret未绑定到Namespace |
| L4:镜像路径 | 镜像名是否含非法字符 | echo "harbor.example.com/project/app:v1.0" | grep -E "[^a-zA-Z0-9._-]" | Harbor项目名含大写字母(不支持)、Tag含斜杠 |
| L5:存储空间 | 节点磁盘是否满 | df -h /var/lib/containerd | containerd默认存储在/var/lib/containerd,而非Docker的/var/lib/docker |
我曾遇到一个诡异案例:所有节点telnet harbor.example.com 443成功,但curl -v https://harbor.example.com/v2/返回401。抓包发现,curl发送了Authorization: Basic xxx头,而telnet没有。最终定位到是Harbor的core服务配置了AUTH_MODE=ldap,但LDAP服务器宕机,导致认证服务降级为匿名模式,却仍返回401——这是Harbor的bug,而非K8s或Docker问题。
4.2 “容器启动失败”的进程树溯源法
当kubectl logs pod-name为空,kubectl describe pod显示CrashLoopBackOff,传统思路是看容器日志。但更高效的方法是直接看进程树:
# 在对应节点执行(需root权限) # 1. 找到Pod对应的containerd shim进程 crictl pods --name pod-name # 输出类似:abcd123456789 Running ... 10m ago # 2. 查看该Pod下的容器 crictl ps --pod abcd123456789 # 输出:efgh789012345 app Running ... 2m ago # 3. 获取容器PID crictl inspect efgh789012345 \| jq '.info.pid' # 4. 查看进程树(关键!) pstree -p <PID>如果输出是runc:[2:INIT]───sh───sleep 1,说明容器进程是sh -c "sleep 1",它会在1秒后退出,导致K8s认为容器崩溃。此时应检查Dockerfile的CMD是否正确。
如果输出是runc:[2:INIT]───python───{python},但ps aux \| grep python看不到进程,说明Python进程已崩溃。此时用crictl exec -it efgh789012345 sh进入容器,手动执行python app.py,往往能捕获到ImportError: No module named 'flask'这类缺失依赖错误——这比看K8s事件日志快得多。
4.3 “网络不通”的iptables与nftables双模排查
K8s网络插件(Calico/Flannel)依赖iptables规则转发流量。但Ubuntu 22.04+默认启用nftables,而老版本Calico仅支持iptables。现象是:Pod能启动,但kubectl exec -it pod-name -- curl http://other-pod:8080超时。
排查步骤:
# 1. 检查当前使用的netfilter后端 lsmod \| grep -E "(iptable|nf_table)" # 若看到nf_tables,说明是nftables模式 # 2. 查看iptables规则是否生效 iptables-save \| grep "KUBE-SERVICES" # 若无输出,说明iptables规则未加载 # 3. 强制kube-proxy使用iptables(临时方案) kubectl edit cm kube-proxy -n kube-system # 修改mode: "iptables"(默认可能是"ipvs") kubectl rollout restart ds kube-proxy -n kube-system但根本解法是升级网络插件。我们在某教育平台项目中,将Calico从v3.18升级到v3.25后,问题自动解决——新版Calico同时支持iptables和nftables后端,通过calicoctl get felixconfiguration -o wide可确认iptablesBackend: Auto。
实操心得:不要迷信
kubectl get nodes的Ready状态。我曾遇到节点Ready但网络插件异常的情况:kubectl get pods -A \| grep calico显示calico-node处于Running,但calicoctl node status返回Error: unable to connect to the Calico datastore。此时kubectl describe node的Events里会有NodeReady事件,但kubectl get endpoints -n kube-system kube-controller-manager为空——说明controller-manager无法同步状态。必须检查etcd连接和RBAC权限,而非重启kubelet。
5. 生产环境避坑指南:来自12个K8s集群的血泪经验
5.1 镜像构建的“三不原则”:不root、不裸奔、不膨胀
不root:所有Dockerfile必须以USER指令指定非root用户。K8s的PodSecurityPolicy(PSP)或PodSecurity Admission(v1.23+)会强制拦截root容器。即使禁用PSP,root容器也是安全审计红线。我们曾因一个遗留的USER root被等保测评扣分,整改方案是用gosu工具降权:
# 替换原来的 USER root RUN apt-get update && apt-get install -y gosu ENTRYPOINT ["gosu", "app:app", "python", "app.py"]不裸奔:容器内必须有健康检查端点。Docker的HEALTHCHECK指令在K8s中无效,必须通过Liveness/Readiness Probe实现:
livenessProbe: httpGet: path: /healthz port: 8000 initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: httpGet: path: /readyz port: 8000 initialDelaySeconds: 5 periodSeconds: 5initialDelaySeconds必须大于应用启动时间。我们实测过:Django应用冷启动需12秒,若设为10秒,Probe会连续失败导致Pod被kill,形成恶性循环。
不膨胀:镜像大小直接影响部署效率。某AI项目用pip install tensorflow生成的镜像达3.2GB。优化后:
- 改用
tensorflow-cpu(非GPU版) - 多阶段构建中,
pip wheel只安装--no-deps,运行时再pip install --no-cache-dir - 删除
/var/lib/apt/lists/*和/usr/share/doc/*最终镜像降至487MB,CI耗时从18分钟降至4分钟。
5.2 K8s节点维护的“黄金四小时”:Docker Engine残留清理清单
当集群升级后,旧Docker Engine可能残留影响。以下是必须执行的清理项(所有worker节点):
删除Docker数据目录(谨慎!确保无业务容器在运行)
systemctl stop docker rm -rf /var/lib/docker # 注意:此操作会删除所有本地镜像和容器清理Docker Socket挂载
# 检查kubelet是否还挂载了docker.sock cat /var/lib/kubelet/kubeadm-flags.env | grep docker # 若存在 --container-runtime-endpoint=unix:///var/run/docker.sock,需删除卸载Docker相关内核模块
lsmod | grep -E "(overlay|br_netfilter|nf_nat|xt_conntrack)" # overlay模块可能被Docker占用,影响containerd modprobe -r overlay modprobe overlay重置containerd存储(仅当出现镜像损坏时)
systemctl stop containerd rm -rf /var/lib/containerd/io.containerd.content.v1.content/blobs/* systemctl start containerd
注意:第1步
rm -rf /var/lib/docker是高危操作,必须在维护窗口执行,且提前备份/etc/docker/daemon.json。我们曾因未备份daemon.json,导致containerd无法读取insecure-registries配置,Harbor拉取失败。
5.3 故障回滚的“三分钟法则”:当K8s升级出问题时的快速止血
在某银行核心系统升级中,v1.25集群因CNI插件兼容问题导致所有Pod网络中断。我们按以下流程3分钟内恢复业务:
立即暂停滚动更新
kubectl rollout pause deployment/nginx-ingress-controller回退到上一个稳定版本
# 查看历史版本 kubectl rollout history deployment/nginx-ingress-controller # 回退到revision 3 kubectl rollout undo deployment/nginx-ingress-controller --to-revision=3强制驱逐异常Pod(当Pod卡在Terminating)
# 绕过K8s优雅终止,直接删除 kubectl delete pod nginx-ingress-xxxxx --grace-period=0 --force验证基础服务
# 检查CoreDNS是否正常 kubectl get pods -n kube-system -l k8s-app=kube-dns # 测试DNS解析 kubectl run -it --rm --restart=Never busybox --image=busybox:1.35 -- nslookup kubernetes.default
整个过程无需重启kubelet或containerd,所有操作在K8s API层面完成。这得益于K8s的声明式设计——你声明“我要什么状态”,K8s控制器会持续调谐到该状态,回滚只是把声明的目标状态改回去而已。
我在实际操作中发现,90%的K8s故障源于对Docker和K8s职责边界的模糊。当你把Docker当成K8s的“子集”,就会陷入“为什么删了dockerd集群还能跑”的困惑;当你把K8s当成Docker的“升级版”,又会写出docker run思维的Deployment。真正的解法是建立分层心智模型:Docker解决“单机上怎么运行一个应用”,K8s解决“千台机器上怎么运行百万个应用”。这个认知转变,比记住一百个kubectl命令更重要。