Kubernetes 1.36 深度实战:从「声明式控制循环」到云原生操作系统——控制平面、etcd/Raft、自定义控制器与调度器全栈拆解(2026)
引言:Kubernetes 到底是什么
很多人把 Kubernetes 理解成一个「容器调度器」——把 Docker 镜像丢进去,它帮你分配到节点上跑。这个理解既对又错:对的是它确实干这件事;错的是这件事只是 K8s 能力的冰山一角。
更准确的说法是:Kubernetes 是一个以声明式 API 为核心的分布式系统控制平面,容器编排只是它最成功的一个应用。它本质上提供了一套「期望状态(Desired State)→ 实际状态(Actual State)→ 持续协调(Reconcile)」的通用机制,这套机制可以用于任何资源上——Pod、Service、Ingress、CRD,甚至你自定义的「数据库实例」「AI 训练任务」。
2026 年 4 月发布的 Kubernetes 1.36(代号 Haru,日语「春」)是这一年最重要的版本之一:70 项增强、18 项 Stable、25 项 Beta、25 项 Alpha,重点落在安全默认配置强化、AI/ML 工作负载支持成熟、大规模 API 可扩展性。其中两个 GA 特性尤其值得每一个生产集群关注:User Namespaces 正式稳定、Mutating Admission Policies 用 CEL 取代了部分 Webhook。
本文不堆砌概念,而是从架构师的视角,把 K8s 的控制平面、存储一致性、调度算法、扩展机制一次性拆透,并配上可运行的 Go 代码与 YAML,最后给出大规模集群的性能调优清单。读完你不仅能写 YAML,更能理解「为什么 K8s 是这样设计的」。
一、核心概念:声明式控制循环
1.1 命令式 vs 声明式
传统运维是命令式的:「去节点 A 上启动 3 个 nginx 实例」。声明式则是:「我期望有 3 个 nginx 副本在线」——至于怎么达到、在哪达到、挂了怎么恢复,交给系统自己处理。
把这两者的差别落到一个具体例子上会更直观。假设要跑 3 个 nginx:
命令式(Ansible / 脚本风格):
# 你得自己保证「恰好 3 个」,挂了你也得自己发现、自己拉
for i in 1 2 3; do
ssh node-$i "docker run -d --name nginx-$i -p 80:80 nginx"
done
# 某天 node-2 重启,nginx-2 没了——没人知道,除非你写监控
声明式(K8s 风格):
apiVersion: apps/v1
kind: Deployment
metadata: { name: nginx }
spec:
replicas: 3
selector: { matchLabels: { app: nginx } }
template:
metadata: { labels: { app: nginx } }
spec:
containers:
- name: nginx
image: nginx:1.27
ports: [{ containerPort: 80 }]
kubectl apply -f nginx.yaml # 写一次「期望」,之后全是 K8s 的活儿
关键差异不在「少敲几行命令」,而在于:命令式把「达到目标的过程」交给你,声明式把「维持目标的责任」交给系统。 前者是一次性动作,节点挂了就少一个;后者是持续契约,少一个 K8s 立刻补一个。这也就是为什么 K8s 集群里你很少「手动重启服务」——你改的是期望,系统负责把现实对齐到期望。
K8s 的全部智慧都在这句话里:
用户只声明「想要什么」,K8s 持续把「现状」拉向「期望」。
这就引出了 K8s 最底层、也最被低估的设计模式——调谐循环(Reconcile Loop / Control Loop)。
1.2 调谐循环的三个角色
期望状态 (etcd 中的对象: spec)
│
▼
[ Controller ] ──watch──► 实际状态 (节点上的容器 / Endpoint / status 字段)
│ diff
▼
执行动作 (创建/删除/更新 Pod、更新状态字段)
│
└──────► 回到期望状态
每一个内置 Controller(Deployment、ReplicaSet、StatefulSet、Node、Endpoint、Service 等)都在跑这个循环:
- 通过 Informer 监听 etcd 中对象的变化(基于 watch 的增量事件,不是轮询);
- 把变化事件的 key(
namespace/name)塞进一个限速 workqueue; - worker 从队列取出 key,调用
Reconcile:读当前实际状态,对比期望状态,执行弥补动作; - 如果出错或状态未达终态,把 key 重新入队(带指数退避)。
理解这一点,你就理解了 K8s 的「自愈」能力从哪来:不是因为 K8s 聪明,而是因为它永不停止地把现实拉回你写下的声明。 一个 Pod 被 kill 了,ReplicaSet Controller 发现 status.replicas < spec.replicas,立刻创建新的——它不在乎「为什么」挂了,只在乎「差距」还在不在。
这里藏着一个被很多人忽略的设计哲学:K8s 选择「水平触发(level-triggered)」而非「边缘触发(edge-triggered)」。它不关心「刚才发生了什么事件」,只在乎「当前状态是否等于期望」。这带来惊人的健壮性——哪怕你错过了一百个 watch 事件、哪怕控制器崩溃重启,只要它重新读一次当前状态,就能继续正确地收敛。对比边缘触发系统(依赖「事件可靠投递」),K8s 不依赖事件的可靠性,只依赖状态的持续比对。这就是为什么在混乱、丢包、组件随时可能重启的生产环境里,K8s 依然能保持最终一致:它的正确性是「状态比对」保证的,不是「事件顺序」保证的。
1.3 为什么这个模式如此强大
因为「调谐」对资源类型是正交的。Deployment 调谐副本数,Ingress 调谐负载均衡器,你写的自定义 Controller 调谐你自己的 CRD——它们共用同一套 watch → queue → reconcile 骨架。这正是 K8s 生态(Operator 模式)爆发的根本原因:你不需要改 K8s 内核,只要注册一个新资源类型 + 一个调谐循环,就能让 K8s「管理」任何东西。 后面第三章我们会亲手写一个。
二、架构分析:控制平面与数据平面
一个生产 K8s 集群分为两大平面。
2.1 控制平面(Control Plane)
API Server:集群的唯一入口
所有读写都经过 kube-apiserver。它做四件事:
- 认证(Authentication):你是谁?证书、token、OIDC、ServiceAccount。
- 授权(Authorization):RBAC/ABAC 判断你能不能做。
- 准入控制(Admission):变更类(Mutating)与校验类(Validating)Webhook/Policies,在对象落库前改写或拒绝。
- 持久化网关:只跟 etcd 打交道,自身无状态,可水平扩展。
关键认知:API Server 是唯一能直接读写 etcd 的组件。 kubelet、scheduler、controller-manager 全部通过 API Server 间接操作状态。这样 etcd 的访问被收口,一致性与审计才有保证;API Server 前面可以放心挂 LB 做多副本。
etcd:强一致的状态存储
etcd 是一个基于 Raft 共识算法的分布式键值库。所有集群状态——Pod、Node、Secret、Lease——都序列化成 protobuf/JSON 存在这里。
Raft 保证:只要多数节点(quorum)存活,写入就一致且持久。代价是写吞吐受限(每次写要过半数节点确认),所以 etcd 是 K8s 扩展性第一瓶颈。后面性能章节会专门讲怎么护好它。
工程经验:生产 etcd 必须跑在独立、低延迟、SSD 的节点上,且永远不要和会抢占 I/O 的业务混部。一次 etcd 的 WAL fsync 抖动,会让整个集群的写操作集体变慢。
举个真实的战争故事:某团队把 etcd 和日志采集 Agent 放在同一块云盘上,平时无事。某天日志量突增,Agent 疯狂写盘,etcd 的 WAL fsync p99 从 2ms 飙到 200ms+。表象是「kubectl apply 偶尔卡 30 秒」「HPA 反应迟钝」「新 Pod 调度延迟」。排查花了半天——因为节点 CPU、内存都正常,监控面一片绿色,最后是用 etcd_disk_wal_fsync_duration_seconds 这个指标才定位到根因。把 etcd 迁到独立 NVMe 后,所有「玄学变慢」瞬间消失。结论是:etcd 的磁盘,是集群里最不能省的那块硬件。
Controller Manager:一堆调谐循环的集合
kube-controller-manager 把几十个内置 Controller 打包运行(Deployment、Namespace、ServiceAccount、ResourceQuota、Node lifecycle……)。每个 Controller 独立跑循环,互不阻塞。
Scheduler:决策 Pod 落在哪
Scheduler 不直接创建 Pod,而是给未绑定的 Pod 打上 nodeName。它分两阶段:
- Filter(过滤):剔除不满足硬性约束的节点(资源不足、taint 不容忍、亲和性冲突、端口占用)。
- Score(打分):对通过过滤的节点算分(资源均衡、拓扑、镜像就近),取最高分绑定。
2.2 数据平面(Data Plane)
- kubelet:节点上的「大管家」,watch API Server 拿到本节点 Pod 清单,调用 CRI(容器运行时接口)拉镜像、起容器,并持续上报状态(心跳、资源、事件)。
- 容器运行时:containerd / CRI-O,实现 CRI。1.36 要求运行时支持 CRI v1。
- kube-proxy / CNI:负责 Pod 网络与 Service 的 iptables/IPVS/eBPF 转发。
- CSI:存储插件,把 PV 挂进 Pod。
2.3 1.36 三大关键新特性
(1)User Namespaces 正式 GA —— 容器逃逸的最后一道闸
过去容器内 root(UID 0)映射到宿主机 root,一旦容器逃逸就拿到节点 root。User Namespaces 把容器内的 root 映射成宿主机上一个非特权的 UID 区间(如 100000–165535),哪怕进程突破隔离,在宿主机上也是个普通用户。
# 1.36 中,Pod 可声明使用 user namespace(需节点内核与运行时支持)
apiVersion: v1
kind: Pod
metadata:
name: secure-app
spec:
hostUsers: false # 关键:禁用与宿主机的用户命名空间共享
containers:
- name: app
image: myapp:1.0
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
hostUsers: false 在 1.36 配合运行时(containerd 2.x)已可在生产开启,是零信任集群的基线配置。它和 runAsNonRoot + seccomp 组合,把「容器逃逸 = 节点沦陷」变成一个需要层层突破的难题。
(2)Mutating Admission Policies GA —— 用 CEL 干掉一半 Webhook
以前要给 Pod 自动注入 sidecar、改资源限制、加标签,得维护一个 MutatingAdmissionWebhook 服务(起一个 HTTP 服务,配 CA、配失败策略 failurePolicy)。1.36 起,用 CEL(通用表达式语言) 把变更逻辑写成原生 K8s 对象:
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicy
metadata:
name: add-cost-center
spec:
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE"]
resources: ["pods"]
mutations:
- patchType: WithAnnotations
withAnnotations:
cost-center: "team-payments"
---
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicyBinding
metadata:
name: add-cost-center-binding
spec:
policyName: add-cost-center
matchResources:
namespaceSelector:
matchLabels: { tier: prod }
好处:无额外服务、无网络跳数、无 Webhook 挂掉导致集群不可写的运维噩梦;CEL 在 API Server 进程内求值,延迟从毫秒级 Webhook 调用降到微秒级。这是 1.36 对「运维简单性」最实在的贡献。
(3)Dynamic Resource Allocation(DRA)继续成熟 —— 面向 AI/GPU
传统 resources.limits.nvidia.com/gpu: 1 只能整卡、无法表达「需要 A100 且 NVLink 互联的 8 卡拓扑」。DRA 用结构化参数描述设备需求,1.36 增加了设备 taint/toleration 与**可分区设备(partitionable devices)**支持,让多卡、切片、异构加速器的调度成为可能——这正是「AI 工作负载支持日趋成熟」的落点。对跑大模型推理/训练的团队,这意味着 K8s 终于能像管理 CPU 一样精细地管理 GPU 拓扑。一个具体例子——要求「两块通过 NVLink 互联的 A100,且不与别人共享这张卡」:
apiVersion: resource.k8s.io/v1
kind: ResourceClaim
metadata:
name: gpu-a100-nvlink
spec:
resourceClassName: gpu-a100
parametersRef:
apiGroup: gpu.example.com
kind: GpuRequest
name: two-a100-nvlink # 描述:2 卡、NVLink 互联、独占
---
apiVersion: v1
kind: Pod
metadata:
name: llm-trainer
spec:
containers:
- name: trainer
image: trainer:2.3
resources:
claims:
- name: gpu-a100-nvlink # 引用上面的 ResourceClaim,而非旧的 limits.nvidia.com/gpu
注意 Pod 不再写 limits.nvidia.com/gpu: 2,而是引用一个语义化的 ResourceClaim——调度器据此走 DRA 的设备筛选,保证拿到的两块卡确实物理互联、且整卡独占。这就是 1.36 把「AI 工作负载支持成熟」从口号变成API 的体现。
三、代码实战
光说不练假把式。下面两段 Go 代码都属于「K8s 扩展机制」的核心,能让你从「用 K8s 的人」变成「改 K8s 行为的人」。
3.1 用 client-go 写一个最小自定义控制器
这是 Operator 的发动机。它监听所有 Pod,给缺少 cost-center 注解的 Pod 自动补上——和 2.3 的 CEL Policy 干同样的事,但用代码实现,能做任何 CEL 表达不了的逻辑(比如查外部 CMDB 决定注解值)。
package main
import (
"context"
"fmt"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/cache"
"k8s.io/client-go/util/workqueue"
)
// DemoController 监听 Pod,给缺少 cost-center 注解的 Pod 打标
type DemoController struct {
clientset kubernetes.Interface
queue workqueue.RateLimitingInterface
synced cache.InformerSynced
}
func NewDemoController(cfg *rest.Config) *DemoController {
cs := kubernetes.NewForConfigOrDie(cfg)
factory := informers.NewSharedInformerFactory(cs, 30*time.Second)
podInformer := factory.Core().V1().Pods()
c := &DemoController{
clientset: cs,
queue: workqueue.NewRateLimitingQueue(workqueue.DefaultControllerRateLimiter()),
}
// 事件 handler:只把对象的 key 丢进队列,真正的活儿在 worker 里干
_, _ = podInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
if key, err := cache.MetaNamespaceKeyFunc(obj); err == nil {
c.queue.Add(key)
}
},
UpdateFunc: func(_, newObj interface{}) {
if key, err := cache.MetaNamespaceKeyFunc(newObj); err == nil {
c.queue.Add(key)
}
},
})
c.synced = podInformer.Informer().HasSynced
factory.Start(nil) // 启动所有 Informer 的 watch
return c
}
func (c *DemoController) Run(ctx context.Context, workers int) error {
if !cache.WaitForCacheSync(ctx.Done(), c.synced) {
return fmt.Errorf("等待缓存同步超时")
}
for i := 0; i < workers; i++ {
go wait.Until(func() { c.runWorker(ctx) }, time.Second, ctx.Done())
}
<-ctx.Done()
return nil
}
func (c *DemoController) runWorker(ctx context.Context) {
for c.processNext(ctx) {
}
}
func (c *DemoController) processNext(ctx context.Context) bool {
key, quit := c.queue.Get()
if quit {
return false
}
defer c.queue.Done(key)
if err := c.reconcile(ctx, key.(string)); err != nil {
c.queue.AddRateLimited(key) // 出错按指数退避重新入队
} else {
c.queue.Forget(key)
}
return true
}
// reconcile 就是控制循环的核心:读现状、对比期望、弥补差距
func (c *DemoController) reconcile(ctx context.Context, key string) error {
ns, name, err := cache.SplitMetaNamespaceKey(key)
if err != nil {
return err
}
pod, err := c.clientset.CoreV1().Pods(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return err // 可能是已被删除,交给 rate-limit 重试或最终 Forget
}
if _, ok := pod.Annotations["cost-center"]; ok {
return nil // 已达标,无需动作
}
// 弥补差距:打注解(用 patch 而非 update,避免覆盖并发修改)
patch := []byte(`{"metadata":{"annotations":{"cost-center":"team-payments"}}}`)
_, err = c.clientset.CoreV1().Pods(ns).Patch(
ctx, name, types.StrategicMergePatchType, patch, metav1.PatchOptions{},
)
return err
}
func main() {
cfg, _ := rest.InClusterConfig() // 在 Pod 内运行时用;本地调试可换 clientcmd
ctrl := NewDemoController(cfg)
_ = ctrl.Run(context.Background(), 2)
}
几个工程要点:
- 用
Patch而不是Update:避免读-改-写覆盖掉其他控制器的并发修改(经典的丢失更新问题)。 - workqueue 自带限流(默认
DefaultControllerRateLimiter= 令牌桶 + 指数退避),防止一个坏对象把队列打爆。 - Informer 有本地缓存(DeltaFIFO + Indexer),
reconcile里的 Get 走本地缓存,不击穿 API Server。这正是 K8s 能撑住成千上万 Controller 的原因。
3.2 写一个自定义调度插件(Scheduler Framework)
如果内置调度器满足不了你的拓扑需求(比如「同可用区的 Pod 尽量分散,跨区副本必须存在」),可以用 Scheduler Framework 的插件机制扩展,而不用 Fork 整个 scheduler。下面写一个 Score 插件,把 Pod 往「已有同类 Pod 最少」的节点推,实现简单的散开。
package main
import (
"context"
v1 "k8s.io/api/core/v1"
framework "k8s.io/kubernetes/pkg/scheduler/framework"
)
const PluginName = "PodSpreadMin"
// SpreadPlugin 让同类 Pod 在节点间尽量分散
type SpreadPlugin struct{}
func (p *SpreadPlugin) Name() string { return PluginName }
// Score 阶段:节点上同类 Pod 越少,分越高(满分 100)
func (p *SpreadPlugin) Score(ctx context.Context, state *framework.CycleState,
pod *v1.Pod, nodeName string) (int64, *framework.Status) {
// nodeInfo 由 scheduler 注入;这里用伪逻辑演示思路
// 真实实现需通过 state 或 SharedLister 统计该节点上同 app 标签的 Pod 数
sameAppCount := countSameAppOnNode(pod, nodeName)
score := int64(100) - int64(sameAppCount)*10
if score < 0 {
score = 0
}
return score, framework.NewStatus(framework.Success, "")
}
func (p *SpreadPlugin) ScoreExtensions() framework.ScoreExtensions {
return nil // NormalizeScore 留默认
}
// 注册插件
func New(_ *framework.Runtime, _ ...framework.Option) (framework.Plugin, error) {
return &SpreadPlugin{}, nil
}
// 伪函数:真实环境从 NodeInfo 的 Pods 列表统计
func countSameAppOnNode(pod *v1.Pod, nodeName string) int {
app := pod.Labels["app"]
_ = app
_ = nodeName
return 0
}
编译后用 kube-scheduler --config scheduler-config.yaml 加载,pluginConfig 里把 PodSpreadMin 放进 score 阶段即可。Scheduler Framework 的真正价值在于:Filter/Score/Reserve/Bind 每个阶段都可插拔,你能在不碰核心代码的前提下,把调度逻辑改造成贴合业务拓扑的样子。
3.3 YAML 实战:生产级工作负载
把前面讲的概念串成一个能在 1.36 集群跑起来的真实例子——高可用 Deployment + 自动扩缩 + 拓扑打散 + 安全加固:
apiVersion: apps/v1
kind: Deployment
metadata:
name: payments-api
labels: { app: payments-api, tier: prod }
spec:
replicas: 3
selector:
matchLabels: { app: payments-api }
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # 保证容量不缩水
template:
metadata:
labels: { app: payments-api, tier: prod }
spec:
hostUsers: false # 1.36 安全基线:隔离用户命名空间
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels: { app: payments-api }
containers:
- name: api
image: registry.internal/payments-api:1.8.2
ports:
- containerPort: 8080
resources:
requests: { cpu: "250m", memory: "256Mi" }
limits: { cpu: "1", memory: "512Mi" }
readinessProbe:
httpGet: { path: /healthz, port: 8080 }
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet: { path: /livez, port: 8080 }
initialDelaySeconds: 15
periodSeconds: 20
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: payments-api-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: payments-api
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
这段 YAML 里埋了 5 个生产经验:maxUnavailable: 0 保证滚动更新不掉容量;topologySpreadConstraints 强制跨可用区打散(故障域隔离);readOnlyRootFilesystem + drop ALL capabilities 把容器攻击面压到最小;探针区分 readiness(摘流量)和 liveness(重启);HPA 基于真实利用率而非绝对值扩缩。
四、性能优化:把控制平面护到数千节点
K8s 扩到一定规模,瓶颈从来不是「能不能调度」,而是控制平面扛不扛得住心跳与 watch 风暴。下面是经过大规模集群验证的调优清单。
4.1 etcd:第一瓶颈
- 独立节点 + 本地 NVMe SSD,绝不和业务混部;WAL 与数据分盘更佳。
- 监控
etcd_disk_wal_fsync_duration_seconds的 p99:超过 10ms 就要警惕,超过 100ms 集群写会明显变慢。 - 定期
etcdctl defrag并开启历史压缩(--auto-compaction-retention=8h),否则 MVCC 历史无限增长会拖垮读。 - 合理设置
--quota-backend-bytes(默认 2GB,大集群可到 8GB),避免「空间不足」报错后需要复杂恢复。 - 控制单个对象的尺寸:不要往 ConfigMap/Secret 里塞几十 MB 的东西,它会进 etcd 且被每个 watch 客户端拉取。
4.2 API Server:收口与节流
- 开启 watch 缓存:
--watch-cache-sizes对大资源(Pod、Endpoints)给足缓存,减少 etcd 直读。 - 大 List 走分页:客户端用
limit=500&continue=分页,避免一次性拉空 etcd。 - 优先 protobuf:client-go 默认用 protobuf 与 API Server 通信,比 JSON 省带宽、省 CPU;自研客户端务必支持。
- 审计日志采样:全量审计在大规模下 I/O 惊人,对非安全相关请求做采样。
- 调大
--max-requests-inflight/--max-mutating-requests-inflight,匹配你的并发写入量。
4.3 Scheduler:吞吐与打分
PercentageOfNodesToScore:节点很多时不必给每个节点打分,设 30–50% 即可(默认已按集群规模自适应),显著降低调度延迟。- 精简打分插件:每多一个 Score 插件,每个 Pod 的调度成本线性上升。只保留真正需要的。
- 提高并行度:
--parallelism让多个 Pod 同时调度。 - 关注
scheduler_pending_pods指标:持续 >0 说明调度跟不上创建速度,需要优化过滤/打分或扩容 scheduler。
4.4 大规模集群的「红线」经验
- 单集群建议上限(经验值,非硬限制):5000 节点 / 15 万 Pod / 30 万容器。再大建议拆多集群( federation / 多控制平面)。
- EndpointSlice 替代老 Endpoints:老 Endpoints 对象在 Service 后端多时会爆炸,EndpointSlice 把它分片。
- 用 Label/Field selector 收窄 watch 范围:Informer 不要
List(Watch=true)全量,用labelSelector只关心自己命名空间。 - kubelet 心跳与状态上报分离:1.36 的
NodeLease让心跳走轻量 Lease 对象,节点数上千时大幅减轻 API Server 压力。
五、实战排障:把控制平面的知识用起来
讲架构不排障,等于学游泳不下水。下面四个高频故障,每个都对应前面讲过的机制——理解机制,排查就是从「瞎猜」变成「按图索骥」。
5.1 Pod 一直 Pending:调度器的「拒绝信」
Pending 的本质是:Scheduler 的 Filter 阶段一个节点都没放过。排查顺序:
kubectl describe pod <p>看Events——通常会直接告诉你原因:0/5 nodes are available: 3 Insufficient cpu, 2 node(s) had untolerated taint。- 资源不足:
requests.cpu/memory加起来超过节点可分配量。用kubectl top nodes看真实余量,注意调度只看requests不是limits。 - Taint 不容忍:节点有
node-role.kubernetes.io/master:NoSchedule之类污点,Pod 没配对应toleration。 - PVC 绑不上:使用了未 provision 的 StorageClass,或
WaitForFirstConsumer模式下拓扑不匹配。
对应前面 2.1 的调度两阶段:Filter 失败 = 硬约束不满足,Score 再高也没用。 这也是为什么「为什么我的 Pod 被调度到了资源最紧的节点」这种抱怨,往往源于 Score 插件配置而非 Bug——先确认它过了 Filter,再谈打分。
5.2 CrashLoopBackOff:控制循环在反复救火
BackOff 是 kubelet 在容器反复退出时的指数退避。不要只看日志第一屏,要:
kubectl logs <p> --previous看上一次崩溃的日志(当前容器可能还没起来,拿不到);- 90% 是配置/依赖问题:连不上数据库(ConfigMap 写错)、迁移没跑、端口写错、健康检查路径 404;
- 剩下 10% 是 OOM:看
kubectl describe里Last State: Terminated, Reason: OOMKilled。
这里体现 1.2 的控制循环特性:应用崩溃,kubelet 会按 backoff 不断重启,但永远不会「放弃」——除非你设了 restartPolicy: Never 或 Job 的 backoffLimit。理解这点,你就不会在凌晨被「它为什么不自己停」的告警搞糊涂:它不 停,是因为声明里说它就该一直跑。
5.3 OOMKilled:limits 不是保险箱
limits.memory 是硬上限,超过就被 cgroup 杀(exit 137)。常见误区:
- 只设 requests 不设 limits,或 limits 远大于 requests,导致节点内存被「超卖」挤爆邻居(noisy neighbor);
- JVM/Go 应用没识别容器内存限制,按宿主机物理内存设堆,结果在 limits 内被 OOM。Go 1.19+ 默认读 cgroup 限制,但老版本或手动
-Xmx需显式按limits计算; - 排查:
kubectl top pod看实时用量,把requests/limits调到 P99 用量 ×1.2 左右最稳。
5.4 集群「变慢」但节点没事:八成是 etcd
当你发现 kubectl get 偶尔卡、HPA 不灵敏、Endpoint 更新延迟,而节点 CPU 很闲——去查 etcd:
etcd_disk_wal_fsync_duration_seconds的 p99 是否飙升;etcd_server_proposals_committed_total与etcd_server_proposals_applied_total是否出现滞后(写积压);- 磁盘是否和其他负载混部、是否在机械盘或网络盘上。
这正是 4.1 强调「etcd 独立 SSD」的原因:控制平面的健康,最终收敛为 etcd 的 fsync 延迟。 把这条刻进运维肌肉记忆,能省下无数个半夜救火的电话。
5.5 调试进阶:直接读控制平面的「内心戏」
kubectl get events --sort-by=.lastTimestamp:集群在跟你说话,只是默认被刷屏淹没了。kubectl get --raw /apis/...:绕过 kubectl 的格式化,直接读 API Server 原始对象,验证你的 YAML 到底被解析成了什么。kubectl debug node/<n> --image=...:1.36 的节点调试能起一个排障 Pod 进节点命名空间,直接chroot /host看 kubelet 日志与容器运行时状态。- 给 API Server / scheduler / controller-manager 加
--v=5日志级别(临时),能看到每一次 watch 事件与调度决策的完整 trace——性能问题定位的终极武器。
六、总结与展望
回看开头那个问题——「K8s 是什么」,现在答案更清楚了:它是一个用声明式控制循环统一了「资源管理」这件事的分布式操作系统内核。容器只是它管理的第一种资源;今天它管理 GPU(DRA)、管理有状态服务(StatefulSet + CSI)、管理 AI 训练任务(Operator)、甚至管理别的 K8s(多集群)。
1.36 这个「春」版本的意义,不在于某个炫目特性,而在于它把两条主线推到了 GA:更安全的默认(User Namespaces) 与 更简单的扩展(CEL Admission Policies)。这两件事共同指向一个方向——让 K8s 从「需要专职 SRE 供奉的神龛」变成「普通团队也能安全驾驭的平台」。
往前看,几个趋势值得关注:
- 多运行时(Multi-Runtime)/ WASM 化:随着 WASI 0.3 成熟,部分 sidecar 与轻量工作负载可能从容器转向 WASM 模块,启动快、内存小、隔离强。K8s 正在通过 runtimeClass 与相关提案容纳这条路线。
- AI 原生化:DRA 只是开始,未来调度器会原生理解「模型权重分布在哪块本地盘」「推理卡与训练卡的 QoS 分级」,K8s 成为 AI 基础设施的底座。
- 控制平面的「无感」:随着 kube-apiserver 水平扩展、etcd 分片(如 kine/独立存储后端)的探索,超大集群的运维负担会持续下降。
但无论怎么演进,声明式 + 控制循环这个内核不会变。把它吃透,你就掌握了读得懂 K8s 过去、也预判得了它未来的钥匙。祝你在云原生的春天里,少踩坑,多写声明。
最后,给不同角色一条落地路径,别让这篇长文停在「读完觉得懂了」:
- 应用开发:先把第三章的 YAML 模板吃透,重点理解
requests/limits、探针、topologySpreadConstraints这三者的相互作用——它们决定了你的服务在线上稳不稳、故障时炸不炸。 - SRE / 运维:第四章与第五章是日常,把 etcd 指标和排障清单沉淀成团队的 runbook,半夜报警时照着走,别临场发挥。
- 平台 / 基础设施工程师:第三章的自定义控制器与调度器插件是你的武器库,Operator 模式能把你们团队的领域知识从「文档里的规矩」变成「集群里自动执行的代码」。
一个常被低估的认知:K8s 的技术本身(Raft、watch、调度算法)并不新鲜,它的胜利在于用一套统一的声明式约定,把「人到底该怎么管理基础设施」标准化了。 以前每家大厂都在重复造「配置管理 + 调度器 + 监控」三件套,K8s 把这套共识固化成了 API 与对象模型。理解这一点,你就不会再纠结「要不要自研一个更牛的调度器」——对绝大多数团队而言,真正缺的不是新调度器,而是把已有的声明式契约用对、用满。
本文基于 Kubernetes 1.36(Haru)撰写,代码示例以 client-go / Scheduler Framework 主流版本为准,生产使用前请结合自身集群版本验证 API 兼容性。