系统架构 · 调度编排与控制面

控制面只做一件事: 让实际状态无限逼近期望状态 — 差多少补多少, 永不停止; 调度器决定"落在哪", HPA 决定"跑几个", CronJob 决定"何时跑"

调度编排与控制面 · 调谐循环 × 两阶段调度 × HPA 双路对比 期望状态是输入, 差距是燃料, 循环永不停止 watch 期望变化 ① 读实际状态 ② 算差距: 少 2 个副本 ③ 行动后再观察 · 永不停止 控制面 Control Plane — Reconciliation 调谐循环 level-triggered: 每轮只比较"期望 vs 实际", 不信任上一次的中间状态 Desired State 期望状态 · spec.replicas=3 Observe 实际状态 list / watch pod, node Act 执行增删改 create / update / delete Diff 计算差异 期望 vs 实际 · 有差才动 事故现场 · Reconcile 冲突报错 controller-runtime 真实日志 — 乐观并发冲突, 高频出现 ERROR Reconciler error {"controller": "rollout"}: Operation cannot be fulfilled on deployments.apps "api-gateway": the object has been modified; please apply your changes to the latest version and try again # 正解: 重新 Get 最新 resourceVersion 再改 — 调谐天然幂等 Controller / Operator / 控制面分离 Operator = CRD(期望状态 schema) + 自研 Controller kube-scheduler / controller-manager 独立部署选主 控制面挂 = 集群"失明"(不添乱), 工作负载继续跑 可行集 最高分 调度两阶段 · Predicates 过滤 → Priorities 打分 → Bind 绑定 kube-scheduler: 先淘汰"不能放"的节点, 再给"能放"的打分挑最优 新 Pod 待调度 requests: 500m / 1Gi 亲和 · 容忍 · 端口 Predicates 硬过滤 ✗ node-3 · InsufficientMemory ✗ node-1 · 不容忍 taint=batch ✓ node-2 · 通过 → 进可行集 Priorities 软打分 (0~100) node-2 · 62 分 (LeastAllocated) node-4 · 88 分 ← 胜出 打分项: 资源均衡 / 亲和 / 镜像本地性 Bind → node-4 kubelet 拉镜像起容器 pod 与节点绑定生效 调度不出来的真实事件原文 (可直接拿去搜索): Warning FailedScheduling 0/3 nodes are available: 1 node(s) had untolerated taint {batch:NoSchedule}, 1 InsufficientMemory. preemption: 0/3 nodes are available: 3 No preemption victims found for incoming pod.. ✗ 事故路 · 无稳定窗口: 指标抖动 → 扩缩震荡 CPU 在阈值附近抖动, 扩了又缩、缩了又扩, 副本数画成锯齿 target: 4 副本 10 0 时间 → 10 分钟 Warning SuccessfullyRescaled deploy/web: New size: 9; reason: cpu resource utilization (percentage of request) above target ✓ 正确路 · 稳定窗口 + 缓坡: 缩容先等 5 分钟均值 behavior.scaleDown.stabilizationWindowSeconds: 300 — 抖动被窗口抹平 10 0 时间 → 10 分钟 behavior: scaleDown: { stabilizationWindowSeconds: 300 } # 扩容要快, 缩容要稳 cyan 入口/待调度 · emerald 计算/正确路 · violet 期望数据 · amber 控制面边界 · rose 事故路径 · orange 目标线 · 箭头 = 控制流接力

机制视角 — 三台引擎

  • • Reconciliation 调谐循环是总纲: Observe → Diff → Act, 所有 controller / Operator 共用这一副骨架
  • • Scheduler 两阶段 (Predicates 过滤 → Priorities 打分 → Bind) 决定 Pod "落在哪"
  • • HPA / VPA 决定 "跑几个、多大", CronJob / Job 决定 "何时跑、跑几次"
  • • 期望状态持久化在 etcd, 控制面组件本身无状态、可重启、靠租约选主

行为视角 — 两条纪律

  • • level-triggered: 每轮只看当前差距, 不信任上次执行到哪 — 所以崩溃重启不丢步骤
  • • 调谐必须幂等: 同一轮逻辑跑 1 次和跑 N 次结果一样, 先查后建、冲突就重读重试
  • • 有反馈就有震荡: 指标抖动 × 过敏的缩容 = 锯齿, 用稳定窗口和速率上限压制
  • • 亲和/污点是"约束", 约束写死 = 调度不出来, 硬约束要用软约束 (preferred) 兜底

