Docker架构与K8S实战:从容器隔离到Pod调度排错

发布时间:2026/10/21 3:00:04
Docker架构与K8S实战:从容器隔离到Pod调度排错
深入解析Docker架构与K8S实战最近团队在梳理一套老服务的容器化改造方案我跟很多同行聊下来发现一个很有意思的现象大家对docker run、kubectl apply这种日常命令都很熟但一旦追问“Docker 底层到底怎么创建容器”“Pod 卡在 Pending 时该去哪里查原因”不少人就开始含糊了。这其实很正常容器和编排工具发展到今天已经成了一层套一层的抽象平时用着顺手但真要排查问题或者做架构决策底层那点逻辑就必须补上。这篇文章我想以自己多年的实操经验为切入点把 Docker 的架构关键角色、镜像和容器的隔离机制讲清楚再把 K8S 的组件地图、工作负载资源、部署实战和排错思路串起来。适合刚接触 K8S 的后端、运维也适合已经会写 Dockerfile 但对集群原理还一知半解的朋友。这里不会只贴命令我会尽量把每个步骤背后的“为什么”一起说清楚。1. Docker到底是怎么跑起来的架构里的几个关键角色1.1 Client、Daemon 和它们之间的会话很多初学者以为docker run就是 Docker 的全部其实它只是客户端工具。Docker 的整套架构是典型的 C/S 模式docker命令行是客户端后台常驻的dockerd是服务端两者通过 REST API 通信。我们敲下docker run时客户端把请求发给 dockerddockerd 经过一系列处理后返回结果给你。这个设计的直接好处是客户端不一定要和守护进程在同一台机器上。生产环境里完全可以通过-H tcp://ip:2375远程管理一台宿主机上的容器。当然这样裸奔很危险必须有充分的鉴权策略否则等于把服务器管理口暴露在网络上。还有个容易忽略的点docker ps、docker stop这些看起来“本地操作”的命令本质上也是在和 dockerd 通信。等 daemon 挂掉之后你执行任何 docker 命令都会报“Cannot connect to the Docker daemon”哪怕只是想看容器列表也不行因为列表也存在 daemon 维护的状态里。1.2 containerd、runc 和 shim被大部分人忽略的底座现代 Docker 版本20.x 以后自己并不是直接创建容器的它下面还有一层更底层的运行时。完整链路大致是这样docker client → dockerd → containerd → containerd-shim → runc → 内核(命名空间cgroups)dockerd负责镜像管理、API 接入、网络配置、存储卷挂载等上层能力。containerd真正管理容器生命周期、镜像解包、存储等能力是业界的标准容器运行时之一。containerd-shim夹在 containerd 和 runc 之间的中间进程负责维护容器的标准输入输出同时在容器主进程退出后把状态报告回去。runc真正调用 Linux 内核特性创建容器的那个小工具也是 OCI 运行时规范的参考实现。这里必须多说一句很多人以为 Docker 和 containerd 是竞争关系其实在新版本里 Docker 自己也已经内置了 containerd。也就是说你在部署 Kubernetes 集群时遇到“容器运行时用 containerd 还是 Docker”的选择本质上不是跑两个东西而是看集群用哪个接口去管理容器。1.3 为什么是“客户端-守护进程”而不是直接执行有一种常见的误解是Docker 就是个进程管理工具像 systemd 一样直接拉起进程不就行了为什么中间要绕这么多层用生活里的例子类比你外卖点餐不是直接绕过平台冲进后厨让厨师开火而是把订单交给平台平台再分配到具体商户和骑手。这个平台就是 dockerd它要负责排队、定位、结算、售后等一系列横切的事。容器也一样如果每次创建都直接调内核那镜像管理、网络分配、日志收集、资源统计这些活儿就没地方放了。正因如此Docker 才能提供分层镜像、数据卷、网络桥接、日志驱动这些“超出一个单独进程”的能力。注意生产环境排查问题时第一件事永远是确认 dockerd 和 containerd 进程是否都活着。经常有人纠结“容器突然停了但 dockerd 还在”结果最后发现是 containerd 卡死了。2. 镜像、容器、隔离机制把 Docker 最难的部分讲透2.1 镜像分层与联合文件系统很多人把 Docker 镜像理解成一个“打包好的软件”这没错但从底层看镜像是多层只读文件的叠加。每一层对应 Dockerfile 里的一步指令比如FROM、RUN、COPY。这些层会在容器启动时通过联合文件系统合并成一个统一的文件系统视角。以 OverlayFS 为例底层叫做 lowerdir就是镜像的只读层容器启动后会在最上面加一个可写的 upperdir也就是容器层。你对容器内文件的修改都发生在这一层不会动到底层镜像。删除文件也不是真的把底层删掉而是通过“whiteout”机制在可写层把它遮住。这个设计带来的实际收益是镜像缓存和传输效率。比如你同一个基础镜像构建多个项目只要基础镜像层不变宿主机上就只需存一份pull 的时候也只要拉取新增的层。但反过来如果 Dockerfile 写得很粗糙比如把易变的代码 COPY 放在最前面那后面每次构建都可能重新执行很多层缓存基本全废。分享一个我常用的多阶段构建示例FROM golang:1.21 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 go build -o app . FROM alpine:3.19 COPY --frombuilder /app/app /usr/local/bin/app ENTRYPOINT [app]这样最终镜像里只包含编译好的二进制和基础运行环境不会带着整个 Go 工具链镜像体积可以从几百兆直接压到二三十兆。2.2 容器的隔离命名空间与控制组容器看起来像一个独立的小系统本质上是内核提供的两类能力合在一起Namespace负责“看不着别人”。PID、Network、Mount、UTS、IPC、User 这些命名空间隔离了进程、网络、文件系统挂载点、主机名、进程间通信和用户 ID 视图。Cgroups负责“用不超额度”。通过控制组限制 CPU、内存、PID 数量、IO 带宽。打个比方命名空间是给每个容器造了一个单独的房间互相看不到对方的家具cgroups 是给每个房间装了独立电表和限流阀防止一个房间把整栋楼的电拉崩。有一点必须提醒容器隔离不等于安全隔离。容器和宿主机共享同一个内核如果内核有漏洞恶意进程有可能逃逸出去。所以生产环境里不要把容器当虚拟机来信任需要强隔离的场景还是要考虑虚拟机或者至少配合更强的安全上下文和 seccomp 策略。2.3 数据卷和容器网络容器的可写层生命周期和容器一致容器一删没放在卷里的数据就全没了。所以需要持久化的数据一定要通过 volume 或 bind mount 挂出来。volume由 Docker 管理的本地存储推荐做法。bind mount直接把宿主机目录映射进容器修改宿主机文件容器里立即可见。网络方面常见模式是 bridge、host、none模式特点适合场景bridge默认容器之间通过虚拟网桥通信有独立 IP单机多容器、需要端口映射host容器直接用宿主机网络栈不隔离端口对网络性能要求极高的场景none无网络只有 loopback离线计算、特殊安全需求一个容易踩的坑是端口映射和绑定地址。docker run -p 8080:80会把宿主机所有网卡的 8080 转发到容器 80如果只想在内网某个地址暴露一定要写完整-p 192.168.1.10:8080:80否则外部流量也可能直接到达服务。3. K8S 集群的组件地图别被各种概念绕晕3.1 控制平面的四个核心组件Kubernetes 集群从逻辑上分为控制平面和工作节点两部分。控制平面负责“决策”工作节点负责“执行”。控制平面主要四个组件API Server所有 kubectl 操作、组件间通信的唯一入口负责认证、授权、准入控制和数据持久化转发。etcd保存集群所有状态数据的分布式键值存储。可以理解成控制平面的“数据库”但它是强一致性的。Scheduler只负责一个新 Pod 该部署到哪个节点通过一系列过滤和打分策略做决策。它不负责真正运行 Pod。Controller Manager一堆控制器的集合比如节点控制器、副本控制器、服务账号控制器。它们通过 watch 机制不断把“实际状态”拉回“期望状态”。为什么拆这么多组件核心是职责单一和可扩展性。API Server 不再管调度逻辑Scheduler 也不管维持副本数每个组件可以做独立的水平扩展。而且任何一个组件出问题不至于整个集群瘫痪。3.2 工作节点上的三件套每个工作节点上至少有这几样东西在跑kubelet和 API Server 保持通信负责 Pod 的真正创建、启动、健康检查和回收。kube-proxy负责实现 Service 的转发规则常见实现是 iptables、ipvs。容器运行时真正创建容器的组件通过 CRIContainer Runtime Interface接入常见的有 containerd、CRI-O。这里多讲一下 Docker 和 K8S 的关系。K8S 默认通过 CRI 和容器运行时交互Docker 本身不直接实现 CRI。早期有 dockershim 这个适配层1.24 版本已经彻底移除。现在想继续用 Docker 做运行时需要单独部署一个叫 cri-dockerd 的适配器但一般没必要直接用 containerd 更省事。注意很多教程还在用docker命令查看 K8S 节点上的容器但在 containerd 环境下docker ps是看不到集群 Pod 的。需要换成crictl ps、crictl logs。3.3 Pod调度、重启、扩展的最小单位K8S 管理的最小单位不是容器而是 Pod。一个 Pod 可以包含一个或多个容器这些容器共享同一个网络命名空间、同一个 IP 地址和存储卷。为什么要把容器再包一层因为有些场景下多个容器必须紧密协作主容器写日志sidecar 容器负责采集转发主容器提供业务接口sidecar 容器做本地代理或流量拦截。如果让它们各自拥有独立 IP 和存储协调起来就很麻烦。Deployment、StatefulSet 这些控制器并不直接创建 Pod而是通过 ReplicaSet 管理 Pod 副本。这解释了为什么你改了 Deployment 的 Pod 模板后K8S 不会原地修改已有 Pod而是创建新的 ReplicaSet滚动替换旧 Pod。这个设计保证了发布过程可控、可回滚。4. 从部署一个应用开始核心编排资源的实操角度4.1 把镜像构建和推仓库的注意点部署到集群之前镜像必须先放到某个 Registry。构建时除了多阶段构建还有几个经验镜像版本不要用latest。生产环境我看到太多因为 latest 漂移导致的环境不一致。建议用构建时间和提交哈希组合比如app-web:20250318-3fa2c91。利用构建缓存。把变化频率低的依赖安装步骤放在 Dockerfile 前面变化频繁的源码 COPY 放最后。注意架构标签。K8S 集群常见的是 amd64但现在 ARM 节点越来越多构建时要留意--platform或者用 manifest 推送多架构镜像。推送过程很简单docker build -t registry.example.com/team/app-web:20250318-3fa2c91 . docker push registry.example.com/team/app-web:20250318-3fa2c91如果是私有仓库需要先docker login registry.example.com凭据会存在~/.docker/config.jsonK8S 节点拉取私有镜像时需要把这个凭据配置成 imagePullSecret否则拉取直接报权限错误。4.2 用 Deployment 和 Service 跑起来下面是一个最小可用的部署示例包含 Deployment 和 ServiceapiVersion: apps/v1 kind: Deployment metadata: name: app-web namespace: production labels: app: app-web spec: replicas: 3 selector: matchLabels: app: app-web template: metadata: labels: app: app-web spec: containers: - name: app-web image: registry.example.com/team/app-web:20250318-3fa2c91 ports: - containerPort: 8080 resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 512Mi readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 3 periodSeconds: 5 --- apiVersion: v1 kind: Service metadata: name: app-web namespace: production spec: selector: app: app-web ports: - port: 80 targetPort: 8080几个关键点resources.requests是调度时用来计算节点是否有足够空间的指标limits是运行时硬限制。如果把 requests 写得过大会导致 Pod 即使节点很空闲也调度不上去。readinessProbe决定流量是否进入 Pod。如果应用启动慢却不加这个探针会有一段时间请求打到还没准备好的实例上造成 502。Service 的selector必须和 Pod 的 labels 匹配否则 Endpoints 列表是空的流量自然不通。4.3 命名空间、标签与日常工作流命名空间namespace相当于集群内部的逻辑隔离单元可以用它区分开发、测试、生产环境或者按团队、业务线划分。但要注意命名空间之间网络默认是通的隔离网络还得靠 NetworkPolicy。标签label是 K8S 里最灵活的元数据机制。用标签可以非常精细地筛选资源kubectl get pods -n production -l appapp-web kubectl get pods -n production -l version20250318 kubectl logs -n production -l appapp-web --tail50我习惯给所有资源打三层标签应用名、版本、环境。比如appapp-web、version20250318、envprod。一开始多花一点时间后面排查问题会非常省力。5. 实战排错部署过程中一定会踩的坑5.1 镜像拉不下来怎么办这是新集群最常见的问题之一现象就是 Pod 状态一直ImagePullBackOff。排查顺序kubectl describe pod pod-name -n namespace然后看 Events 里的具体报错。常见原因有镜像不存在或 tag 写错比如把app-web:20250318写成了app-web:20250318-old。私有仓库没配 imagePullSecret。节点网络到镜像仓库不通特别是跨云或离线和内网环境。解决办法也分场景。如果集群整体都无法访问外部仓库最好在能访问外网的机器上先把镜像 pull 下来再docker save导出、到节点上docker load导入或者把镜像推到内网私有仓库。5.2 Pod 一直 Pending 的排查思路Pod 一直 Pending说明调度器还没把 Pod 放到节点上。最常见的原因就是资源不足。执行kubectl describe pod pod-name -n namespaceEvents 里会出现0/3 nodes are available这种提示。需要看节点状态kubectl get nodes kubectl top nodes另外还要考虑污点和容忍度。如果工作节点有taint而 Pod 没有对应的tolerations也不会调度上去。这类问题看 Events 基本都能定位。还有一种隐蔽情况节点虽然资源够但 PVC 没绑定成功Pod 会因为等待存储卷一直 Pending。5.3 CrashLoopBackOff 和网络不通启动后反复崩溃是最折磨人的问题。第一步是看日志kubectl logs pod-name -n namespace --previous--previous参数非常重要因为 Pod 重启后当前容器的日志可能是空的必须看上一次进程退出前的日志。常见原因包括启动命令依赖的环境变量没传。配置挂载到了错误路径。健康检查失败导致 K8S 主动杀容器看起来像是“启动崩溃”。网络不通时先确认 Service 是否有正确的 Endpointskubectl get endpoints svc-name -n namespace如果列表为空说明 selector 没匹配上任何 Pod。再检查 Pod 是否全部 Ready以及 targetPort 是不是写错。5.4 节点磁盘和运行时故障的自救集群跑久了节点磁盘爆满非常常见。原因往往是镜像堆积、容器日志无限增长、以及 /var/lib/containerd 下面遗留数据。你可以用这些命令快速查看占用df -h journalctl --disk-usage crictl stats crictl images --digests清理方式按照安全级别来docker system prune # 如果用 Docker 运行时清理悬空镜像和停止容器 crictl rmi --prune # containerd 环境删未使用镜像有些公司会直接给 kubelet 配置容器日志轮转避免单日志文件把磁盘挤满。结尾一点个人实战体会我自己做过不少容器化迁移和集群运维最大的体会是Docker 和 K8S 真正的挑战从来不在某个命令怎么写而在于你能否把“进程、文件系统、网络、资源限制”这些操作系统底层概念串起来。镜像分层离不开文件系统容器隔离离不开内核机制Pod 调度离不开资源模型。谁先把这层窗户纸捅破谁看问题就能快人一步。最后再分享一个小技巧排查问题别总盯着 pod log很多隐藏线索藏在kubectl describe pod的 Events 里它记录了从调度到拉镜像到启动探针的完整过程。养成“先 describe、再 logs、最后 exec”的排查顺序你会发现大部分问题其实三分钟内就能定位。希望这篇带着架构视角的实战梳理能帮你少走一点我当年走过的弯路。