ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Docker与K8S关系全解:Kubernetes架构、核心组件与调度实践

Docker与K8S关系全解:Kubernetes架构、核心组件与调度实践

说个特别常见的场景:某团队把一个跑了两年多的服务从物理机搬进容器,环境终于统一了,大家很开心;结果第二周服务高峰扩容,运维同事凌晨三点手动起容器、配端口、改上游转发,改完发现新容器跑在了另一台机器上,本地缓存全空,接口直接超时。第二天复盘,问题不在容器,而在于他们只用了 Docker,没有用任何编排层。这类事我听过的版本不下十种,本质上都是同一个认知缺口:Docker 解决的是"单个容器怎么跑起来",K8S(Kubernetes)解决的是"成百上千个容器怎么在几十台机器上稳定地跑下去"。前者是造积木的手艺,后者是让积木自动搭成房子的规则体系。这篇我想把 Docker 和 K8S 的关系彻底讲清楚,重点拆 Master 节点和 Node 节点上到底跑着什么、每个组件守着什么职责,再带你把架构图在本地真实跑起来,而不是停在背诵组件名称的层面。

1. Docker 到底是什么:把运行环境打包成一块可搬运的积木

1.1 从"在我机器上是好的"这个老问题说起

要理解 Docker 存在的意义,得先理解它替代了什么。传统部署一个 Java 服务,你需要:一台机器、装对的 JDK 版本、配好的环境变量、对的时区、对的字体库、对的 glibc 版本,以及一堆因为历史原因装在系统里的依赖包。这些东西没有任何一份文件完整记录它们,它们只存在于那台机器的磁盘上。开发在本地跑通,测试环境跑不通,因为测试环境的 JDK 是 1.8 而本地是 17;生产又跑不通,因为生产系统的中文编码配置不一样。

Docker 给出的答案非常直接:别去适配环境了,把应用和它依赖的整个文件系统一起打包。产物就是一个镜像,镜像在任何装了容器运行时的机器上解开,里面看到的目录结构、库文件、环境变量完全一致。这跟虚拟机不一样——虚拟机连内核和硬件都模拟一遍,启动要几十秒,一个虚拟机镜像动辄几个 G;容器只是宿主机上的一个被隔离的进程,共用宿主内核,启动毫秒级,镜像可以做到几十兆。

我通常用一个比喻解释:虚拟机是自己盖了一整栋房子,水电管网全独立;容器是住进一栋公寓楼里的一间,墙和门是自己独立的(命名空间隔离),但水电网(内核)是共享的。共享带来轻量,也带来约束——你不能在容器里跑一个和宿主内核版本冲突的东西,Windows 容器镜像没法直接跑在 Linux 宿主上。

1.2 镜像分层与写时复制:Docker 快在哪

镜像不是一个大 tar 包,而是一层层叠加起来的只读层。写一个 Dockerfile:

FROM ubuntu:22.04 RUN apt-get update && apt-get install -y curl COPY app /opt/app CMD ["/opt/app/run.sh"]

每条会改动文件系统的指令产生一层。FROM那一层是基础镜像,RUN apt-get那一层只记录"新增了哪些文件、改了哪些文件",COPY又是一层。最终镜像是一个层列表加上一份配置(JSON),每层有自己的内容哈希。

分层设计带来两个非常实际的好处。第一是复用:你本地有十个服务都基于ubuntu:22.04,那基础层在磁盘上只有一份,拉取时也只需要拉一次。第二是构建缓存:改了COPY app这一层的代码,前面几层直接命中缓存,构建只需要几百毫秒。这也解释了一条非常经典的 Dockerfile 优化原则——把变化频率低的内容写在前面,变化频率高的写在后面。很多新手把COPY . .放在RUN npm install之前,结果每次改一行代码就得重新装一遍依赖,构建时间从 30 秒变成 5 分钟。

容器启动时,Docker 在所有只读层之上再挂一个可写层,所有写操作都落在这个可写层里,这就是写时复制(Copy-on-Write)。底层镜像文件被读取时不复制,只有第一次被修改时才复制一份到可写层。所以容器删掉,可写层就没了,数据全丢——这是新手最常见的"数据丢失"事故来源,需要持久化的东西必须挂卷,不能指望容器自己的文件系统。

1.3 Docker 已经不是唯一的运行时了

这里有个必须澄清的认知:很多人以为"K8S 底层就是 Docker",这个说法从 2022 年之后就不准确了。Docker 引擎的架构在早期就做过拆分:最上层是面向用户的 docker CLI 和守护进程,中间是 containerd(负责镜像和容器生命周期管理),最下层是 runc(真正调用内核的 namespace 和 cgroup 去创建进程)。

