1. 一条 kubectl apply 发出去之后,集群里到底发生了什么
K8S 核心原理这六个字,刚接触时最容易走偏的方向,是把 K8S 当成一台“远程执行器”——我发一条命令,它就去把容器拉起来。真到线上排障时你会发现这个心智模型根本对不上号:明明 yaml 没改,Pod 却在反复重建;明明 Pod 状态是 Running,Service 却一个后端都没有。问题几乎都出在对“声明式”这三个字的理解深度上。这一节我想把kubectl apply这条链路拆开,讲清楚一次提交从客户端到落盘、再到被控制器接手的完整路径;搞懂这条路径,后面调度、网络、存储的很多“怪现象”都会自动解释得通。适合刚上手集群、或者已经会用但总觉得自己在“试错”的读者。
1.1 apply 不是“远程执行”,而是提交一份期望状态
kubectl apply的本质动作是:把本地 yaml 序列化成一个对象,发给 apiserver,由 apiserver 写入 etcd。它跟你写了什么业务逻辑完全无关,apiserver 不会去“启动容器”,它只负责校验、存储、通知。真正干活的是运行在各个组件里的控制器,它们通过监听对象变化来决定要不要做事。
这里有个特别容易忽略的细节:apply和create不是一回事。create遇到同名对象直接报 AlreadyExists 就结束了;apply走的是 patch 语义,它会读对象上那个kubectl.kubernetes.io/last-applied-configuration注解,把“上次提交的内容”“这次提交的内容”“集群里的当前内容”三者做一次三方合并,算出该增删改哪些字段。所以你删掉 yaml 里的一个字段再 apply,字段会被清掉;而用edit手改的字段,apply 一般不会动它。
这解释了一个高频困惑:为什么同事手动kubectl edit加了个注解,我 apply 之后它还在?因为三方合并只处理“我上次声明过、这次没声明”的字段。想让 apply 完全接管,得用服务端 apply,靠 fieldManager 做字段所有权管理,谁管的字段谁负责,冲突时直接报错而不是悄悄覆盖。
提示:
kubectl apply的合并逻辑依赖注解,如果对象是从别的集群导出的、没有这个注解,第一次 apply 会退化成“补丁式覆盖”,行为和你预期可能不一样。
1.2 控制器循环:把“现状”一点点拽向“期望”
控制器模式是整个 K8S 的骨架。所有控制器,不管它管的是 Deployment、Node 还是 Service,内部结构几乎都是一个死循环:读期望状态、读实际状态、比较、执行差异动作、更新状态字段、然后等下一次事件。这个循环叫 reconcile,中文一般翻成“调谐”。
关键在于它不关心“怎么变成这样”,只关心“现在差多少”。你删掉一个由 Deployment 管理的 Pod,ReplicaSet 控制器收到删除事件,一算发现实际副本数比期望少 1,立刻建一个新的。它不知道也不在乎那个 Pod 是被你手动删的、被节点驱逐的、还是被 OOM 干掉的,对它来说都是“少了一个,补一个”。
这种设计的威力在于自愈能力几乎不需要额外代码。但代价也很明显:它假设动作是幂等的、可重复执行的。如果你在控制器外面手动改被管理的对象,比如给 ReplicaSet 管的 Pod 打标签改成别的值,控制器会发现“我的 Pod 少了一个”,于是再建一个,而你改过标签的那个变成孤儿。很多时候所谓的“Pod 莫名其妙多出来一个”,就是这么来的。
1.3 乐观并发:resourceVersion 冲突不是 bug
每个对象都有metadata.resourceVersion,本质是 etcd 里的版本号。更新时如果带上旧版本号,apiserver 会校验:版本对不上就返回 409 Conflict。这是乐观锁,不加数据库行锁,代价是客户端要自己处理冲突。
实际写 Operator 或者自研控制器的时候这个坑很典型:先 get 对象,处理一会儿,再 update。如果这中间有人改了,update 直接失败。正确做法是失败后重新 get 再重试,一般封装个几行重试逻辑就够了。更省事的做法是用 patch 而不是 update,只提交你要改的那部分字段,apiserver 在服务端合并,冲突概率大幅下降。
理解这三层——提交期望、控制器调谐、版本控制——之后再看到“对象存在但行为没生效”,你的第一反应应该是去查对应控制器的状态和事件,而不是怀疑 apiserver 抽风。
2. etcd、list-watch 与本地缓存:集群规模上去靠什么撑住
小集群跑得好好的,一旦节点上到几十上百、Pod 上千,很多人会突然遇到 apiserver CPU 打满、watch 连接断开、控制器延迟几分钟才响应的情况。这背后不是某个参数没调优,而是整个数据流设计在大规模下的表现差异。这一节讲清楚两件事:数据到底存在哪、各个组件怎么“知道”数据变了。理解了这两点,你就明白为什么官方一直在强调“别让控制器裸调 apiserver 做轮询”。
2.1 apiserver 不存数据,etcd 才是唯一真相源
集群里所有持久化状态都在 etcd 里,apiserver 是一个无状态的前端。它负责认证、鉴权、准入、编解码、缓存,但真正的数据只有 etcd 有。这个设计带来两个直接后果。
第一,apiserver 可以水平扩展,前面挂个负载均衡就能加机器。第二,etcd 一旦出问题,整个集群的“写”就停了——不是集群崩了,而是没人能提交新状态,已有的容器会继续跑,业务流量不受影响,但你无法扩容、无法滚动更新、无法删除 Pod。我在一次机房维护里遇到过 etcd 三节点里两节点磁盘写满的情况,表现就是 kubectl 所有命令都超时,但线上服务一条告警都没有,差点误判成运维工具的问题。
etcd 里存的数据量其实很小,因为只存对象定义,不存日志、不存业务数据,但写入频率可能很高,尤其是节点心跳、状态更新。所以 etcd 对磁盘延迟非常敏感,机械盘上跑大概率会在高峰期出问题,这也是为什么生产环境基本都要求 SSD。
2.2 Informer 的本地缓存:绝大多数读请求根本没到 etcd
如果有人跟你说“控制器每次 reconcile 都去查一次 apiserver”,那大概率是没用过 client-go 的 informer。真实的链路是:控制器启动时先 list 一次全量对象,然后开一个 watch 长连接持续接收增量事件。这些事件进到一个本地队列,同时写入一个带索引的本地缓存(Indexer)。控制器从队列里取 key,再从本地缓存里读对象,全程不碰 apiserver。
这个设计的核心价值是削峰。一千个 Pod 变更,watch 只推一千条事件,缓存更新一千次,控制器按 key 去重后可能只处理几十次——因为同一个 key 在队列里会被合并。如果换成轮询,一千个控制器每秒查一次,apiserver 直接被打穿。
resourceVersion在这里的作用是游标。watch 请求带上它,服务端就从那个版本之后开始推送。如果控制器断线太久,etcd 已经把旧版本压缩掉了,服务端会返回 410 Gone,客户端收到后自动重新 list 全量,缓存重建,然后接着 watch。这个机制是自动的,你不需要写代码处理。
2.3 控制器重启后为什么能“自己接着干”
新手常问:控制器挂了重启,之前处理到一半的操作会不会丢?答案是会丢中间状态,但不会丢最终一致性。因为控制器的状态全在 etcd 里,缓存重建后它看到的“实际状态”是准确的,于是重新算一遍差异,该补的补、该删的删。中间那点进度丢了无所谓,反正结果是收敛的。
这也是为什么写控制器要尽量做到“无状态”。别在内存里记“这个 Pod 我已经处理过了”,重启就忘了。正确做法是把处理进度写成对象上的注解或者 status 字段,让 etcd 帮你记着。这个习惯在写自定义控制器的时候尤其重要,我见过太多在内存 map 里维护状态、一出问题就疯狂重复建资源的实现。
大规模集群还有一个容易踩的点:list 全量在对象特别多的时候会很慢,甚至把 apiserver 内存顶上去。这时候需要在 watch 请求里做分页和字段过滤,比如只 watch 自己关心的 namespace,或者用 labelSelector 缩小范围。默认全量 list 在几千个对象以内还好,上万就要留心了。
3. Pod 被设计成最小单元,而不是容器
几乎所有 K8S 教程第一句话都是“Pod 是最小调度单元”,然后就跳过去讲 Deployment 了。但为什么不是容器?为什么不能让两个容器简单地跑在同一台机器上共享网络?这个问题想不通,后面很多现象都会变成玄学,比如“为什么同一个 Pod 里的容器能直接 localhost 访问”“为什么 Sidecar 能拦截主容器的流量”“为什么 Pod 里一个容器挂了整个 Pod 会被重建”。这些问题全部指向 Pod 的底层实现。
3.1 共享 Network Namespace:Pod 内 localhost 直通的来历
Linux 的 network namespace 是网络隔离的边界,每个 namespace 有自己独立的网卡、路由表、iptables 规则。正常情况下两个容器各在自己的 namespace 里,互相访问要走网络。而 Pod 的做法是:先创建一个 network namespace,然后把所有属于这个 Pod 的容器都“塞”进同一个 namespace。
结果就是所有容器看到的是同一块网卡、同一个 IP、同一份端口空间。容器 A 监听 8080,容器 B 想访问它,直接localhost:8080就行,因为对内核来说它们就是同一台“主机”上的两个进程。反过来说,同一个 Pod 里两个容器不能同时监听 8080,会端口冲突。
这个设计直接决定了 Sidecar 模式的可行性。服务网格那些方案,本质是往 Pod 里注入一个代理容器,通过修改 iptables 规则把进出流量重定向到代理上。如果容器之间不能共享 network namespace,这套方案根本没法透明实现。同理,日志收集、监控采集这类 Sidecar 也全靠这个机制。
注意:共享 namespace 也意味着容器之间没有网络隔离。如果一个 Pod 里有不可信的第三方容器,它能直接访问同 Pod 内其他容器的端口,这一点在多租户场景下必须考虑清楚。
3.2 Pause 容器:那个看不见的骨架
如果你上节点执行crictl pods或者在容器运行时里看进程,会发现每个 Pod 都有一个叫pause的容器,镜像极小,就几十 KB。它的唯一职责是:创建并持有那个 network namespace,然后自己挂在那里pause()不动。
为什么要这么设计?因为 namespace 需要有一个进程持有,如果所有容器都退出了,namespace 就没了。有了 pause 容器,即使业务容器全部崩溃重启,network namespace 依然存活,Pod 的 IP 不变、端口不变,重启的容器直接 join 回原来的 namespace 即可。这就是为什么容器重启后 Pod IP 不会变,而 Pod 被删除重建后 IP 一定会变。
pause 容器还承担了 PID namespace 的持有职责(如果开启了 shareProcessNamespace),以及作为整个 Pod cgroup 的挂载点。你可以把它理解成 Pod 的“地基”,业务容器是盖在上面的房子。地基不动,房子拆了重建不影响地址。
3.3 原子性:为什么调度、扩缩、生命周期都挂在 Pod 上
Pod 是调度的原子单位。调度器不会说“这个容器去 A 节点、那个去 B 节点”,它只能整 Pod 地选一个节点。因为容器之间要共享 network namespace,一旦拆到两台机器上,共享就无从谈起。
Pod 也是扩缩容的原子单位。HPA 扩的是 Pod 数量,不是容器数量。这里有个实践中的常见误区:把强耦合的两个进程拆成两个容器放进同一个 Pod,觉得这样“更内聚”。但这样一来它们只能 1:1 扩容,没法独立伸缩。如果两个组件的负载比例会变化,比如一个吃 CPU 一个吃内存,放同一个 Pod 里就是互相拖累。
我一般的判断标准是:如果两个进程需要独立扩缩容,分开放,用 Service 通信;如果必须共享网络命名空间或者共享本地文件(比如通过 emptyDir 交换数据),才放同一个 Pod。这个判断标准看起来简单,但能避免掉大部分后期重构的麻烦。生命周期同理——Pod 里的容器一起创建、一起销毁,探针失败导致的重启是容器级的,但 Pod 删除是整体删除,中间没有“部分存活”这种状态。
4. 调度器的两步走:先过滤,再打分
Pod 卡在 Pending 是最常见的求助场景之一。大部分人的处理方式是看kubectl describe pod底部的事件,看到Insufficient cpu就去加资源,看到node(s) had taint就去加容忍。这能用,但下次换个场景还是不会。真正该建立的心智模型是:调度器到底在什么约束下做选择,以及这些约束的优先级是什么。
4.1 Filter 阶段:先排除硬性不合适的节点
调度器收到一个待调度的 Pod,第一步是把所有节点过一遍,用一系列 Filter 插件(老版本叫 Predicates)做硬性过滤,不满足的直接出局。常见的过滤项包括:
- 资源是否够,看的是节点的Allocatable减去已分配 requests,注意是 requests 不是实际使用量
- 节点选择器
nodeSelector和节点亲和性的硬约束是否匹配 - 污点与容忍是否匹配,节点上打了 NoSchedule 的污点而 Pod 没声明对应容忍,直接出局
- 端口冲突,HostPort 是否被占用
- PVC 是否已经绑定,没绑定且是 Immediate 模式的直接出局
- 拓扑分布约束是否满足
过滤完如果结果为空,Pod 就进 Pending,事件里会写0/N nodes are available,后面跟一串原因。这行原因非常有价值,它把每个节点被淘汰的理由都列出来了,按数量聚合。看到3 Insufficient memory就说明三个节点内存不够,方向明确。
这里有个坑:资源判断用的是 requests 之和,不是 limit。很多人给容器写limits.cpu: 2但不写 requests,调度器认为它需要 0,于是疯狂往同一台机器上塞,塞到最后所有 Pod 都在抢 CPU,延迟飙升。生产环境 requests 一定要写,而且要和实际负载对齐。
4.2 Score 阶段:剩下的节点怎么排队
过滤完还剩一批候选节点,调度器给每个节点打 0-100 分,加权求和,选最高的。打分的维度包括:节点资源空闲比例(越空越高)、已经有多少同 ReplicaSet 的 Pod(越少越高,为了打散)、节点亲和性的软偏好、镜像是否已经在节点上(有镜像加分,省拉取时间)等等。
这些权重可以在调度器配置里调整,但一般不建议动。默认权重是大量生产环境验证过的平衡点,改了容易出现“某些节点被打爆、另一些闲着”的情况。
有个细节值得知道:调度器把结果写回 Pod 的nodeName字段后,真正在节点上创建容器的是该节点的 kubelet。调度器和 kubelet 之间没有直接通信,全靠 apiserver 中转。所以如果调度成功但节点失联,Pod 会一直停在ContainerCreating,因为 kubelet 根本没收到活儿。
4.3 亲和性、污点容忍、拓扑分布的实际取舍
这三类机制经常被混用,其实定位完全不同,用错场景会导致集群失去弹性。
| 机制 | 作用方向 | 典型用途 | 用错的后果 |
|---|---|---|---|
| nodeSelector | Pod 选节点 | 指定架构、指定机型 | 硬绑死,节点池变动时失效 |
| 节点亲和性 | Pod 选节点(可软可硬) | 优先就近、优先同可用区 | 软约束写多了调度变慢 |
| Pod 亲和/反亲和 | Pod 选 Pod | 同服务聚集、不同副本打散 | 反亲和写死会导致扩容失败 |
| 污点与容忍 | 节点排斥 Pod | 专用节点池、隔离故障节点 | 容忍写太宽等于没用 |
| 拓扑分布约束 | 按故障域均衡 | 多可用区均匀分布 | 域数量少于副本数会卡住 |
我踩过最典型的一个坑是 Pod 反亲和用了硬约束(requiredDuringSchedulingIgnoredDuringExecution),要求同一服务的副本不能在同一节点。集群一共 3 个节点,服务副本数扩到 4 的时候,第 4 个直接 Pending,事件写得很清楚但当时没经验,排查了半天。后来改成软约束加上topologySpreadConstraints,既能打散又不至于扩不了容。
污点这块,专用节点池的做法是给节点打dedicated=xxx:NoSchedule,然后给需要跑在上面的 Pod 加容忍。注意 NoSchedule 只影响新调度,已经跑着的 Pod 不会被赶走;要驱逐得用 NoExecute,并且最好配上tolerationSeconds,给 Pod 一点迁移时间,否则会瞬间全部驱逐造成服务抖动。
4.4 调度失败时该看哪几行
我的固定套路是三步。第一步kubectl describe pod看 Events 最后几行,先分清是调度问题(FailedScheduling)还是调度之后的阶段(ImagePullBackOff、CreateContainerError)。第二步如果是调度问题,看事件里聚合的节点淘汰原因,判断是资源、污点还是亲和性。第三步去看节点实际的可分配资源,kubectl describe node里的 Allocated resources 那一段,能直接看到 requests 占比。
有一种情况事件看起来很怪:0/5 nodes are available: 5 node(s) didn't match Pod's node affinity。这时候别急着重写亲和性,先检查是不是节点标签被谁改了或者压根没打。标签这种东西在多团队共用的集群里经常被误改,出问题时第一时间核对标签比怀疑调度器有效得多。
5. Service 的流量去哪了:ClusterIP 为什么 ping 不通
刚接触的时候我确实试过ping一个 Service 的 ClusterIP,结果不通,然后花了很久怀疑网络插件坏了。这个疑问非常普遍,本质是把 Service 当成了“一台有 IP 的机器”。它其实是 iptables 或者 IPVS 里的一组规则,只在特定条件下才会被触发。理解这层虚拟性,Service 的很多“反直觉”行为就都能解释。
5.1 Pod 网络的三条铁律与 CNI 的边界
K8S 对 Pod 网络只有三条要求:每个 Pod 有自己的 IP;同一集群内任意两个 Pod 之间可以直接通信,不需要 NAT;节点上的进程和 Pod 之间也能直接通信。满足这三条,剩下的实现方式随便你。
具体实现交给 CNI 插件:它负责在 Pod 创建时给容器配网卡、分配 IP、写路由,在删除时回收。不同插件实现方式差异很大,overlay 类型的会在物理网络之上再封一层,性能略低但不受底层网络限制;路由型的直接下发路由规则,性能好但要求底层网络能配合。
一个常被忽略的点:Pod 重建后 IP 会变。所以任何依赖 IP 的配置都是脆弱的,这也是 Service 存在的根本理由——提供稳定的访问入口。同样,如果某些中间件配置里写死了 Pod IP,滚动更新后必然出问题,正确做法是走 Service 名字或者 headless Service 拿到稳定的 DNS 记录。
5.2 kube-proxy:iptables 与 IPVS 的差异
Service 创建后,每个节点上的 kube-proxy 会监听变化,在本机生成转发规则。两种主流模式:
iptables 模式下,ClusterIP 只是规则里的一个匹配条件。当流量进入 PREROUTING 链,被规则匹配到,执行 DNAT 改成某个 Pod IP,然后按正常路由转发。这条规则里通常挂着一串后端,用 statistic 模块做概率负载均衡。规则数量随 Service 和 Endpoint 数量线性增长,几千个 Service 之后规则条数上万,每次新增 Endpoint 都要全量重写规则表,同步延迟明显。
IPVS 模式用的是内核的四层负载均衡,本质是一张哈希表,查找复杂度接近 O(1),规则增多时性能下降不明显,还支持多种调度算法(rr、wrr、lc 等)。大规模集群一般推荐这个模式。
至于为什么 ping 不通:ClusterIP 从来没有被真正绑定到任何网卡上,它是规则里虚构出来的地址。ICMP 包到达本机后没有规则去 DNAT 它,内核找不到这个地址的归属,直接丢弃。而 TCP 流量能被规则捕获并改写目标,所以curl是通的。这不是故障,是设计如此。真正需要打通的话,有些实现会给虚拟网卡配上这个地址来响应,但那是额外功能,不属于标准行为。
5.3 EndpointSlice 和就绪探针的联动
Service 本身不知道后端是谁,它靠 EndpointSlice 对象维护一份 IP 列表。有个控制器专门盯着 Service 的选择器和 Pod 状态,把匹配的、通过就绪探针的 Pod IP 写进列表,kube-proxy 再据此生成转发规则。
这里的因果关系很关键:Pod 变成 Ready 之前,不会进入 EndpointSlice,也就不会收到流量。所以如果就绪探针配置不当,会出现服务整体不可用或者新版本流量为零的情况。我就遇到过一个问题:新版本加了就绪探针,探测路径依赖某个外部服务,外部服务抖动导致新 Pod 一直不 Ready,滚动更新卡在中间状态,新旧版本共存但新版本收不到流量,查了很久才发现是探针的锅。
排查服务不通时,我的顺序是:先kubectl get endpointslice看有没有后端,没有就查 Pod 的 Ready 状态和选择器是否匹配;有后端但访问不通,就查 NetworkPolicy 是否拦截、目标端口是否对、Pod 内进程是否真的在监听。按这个顺序走,绝大多数问题三分钟内能定位。
6. 存储与有状态负载:PVC 绑定 PV 那套逻辑
无状态服务好说,挂了重建就行。一旦涉及数据库、消息队列这类需要持久化数据的组件,PVC、PV、StorageClass 这一套就必须吃透。这套抽象的设计意图是把“我要多大、什么访问模式的空间”和“具体用哪块盘”解耦,让应用不用关心底层是本地盘、云盘还是网络存储。但解耦也带来了额外的失败点,Pod 卡在 Pending 或者挂载超时,原因往往和调度、存储的交互有关。
6.1 PVC 和 PV 是怎么“配对”的
PVC 是用户的申请单,PV 是管理员或者自动创建出来的实际存储。绑定过程就是一个匹配:StorageClass 名字相同、访问模式兼容、容量不小于申请值,找到就绑定,一对一,绑上之后别人用不了。
动态供给是现在的常态:PVC 指定 StorageClass,一个叫 provisioner 的组件收到请求后去底层创建卷,再自动建一个 PV 跟它绑上。这个流程是异步的,所以 PVC 刚创建时状态是 Pending,Pod 引用它就会一直等。
访问模式这块特别容易踩坑。ReadWriteOnce意思是只能被单个节点挂载读写,不是单 Pod。同一节点上的多个 Pod 挂同一个 PVC 是允许的。而很多云盘的 RWO 实际只能被单个节点挂载,所以如果两个副本被调度到不同节点,第二个就会挂载失败,Pod 卡在 ContainerCreating。这个错误的报错信息通常很长,核心就一句Multi-Attach error。解决办法要么用支持多节点读写的存储类型,要么用反亲和把副本聚到同一节点,要么干脆一个副本挂一块盘。
还有reclaimPolicy,删除 PVC 之后 PV 是保留还是删除。生产环境用数据库的时候我一般设成 Retain,误删 PVC 至少数据还在,可以手动恢复。默认的 Delete 在测试环境省事,生产上真的会出事。
6.2 CSI 在挂载链路上的位置
CSI 是容器存储接口标准,插件厂商按这个标准实现驱动,K8S 侧就不用为每家存储写代码。一个完整的 CSI 驱动通常拆成几个组件:控制器侧的负责创建、删除、扩容、挂载到节点,节点侧的负责把卷真正 mount 到容器里。
挂载链路的时序大概是:Pod 调度到节点 → kubelet 发现需要某个 PVC → 等控制器侧把卷 attach 到这台机器 → 节点侧驱动把设备格式化并 mount 到本地目录 → 再把目录 bind mount 进容器的目标路径。
这条链上任何一环超时,Pod 就卡在 ContainerCreating。我遇到过节点侧驱动版本不匹配,日志里反复重试 mount 失败,现象就是所有新 Pod 都起不来,已在跑的没事。排查的时候直接journalctl -u kubelet看 kubelet 日志,比看 Pod 事件更详细,能看到驱动返回的原始错误。
6.3 StatefulSet 的稳定标识意味着什么
StatefulSet 跟 Deployment 的核心区别是“稳定”:稳定的网络标识、稳定的存储、有序的创建和删除。
网络标识靠 headless Service(ClusterIP 为 None)实现,每个 Pod 会得到一个固定 DNS 名,形如pod-序号.service名.namespace.svc。只要 StatefulSet 名字和序号不变,这个域名就一直是它。集群内其他服务靠这个方式找到具体的某个实例,主从架构的中间件基本都依赖这个。
存储靠 volumeClaimTemplates,每个 Pod 各自一个 PVC,名字带序号。Pod 被删除重建,PVC 依然保留,新 Pod 挂回原来那块盘,数据还在。这点很关键——Deployment 里的 Pod 共享或者不带 PVC,而 StatefulSet 的每个副本有自己的存储身份。
有序性体现在创建和删除按序号来,前一个 Ready 才创建下一个。这个特性让主从选举变得简单,0 号先起来当主,其他的找来当从。但代价是启动慢,副本多了要等很久。另外删除也是倒序的,缩容会从最大序号的开始删。
注意:StatefulSet 的 PVC 不会随 Pod 一起删除,缩容后 PVC 还留着。反复扩缩容易留下大量闲置卷,需要定期清理或者用脚本管理。
7. 请求进入 apiserver 的三道门与被改写的字段
线上经常遇到“权限明明给了但还是 Forbidden”“yaml 里没写这个字段但对象上有”这类问题。这类现象全部出在 apiserver 的处理链路上——每个请求要依次过认证、鉴权、准入三道门,任何一道拦下来就返回错误,而准入阶段还可能悄悄改你的对象。
7.1 认证、鉴权、准入:顺序不能乱
第一道是认证,解决“你是谁”。常见方式包括客户端证书、持有者令牌、OIDC 等。Pod 里访问 apiserver 用的是 ServiceAccount 令牌,挂载在/var/run/secrets/下自动轮换。如果一个 Pod 报 401,先确认令牌是否挂载正常、ServiceAccount 是否存在。
第二道是鉴权,解决“你能干什么”。主流是 RBAC,走的模型是“角色绑权限、绑定连用户和角色”。Role 作用在 namespace 内,ClusterRole 是集群级。这里最常见的错误是把 RoleBinding 写成了 ClusterRoleBinding,或者 Role 的 namespace 跟对象不在一个 namespace 里。看到 Forbidden 别急着加权限,先用kubectl auth can-i模拟一下,能看到到底缺的是哪个动词、哪个资源。
第三道是准入控制,解决“这个对象能不能这样写”。分两个阶段:变更型准入先执行,可以修改对象;校验型准入后执行,只能拒绝。内置的规则包括默认值填充、LimitRange 资源限制注入、ServiceAccount 自动挂载、各种配额校验。很多自定义策略也是挂在这里。
顺序是严格的:认证不过直接 401,鉴权不过返回 403,准入不过返回 422 或者 403 带具体原因。看一眼 HTTP 状态码就知道卡在哪一关了。
7.2 为什么有些字段在 yaml 里写不进去
有两个原因。一是默认值填充:很多字段你没写,但 apiserver 在准入阶段给你补上了,比如spec.restartPolicy不写默认是 Always,imagePullPolicy在镜像 tag 是 latest 时自动变成 Always。所以看对象时经常发现“多出来一堆我没写的东西”,这是正常的。
二是变更型准入控制器会改对象。典型的是注入 Sidecar 的 webhook,它监听 Pod 创建事件,往spec.containers里塞一个代理容器。用户视角就是“我明明只写了一个容器,跑起来变成两个了”。排查这类问题要用kubectl get pod -o yaml对比原始 yaml,看看多出来什么,再查是哪个 webhook 干的——kubectl get mutatingwebhookconfiguration能看到所有注册的钩子,逐个排查。
还有一种情况是字段被拒绝:校验型 webhook 返回了拒绝原因,但错误信息可能被截断。加上--v=8之类的日志级别能看到完整请求和响应,或者直接看 webhook 服务本身的日志,比猜有效得多。
8. 排障经验:从 Pending 到服务不通的几条固定路径
原理讲完了,落到实际还是得有一套能照着走的排查路径。我在不同规模集群上处理过的故障,八成以上都能归到几条固定模式里,真正需要深入挖的少数派反而好处理,因为特征明显。这一节把最常见的几类现象和对应的查法列出来,按现象分类,不按组件分类,更贴合实际。
8.1 Pending:先分清是调度问题还是存储问题
Pod 停在 Pending,第一件事是看事件,分两大类。
如果是FailedScheduling,说明调度器跑完了但没选到节点。看聚合原因:资源不够就去查节点的可分配资源,注意看 requests 而不是实际用量;污点不匹配就核对容忍;亲和性不满足就核对标签;拓扑分布约束卡住就检查副本数和故障域数量是否匹配。这四类覆盖绝大部分。
如果不是调度相关,很可能是 PVC 没绑定。这时候kubectl get pvc看状态,Pending 就describe看事件。常见原因:StorageClass 不存在、provisioner 没跑起来、后端存储容量不足、访问模式不支持。还有一种隐蔽情况:PVC 已经 Bound,但 Pod 依然 Pending,那就回到调度问题上查,因为卷的节点亲和性可能限制了可调度节点。
8.2 Running 但访问不通:从 EndpointSlice 倒着查
Pod 是 Running 的,Service 也能解析,但请求超时或者拒绝。我的固定顺序是:
kubectl get endpointslice -l kubernetes.io/service-name=xxx,看有没有后端 IP。没有就说明 Pod 没 Ready 或者选择器不匹配。- 有后端的话,直接 exec 进一个同 namespace 的 Pod,
curl目标 Pod IP 加端口,确认应用本身是通的。 - 应用通但 Service 不通,查 NetworkPolicy 有没有拦截,这是新手最容易忽略的——默认策略如果有 deny-all,新服务上线必然不通。
- 都通但偶发超时,看 kube-proxy 模式和后端数量,iptables 模式下大量规则可能导致同步延迟,考虑切 IPVS。
- 最后才怀疑网络插件,这个概率其实最低,因为插件挂了通常是全集群性问题,特征很明显。
把这套顺序走完,绝大多数“服务不通”都能定位到具体一层。我个人的体会是,排障效率高低不取决于记了多少命令,而取决于脑子里有没有一条从外到内、从虚到实的链路图。链路清楚了,每一步看什么、看到什么值代表什么,都是自然的。集群规模越大,越要养成看事件和看对象 status 的习惯,而不是凭感觉重启。