控制面只做一件事: 让实际状态无限逼近期望状态 — 差多少补多少, 永不停止; 调度器决定"落在哪", HPA 决定"跑几个", CronJob 决定"何时跑"
把控制面想成一台恒温空调: 你只在面板上设定"24℃"(期望状态), 压缩机从不关心"我上一步做了什么", 它只做三件事——测温(Observe)、算差值(Diff)、制冷或制热(Act), 然后立刻开始下一轮。这个循环有一个名字: Reconciliation 调谐。
调度编排的全部秘密都在这个循环里: kube-scheduler 负责"新 Pod 落在哪"(硬过滤 + 软打分两阶段), HPA 负责"跑几个"(按指标比例算副本数), CronJob / Operator 负责"何时跑、怎么运维"——全是循环的不同皮。它解决的是人肉运维的遗忘与手抖; 它制造的新问题是: 循环永不停止, 每一轮都必须幂等, 否则副作用滚雪球; 有反馈就有震荡, 没有稳定窗口的自动扩缩会自己抖死自己。
etcd 而不是控制面内存——控制面随便重启, 状态不丢。 # deployment.yaml — 期望状态, 提交后存进 etcd, 不会"执行完就丢" spec: replicas: 3 # 声明"我要 3 个", 不是"帮我起 3 个" # 关键: 控制面重启后期望状态还在 etcd 里, 调谐从断点继续
for { want := 3 // 期望: spec.replicas got := countRunningPods() // 实际: 只有 2 个 if got == want { continue } // 无差异 → 本轮结束 createPod() // 差 1 补 1 } // → 永不退出, 但每轮都是短平快的一小步 // 关键: level-triggered — 只比对现状, 不依赖上次执行到哪
workqueue, worker 取出后跑一轮调谐; 处理失败按指数退避重新入队, 既不丢事件也不会热循环。 informer.AddEventHandler(cache.ResourceEventHandlerFuncs{ UpdateFunc: func(_, n interface{}) { queue.AddRateLimited(n) }, }) // 关键: AddRateLimited — 失败按退避节奏重试, 不是 for 空转打爆 apiserver
Predicates(硬过滤: 不满足直接淘汰, 得到可行集) + Priorities(软打分: 在可行集里挑最优), 把"能不能放"和"放哪最好"解耦成两个可插拔阶段。 # kube-scheduler 配置: 两阶段插件各自可插拔 (v1, k8s 1.25+) profiles: - plugins: filter: # 硬性淘汰 — 资源不够 / 端口冲突 / 污点不容忍 enabled: [{ name: NodeResourcesFit }] score: # 软性打分 — weight 1~10 加权求和 enabled: [{ name: NodeResourcesBalancedAllocation, weight: 3 }] # 关键: filter 求可行集, score 求最优解, 顺序不能反
requests(声明值)而非实际用量——requests 乱填, 装箱全错: 填小了节点超卖, 填大了机器白养。 # kube-scheduler pluginConfig → NodeResourcesFit.args scoringStrategy: type: MostAllocated # 装满优先(省机器); LeastAllocated = 打散优先(求稳) resources: [{ name: cpu, weight: 1 }, { name: memory, weight: 1 }] # 关键: 装箱依据是 requests 声明值 — 填不准, 装箱全错
topologyKey 指定"在什么范围里分开", key 写错或标签没人有, 整批 Pod 卡 Pending。 affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - topologyKey: kubernetes.io/hostname # 按"节点"维度打散 labelSelector: matchLabels: { app: payments } # 关键: required=硬性必须满足(节点不够就 Pending); 高可用常用 preferred
# 节点侧: kubectl taint nodes gpu-1 dedicated=batch:NoSchedule tolerations: - key: dedicated operator: Equal value: batch effect: NoSchedule # 没这张通行证的 Pod 被拒之门外 # 关键: 污点是节点主动赶人, 容忍是 Pod 亮通行证 — 方向相反
期望副本 = ceil(当前副本 × 实际值 / 目标值) 缩放; 指标天然抖动, 稳定窗口(stabilizationWindowSeconds)与扩缩速率上限(policies)负责防震。 metrics: - type: Resource resource: name: cpu target: { type: Utilization, averageUtilization: 60 } # 关键: ceil(4 副本 × 90%/60%) = 6 — 按"实际值/目标值"比例放大
apiVersion: scheduling.k8s.io/v1 kind: PriorityClass value: 900000 # 越大越优先; 系统级从 2000000000 起 preemptionPolicy: PreemptLowerPriority # 关键: 抢占是"调度器删别人 Pod 给我腾地方", 不是排队插队
CRD 定义期望状态的 schema, 自研 controller 负责调谐——本质是"会写死循环的运维"。 // controller-runtime (sigs.k8s.io v0.16+) 的调谐入口签名 func (r *AppReconciler) Reconcile( ctx context.Context, req ctrl.Request) (ctrl.Result, error) { var app v1alpha1.App if err := r.Get(ctx, req.NamespacedName, &app); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) // 已删 → 无事可做 } return ctrl.Result{}, nil // 关键: 返回值只有"再来一次"或"报错", 没有成功态 }
backoffLimit 控制重试预算), CronJob 管周期任务; 两个必配项: 防重叠(concurrencyPolicy)与错过触发的补偿(startingDeadlineSeconds)。 concurrencyPolicy: Forbid # 上一轮没跑完, 本轮直接跳过 startingDeadlineSeconds: 300 # 错过触发点 5 分钟内允许补跑, 过时不候 backoffLimit: 3 # 失败最多重试 3 次, 防无限重试 # 关键: schedule 按 UTC 解释("*/5 * * * *"), 北京时间要设 timeZone 字段
kube-scheduler / kube-controller-manager), 自身用静态 Pod + 租约选主, 与业务 Pod 处于不同故障域; 控制面挂了集群只是"失明", 不会弄死正在跑的工作负载。 $ kubectl -n kube-system get pods -l component=kube-scheduler
kube-scheduler-control-plane 1/1 Running
# 关键: 控制面组件静态 Pod + 租约选主 — 挂了只失明, 不添乱大促压测时副本数 2→9→3 来回横跳: 每次缩掉的副本刚被流量打回原形又要冷启动, 扩容风暴白烧算力。把缩容侧的稳定窗口和速率上限显式写进 YAML。
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler spec: minReplicas: 4 maxReplicas: 20 metrics: - type: Resource resource: { name: cpu, target: { type: Utilization, averageUtilization: 65 } } behavior: scaleUp: stabilizationWindowSeconds: 0 # 扩容要快: 秒级响应流量洪峰 scaleDown: stabilizationWindowSeconds: 300 # 显式写出来, 防止被人改成 0 policies: - { type: Percent, value: 25, periodSeconds: 60 } # 每分钟最多缩 25%
改完后副本曲线从锯齿变成台阶, 高峰期冷启动次数下降约 80%, 尾部延迟不再被"缩容-扩容"循环打爆。
事故排查思路: 先看 HPA 事件还原扩缩原因, 再直接查 metrics API 看原始采样——发现采样窗只有 30 秒, 一次 GC 尖刺就够触发一次扩容。
$ kubectl describe hpa web -n shop | grep -B1 -A3 "SuccessfullyRescaled" Warning SuccessfullyRescaled New size: 9; reason: cpu resource utilization (percentage of request) above target $ kubectl get --raw /apis/metrics.k8s.io/v1beta1/namespaces/shop/pods/web \ | jq -c '.window, .containers[0].usage.cpu' "30s" "812m" # 采样窗仅 30s — 一个 GC 尖刺即可让利用率翻倍 # 对策: behavior 稳定窗口 300s + target 从 60% 提到 70%, 宁可晚扩不乱扩
运维每周人肉检查证书到期日, 忘一次就是 P2 事故。用 controller-runtime 写成调谐循环: 到期前 30 天自动续, 每天巡检一次, 出错自动退避重试。
func (r *CertReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { var cert v1alpha1.Certificate if err := r.Get(ctx, req.NamespacedName, &cert); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) // 被删了就别再动 } if time.Until(cert.Status.NotAfter) > 30*24*time.Hour { return ctrl.Result{RequeueAfter: 24 * time.Hour}, nil // 没到期, 明天再看 } if err := renewAndApply(ctx, r.Client, &cert); err != nil { return ctrl.Result{}, err // 出错 → 队列指数退避, 自动重试 } return ctrl.Result{RequeueAfter: 24 * time.Hour}, nil }
不带反亲和时, 调度器完全可能把三个副本堆到同一节点。给核心服务加"尽量打散"的反亲和, 再用 PDB 兜底主动驱逐场景。
affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: # 尽量打散, 不至于 Pending - weight: 100 podAffinityTerm: topologyKey: kubernetes.io/hostname labelSelector: matchLabels: { app: payments } # 关键: 支付这类核心服务再配 PDB 兜底 — drain 节点时最多断 1 个副本
离线批处理和在线业务混跑, 大任务一上在线接口 P99 翻倍。给批处理节点打污点 + 打标签, 让在线 Pod 根本进不来, 批处理也只进这些节点。
# 节点侧: kubectl taint nodes batch-01 dedicated=batch:NoSchedule # kubectl label nodes batch-01 pool=batch # Pod 侧: 双保险 — 容忍(进得去) + 选择器(只进去) tolerations: - { key: dedicated, operator: Equal, value: batch, effect: NoSchedule } nodeSelector: pool: batch resources: requests: { cpu: "8", memory: 16Gi } # 批处理如实申报, 不挤占在线业务
控制面升级重启了 8 分钟, CronJob 错过触发点后又立即补跑, 和上一轮没跑完的实例叠在一起执行。三件套防住: 禁止重叠 + 幂等键 + 明确的补跑窗口。
schedule: "30 2 * * *" # 注意: 默认按控制器时区(UTC)解释 concurrencyPolicy: Forbid # 上一轮还没跑完 → 本轮跳过, 绝不叠加 startingDeadlineSeconds: 600 # 错过触发点 10 分钟内允许补跑, 再晚放弃 jobTemplate: spec: backoffLimit: 2 # 失败最多重试 2 次, 然后告警人工介入 template: spec: restartPolicy: Never containers: - name: settle image: settle:v1.8 env: - name: RUN_ID # 幂等键: Pod 名带时间戳, 重试也不会重复扣款 valueFrom: { fieldRef: { fieldPath: metadata.name } }
清理脚本散在 20 台机器的 crontab 里, 谁改过配置没人知道。把期望状态定义成 CRD, 运维动作变成调谐循环, kubectl 直接看结果。
apiVersion: ops.example.com/v1alpha1 kind: ArchiveCleanup # CRD: 把运维手册变成 API 对象 spec: bucket: logs-prod keepDays: 30 schedule: "0 3 * * 0" status: # controller 调谐后回写实际状态 lastCleanup: "2026-09-20T03:00:12Z" deletedObjects: 14823 # 关键: kubectl get archivecleanup 直接看运维结果, 不再 SSH 翻 cron 日志
各组申报资源全靠拍脑袋, 六成 Pod 无脑填 100m, 调度器按声明值装箱, 机器"名义上满了实际很闲"。先用 VPA recommender 给建议值, 再压测校准。
$ kubectl top nodes --sort-by=cpu | head -3 NAME CPU(cores) CPU% MEMORY node-a31 812m 10% 42% # 声明满了, 实际很闲 $ kubectl get pods -A -o json | jq '[.items[].spec.containers[].resources.requests.cpu // "unset"] | group_by(.) | map({cpu: .[0], n: length}) | sort_by(-.n)' [{"cpu":"100m","n":4123},{"cpu":"500m","n":980},{"cpu":"unset","n":318}] # → 六成 Pod 无脑填 100m; 按 VPA recommender 建议值 + 压测校准 requests
校准后集群从 200 台缩到 130 台, 同样的业务量, 一年省下几十万机器成本。
大促预热时资源紧张, 支付 Pod 调度不进去。给支付挂高优先级, 调度器自动驱逐低优先级报表腾位; 报表延后几分钟没人投诉, 支付上不去是 P0。
apiVersion: scheduling.k8s.io/v1 kind: PriorityClass metadata: { name: payment-critical } value: 900000 # 高于普通业务(0), 低于系统级(2000000000+) preemptionPolicy: PreemptLowerPriority --- # 被抢占 Pod 上会看到真实事件: # Preempted by pod "pay-7d9f4" on node "node-a31" # 报表让位 → 支付上位; 资源回落后报表自动重新排队调度回来
运维 kubectl drain 直接把持有租约的 leader 副本一起驱逐, 结算中断。给核心服务配 PodDisruptionBudget, 让主动驱逐(drain/缩容)永远保住最少副本数。
# PodDisruptionBudget: 主动驱逐(drain/缩容)时至少保 2 个副本在线 apiVersion: policy/v1 kind: PodDisruptionBudget spec: minAvailable: 2 selector: { matchLabels: { app: payments } } # $ kubectl drain node-a31 --ignore-daemonsets --delete-emptydir-data # → error when evicting pods/"payments-7d9f4" -n shop (will retry after 5s): # Cannot evict pod as it would violate the pod's disruption budget. # → 等 leader 优雅切换后自动放行, 服务不断连
time.Sleep 轮询等待, 把唯一的 worker 占死. 正解: 立刻返回, 用 RequeueAfter 定时再进队列。 // 错: 调谐里 sleep 等待 — worker 被拖死, 队列越积越长 func (r *R) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { time.Sleep(10 * time.Second) // → 其他资源全部排队饿死 // 对: 立刻返回, 到点再进来 return ctrl.Result{RequeueAfter: 10 * time.Second}, nil }
// 错: 每轮都调外部接口 — 调谐跑 3 轮就建了 3 个 CLB createExternalLB(name) // → 账单 ×3, 外部资源泄漏 // 对: 先查后建, 存在即跳过 (调谐必须幂等) if _, err := findLB(name); apierrors.IsNotFound(err) { createExternalLB(name) }
# 错: 缩容不限速 — CPU 在 59%/61% 间抖动, 副本跟着锯齿 scaleDown: { stabilizationWindowSeconds: 0 } # → 反复扩缩, 冷启动风暴 # 对: 窗口抹平抖动, 速率限制缓坡 scaleDown: { stabilizationWindowSeconds: 300, policies: [{type: Percent, value: 25}] }
required 硬反亲和要求每节点最多 1 副本, 节点数小于副本数. 正解: 高可用用 preferred 软约束, 打不开也不阻塞上线。 # 错: required 反亲和 + 只有 2 台节点却要跑 3 副本 requiredDuringSchedulingIgnoredDuringExecution: {...} # → Warning FailedScheduling: 0/2 nodes are available: 2 node(s) didn't match pod anti-affinity rules. # 对: 改 preferred — 尽量打散, 打不开也能上线 preferredDuringSchedulingIgnoredDuringExecution: [...]
concurrencyPolicy: Allow, 上轮没跑完下轮照常触发. 正解: Forbid + 业务幂等键双保险。 # 错: 默认 Allow — 上轮 10 分钟没跑完, 下轮照常触发 concurrencyPolicy: Allow # → 两个结算进程同时扣款, 金额翻倍 # 对: 禁止重叠 + 落库唯一索引兜底 concurrencyPolicy: Forbid # → 上轮未完, 本轮跳过并记 event
activeDeadlineSeconds 和重试预算. 正解: 失败给预算, 卡死给闹钟。 # 错: 只配 schedule — 容器 hang 死, Job 永远 Running jobTemplate: { spec: {} } # 对: 给失败上预算, 给卡死上闹钟 jobTemplate: { spec: { backoffLimit: 3, activeDeadlineSeconds: 1800 } }
# 错: 自研 controller 与被管服务挤同一节点池 — 节点宕机一起走 kubectl -n ops get pods -o wide # → controller 和 web 同在 node-a31 # 对: 控制面独立节点池 + 污点隔离 kubectl taint nodes cp-01 pool=control:NoSchedule # → 只让控制面进
PDB 保底 + 收到 SIGTERM 先还租约再退出。 // 错: 忽略 SIGTERM 强杀 — 租约等 TTL(默认 15s)过期, 期间无人干活 // 对: 用 manager 自带信号处理, 优雅退出时立刻释放 leader 租约 ctx := ctrl.SetupSignalHandler() // ctrl-runtime: 停机自动释放租约
// 错: 期望状态放 map — 控制面一重启全丢, 调谐失去依据 want := map[string]int{"web": 3} // → 重启后 map 为空, 再也不补副本 // 对: 每轮从存储读真相, 内存只做缓存 r.Get(ctx, key, &app) // → etcd 里的期望状态永不丢
resourceVersion, 内容没变不入队。 // 错: 无条件入队 — 回写状态 → 触发 watch → 再入队 = 热循环 UpdateFunc: func(o, n interface{}) { queue.Add(n) } // 对: 内容没变就不入队, 打断自我触发 if o.(*appsv1.Deployment).ResourceVersion == n.(*appsv1.Deployment).ResourceVersion { return }
# 错: 无脑 100m — 节点按声明值装箱, 实际用量 800m resources: { requests: { cpu: 100m } } # → 超卖, OOMKill/被驱逐 # 对: 贴近真实用量, 装箱才准 resources: { requests: { cpu: 750m, memory: 512Mi } }
# 错: 依赖自建机架标签 — 重组后没有任何节点带它 topologyKey: rack-id # → 无节点匹配, 全部 Pending # 对: 内置标签由 kubelet 维护, 一定存在 topologyKey: topology.kubernetes.io/zone
# 错: 只依赖自定义指标 — 事件: FailedGetCustomMetric # unable to fetch metrics from custom metrics API: # no custom metrics API (custom.metrics.k8s.io) registered in the cluster # 对: 补一条资源指标, 指标源互为备份 metrics: [{type: Resource, resource: {name: cpu, target: {type: Utilization, averageUtilization: 70}}}]
timeZone (k8s 1.27+ GA)。 # 错: 以为写的是北京时间 — 实际 UTC 02:30 = 北京 10:30 schedule: "30 2 * * *" # → 高峰期跑批, 下游数据库被打挂 # 对: 显式时区 schedule: "30 2 * * *" timeZone: Asia/Shanghai
Never 不参与抢占。 # 错: 10 个优先级互相抢 — 抢占风暴, 一轮抖动驱逐 40 个 Pod # 对: 批处理只排队, 永不驱逐别人 preemptionPolicy: Never # → 资源不够就 Pending 等位, 不制造事故
ownerReference, K8s GC 自动级联删除。 // 错: 手写删除顺序 — 先删 CR, 依赖资源从此没人认领(孤儿) // 对: 带 ownerReference, 删 CR 时 GC 自动级联清理 ownerRef := *metav1.NewControllerRef(&app, v1alpha1.SchemeGroupVersion.WithKind("App"))
recorder.Event, 用户 kubectl describe 就能看到。 // 错: 调谐黑盒 — 出事只有一行 "reconcile failed", 无从下手 // 对: 关键动作写 Event, kubectl describe 直接可见 r.Recorder.Event(&app, "Normal", "ScaledUp", "replicas 3 -> 5")
# 错: 只看 CPU 打分 — CPU 90% 装满, 内存 71% 时 OOM 频发 # 对: 双维同权打分 scoringStrategy: { type: MostAllocated, resources: [{name: cpu, weight: 1}, {name: memory, weight: 1}] }
// 错: List 全集群对象 — apiserver 内存尖峰, controller 启动 5 分钟 r.List(ctx, &list) // 对: informer 增量 watch, cache 只订阅需要的 namespace (v0.15+) opt := cache.Options{DefaultNamespaces: map[string]cache.Config{"shop": {}}} mgr, _ := ctrl.NewManager(cfg, ctrl.Options{Cache: opt})
-- 错: 直接 INSERT — 重放一次, 结算数字翻倍 INSERT INTO settle_log(run_id, amount) VALUES('job-17352', 500); -- 对: 幂等键 + 冲突即跳过 INSERT INTO settle_log(run_id, amount) VALUES('job-17352', 500) ON DUPLICATE KEY UPDATE amount = amount; -- → 重复执行只生效一次