K8S 为了不绑定某一家实现,定义了一套叫CRI(Container Runtime Interface)的 gRPC 接口,只要一个运行时实现了这套接口,kubelet 就能用它。Docker 本身没有实现 CRI,所以早期 K8S 里塞了一个叫 dockershim 的适配层去做转换,这个适配层在 1.24 版本被正式移除。现在主流的做法是 kubelet 直接对接 containerd 或 CRI-O。

对使用者来说这意味着什么?你在 K8S 节点上用docker ps是看不到 Pod 容器的,即使那个节点上恰好装了 Docker。要查容器得用crictl ps,或者用kubectl get pods。这个坑我在现场见过太多次,有人 SSH 上节点敲docker ps一片空白,以为容器没起来,慌了半小时。

概念定位典型工具常见误用
镜像只读的应用打包产物docker build / buildkit用 latest 标签上生产
容器镜像的一次运行实例docker run把数据写在容器可写层
容器运行时负责拉镜像、起进程containerd、CRI-O节点上docker ps找不到容器
编排系统管理大量容器的调度与自愈Kubernetes把编排器当单机 Docker 用

2. 单机 Docker 撑不住的时候:编排需求是怎么长出来的

2.1 三种典型的手工运维崩塌现场

Docker 在单机上非常好用,但业务一旦变成"十几台机器、几十个服务、每天几十次发布",手工操作就会以三种方式崩掉。

第一种是调度崩掉。你手上有 5 台机器,A 机器 CPU 满了,B 机器空着,你得自己判断该把新容器放哪台。机器少的时候凭感觉,机器多了就得写脚本,脚本一写就发现要考虑资源余量、亲和性、端口冲突、机器健康状态,很快变成一个没人敢改的怪物脚本。

第二种是自愈崩掉。容器进程挂了,谁去重启它?你可以写一个 systemd 单元或者一个守护脚本,但如果整台机器宕机呢?如果容器卡死不退出呢?如果重启后又挂了 5 次呢?这些策略组合起来,就是一个小型编排器的雏形。

第三种是发布崩掉。滚动更新时,你要控制"先起 2 个新的、等健康检查通过、再停 2 个旧的",中途出错要能回滚。手工做一次能成,做一百次几乎必然出错。

这三种崩塌就是编排系统存在的全部理由。K8S 不是凭空设计出来炫技的,它是把这三件事抽象成了三个核心机制:调度器、控制器、声明式发布策略。

2.2 声明式 API:从"我要执行什么"到"我要什么状态"

Docker 的命令行是命令式的:docker run、docker stop、docker rm,你告诉系统要执行什么动作,动作执行完就结束了,系统不记得你的意图。

K8S 是声明式的:你提交一份 YAML,内容大概是"我要 3 个副本,镜像用这个,占 100m CPU",然后就不管了。K8S 内部会持续对比"期望状态"和"实际状态",不一致就动手修正。这个对比修正的循环叫调谐(reconcile)。

这个思维切换是新手最大的坎。命令式思维的人会问:"我怎么让 K8S 重启这个 Pod?" 声明式的答案是:"你不用让它重启,你删掉它,副本控制器会发现少了一个,自动补一个。" 又有人问:"我怎么把一个节点上的 Pod 全停掉做维护?" 答案是 cordon + drain,让调度器别再往上放,让驱逐控制器负责搬家,而不是自己去docker stop。

理解这一点之后很多行为就顺了:为什么 K8S 的 YAML 里经常要写一些看起来"重复"的东西(比如 selector 和 labels 要对应),因为控制器就是靠标签选择器去认领它该管哪些对象的。

2.3 编排器真正要解决的四个硬问题

抛开一堆名词,任何容器编排系统都得回答四个问题,K8S 的架构其实就是这四个答案的物理落地。

第一个问题:这个容器该放在哪台机器上?答案是 scheduler,它负责看资源、看约束、打分选优。

第二个问题:放上去之后,谁来保证它一直活着?答案是 kubelet 加各种控制器,kubelet 盯着本机的容器,控制器盯着集群层面的副本数量。

第三个问题:多个副本之间怎么互相发现、怎么被外部访问?答案是 Service 加 DNS,给一组会变的 Pod 一个不变的虚拟 IP。

第四个问题:整个集群的状态存在哪、怎么保证一致?答案是 etcd,一个强一致的键值存储,是集群唯一的事实来源。