生产价值 — 省机器保核心

  • • bin packing + 准确的 requests: 同样的集群装下更多业务, 利用率从 18% 到 45%
  • • 反亲和 + 拓扑打散: 一个机柜断电不再团灭整组副本
  • • PriorityClass 抢占 + PDB: 大促时报表让路给支付, 维护时排水不断连
  • • 把运维手册写成 Operator: 凌晨 3 点的 runbook 变成永不打瞌睡的调谐循环

💡 一句话理解

把控制面想成一台恒温空调: 你只在面板上设定"24℃"(期望状态), 压缩机从不关心"我上一步做了什么", 它只做三件事——测温(Observe)、算差值(Diff)、制冷或制热(Act), 然后立刻开始下一轮。这个循环有一个名字: Reconciliation 调谐。

调度编排的全部秘密都在这个循环里: kube-scheduler 负责"新 Pod 落在哪"(硬过滤 + 软打分两阶段), HPA 负责"跑几个"(按指标比例算副本数), CronJob / Operator 负责"何时跑、怎么运维"——全是循环的不同皮。它解决的是人肉运维的遗忘与手抖; 它制造的新问题是: 循环永不停止, 每一轮都必须幂等, 否则副作用滚雪球; 有反馈就有震荡, 没有稳定窗口的自动扩缩会自己抖死自己。

🧠 必知必会 必考 & 必会

Desired State 期望状态
声明式而非命令式: 你只提交"要 3 个副本", 不写"先起 A 再起 B"。期望状态持久化在 etcd 而不是控制面内存——控制面随便重启, 状态不丢。
# deployment.yaml — 期望状态, 提交后存进 etcd, 不会"执行完就丢"
spec:
  replicas: 3        # 声明"我要 3 个", 不是"帮我起 3 个"
# 关键: 控制面重启后期望状态还在 etcd 里, 调谐从断点继续
Reconciliation 调谐循环
永不停止的死循环: 观察 → 算差 → 行动。level-triggered(电平触发)是灵魂: 每轮只比对当前期望与实际, 不信任上一次的中间状态, 所以崩溃、重启、漏掉几次事件都不会"丢步骤"。
for {
    want := 3                    // 期望: spec.replicas
    got := countRunningPods()    // 实际: 只有 2 个
    if got == want { continue } // 无差异 → 本轮结束
    createPod()                  // 差 1 补 1
}  // → 永不退出, 但每轮都是短平快的一小步
// 关键: level-triggered — 只比对现状, 不依赖上次执行到哪
Controller 控制器
watch + 限速队列: informer 把资源变化丢进 workqueue, worker 取出后跑一轮调谐; 处理失败按指数退避重新入队, 既不丢事件也不会热循环。
informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
    UpdateFunc: func(_, n interface{}) { queue.AddRateLimited(n) },
})
// 关键: AddRateLimited — 失败按退避节奏重试, 不是 for 空转打爆 apiserver
Scheduler 两阶段调度
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 求最优解, 顺序不能反
Bin Packing 装箱
把 Pod 当货物往节点集装箱里装, 装得越满越省钱。装箱的依据是 requests(声明值)而非实际用量——requests 乱填, 装箱全错: 填小了节点超卖, 填大了机器白养。
# kube-scheduler pluginConfig → NodeResourcesFit.args
scoringStrategy:
  type: MostAllocated   # 装满优先(省机器); LeastAllocated = 打散优先(求稳)
  resources: [{ name: cpu, weight: 1 }, { name: memory, weight: 1 }]
# 关键: 装箱依据是 requests 声明值 — 填不准, 装箱全错
Affinity/Anti-affinity
亲和 = 往哪凑(数据本地性), 反亲和 = 必须分开(高可用); topologyKey 指定"在什么范围里分开", key 写错或标签没人有, 整批 Pod 卡 Pending。
affinity:
  podAntiAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      - topologyKey: kubernetes.io/hostname   # 按"节点"维度打散
        labelSelector:
          matchLabels: { app: payments }
# 关键: required=硬性必须满足(节点不够就 Pending); 高可用常用 preferred
Taint/Toleration 污点容忍
给节点打"气味"把 Pod 熏走(污点), 只有声明"忍得了"的 Pod 才进得去——在线/离线业务物理隔离的标准做法, 比 nodeSelector 多一个"赶人"方向的语义。
# 节点侧: kubectl taint nodes gpu-1 dedicated=batch:NoSchedule
tolerations:
  - key: dedicated
    operator: Equal
    value: batch
    effect: NoSchedule   # 没这张通行证的 Pod 被拒之门外
