说个特别常见的场景:某团队把一个跑了两年多的服务从物理机搬进容器,环境终于统一了,大家很开心;结果第二周服务高峰扩容,运维同事凌晨三点手动起容器、配端口、改上游转发,改完发现新容器跑在了另一台机器上,本地缓存全空,接口直接超时。第二天复盘,问题不在容器,而在于他们只用了 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 卡在 ContainerCreating | CNI 或镜像拉取 | kubectl describe pod,节点上看 crictl |
| Pod 卡在 ImagePullBackOff | 镜像地址、凭据、网络 | kubectl describe pod,检查 imagePullSecrets |
| Pod Running 但服务不通 | readiness 未过,未进 EndpointSlice | kubectl 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: 64Mikubectl 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),剩下的活儿全由这套系统自己流转。每次遇到诡异问题,我都会问自己一句:这个请求现在走到哪个角色手上了?大多数时候,答案能直接把排查范围缩小一大半。