你在任何一份 K8S 架构图里看到的组件,几乎都能对上这四个问题中的某一个。带着这个框架去看架构图,比死记组件名有用得多。

3. Master 节点拆解:控制面这四件套各自守着什么

3.1 apiserver:唯一入口与它的无状态设计

控制面最容易理解也最容易被低估的组件是kube-apiserver。它是整个集群的唯一入口,所有组件之间的通信都走它,没有任何组件绕过它直接跟 etcd 说话(除了 apiserver 自己)。

apiserver 干的事可以拆成四件:认证(你是谁)、鉴权(你能不能干这事)、准入控制(这个请求要不要改写或拒绝)、读写 etcd。这四步的顺序是固定的,很多排错场景就卡在这条链路上——比如 Pod 创建被拒,先分清是 RBAC 不给权限,还是准入 webhook 把它拦了,两者的报错完全不一样。

apiserver 本身是无状态的,它不缓存业务状态,所有状态都在 etcd。这个设计是它能水平扩展的前提:你可以起三个 apiserver 实例,前面挂负载均衡,任意一个挂了都不影响。但也因为无状态,它的性能瓶颈通常在 etcd 的写入能力和 watch 连接的规模上,而不是 apiserver 自己的 CPU。

还有一个关键机制叫list-watch:几乎所有组件跟 apiserver 交互都是"先 list 拿全量,然后 watch 拿增量"。这比轮询高效太多,但 watch 连接数是个实打实的资源,集群规模大了之后 apiserver 的内存和连接数要重点盯。

3.2 etcd:存的是期望状态,不是现状

etcd是基于 Raft 协议的分布式键值存储,它存的是整个集群的所有对象定义:每个 Deployment 的 spec、每个 Pod 的 spec、每个 Service 的规则、每个 Secret 的内容。

这里有个非常反直觉但极其重要的点:etcd 里存的主要是"你要什么",而不是"现在是什么"。你提交一个 3 副本的 Deployment,etcd 里存的是 spec.replicas=3 这个期望。至于现在到底有几个 Pod 在跑,是由各个控制器算出来之后,再以 Pod 对象的形式写回 etcd 的。Pod 的 status 字段确实也存回 etcd,但它更新的频率远低于 spec。

理解这一点能解释很多现象。比如你手工kubectl edit改了一个被控制器管理的 Pod 的副本数,改完马上又变回去了——因为控制器的期望值没变,调谐循环一跑就给你改回来。

etcd 的运维有几个硬要求:必须定期快照备份,因为整个集群的"记忆"都在这里面;必须用 SSD,Raft 的写入对磁盘 fsync 延迟极其敏感;集群规模建议奇数节点,三个或五个,Raft 靠多数派选举。我在现场见过控制面卡顿查了两天,最后发现是 etcd 的数据盘和日志盘共用一块低性能云盘,写入延迟抖动到了几百毫秒。

3.3 scheduler:两阶段调度与打分插件

kube-scheduler的职责单一而关键:给每个还没绑定节点的 Pod 挑一台 Node。它的工作分两个阶段。

过滤阶段(Filter)先把不满足硬性条件的节点全排除掉:资源不够的排除、节点有污点且 Pod 不容忍的排除、节点亲和性不匹配的排除、端口冲突的排除。跑完这一轮,剩下的叫"可行节点"。

打分阶段(Score)给每个可行节点打 0 到 100 分,选出最高分。打分的维度包括资源均衡度(尽量让各节点使用率接近)、镜像本地性(节点上已经有这个镜像的加分,省一次拉取)、Pod 拓扑分布(同一 Deployment 的副本尽量分散到不同节点)等等。如果所有节点分数一样,就随机选一个。

调度器是插件化的,K8S 把它拆成了一套调度框架,在 PreFilter、Filter、PostFilter、PreScore、Score、Reserve、Permit、Bind 这些扩展点上可以插自定义插件。这意味着你可以写自己的调度策略,比如"这个服务必须调度到带 GPU 标签的节点"——虽然更常见的做法是直接用 nodeSelector 或 nodeAffinity 表达。

一个实操细节:如果没有任何节点通过过滤阶段,Pod 会一直处于 Pending。这时候kubectl describe pod的 Events 里会明说原因,比如0/3 nodes are available: 3 Insufficient cpu。看到 Pending 第一件事就是看这个 Events,比猜快一百倍。

3.4 controller-manager:永不停止的调谐循环