# 关键: 污点是节点主动赶人, 容忍是 Pod 亮通行证 — 方向相反
HPA 水平自动扩缩
默认每 15 秒读一次指标, 按 期望副本 = ceil(当前副本 × 实际值 / 目标值) 缩放; 指标天然抖动, 稳定窗口(stabilizationWindowSeconds)与扩缩速率上限(policies)负责防震。
metrics:
  - type: Resource
    resource:
      name: cpu
      target: { type: Utilization, averageUtilization: 60 }
# 关键: ceil(4 副本 × 90%/60%) = 6 — 按"实际值/目标值"比例放大
Priority/Preemption 抢占
高优先级 Pod 调度不下时, 调度器驱逐低优先级 Pod 腾位——保证核心业务在资源紧张时活下来; 分层过细会引发连锁驱逐, 批处理任务应设为不参与抢占。
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
value: 900000          # 越大越优先; 系统级从 2000000000 起
preemptionPolicy: PreemptLowerPriority
# 关键: 抢占是"调度器删别人 Pod 给我腾地方", 不是排队插队
Operator 与 CRD
把人类的运维手册(安装、升级、备份、故障切换)写成代码: 用 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 // 关键: 返回值只有"再来一次"或"报错", 没有成功态
}
Job/CronJob 调度
Job 管一次性任务(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 + 租约选主 — 挂了只失明, 不添乱

🏭 生产实战 real world

场景 1 · 大促前把 HPA 从"抽风"调稳 —— 稳定窗口 + 缩容缓坡

大促压测时副本数 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%, 尾部延迟不再被"缩容-扩容"循环打爆。

场景 2 · 深夜告警: HPA 半小时扩缩 40 次 —— 指标源抖动排查

事故排查思路: 先看 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%, 宁可晚扩不乱扩

场景 3 · 自己写"证书过期自动轮换" controller —— Reconcile 正确模板

运维每周人肉检查证书到期日, 忘一次就是 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
}

场景 4 · 三个支付副本全在一台宿主机, 机柜断电整组团灭 —— 反亲和打散

不带反亲和时, 调度器完全可能把三个副本堆到同一节点。给核心服务加"尽量打散"的反亲和, 再用 PDB 兜底主动驱逐场景。

affinity:
  podAntiAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:   # 尽量打散, 不至于 Pending
      - weight: 100
        podAffinityTerm:
          topologyKey: kubernetes.io/hostname
          labelSelector:
            matchLabels: { app: payments }
# 关键: 支付这类核心服务再配 PDB 兜底 — drain 节点时最多断 1 个副本

场景 5 · Flink 批处理把在线服务 CPU 挤到 95% —— 污点物理隔离批处理节点

离线批处理和在线业务混跑, 大任务一上在线接口 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 }   # 批处理如实申报, 不挤占在线业务

场景 6 · 结算 CronJob 重叠执行, 商户被扣两遍钱 —— Forbid + 幂等键 + 补跑窗口

控制面升级重启了 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 } }

场景 7 · 把"每周清理过期归档"的人肉脚本封装成 Operator (CRD)

清理脚本散在 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 日志

场景 8 · 200 台节点 CPU 利用率常年 18% —— requests 乱填毁掉装箱

各组申报资源全靠拍脑袋, 六成 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 台, 同样的业务量, 一年省下几十万机器成本。

场景 9 · 大促核心支付要保命, 报表任务全部让路 —— PriorityClass 抢占

大促预热时资源紧张, 支付 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"
# 报表让位 → 支付上位; 资源回落后报表自动重新排队调度回来

场景 10 · 节点例行维护, drain 一条命令带走支付 leader —— 排水 + PDB

运维 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 优雅切换后自动放行, 服务不断连

⚠️ 编码注意与常见坑 pitfalls

