系列第 3 篇《为什么 HPA 会震荡?手写一个带冷却窗口的伸缩决策引擎》让 KubeSentry 学会了聪明地伸缩,但那有个隐含前提------应用是健康的 。这篇处理应用不健康的时刻:CrashLoopBackOff、5xx 飙升、金丝雀发布翻车。我们要把它变成一个"自动值班员":会扩容、会摘除、会回滚,以及最重要的------知道什么时候该收手,打电话叫醒人类。
一、半夜 3 点的 rollout undo
先看一个每个后端都经历过(或即将经历)的现场:
- 凌晨 2:47,值班电话响了:订单服务 5xx 从 0.2% 飙到 40%
- 2:49,爬起来开电脑、连 VPN、登录
- 2:55,定位到 20 分钟前的一次发布("就改了个小依赖")
- 3:02,
kubectl rollout undo deployment/order - 3:08,5xx 回落,写字事故报告
从故障发生到恢复,21 分钟------这已经是反应很快的团队了。而这 21 分钟里损失的订单、烧掉的下游重试、污染的用户会话,都不会出现在复盘报告的"损失估算"里。
更扎心的是:Kubernetes 其实一直在"自愈",只是它修不了这个问题 。kubelet 检测到容器挂了会重启它,重启失败进入 CrashLoopBackOff,然后------继续重启,退避时间越拉越长。kubelet 的世界观里只有"单个容器",它没有"版本"的概念。一个坏镜像,重启一百次也是坏的 。CrashLoopBackOff 本质上是 kubelet 在说:"我尽力了,该你们上了。"
所以 KubeSentry 的自愈引擎要做的事很明确:在 kubelet 的能力边界之上,把"版本级"和"应用级"的故障接过来。
二、先给故障分类:三个起点,而不是一条流水线
写自愈引擎最容易犯的错误,是把"分级"理解成流水线:先扩容,不行再降级,再不行回滚。错。对一个全量 CrashLoop 的应用做"扩容",等于往火场里加人------多出来的 Pod 还是一起来一起崩,白白消耗集群资源,还稀释了关键信号("扩容了怎么没好转"会让后面的判断更难做)。
正确的做法是:故障模式决定起点,升级才走流水线。KubeSentry 把故障分成三类:
| 故障类型 | 典型信号 | 起点等级 | 为什么 |
|---|---|---|---|
| 过载型 | 5xx 飙升 + Pod 全 Ready + P99 高 | L1 扩容 | 容量不够,加机器立竿见影,成本最低 |
| 局部故障 | 部分副本异常(5xx 集中在个别 Pod / 单节点) | L2 摘除 | 坏的是"少数派",定点清除比全局动作精准 |
| 版本级故障 | 发布后全量 CrashLoop / 金丝雀窗口错误率越阈 | L3 回滚 | 根因在镜像,任何扩容摘除都是拖延 |
信号识别的实现(healer.Inspect 的核心部分):
go
// Inspect 采集 Pod 状态与应用指标,输出故障报告。
func (e *Engine) Inspect(ctx context.Context, app *appsv1alpha1.App,
dep *appsv1.Deployment) Report {
pods := &corev1.PodList{}
// 只看本 App 管的 Pod:Deployment 的 selector
if err := e.client.List(ctx, pods, client.MatchingLabels(dep.Spec.Selector.MatchLabels),
client.InNamespace(app.Namespace)); err != nil {
return Report{}
}
var signals []string
crashing, notReady := 0, 0
for _, pod := range pods.Items {
for _, cs := range pod.Status.ContainerStatuses {
// CrashLoopBackOff:kubelet 已经放弃的标志
if cs.State.Waiting != nil && cs.State.Waiting.Reason == "CrashLoopBackOff" {
crashing++
signals = append(signals,
fmt.Sprintf("CrashLoopBackOff(pod/%s, restarts=%d)", pod.Name, cs.RestartCount))
}
// OOMKilled:上一次退出是被内存杀的
if cs.LastTerminationState.Terminated != nil &&
cs.LastTerminationState.Terminated.Reason == "OOMKilled" {
signals = append(signals, fmt.Sprintf("OOMKilled(pod/%s)", pod.Name))
}
}
if !podutil.IsPodReady(&pod) {
notReady++
}
}
// 指标型信号:Prometheus 查错误率与 P99(实现见第 3 篇的查询模板)
errRate := e.queryErrorRate(ctx, app)
return e.diagnose(pods.Items, crashing, notReady, errRate)
}
diagnose 就是起点映射表:
go
// diagnose:故障模式 -> 起点等级。这是整个引擎最重要的 20 行。
func (e *Engine) diagnose(pods []corev1.Pod, crashing, notReady int,
errRate float64) Report {
switch {
// 版本级:发布后出现全量崩溃 ------ 任何扩容摘除都是拖延
case crashing == len(pods) && len(pods) > 0:
return Report{Unhealthy: true, SuggestedLevel: L3Rollback,
Signals: append([]string{"all pods crash-looping"})}
// 局部故障:部分副本异常(含 Ready 但持续 5xx 的"半死"副本)
case crashing > 0 || (errRate > e.errRateThreshold && notReady > 0):
return Report{Unhealthy: true, SuggestedLevel: L2Isolate}
// 过载型:Pod 都活着但错误率超标 ------ 容量问题
case errRate > e.errRateThreshold:
return Report{Unhealthy: true, SuggestedLevel: L1ScaleUp}
}
return Report{Unhealthy: false}
}
注意 crashing == len(pods) 这个判断------全量崩溃是版本级故障最可靠的指纹。发布刚完成时 Pod 一定是全新的,如果它们全部进入 CrashLoop,除了镜像坏了几乎没有别的解释。
三、状态机:五条规则
有了起点,还需要一套状态机来管"动作的节奏"。自愈引擎最大的危险不是不动作,而是乱动作:误判一次就扩容、观察半分钟没好转就回滚、回滚完看指标波动又切回来------值班员亲手把事故变成事故连环现场。
KubeSentry 的状态机只有五条规则:
markdown
┌──────────────────────────────────────────┐
│ │
┌────────┐ │ 连续 2 个窗口 unhealthy │ 观察窗结束仍不健康
│ Stable │───┴──▶ ①防抖(streak ≥ 2) ▼
└────────┘ ┌─────────────┐
▲ │ Healing │
│ 观察窗内恢复 healthy │ L1 → L2 → L3│
├─────────────────────────────────────────┤ │
│ └──────┬──────┘
┌──────────┐ 动作数 ≥ 3 或 L3 后连续 2 窗口仍不健康 │
│ Degraded │◀─────── ④上限与反乒乓 ────────────────────┘
└──────────┘
│ Event: KubeSentryHealGiveUp + Condition Degraded
▼
打电话给人类(人工处理后重置)
- 防抖:连续 2 个观察窗口(默认各 60s)不健康才进入 Healing。单 Pod 重启、一次抓取抖动不该触发任何动作------第 3 篇的迟滞带管"数值上的小波动",这条管"时间上的小波动"。
- 起点映射:进入 Healing 时按第二节的三分类选起点,不从 L1 傻跑。
- 升级 :每个动作之后必须空出一个完整观察窗;窗口结束仍不健康才升到下一级。一次只做一个动作------同时"扩容 + 回滚",你永远不知道是哪个起了作用,事故现场也被毁了。
- 反乒乓:L3 是终点,没有 L4。回滚之后如果连续 2 个窗口仍不健康,说明回滚目标也有问题,继续动作只会在两个坏版本之间来回切------进入 Degraded。
- 上限 :单次事故最多 3 个自愈动作。超过就 Degraded、发 Event、写 Condition,然后停下来等人类。
第 5 条值得多聊两句。为什么是 3 次?因为自愈系统最坏的失败模式不是"没修好",而是无限重试 :扩容→摘除→回滚→又扩容......每次动作都真实地打在集群上,Pod 反复生死,事件刷屏,而根因纹丝不动。值班员最需要的不是"一个一直很努力的系统",而是"一个知道放弃并大声喊人的系统"。知道收手的自动化,才配叫自动化。
Degraded 之后为什么不让它自己恢复重试?因为 Degraded 本身就是最重要的告警信号 。如果引擎自己悄悄 reset 重新跑,人会永远看不到这条告警,而故障还在。解锁必须是显式的人工动作:改一次 spec,或者手工清掉 Condition。
四、三个动作的实现
L1:紧急扩容(绕过冷却窗口)
第 3 篇留过一个扣子:"冷却窗口该不该锁扩容?我倾向于不该。"这里兑现:自愈触发的扩容是急诊 ,不走伸缩引擎的冷却与迟滞,但尊重两条硬约束------maxReplicas 上限和 ×2 限速:
go
func (e *Engine) scaleUp(dep *appsv1.Deployment, maxReplicas int32) *Action {
cur := int32(1)
if dep.Spec.Replicas != nil {
cur = *dep.Spec.Replicas
}
// ×2 限速 + 上限,与 scaler 的扩容刹车保持一致
want := min(cur*2, maxReplicas)
if want <= cur {
return nil // 已在 maxReplicas,L1 无空间 ------ 直接告诉状态机升级
}
dep.Spec.Replicas = &want
return &Action{Level: L1ScaleUp, Name: "scale-up",
Detail: fmt.Sprintf("%d -> %d replicas (emergency)", cur, want)}
}
注意返回 nil 的情况:如果已经在 maxReplicas,扩容没有空间,这不是"动作失败重试",而是"L1 注定无效"------状态机应该跳过观察窗直接升级。动作执行的结果分类(成功/无效/失败)比动作本身更重要,后面 Degraded 的判断全靠它。
L2:定点摘除
对"部分副本坏"的场景,动作是 delete 掉异常 Pod:
go
func (e *Engine) isolate(ctx context.Context, pods []corev1.Pod) *Action {
var removed []string
for _, pod := range pods {
if !isUnhealthy(pod) { // CrashLoop / OOM / Ready 但被诊断异常
continue
}
if err := e.client.Delete(ctx, &pod); err == nil {
removed = append(removed, pod.Name)
}
}
return &Action{Level: L2Isolate, Name: "isolate",
Detail: fmt.Sprintf("removed unhealthy pods: %v", removed)}
}
第一版我写的是"给坏 Pod 打标签,然后改 Service selector 摘流量"。写的时候就很别扭,因为它是在重新发明 readiness 探针:NotReady 的 Pod 本来就不在 Endpoints 里,Service 根本不会给它发流量。对 CrashLoop 的 Pod,"摘除流量"是伪需求------它压根没收到过流量。
L2 真正有意义的场景是两类:
- 节点级故障:Pod 本身没病,是宿主机坏了(磁盘满、内核 panic 前兆)。delete Pod 触发重建,新 Pod 被调度到健康节点,问题消失。
- "半死"副本:Ready 但持续 5xx 的僵尸(连接池死了但探针还过)。这类 Pod readiness 探针发现不了,定点 delete 是唯一手段。
这就是为什么 diagnose 里局部故障的判据是"部分异常"而不是"有异常"------动作必须和诊断对齐,否则 L2 就退化成重启大法。
L3:回滚,但要回滚到 CR 层
回滚的实现里藏着本篇我最喜欢的一个设计决策:
go
// Rollback 读取 ReplicaSet 修订历史,回退到上一个版本的镜像。
func (e *Engine) Rollback(ctx context.Context, app *appsv1alpha1.App,
dep *appsv1.Deployment) (*Action, error) {
rsList := &appsv1.ReplicaSetList{}
if err := e.client.List(ctx, rsList, client.InNamespace(app.Namespace),
client.MatchingLabels(dep.Spec.Selector.MatchLabels)); err != nil {
return nil, err
}
// 按 revision annotation 找到上一个非当前版本的 RS
prev, ok := previousRevision(rsList, dep)
if !ok {
return nil, fmt.Errorf("no previous revision to roll back to")
}
prevImage := containerImage(prev.Spec.Template)
if prevImage == dep.Spec.Template.Spec.Containers[0].Image {
return nil, fmt.Errorf("previous revision has same image, cannot roll back")
}
// 关键:不改 Deployment,改 App.Spec.Image
app.Spec.Image = prevImage
if err := e.client.Update(ctx, app); err != nil {
return nil, err
}
return &Action{Level: L3Rollback, Name: "rollback",
Detail: fmt.Sprintf("image -> %s (revision %d)", prevImage, revisionOf(prev))}, nil
}
两种写法的区别:
- 改 Deployment (
kubectl rollout undo的做法):直接、快,但 KubeSentry 下一轮 Reconcile 一看 Deployment 和app.Spec.Image不一致,会把它改回来------你刚回滚完,自己的控制器又把坏镜像推上去了。要么就得在 Reconcile 里加各种例外逻辑。 - 改 App.Spec.Image :让声明式自己完成回滚。Spec 变了 → Reconcile 发现 Deployment 与 Spec 不一致 → 对齐。一行例外代码都不用写,而且审计记录(谁在什么时候把镜像改回了什么)天然留在 CR 的 resourceVersion 里。
这就是 Operator 模式的好处:你的控制器只有一个"把现实对齐到期望"的逻辑,所有修复都应该是"修改期望",而不是"修改现实"。绕过自己的控制器改现实,等于亲手拆自己的地基。
顺带说坑:previousRevision 依赖 ReplicaSet 的 deployment.kubernetes.io/revision annotation。如果 Deployment 的 .spec.revisionHistoryLimit 被调成了 0(有些人为了"省 etcd"),修订历史就没了,回滚无从谈起------L3 必须处理这个分支,返回"无回滚目标"并让状态机直接 Degraded,而不是空指针 panic。
五、金丝雀观察窗口:把回滚从"救火"提前到"体检"
上面所有逻辑都是"病了再治"。金丝雀窗口做的是"发布时就盯着":
yaml
health:
canary:
enabled: true
observationSeconds: 300 # 发布后观察 5 分钟
errorRateThreshold: "0.02" # 错误率超过 2% 就回滚
Reconcile 主循环里它是优先级最高的检查(对应设计文档 4.1 的第 4 步):
go
// 金丝雀窗口内:镜像刚变更,其余动作全部让路
if healer.InCanaryWindow(app) {
if bad := healer.CheckCanary(ctx, app); bad {
// 直达 L3,跳过防抖 ------ 发布窗口的错误率抬升不需要再等 2 个窗口确认
act, err := healer.Rollback(ctx, app, dep)
// Event + Status.RollbackRevision 写回...
return ctrl.Result{RequeueAfter: time.Minute}, err
}
// 窗口内不伸缩、不自愈,专心观察
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}
两个细节:
- 观察窗内的错误率查询必须按时间过滤 :
rate(http_requests_total{...}[5m])会把发布前的健康样本也卷进来,稀释错误率。要用error_rate AND on() (last_over_time(...发布时间...))或者直接查询"发布时间之后"的增量------我们的实现是给 Deployment 打上kubesentry.io/last-rollout-atannotation,PromQL 里用 timestamp 比较。 - 金丝雀窗口跳过防抖:发布是已知的风险时刻,窗口内的错误率抬升几乎不可能是误报,多等 2 分钟只是多烧 2 分钟流量。防抖保护的是稳态,不是变更窗口。
六、该重试还是该放弃:三条时间线
把状态机跑起来,参数:tick = 60s,防抖 = 2 窗口,动作上限 = 3。下面三条时间线全部来自状态机推演(可以直接跑测试复现),不是编的:
场景 1:发布坏镜像,全量 CrashLoop(起点 L3)
| 时间 | 状态 | 事件 |
|---|---|---|
| t+01:00 | Stable | unhealthy ×1(新 Pod 开始崩溃) |
| t+02:00 | Stable | unhealthy ×2 → 进入 Healing |
| t+03:00 | Healing | L3 回滚 (App.Spec.Image 改回 v1) |
| t+04:00 | Healing | 观察窗:v1 滚动中,仍 unhealthy,不升级(反乒乓) |
| t+05:00 | Stable | RECOVERED |
从故障出现到恢复 5 分钟,其中人工参与时间为 0。
场景 2:流量过载,5xx 飙升(起点 L1)
t+02 确认 → t+03 L1 扩容(3→6)→ t+04 RECOVERED。4 分钟,一个动作。
场景 3:L1 无效的深水区(升级链 L1→L2→L3)
流量过载背后其实是当前版本的内存慢泄漏,扩容只是续命:
t+03 L1 扩容 → t+04 观察窗仍不健康 → 升级 → t+05 L2 摘除 → t+06 仍不健康 → 升级 → t+07 L3 回滚 → t+08 RECOVERED。8 分钟、3 个动作,每个动作之间都有一个完整的观察窗。升级慢是特性不是缺陷------每一次升级都应该伴随"上一级为什么没用"的判断,秒级连招只会把三种动作搅成一锅粥。
场景 4:回滚目标也坏了(Degraded)
| 时间 | 状态 | 事件 |
|---|---|---|
| t+03:00 | Healing | L3 回滚到 v1 |
| t+04:00 | Healing | 观察窗仍 unhealthy(回滚后第 1 个窗口) |
| t+05:00 | Degraded | 回滚后连续 2 窗口不健康 → 放弃,Event KubeSentryHealGiveUp,Condition 写入 Degraded |
| t+05 之后 | Degraded | 不再有任何自动动作,等人类 |
对照一下第一节的人工流程:21 分钟(而且需要一个活人凌晨爬起来)。场景 1 是 5 分钟、0 人工;场景 4 这种"两个版本都坏"的绝境,自动值班员 3 分钟内就完成了放弃决策并发出了高置信度告警------值班同学看到的不再是"5xx 飙升"这种需要定位的模糊告警,而是"我已经试过回滚了、两个版本都不行、大概率是依赖问题"的行动建议。这才是自动化对 on-call 的真正价值:不是替你修,是替你把排查做完。
七、在 kind 里演练一遍
仓库里放了 hack/chaos/ 脚本,可以在本地集群完整复现场景 1:
bash
kind create cluster
make install && make run # 部署 CRD,本地跑 Operator
# 正常版本先跑起来
kubectl apply -f config/samples/app_v1alpha1_app.yaml
kubectl get app sample -w # 另开终端盯着 Status
# 模拟翻车:推一个必然 CrashLoop 的镜像上去
./hack/chaos/break-image.sh # 直接 patch App.Spec.Image 为坏镜像
# kubectl get app -w 的输出:
# PHASE LASTHEALACTION
# Stable
# Healing L3 rollback: image -> ghcr.io/weirong-ui/demo:v1 (revision 2)
# Stable ← 5 分钟后自己回来了
全程不需要人碰键盘。事后 kubectl describe app sample 里每一步都有 Event 留痕(KubeSentryScaleUp / KubeSentryIsolate / KubeSentryRollback / KubeSentryHealGiveUp),这就是"值班员的工作日志"。
八、五个踩过的坑
坑 1:回滚乒乓。 v1→v2(坏)→回滚 v1→又自动发布 v2→又回滚......如果发布系统有重试,自愈引擎就会和它打架。对策:Status.RollbackRevision 记录上次回滚目标,同一对版本间的第二次往返直接 Degraded。自动化系统之间的冲突比自动化本身更危险。
坑 2:全量 CrashLoop 时做 L2。 上面说过:NotReady 的 Pod 不在 Endpoints 里,摘除是伪需求。诊断必须区分"摘不掉流量的坏"和"还在接流量的坏"。
坑 3:观察窗内插动作。 曾经为了"加速",在回滚观察窗内顺手把副本也扩了。结果回滚的滚动更新和扩容的滚动更新叠加,Pod 数量飙到 max,节点被打爆。一次 Reconcile 只允许一个动作,写进状态机而不是靠自觉。
坑 4:把 kubelet 的重启计数当故障指标。 Pod 重启 3 次可能是启动慢(JVM 预热超时),重启 30 次才是真崩。单次窗口的 restartCount 增量比绝对值可靠,配合防抖一起用。
坑 5:Degraded 没接告警。 状态机设计得很完善,Degraded 的 Event 却没人订阅------等于值班员把电话打在了没人接的分机上。Event 只在集群内可见,生产上要接 Alertmanager 或 webhook 出去。这个我们放到第 7 篇"上生产"里一起做。
九、下一篇
值班员上线后,KubeSentry 已经"会治病"了。但它的每次出手都在真实修改集群------如果有人建了个 minReplicas > maxReplicas 的 App,或者镜像没打 tag 就 apply 进来,最好在入口就拦住,而不是等 Reconcile 报错。
下一篇《K8s 准入控制实战:Validating 与 Defaulting Webhook 踩坑记》会做两件事:Validating Webhook 在 kubectl apply 阶段拦住非法配置;Defaulting Webhook 给所有受管应用强制注入安全上下文(readOnlyRootFilesystem、runAsNonRoot、drop ALL capabilities)------这也是我把 CKA/CKS 里"Pod 安全标准"的知识真正变成代码的地方。
代码在 github.com/weirong-ui/...(internal/healer/)。觉得有用点个赞,仓库点个 star 追更不迷路;如果你觉得"动作上限 3 次太保守"或者"Degraded 应该允许自动重试",非常欢迎评论区来辩------这两条是我自己都还在犹豫的参数。
系列目录:01 项目设计 | 02 第一个 Reconcile | 03 伸缩决策引擎 | 04 分级自愈(本篇) | 05 Admission Webhook | 06 手写 mini-Informer | 07 上生产前的 6 件事