kube-controller-manager是一个进程里打包了几十个控制器的集合,最核心的几个:

  • Deployment 控制器:管 ReplicaSet,管滚动更新。
  • ReplicaSet 控制器:保证实际 Pod 数等于期望数,少了补、多了删。
  • Node 控制器:监控节点心跳,节点失联超时后把上面的 Pod 标记为待驱逐。
  • EndpointSlice 控制器:把 Service 和 Pod IP 的对应关系算出来,供 kube-proxy 消费。
  • Job / CronJob 控制器:管批处理任务的生命周期。

所有这些控制器共享一个模式:读期望状态,读实际状态,做差,采取动作,重复。这个循环每秒都在跑,集群越大,每个控制器的队列越长。这也是为什么大集群里控制器的处理延迟会成为一个需要关注的点。

Node 控制器有个参数值得记住:默认节点心跳失联 40 秒标记 NotReady,5 分钟后开始驱逐 Pod。这个 5 分钟是可以通过启动参数调的,但不要随便调小,因为网络抖动几秒就驱逐会导致大规模误迁移。

3.5 生产环境控制面高可用的三处关键配置

单 Master 的集群只能用于学习和测试,生产必须多副本。高可用有三处容易配漏:

关注点单节点做法生产做法漏配的后果
apiserver 副本1 个3 个,前挂负载均衡 VIP单点故障,集群管理接口全挂
etcd 部署与控制面同机单实例3 或 5 节点独立集群数据丢失风险,恢复困难
控制器选举默认开启多实例靠 Lease 选举单活多实例同时调谐产生冲突

控制器选举这一点特别容易被忽略:controller-manager 和 scheduler 都是多副本部署但同一时刻只有一个在干活,靠的是 etcd 里的 Lease 对象做租约抢占。如果这个机制出问题,你会看到两个实例同时动作,产生一些很诡异的现象,比如 Pod 被反复创建删除。

4. Node 节点拆解:真正干活的那一层

4.1 kubelet 与 CRI:容器到底是谁拉起来的

Node 节点上最核心的组件是kubelet。它是一个跑在每个节点上的代理进程,以系统服务的形式存在(不是容器),职责是把分配到本节点的 Pod spec 变成真正运行的容器。

它的工作流程大致是:watch apiserver 拿到分配给自己的 Pod 列表,逐个对比本地实际状态,然后通过CRI 接口调用容器运行时去拉镜像、创建沙箱、启动容器。健康检查(livenessProbe、readinessProbe)也是 kubelet 在跑——它直接向容器发 HTTP 请求或者执行 exec 命令,不是通过网络中转。

这里要强调一下 CRI 的意义:kubelet 不关心你用的是什么运行时,它只认接口。你在节点上装 containerd,kubelet 的配置里写--container-runtime-endpoint=unix:///run/containerd/containerd.sock;装 CRI-O 就换一个 socket 路径。这个解耦让运行时可以独立演进,也让"换运行时"变成一件不需要重装集群的事。

还有一个常被问的问题:镜像拉取失败怎么办?答案取决于imagePullPolicy。默认策略是:标签是 latest 时用 Always,其他标签用 IfNotPresent。生产上强烈建议不要用 latest 标签,因为你根本不知道某个时刻拉到的到底是哪个版本,回滚也无从谈起。用明确的版本号或者镜像 digest,是基本功。

4.2 kube-proxy 与 Service 的流量转发

kube-proxy解决的是 Service 的实现问题。Service 给你一个固定的 ClusterIP,但 Pod 的 IP 是会变的,所以需要一个东西把"访问 ClusterIP:Port"翻译成"访问某个 Pod IP:Port"。

kube-proxy 有三种工作模式:

  • iptables 模式(当前默认):在每个节点写 iptables NAT 规则,流量到达时由内核直接做 DNAT,转发到后端某个 Pod。优点是稳定成熟,缺点是规则是线性的,Service 数量上万之后规则链会很长,匹配变慢。
  • IPVS 模式:用内核的 IPVS 做四层负载均衡,用哈希表而不是链式规则,大规模下性能更好,支持更多调度算法。需要内核模块支持。
  • userspace 模式:早期实现,流量要经过用户态,性能差,现在基本不用了。

iptables 模式下有个细节值得知道:默认的负载均衡是随机的,靠statistic模块做概率匹配,不是轮询。所以你在客户端看到的分发不完全均匀是正常的,连接数一多就会趋于平均。

另外,kube-proxy 只负责服务发现和转发,它不做健康检查本身——它消费的是 EndpointSlice 里被 readinessProbe 筛过的就绪 Pod。一个 Pod readiness 不过,它会被从 EndpointSlice 里摘掉,新流量就不会打到它。这条链路是理解"为什么我的服务有一段时间返回 502"的关键。