坑 1 · Reconcile 里 sleep, worker 越积越多 — 症状: 调谐队列堆积, 其他资源半天不被处理. 原因: 在 Reconcile 里 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
}
坑 2 · 调谐跑了三轮, 外部负载均衡建了三个 — 症状: 云账单翻倍, 凭空多出同名资源. 原因: 调谐逻辑不幂等, 每轮都执行"创建"而不先查. 正解: 先查后建, 存在即跳过。
// 错: 每轮都调外部接口 — 调谐跑 3 轮就建了 3 个 CLB
createExternalLB(name)            // → 账单 ×3, 外部资源泄漏
// 对: 先查后建, 存在即跳过 (调谐必须幂等)
if _, err := findLB(name); apierrors.IsNotFound(err) { createExternalLB(name) }
坑 3 · HPA 副本数 4→9→4 来回锯齿 — 症状: 扩完就缩、缩完就扩, 冷启动白烧算力. 原因: 缩容侧稳定窗口被改小或策略允许一次缩 100%, 指标一抖就跟着动. 正解: 稳定窗口 300s + 每分钟最多缩 25%。
# 错: 缩容不限速 — CPU 在 59%/61% 间抖动, 副本跟着锯齿
scaleDown: { stabilizationWindowSeconds: 0 }  # → 反复扩缩, 冷启动风暴
# 对: 窗口抹平抖动, 速率限制缓坡
scaleDown: { stabilizationWindowSeconds: 300, policies: [{type: Percent, value: 25}] }
坑 4 · 反亲和上线后 Pod 全卡 Pending — 症状: 发布后新副本一个都起不来. 原因: 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: [...]
坑 5 · 结算 CronJob 两轮重叠, 商户被扣两遍 — 症状: 同一笔订单出现两条扣款流水. 原因: 默认 concurrencyPolicy: Allow, 上轮没跑完下轮照常触发. 正解: Forbid + 业务幂等键双保险。
# 错: 默认 Allow — 上轮 10 分钟没跑完, 下轮照常触发
concurrencyPolicy: Allow    # → 两个结算进程同时扣款, 金额翻倍
# 对: 禁止重叠 + 落库唯一索引兜底
concurrencyPolicy: Forbid   # → 上轮未完, 本轮跳过并记 event
坑 6 · Job 卡 Running 一整夜没个完 — 症状: 任务既不成功也不失败, Forbid 之后所有后续轮次被跳过. 原因: 容器卡死不退出, 没配 activeDeadlineSeconds 和重试预算. 正解: 失败给预算, 卡死给闹钟。
# 错: 只配 schedule — 容器 hang 死, Job 永远 Running
jobTemplate: { spec: {} }
# 对: 给失败上预算, 给卡死上闹钟
jobTemplate: { spec: { backoffLimit: 3, activeDeadlineSeconds: 1800 } }
坑 7 · 控制面节点宕机, 应用挂了没人修 — 症状: 业务和自研 controller 一起失联, 无人自愈. 原因: 控制面与被管对象同一个节点池, 同故障域. 正解: 控制面独立部署 + 跨可用区打散。
# 错: 自研 controller 与被管服务挤同一节点池 — 节点宕机一起走
kubectl -n ops get pods -o wide   # → controller 和 web 同在 node-a31
# 对: 控制面独立节点池 + 污点隔离
kubectl taint nodes cp-01 pool=control:NoSchedule   # → 只让控制面进
坑 8 · 缩容/维护恰好杀掉 leader — 症状: 每次缩容或 drain, 任务中断几十秒. 原因: kube 只认副本数不认业务主从, 随机选中的副本恰好持有租约. 正解: PDB 保底 + 收到 SIGTERM 先还租约再退出。
// 错: 忽略 SIGTERM 强杀 — 租约等 TTL(默认 15s)过期, 期间无人干活
// 对: 用 manager 自带信号处理, 优雅退出时立刻释放 leader 租约
ctx := ctrl.SetupSignalHandler()  // ctrl-runtime: 停机自动释放租约
坑 9 · 控制面重启后忘了自己该干什么 — 症状: 控制面一重启, 副本不再补齐、任务不再调度. 原因: 期望状态只存在进程内存的 map 里. 正解: 期望状态落 etcd/DB, 内存只做只读缓存。
// 错: 期望状态放 map — 控制面一重启全丢, 调谐失去依据
want := map[string]int{"web": 3}  // → 重启后 map 为空, 再也不补副本
// 对: 每轮从存储读真相, 内存只做缓存
r.Get(ctx, key, &app)             // → etcd 里的期望状态永不丢
坑 10 · API server 被自家 controller 打挂 — 症状: apiserver QPS 飙高, 全集群变慢. 原因: controller 状态回写又触发自己的 watch, 无条件入队形成热循环. 正解: 对比 resourceVersion, 内容没变不入队。
// 错: 无条件入队 — 回写状态 → 触发 watch → 再入队 = 热循环
UpdateFunc: func(o, n interface{}) { queue.Add(n) }
// 对: 内容没变就不入队, 打断自我触发
if o.(*appsv1.Deployment).ResourceVersion == n.(*appsv1.Deployment).ResourceVersion { return }
坑 11 · 全组 requests 乱填, 节点装箱全错 — 症状: 节点"名义满了实际很闲", 或装箱后频繁 OOM/驱逐. 原因: requests 与真实用量脱节, 装箱按声明值计算. 正解: 压测 + VPA recommender 校准 requests。
# 错: 无脑 100m — 节点按声明值装箱, 实际用量 800m
resources: { requests: { cpu: 100m } }   # → 超卖, OOMKill/被驱逐
# 对: 贴近真实用量, 装箱才准
resources: { requests: { cpu: 750m, memory: 512Mi } }
坑 12 · 依赖的节点标签被改, 亲和全失效 — 症状: 半年后新扩容全卡 Pending 或打散失效. 原因: 亲和/反亲和依赖自建节点标签, 运维重组后标签消失. 正解: 用平台保证存在的内置标签。
# 错: 依赖自建机架标签 — 重组后没有任何节点带它
topologyKey: rack-id          # → 无节点匹配, 全部 Pending
# 对: 内置标签由 kubelet 维护, 一定存在
topologyKey: topology.kubernetes.io/zone
坑 13 · 自定义指标断供, HPA 不扩容了 — 症状: 大促流量翻倍, 副本数纹丝不动. 原因: HPA 只挂了自定义指标(QPS), prometheus-adapter 一挂拿不到值. 正解: 自定义指标 + CPU 资源指标双保险, 断一个还能扩。
# 错: 只依赖自定义指标 — 事件: 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}}}]
坑 14 · 定时任务"凌晨 2 点"跑在了上午 10 点 — 症状: 结算在业务高峰执行, 打挂下游. 原因: CronJob 的 schedule 默认按 UTC 解释, 北京时间差 8 小时. 正解: 显式声明 timeZone (k8s 1.27+ GA)。
# 错: 以为写的是北京时间 — 实际 UTC 02:30 = 北京 10:30
schedule: "30 2 * * *"      # → 高峰期跑批, 下游数据库被打挂
# 对: 显式时区
schedule: "30 2 * * *"
timeZone: Asia/Shanghai
坑 15 · 一次抖动引发抢占连环驱逐 — 症状: 一次资源抖动, 40 个 Pod 被连环驱逐. 原因: 优先级分了 10 层, A 抢 B 的位, B 落地又去抢 C. 正解: 优先级只留 3 层(核心/普通/批), 批处理 Never 不参与抢占。
# 错: 10 个优先级互相抢 — 抢占风暴, 一轮抖动驱逐 40 个 Pod
# 对: 批处理只排队, 永不驱逐别人
preemptionPolicy: Never   # → 资源不够就 Pending 等位, 不制造事故
坑 16 · 删了 CR, 依赖资源成了孤儿 — 症状: 删自定义资源后, 它创建的外部 DB/CLB 没人认领, 泄漏到账单. 原因: 手写删除顺序, 先删 CR 后删依赖. 正解: 创建资源时带 ownerReference, K8s GC 自动级联删除。
// 错: 手写删除顺序 — 先删 CR, 依赖资源从此没人认领(孤儿)
// 对: 带 ownerReference, 删 CR 时 GC 自动级联清理
ownerRef := *metav1.NewControllerRef(&app, v1alpha1.SchemeGroupVersion.WithKind("App"))
坑 17 · controller 出事, 日志只有一行 error — 症状: 排查时不知道调谐在干什么、卡在哪步. 原因: 调谐黑盒, 不写事件也不打结构化日志. 正解: 关键动作 recorder.Event, 用户 kubectl describe 就能看到。
// 错: 调谐黑盒 — 出事只有一行 "reconcile failed", 无从下手
// 对: 关键动作写 Event, kubectl describe 直接可见
r.Recorder.Event(&app, "Normal", "ScaledUp", "replicas 3 -> 5")
坑 18 · 按 CPU 装满的节点, 内存先爆了 — 症状: 装箱率"很高", 但节点频繁 OOM. 原因: 只压测/只看 CPU 维度装箱, 内存碎片被忽略. 正解: 打分同时算 CPU 与内存两维, 哪维先满听哪维。
# 错: 只看 CPU 打分 — CPU 90% 装满, 内存 71% 时 OOM 频发
# 对: 双维同权打分
scoringStrategy: { type: MostAllocated,
                   resources: [{name: cpu, weight: 1}, {name: memory, weight: 1}] }
坑 19 · controller 一启动, apiserver 内存尖峰 — 症状: 大集群里 controller 启动即 OOM, apiserver 跟着抖. 原因: 直接 List 全集群百万级对象. 正解: informer 本地缓存 + 按 namespace/label 收窄范围。
// 错: 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})
坑 20 · 消息重放一次, 报表金额翻倍 — 症状: 调谐重放/MQ 重投后任务重复执行, 数字对不上. 原因: 任务没有幂等键, 重复消费直接落表. 正解: 数据库唯一索引是幂等最后的闸门。
-- 错: 直接 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;  -- → 重复执行只生效一次