4.3 Pod 为什么不是容器:pause 容器的秘密

新手最容易混淆的概念就是 Pod 和容器。Pod 是 K8S 的最小调度单元,它是一组共享网络命名空间和存储卷的容器的集合。

每个 Pod 起来的时候,运行时首先创建一个叫pause(也叫 sandbox)的容器,这个容器里只跑一个几乎不干事的进程,作用是持有网络命名空间。后续所有业务容器加入这个命名空间,于是它们共享同一个 IP、同一个端口空间,可以用 localhost 互相访问。

这个设计的直接后果是:同一个 Pod 里的两个容器不能监听同一个端口,会冲突。另一个后果是:Pod 重建时 IP 会变,因为 pause 容器是新的。这也解释了为什么 Pod IP 不能当稳定地址用。

那什么时候该在一个 Pod 里放多个容器?典型场景是边车模式:主容器跑业务,边车容器做日志采集、做代理转发、做配置同步。它们的生命周期必须一致,共享网络和卷,这种耦合关系用 Pod 表达最自然。反面例子是把"前端 + 后端 + 数据库"塞进一个 Pod,这就完全跑偏了——它们需要独立扩缩容、独立发布、独立故障隔离,塞一起等于放弃了 K8S 的所有好处。

4.4 Node 状态上报与心跳超时

kubelet 会定期向 apiserver 上报节点状态:CPU 和内存容量、已分配量、镜像列表、节点条件(Ready、MemoryPressure、DiskPressure、PIDPressure)。上报分两种:一种是定期更新 Lease 对象(默认 10 秒一次),这算轻量心跳;另一种是定期更新 Node 的 status 字段(默认 5 分钟一次)。

节点被判定 NotReady 的默认阈值是 40 秒没收到心跳。这时候调度器不再往这个节点放新 Pod,但已有 Pod 还不会立刻被驱逐——要等 5 分钟的容忍期过去,Node 控制器才把它们标记为待删除,然后由 ReplicaSet 控制器在其他节点重建。

这个机制在真实故障中表现如何?假设一台机器断电,上面的 Pod 大概 40 秒后节点变 NotReady,5 分钟后开始在其他节点重建。也就是说故障恢复的实际时间窗口是 5 分钟级别,不是秒级。如果你的业务要求更快的切换,得靠应用层多活或者更激进的探针策略,不能指望 K8S 的默认参数。

5. 把架构图跑起来:一次 kubectl apply 的完整链路

5.1 写入阶段:从 YAML 到 etcd 的那几百毫秒

架构图看懂了,但真正的理解发生在你能把一次请求的路径完整走一遍的时候。我们跟着kubectl apply -f deploy.yaml这条命令走。

kubectl 先读本地 kubeconfig,拿到集群地址、证书、当前上下文,然后把 YAML 转成 JSON,向 apiserver 发起 PATCH 或 POST 请求(apply 走的是服务端 apply 或者 patch 逻辑,取决于写法)。

请求到了 apiserver,先过认证:它验证客户端证书,确认你确实是这个集群里的某个身份。再过鉴权:RBAC 会检查这个身份有没有对apps/deployments资源的 create 权限。再过准入控制:Mutating webhook 可以改写对象(比如注入边车容器、加默认标签),Validating webhook 可以拒绝对象(比如检查镜像仓库白名单)。

全部通过之后,apiserver 把对象序列化写入 etcd,拿到一个 revision 号。到这一刻,写入阶段就结束了。注意,这时候集群里还没有任何容器被创建,etcd 里只是多了一条"我要 3 个副本"的记录。

5.2 生效阶段:调度器、kubelet 与运行时接力

etcd 写入触发 watch 事件,Deployment 控制器收到通知,发现期望副本数是 3,但对应的 ReplicaSet 还不存在,于是创建一个 ReplicaSet。ReplicaSet 控制器收到 ReplicaSet 创建事件,发现期望 3 个 Pod 实际 0 个,于是创建 3 个 Pod 对象,写入 etcd。

这 3 个 Pod 的spec.nodeName是空的,所以调度器 watch 到它们,开始跑过滤和打分,选出一台节点,把 nodeName 写进去(这一步叫 Bind)。

目标节点的 kubelet watch 到有 Pod 被绑定到自己,开始干活:通过 CRI 调用 containerd 拉镜像(如果本地没有),创建 sandbox 容器(pause),配置网络(调用 CNI 插件分配 IP、设置路由),然后依次启动业务容器。每个容器起来后,kubelet 开始跑 probes。

容器真正 run 起来之后,kubelet 把 Pod 的 status 更新回 apiserver,写入 etcd。这时候kubectl get pods才能看到 Running。

整条链路里,任何一环出问题都会表现出不同的症状。这个对应关系我整理成了一张表,排错时非常省时间:

现象大概率卡在哪一环排查入口
apply 报错,提示 forbidden认证或 RBAC 鉴权kubectl auth can-i
apply 报错,提示被 webhook 拒绝准入控制看 webhook 服务日志
Pod 一直 Pending调度器找不到可行节点kubectl describe pod看 Events
Pod 卡在 ContainerCreatingCNI 或镜像拉取kubectl describe pod,节点上看 crictl
Pod 卡在 ImagePullBackOff镜像地址、凭据、网络kubectl describe pod,检查 imagePullSecrets
Pod Running 但服务不通readiness 未过,未进 EndpointSlicekubectl get endpointslice

5.3 访问阶段:ClusterIP 背后的 iptables 规则

Pod 跑起来之后,Service 的链路才开始工作。EndpointSlice 控制器把就绪的 Pod IP 写进 EndpointSlice,kube-proxy watch 到变化,在本节点上更新 iptables 规则。

规则的形态大致是:进入的流量如果目标是 ClusterIP:Port,就跳转到一条KUBE-SVC-xxxx链,这条链里按概率把流量分到若干个KUBE-SEP-xxxx链,每个 SEP 链做一次 DNAT,把目标地址改成某个 Pod IP:Port。

所以本质上,Service 就是一个分布式的 iptables 规则集,没有中心化的负载均衡器在处理流量,转发是内核在做。这也解释了为什么 Service 的 ClusterIP 是"虚拟"的——没有任何网卡上真的配了这个 IP,它只是规则里的一个匹配条件。

外部访问有三种常见方式:NodePort 在每个节点开一个高端口映射到 Service;LoadBalancer 依赖云厂商的负载均衡器;Ingress 则是在集群里跑一个七层网关(比如基于 nginx 或 envoy 的实现),按域名和路径转发到不同的 Service。生产环境最常用的是 Ingress 或者更上层的 Gateway API,NodePort 一般只作为临时调试手段。

6. 手把手搭一个能看清架构的本地集群

6.1 用 kind 起一个控制面加两个工作节点

要看架构,最好的办法是自己造一个。本地起 K8S 有几种选择:minikube 适合单节点体验,k3s 适合资源受限环境,kind 的优势是每个节点就是一个容器,能模拟多节点,而且用的是标准 kubeadm 部署流程,控制面组件都是静态 Pod,观察起来特别直观。

先准备配置文件:

# kind-arch.yaml kind: Cluster apiVersion: kind.x-k8s.io/v1alpha4 name: arch-lab nodes: - role: control-plane - role: worker - role: worker

执行:

kind create cluster --config kind-arch.yaml kubectl get nodes -o wide

你大概会看到三个节点,名字是arch-lab-control-plane、arch-lab-worker、arch-lab-worker2,角色分别是 control-plane 和 。这里的<none>不是说它不是工作节点,而是没有打上node-role.kubernetes.io/worker这个标签,看角色要看kubectl get nodes的 ROLES 列,没标签的都算普通工作节点。

有意思的是,因为 kind 的每个"节点"本身是一个 Docker 容器,你可以用docker ps看到它们:

docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'

这一层关系很值得玩味:宿主机跑 Docker,Docker 里跑着三个充当节点的容器,节点里跑着 containerd 和各种 K8S 组件,containerd 里才是你的业务容器。"Docker 和 K8S 的关系"在这里变成了一个可以亲手触摸的层级结构,比看一百张架构图都直观。

6.2 逐个核对控制面组件的静态 Pod

kubeadm 部署的控制面组件不是以系统服务形式跑的,而是静态 Pod——kubelet 会监视/etc/kubernetes/manifests/目录,目录里有 YAML 文件就自动创建对应 Pod,文件删了 Pod 就没了。这个设计让控制面组件也能被 kubelet 统一管理,不需要额外的守护进程。

进控制面节点看一眼:

docker exec -it arch-lab-control-plane ls /etc/kubernetes/manifests/

你会看到etcd.yaml、kube-apiserver.yaml、kube-controller-manager.yaml、kube-scheduler.yaml四个文件。再看 Pod:

kubectl -n kube-system get pods -o wide

四个组件的 Pod 名字后面都带节点名后缀,比如kube-apiserver-arch-lab-control-plane,状态是 Running,而且 RESTARTS 计数应该是 0 或者很小。如果某个组件反复重启,可以直接看它的日志:

kubectl -n kube-system logs kube-scheduler-arch-lab-control-plane

顺手把 apiserver 的启动参数看一眼,你会看到一堆--enable-admission-plugins、--authorization-mode、--etcd-servers之类的配置,正好对上前面讲的认证、鉴权、准入、存储四件事:

docker exec arch-lab-control-plane cat /etc/kubernetes/manifests/kube-apiserver.yaml

工作节点上的组件就不在 manifests 目录里了。kubelet 是系统服务,kube-proxy 是以 DaemonSet 形式跑的普通 Pod:

kubectl -n kube-system get daemonsets kubectl -n kube-system get pods -l k8s-app=kube-proxy -o wide

你会发现 kube-proxy 在每个节点上都有一个,包括控制面节点——因为控制面节点默认也允许跑普通 Pod,除非你打了污点。

6.3 做一个能被观察的调度实验

光看组件不动手,理解还是浅的。我们来做一个能观察到调度行为的实验。先造一个 6 副本的 Deployment,每个副本声明 100m CPU 的请求:

apiVersion: apps/v1 kind: Deployment metadata: name: probe spec: replicas: 6 selector: matchLabels: app: probe template: metadata: labels: app: probe spec: containers: - name: probe image: nginx:1.25 resources: requests: cpu: 100m memory: 64Mi
kubectl apply -f probe.yaml kubectl get pods -l app=probe -o wide --watch

观察分布。你会发现调度器不是简单轮询,它会让两个工作节点的负载尽量接近,但如果两个节点的可分配资源本身不一样,分布就可能不均。再试一下把其中一个节点封住:

kubectl cordon arch-lab-worker2 kubectl scale deployment probe --replicas=10 kubectl get pods -l app=probe -o wide

新起的 Pod 全都落在arch-lab-worker上了,arch-lab-worker2上的 Pod 还在跑但不会再增加。cordond 的作用就是给节点打一个unschedulable标记,调度器过滤阶段会直接跳过它。

再狠一点,把节点上的 Pod 赶走:

kubectl drain arch-lab-worker2 --ignore-daemonsets --delete-emptydir-data

这条命令会逐个体面地驱逐 Pod,同时遵守 PodDisruptionBudget。驱逐完成后,ReplicaSet 控制器发现副本数不足,会在剩下的节点上补。你可以用kubectl get pods -w看到整个迁移过程。做完记得kubectl uncordon arch-lab-worker2恢复。

关于--ignore-daemonsets这个参数有个常见疑问:为什么必须加?因为 DaemonSet 的 Pod 是"每节点一个"的语义,它不能迁移到别的节点去,drain 遇到它只能跳过,所以你必须显式声明"我知道并且同意跳过"。

6.4 模拟节点故障看调谐循环

比 cordon 更真实的是直接让节点失联。kind 里最简单的做法是停掉那个充当节点的容器:

docker stop arch-lab-worker2 kubectl get nodes --watch

大概 40 秒后,arch-lab-worker2的状态会从 Ready 变成 NotReady。但注意,上面的 Pod 不会立刻被驱逐,还是显示 Running(虽然实际上已经不可达了)。要等大约 5 分钟,Node 控制器才把 Pod 标记为待删除,然后 ReplicaSet 在其他节点重建。

这 5 分钟你可以干点别的,也可以手动观察 Pod 的状态变化。等它真的开始重建了,恢复节点:

docker start arch-lab-worker2 kubectl get nodes

节点会重新变成 Ready,但上面原来的 Pod 已经被删了,新的 Pod 分布在健康节点上。这时候如果节点恢复了资源,后续扩容又会往上放。

这个实验的价值在于让你对故障恢复的时间尺度有体感。很多人以为 K8S 是秒级自愈,实际上默认配置下是分钟级。如果你的业务 SLA 要求更严,需要调整的参数不止一个,而且要考虑误判风险。

7. 我踩过的那些坑:几个流传很广的误解

7.1 一个 Pod 塞多个容器

这是新手最普遍的误解,根源是把 Pod 当成了"虚拟机",觉得一个 Pod 里装的应用越多越省事。实际上一个 Pod 里的所有容器是同生共死的:它们一起被调度到同一台节点,一起被创建,一起被销毁。你把前端、后端、缓存塞进去,结果是前端要扩 10 个副本,数据库也跟着扩了 10 个,而且每一个都是完整的一份——数据一致性直接崩掉。

判断标准很简单:这两个容器需不需要独立扩缩容?需不需要独立发布?只要有一个答案是"需要",就该拆成两个工作负载。真正该合在一起的,是那些生命周期强绑定、必须共享网络或文件的东西,比如日志采集边车、服务网格代理。

顺带说一个技术细节:同一个 Pod 内的容器共享网络命名空间,所以端口不能冲突。有人把两个都要监听 8080 的服务塞进一个 Pod,结果第二个一直起不来,报 address already in use,还以为是镜像问题。

7.2 以为 NodePort 能上生产

NodePort 用起来很爽,随便一个 Service 改成 type: NodePort,kubectl get svc看到一个 30000-32767 的端口,浏览器直接就能访问。于是有人就把它当成生产方案了。

问题在哪?NodePort 暴露的是每个节点的端口,客户端得知道至少一个节点 IP。节点扩缩容、IP 变化,客户端配置就得跟着改。而且它工作在四层,没有域名路由、没有路径路由、没有 TLS 终止,多个服务要共享 80 端口根本做不到。安全层面它也绕过了集群边界,任何能访问节点 IP 的人都能打进来。

生产上的标准做法是 Ingress 或者云厂商的 LoadBalancer 类型 Service。Ingress 在集群里跑一个七层网关,外部流量只进这一个入口,按 Host 和 Path 分发,证书统一在这一层管。NodePort 的正确用途是临时调试、或者给 Ingress 控制器自身暴露入口。

7.3 不写 resources.requests 的后果

YAML 里resources那段很多人直接省掉,能跑就行。短期确实没感觉,集群规模一上来问题就全冒出来了。

requests是调度器做决策的唯一依据。你不写,调度器就认为这个 Pod 不需要任何资源,于是往一个已经很满的节点上继续塞。塞到实际资源耗尽,节点开始 OOM,kubelet 触发驱逐,你可能看到一堆 Pod 莫名其妙被干掉,日志里就一句 evicted。

limits则是运行时的硬约束,CPU 超了会被 cgroup 限流(表现为响应变慢但不会被杀),内存超了会直接被 OOMKilled(表现为容器反复重启,退出码 137)。这两个语义完全不同,配的时候要分开想:CPU 的 limit 可以设得比 request 高一些允许突发,内存的 limit 建议贴近实际峰值,因为内存不像 CPU 可以"借用"。

我一般建议的起步值是:先用压测或者历史监控数据估一个峰值,request 设在 P50 到 P70 的水平,limit 设在峰值往上留 20%。然后观察一段时间再调,而不是一开始就拍脑袋。

7.4 docker ps 看不到 kubelet 的容器

这个坑前面提过,但值得再强调一次,因为它的误导性太强。节点上装了 Docker,docker ps空空如也,人就开始怀疑人生。

原因是 kubelet 根本不跟 Docker 说话,它对接的是 containerd。容器确实在跑,只是不在 Docker 的视野里。要用 crictl:

# 在 kind 的 worker 节点容器里执行 docker exec -it arch-lab-worker crictl ps docker exec -it arch-lab-worker crictl images docker exec -it arch-lab-worker crictl logs <container-id>

crictl 是 CRI 的命令行客户端,命令风格跟 docker 很像但语义不同——它操作的是 CRI 层面的沙箱和容器,没有网络和卷的管理能力。排错时crictl ps -a能看到所有容器包括退出的,crictl inspect能看详细配置,crictl pods能看沙箱。这几个命令在节点层面排查问题时是刚需。

还有一个相关问题:节点磁盘被镜像占满。kubelet 有镜像回收机制(默认磁盘使用率到 85% 开始回收),但如果你手动docker pull了一堆镜像,那些不在 containerd 的视野里,不会被回收,白占空间。用 crictl 管镜像,别混着用。


最后分享一个我自己一直用的理解方法:把 K8S 想象成一个大型的、分布式的"施工调度系统"。etcd 是图纸档案室,apiserver 是唯一收件窗口,scheduler 是派工员,controller-manager 是一群盯着进度表的监理,kubelet 是每块工地上真正带工人干活的工头,kube-proxy 是电话交换台。你提交一份需求单(YAML),剩下的活儿全由这套系统自己流转。每次遇到诡异问题,我都会问自己一句:这个请求现在走到哪个角色手上了?大多数时候,答案能直接把排查范围缩小一大半。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进