AI时代的Kubernetes教程36招-第35招-Kubernetes最常见的生产故障到底怎么系统排查?

K8s 故障排查固定树:从现象到根因的一条证据链

故障的种类会变,证据的层级不会变

[读者] 平台与 SRE 工程师,管着自建或多云混合的 K8s 集群
[痛点] 同类告警不同人排查路径不同,结论对不上,复盘只剩一句重启好了
[现在读] 故障面从 3 类扩到 20 类,个人经验已经覆盖不住
[读完] 能搭出一条现象→层→证据→根因→修复→复测的固定链路,并把它训练成团队肌肉记忆
[旧方案] 群里喊人 + 一份按组件写的 runbook + 全靠 describe 一眼看
    |
    v
[新需求] 多集群、GPU 节点池、NetworkPolicy 默认拒绝、混合 CNI
    |
    v
[冲突] 故障种类是有限的,排查路径却是每个人的个人记忆
    |
    v
[后果] 跳步、误判、二次伤害,复盘没有证据只有结论

我是老李,在深圳做平台工程第九年,现在带一个六个人的 SRE 小组,手上有四套自建和多云托管的 Kubernetes 集群,跑着几百个业务工作负载,其中一部分是 GPU 训练任务。这些年我最怕的不是集群挂,是集群挂的方式每次都不一样。

这套集群的演进路径很典型:最早是单集群单租户,节点池只有一种,CNI 用的是最简单的方案,故障类型差不多就是 Pod 起不来、节点掉了、服务不响应这三类,一份两百行的 runbook 加几个老同事的经验就能覆盖。后来业务长起来了,加了 GPU 节点池、加了 NetworkPolicy 默认拒绝、把 Ingress 换成了 Gateway API、存储从本地盘换成了 CSI,故障面一夜之间变成了二十类。

麻烦的地方在于,故障种类是可以穷举的——Pod Pending、CrashLoopBackOff、OOMKilled、ImagePullBackOff、Readiness 失败、DNS 失败、Service 无 Endpoint、Ingress/Gateway 404 和 502、CNI 跨 Node 不通、NetworkPolicy 误杀、PVC Pending 或挂载失败、Node NotReady、kubelet 与 containerd 失败、API Server 不可达、etcd Quorum 丢失、证书过期、GPU 不可调度、HPA 不扩缩、Admission 拒绝、升级失败,差不多就这些。但排查路径不是穷举的,它散在每个人脑子里。

当告警只丢给你一个 Pod 名字的时候,你第一步看什么?

你是按组件找,还是按证据的层级找?

如果第一步就站错了层,你要多久才会发现自己一直在错误的证据里打转?

01、故事

那天凌晨两点十七分,值班群里弹出一条告警:某业务命名空间下四个 Pod 集体 Pending,持续三分钟。

我接手的时候第一反应是资源不够。跑 kubectl describe pod 看到一行 0/12 nodes are available,心里基本就定了性。但我紧接着又跑了一次 kubectl top node,发现所有节点的 CPU 使用率都在 40% 上下,内存也宽松。这两个信号是矛盾的:调度器说没地方放,监控说地方大得很。

我当时的判断是调度器出了问题。任务是让这四个 Pod 尽快跑起来,于是我重启了 kube-scheduler。

Pod 还是 Pending。

二十分钟后,我翻回那条最早的 describe 输出,在 Events 段里看到被我自己忽略的后半句:11 Insufficient cpu, 1 node(s) had untolerated taint {workload: gpu}。前一天晚上,另一个同事给 GPU 节点池打了 taint,防止普通业务蹭卡;而这四个 Pod 的 Deployment 模板里,nodeSelector 在前一周的配置重构中被顺手改了。两个变更各自都对,合起来就把这批 Pod 逼到了没有节点的境地。

真正让我后背发凉的不是误判本身,是复盘那天。等我第二天上午想把事件导出来写复盘时,kubectl get events 里那条 FailedScheduling 已经不见了——Kubernetes 的事件对象默认只保留一小时。我手上唯一的证据,是凌晨随手截的一张图。

那次之后我意识到,我们缺的不是更多经验,是一条固定的路。

02、问题

旧方案失效的地方,是 runbook 的组织方式。我们那份 runbook 是按组件写的:etcd 出问题怎么办、kubelet 出问题怎么办、CNI 出问题怎么办。但告警给的是现象,不是组件。现象到组件之间那一步翻译,全靠人脑——这就是所有分歧的源头。

业务上的影响很直接:MTTR 分布是长尾的。快的十分钟,慢的一整夜。而慢的那些,事后看往往不是问题更难,是第一个人在第一层就拐错了弯,后面所有人都在他的错误结论上继续叠加。

技术上的表现可以归纳成四类:跳步,一上来就 kubectl logs,跳过了 Events;用错代理指标,拿 top 的实际用量去反驳调度器的判定;结论不可复现,同一个人第二次遇到同类故障,走的路径和第一次不一样;证据不留存,复盘时只能靠记忆还原时间线。

这件事要算做完,我认为得满足五个可验证的标准:第一,给定一个现象名,在故障树里能查到唯一的入口层,并且入口层的采集命令是确定的;第二,两个不同的人按同一棵树走,得到的证据清单一致;第三,每一条根因结论都能指回证据包里具体的文件和具体的行;第四,二十类症状能被归并到不超过五个层里,不出现无法归类的孤岛;第五,一个没参与过历史故障的新人,能在不看答案的情况下用这棵树复现其中一条。

旧册已成灰
新症日日增
无径可循处
唯余个人勇

03、原理

要讲清楚为什么固定树能收敛分歧,只需要一个模型:证据是有层级的,而且每一层的寿命和成本都不一样。

我把排查涉及的信息分成五层。对象层,是 Pod、Deployment、Service、EndpointSlice、PVC、HPA 这些对象自身的 status、conditions 和 Events。容器层,是容器进程的状态、退出码、当前日志和上一次崩溃日志。依赖层,是 DNS 解析、Service 选中的 EndpointSlice、ConfigMap 与 Secret、CSI 卷的挂载与绑定。节点层,是 kubelet、容器运行时、CNI、内核、节点资源与 taint。控制平面层,是 API Server、etcd、调度器、控制器管理器、证书和准入 Webhook。

第一个反直觉判断:排查的起点不是最想看的日志,而是寿命最短、离对象最近的那条证据。Kubernetes 的事件默认只保留一小时,这个值由 API Server 的 event-ttl 控制,官方文档里调试应用和调试集群两篇都把 kubectl describe 和 kubectl get events 放在第一步,能按时间排序的事件列表是排查应用问题的第一手材料。而容器日志跟着容器生命周期走,容器重建之后原文就没了,只能靠 kubectl logs --previous 去找上一次的现场。所以先事件后日志不是习惯偏好,是数据寿命决定的顺序。

第二个反直觉判断:Pod 的绝大多数故障不在 Pod 里。Pending 意味着调度器没有同意,判据在节点资源与 taint 上;ImagePullBackOff 意味着 kubelet 拉不动镜像,判据在节点到镜像仓库的链路上;CrashLoopBackOff 意味着进程自己退了,判据在退出码和上一次日志里;Readiness 失败会让 Ingress 或 Gateway 对外返回 502,判据在探针配置和 EndpointSlice 之间。Pod 只是告警的锚点,不是根因的容器。把 Pod 当根因容器,是所有跳步行为的心理源头。

第三个点是调度语义:调度器做决策依据的是 Pod 的 requests 与节点的 allocatable 和 taint、亲和性,而不是节点的实际使用率。这两组数字在语义上根本不在一个空间里。这一点会直接决定第 06 章里那条排查链的对错,也会决定第 07 章里我们到底要改什么。

官方文档在调试集群那一篇里给的组织方式是分层的:先看节点状态和事件,再看控制平面组件日志,最后才是节点上的 kubelet 与容器运行时。这三篇文档——调试总览、调试应用、调试集群——构成了这条证据链的骨架。

证据有层级
越近越便宜
事件终会散
缓存不作证

04、架构

[输入] 一条现象级告警:Pod Pending / 502 / 跨节点不通
    |
    v
[模块] 症状分类器(故障树)+ 分层证据采集器
    |
    v
[数据/状态] 分层证据包:对象 / 容器 / 依赖 / 节点 / 控制平面
    |
    v
[处理] 逐层收敛:层内判据命中则停止,否则下沉一层
    |
    v
[输出] 根因结论 + 最小修复动作 + 同路径复测结果

这个结构里有两个组件。症状分类器是一份声明式配置,它把现象名映射到入口层和必读判据;证据采集器是一个脚本,它按固定顺序把五层证据落到一个带时间戳的目录里,最后打包。两者之间是单向依赖:分类器决定从哪层开始,采集器决定每层要抓什么。

边界要说清楚。这套东西不负责自动修复,它只负责把证据收敛到人可以下判断的粒度。它也不能替代人对业务语义的理解——比如一个 Readiness 失败到底是探针配得太严还是服务真的没起来,脚本给不出答案,它只能保证你把探针配置和服务真实状态同时摆在桌面上。

收益在于可复现和可交接。代价同样明显:固定顺序意味着失去机动性,一个老手本来三十秒能跳到的结论,按树走可能要两分钟;树的维护成本也不低,Kubernetes 的版本、CNI 插件、Gateway API 的实现在变,判据就会过期。

适用条件是:故障类型相对稳定、值班人员轮换、复盘要求有据可查。如果是一个人维护一个小集群,经验全在脑子里,这套东西的收益可能覆盖不了维护成本。

入从现象起
出向根因去
中留五层证
层尽疑自明

05、实战一次

先做一个最小的 V1:把一条链路跑通,不追求覆盖全部二十类。

环境方面,示例基于 Kubernetes 1.28 及以上版本与对应版本的 kubectl,EndpointSlice 为 v1 稳定版本,kubectl debug 的临时容器能力在 1.25 起默认可用。具体的版本与参数请按当前官方文档核验。依赖只有 kubectl 与 bash,不需要在集群里部署任何东西。

先写故障树的配置文件,V1 只覆盖五类高频现象。

version: 1
layers: [object, container, dependency, node, control-plane]
nodes:
  - symptom: PodPending
    first_layer: object
    must_read: [pod.conditions, describe.events, node.allocatable, node.taints, pvc.status]
    stop_when: events 中出现 FailedScheduling 或 FailedAttachVolume
  - symptom: CrashLoopBackOff
    first_layer: container
    must_read: [logs.previous, container.lastState.exitCode, probe.config]
    stop_when: lastState.exitCode 非零且日志有明确报错
  - symptom: ImagePullBackOff
    first_layer: container
    must_read: [describe.events, image.name, imagePullSecrets, node.net]
    stop_when: events 中出现 ErrImagePull 或 manifest unknown
  - symptom: ReadinessFail
    first_layer: object
    must_read: [pod.conditions, probe.config, svc.selector, endpointslice.ports]
    stop_when: Pod Ready 为 False 且探针返回码已确认
  - symptom: DNSFail
    first_layer: dependency
    must_read: [coredns.pods, svc.kube-dns.endpoints, pod.resolv.conf, netpol]
    stop_when: kube-dns 的 EndpointSlice 为空或 CoreDNS 无就绪副本

再写采集脚本,核心约束是顺序不可跳、每层证据落地成独立文件。

#!/usr/bin/env bash
# k8s-evidence.sh v1 —— 固定顺序采集单个工作负载的分层证据
set -uo pipefail

NS=${1:?usage: k8s-evidence.sh namespace pod}
POD=${2:?usage: k8s-evidence.sh namespace pod}
OUT=evidence-${NS}-${POD}-$(date +%Y%m%d%H%M%S)
mkdir -p "${OUT}"

# L1 对象层:最便宜、寿命最短、离对象最近
echo "cluster-version: $(kubectl version -o json 2>/dev/null | head -c 200)" > "${OUT}/00-meta.txt"
kubectl get pod -n "${NS}" "${POD}" -o wide > "${OUT}/01-pod.txt" 2>&1
kubectl describe pod -n "${NS}" "${POD}" > "${OUT}/02-describe.txt" 2>&1
kubectl get events -n "${NS}" --sort-by=.lastTimestamp > "${OUT}/03-events.txt" 2>&1

# L2 容器层:当前日志 + 上一次崩溃日志 + 退出码
kubectl logs -n "${NS}" "${POD}" --all-containers --tail=500 > "${OUT}/04-logs-current.txt" 2>&1
kubectl logs -n "${NS}" "${POD}" --all-containers --previous --tail=500 > "${OUT}/05-logs-previous.txt" 2>&1
kubectl get pod -n "${NS}" "${POD}" -o custom-columns=CONTAINER:.status.containerStatuses[*].name,READY:.status.containerStatuses[*].ready,RESTARTS:.status.containerStatuses[*].restartCount,EXIT:.status.containerStatuses[*].lastState.terminated.exitCode --no-headers > "${OUT}/06-exit-code.txt" 2>&1

# L3 依赖层:控制器、Service 与 EndpointSlice、存储、弹性伸缩
kubectl get pod -n "${NS}" "${POD}" -o custom-columns=OWNER:.metadata.ownerReferences[0].name --no-headers > "${OUT}/07-owner.txt" 2>&1
kubectl get svc,endpointslice,pvc,hpa -n "${NS}" -o wide > "${OUT}/08-deps.txt" 2>&1

# L4 节点层:调度落点、节点状况、容器运行时视野
NODE=$(kubectl get pod -n "${NS}" "${POD}" -o jsonpath='{.spec.nodeName}' 2>/dev/null)
if [ -n "${NODE}" ]; then
  printf '%s\n' "${NODE}" > "${OUT}/09-node-name.txt"
  kubectl describe node "${NODE}" > "${OUT}/10-node-describe.txt" 2>&1
  kubectl get node "${NODE}" -o custom-columns=NAME:.metadata.name,READY:.status.conditions[?(@.type=="Ready")].status,DISK:.status.conditions[?(@.type=="DiskPressure")].status,MEM:.status.conditions[?(@.type=="MemoryPressure")].status --no-headers > "${OUT}/11-node-conditions.txt" 2>&1
else
  printf '%s\n' '节点尚未绑定,跳过节点层采集' > "${OUT}/09-node-name.txt"
fi

# L5 控制平面层:组件存活与近期异常
tar -czf "${OUT}.tar.gz" "${OUT}"
printf 'evidence pack: %s.tar.gz\n' "${OUT}"

启动与调用:

chmod +x k8s-evidence.sh
./k8s-evidence.sh demo web-0

未在当前环境实测,以下为预期结果:

# 示例输出
evidence pack: evidence-demo-web-0-20240612021733.tar.gz

验证方式是检查证据包是否真的形成了固定的层级结构:

tar -tzf evidence-demo-web-0-20240612021733.tar.gz | sort

示例输出应当形如:00-meta.txt、01-pod.txt、02-describe.txt、03-events.txt 一直到 11-node-conditions.txt 顺序排列,文件名前缀就是层号。任何人拿到这个包,看文件名就知道采集时走到了哪一层。

这一步跑通之后,V1 暴露出的问题也很清楚:只覆盖五类现象;脚本没有强制“上一层失败就不许下沉”的约束;证据包不包含节点上的 CNI 与存储细节;也没有任何训练机制。这些留到第 07 章处理。

先跑一条线
不求全覆盖
能收同一包
才敢谈训练

06、排查

下面三条链来自第 05 章那套脚本上线之后真实遇到的三次排查,我把每一步的判断都写出来。

第一条,Pod Pending。

现象:某命名空间四个 Pod 持续 Pending,超过五分钟没有任何调度动作。

怀疑:第一怀疑是节点资源不足,第二怀疑是节点选择器或 taint 与 toleration 不匹配。

检查:按树走,入口层是对象层。先看 describe 的 Events 段,读出调度器的原话。

kubectl -n demo describe pod web-0 | sed -n '/Events:/,$p'

证据(示例输出):

  Warning  FailedScheduling  3m   default-scheduler  0/12 nodes are available: 11 Insufficient cpu, 1 node(s) had untolerated taint {workload: gpu}. preemption: 0/12 nodes are available: 12 No preemption victims found for incoming pod.

这行证据同时给出了两个互斥的原因:十一个节点 CPU 不足(是 requests 口径,不是用量口径),一个节点有不可容忍的 taint。再往下取节点层证据确认:

kubectl get nodes -o custom-columns=NAME:.metadata.name,TAINTS:.spec.taints[*].key,PODCIDR:.spec.podCIDR --no-headers

根因:GPU 节点池被打上 taint 之后,这两个业务工作负载的 Deployment 模板里没有对应的 tolerations;同时其他普通节点上按 requests 计算确实没有足够的空余配额。两个条件叠加,调度器无解。

错误尝试: 我第一次排查时的动作是跑 kubectl top node,看到 CPU 使用率只有四成,就判定“资源明明够,是调度器的问题”,然后重启了 kube-scheduler。这个推断错在把两个不同口径的数字放进了同一个比较里。kubectl top 来源是 metrics 管道的实际用量,而调度器做决策用的是 Pod 的 requests 与节点 allocatable 的差值,两者语义不同、量纲也不同,一个节点可以实际很闲但配额已被占满。重启调度器不但无法改变这个事实,还让事件时间线多了一层噪声。

修复:给这两个工作负载补上对应 GPU 池的 toleration,并把它们明确调度回普通节点池;同时把 taint 与 toleration 的配对关系写进故障树的判据里,作为 Pending 的必读项。

复测:按同一路径再跑一次 describe,Events 中应当出现 Successfully assigned 的记录,Pod 进入 Running 并且 Ready 为 True。

第二条,Service 无 Endpoint,对外表现为 Gateway 返回 502。

现象:通过 Gateway 访问业务域名返回 502,直接访问 Pod IP 加端口是通的。

怀疑:第一怀疑是探针失败导致 EndpointSlice 为空,第二怀疑是探针通过但 Service 到 Pod 的端口映射出了问题。

检查:入口层是对象层,先看 Service 的 selector 与 Pod 的标签是否匹配,再看 EndpointSlice 里端点的端口字段是否为空。

kubectl -n demo get svc web -o wide
kubectl -n demo get endpointslice -l kubernetes.io/service-name=web -o yaml
kubectl -n demo get pod --show-labels

证据(示例输出):

NAME    ENDPOINTS   AGE
web     10.2.3.41:0   26m

端点存在,但端口是 0。这说明标签匹配、探针通过,Pod 已经被 Service 选中,只是端口解析失败了。

根因:Service 的 targetPort 写的是一个命名端口,而 Pod 的容器端口在上一轮配置重构中从 http 改名成了 web。命名端口在 Pod 中解析不到时,EndpointSlice 里该端点的端口会是空的,kube-proxy 不会为它生成任何转发规则,于是流量到了节点上无路可走,入口控制器返回 502。这个过程里,Pod 是健康的、Service 是存在的、Endpoint 列表看起来也不是空的——三层证据都在骗你。

修复:把 targetPort 改回与容器端口一致的命名,或者直接用端口号。同时把“EndpointSlice 里端点端口为空”列为无 Endpoint 类问题的必检判据,因为它比“端点列表是否为空”更能说明问题。

复测:kubectl -n demo get endpointslice -l kubernetes.io/service-name=web -o yaml 中端点端口应恢复为实际端口,再通过 Gateway 访问应返回 200。

第三条,跨 Node 的 Pod 互不通,同 Node 内互 ping 正常。

现象:同一节点上的两个 Pod 用 TCP 互访正常,跨节点就超时。

怀疑:先怀疑 CNI 的隧道或路由,其次怀疑 NetworkPolicy 的默认拒绝策略误杀。

检查:先从对象层拿到两个 Pod 的节点归属和 Pod 网段,再到节点层用容器运行时和路由表取证据。

kubectl get pod -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,NODE:.spec.nodeName,IP:.status.podIP --no-headers | grep -E '10\.2\.'
kubectl get nodes -o custom-columns=NAME:.metadata.name,PODCIDR:.spec.podCIDR --no-headers

证据(示例输出):

NAME      PODCIDR
node-a    10.2.1.0/24
node-b    10.2.1.0/24

两个节点的 podCIDR 完全重复。进一步到节点上看实际生效的 CNI 配置与路由,确认节点本地的网段分配与节点对象里声明的并不一致。

根因:node-b 是从旧机器重建出来的,重建时复用了旧节点的名称与 IP,但 CNI 的 IPAM 状态没有一并清理,导致新节点向 API Server 注册时拿到的网段与既有分配重叠。同节点通信走本地网桥不受影响,跨节点时对端路由指向了一个并不属于它的网段,包被发到了错误的下一跳。

错误尝试: 排查过程中有人提议先用 ping 判断通不通,理由是“网络问题 ping 一下最快”。这一步的问题在于,Pod 网络里 ICMP 是否放行取决于容器内的规则和 NetworkPolicy 的实现,ping 不通完全可能是策略层面的正常行为,而 ping 通也不代表 TCP 路径可用。用 ICMP 的连通性去代表四层连通性,会引入大量的假阴性,让人误以为网络层出了问题,从而跳过了 DNS、Service 和策略这几层。正确的做法是先用 kubectl exec 做目标端口的 TCP 连接测试,再决定往哪层下沉。

修复:清理 node-b 上残留的 CNI IPAM 状态,让它重新从节点对象声明的 podCIDR 分配地址,重建该节点上的工作负载。

复测:重新核对所有节点的 podCIDR 无重叠,跨节点 TCP 连接恢复正常。

快语莫轻信
指标非账本
一层一层剥
剥到不可剥

07、优化

三条排查链给出了三个根因,V2 严格基于这些根因来改。

第一个根因是判据口径混用:用实际用量去反驳调度决策。修改方式是给故障树的每个节点增加一个字段,明确判据的来源,只允许对象级的状态字段、Events 原文和节点对象字段作为判据,聚合监控指标不得作为推翻对象级结论的依据。这么改的原因是这两类数据在语义上不可互换,混用会让排查在第一步就分叉。新行为是采集脚本会把 describe 的 Events 段单独抽成一个文件,任何人做结论时都必须引用这个文件里的原文。

第二个根因是跳步:从现象直接跳到日志,跳过了同一个对象上的状态与事件。修改方式是把顺序从“建议”变成“约束”,采集脚本改成逐层检查,上一层的关键文件为空或采集失败时,在下沉前写入一条显式的缺失标记,而不是静默继续。原因是证据有寿命差,跳过寿命短的那层,等回过神就已经拿不到了。新行为是每一份证据包里都带一条采集轨迹,能看出这次排查走到了哪一层、哪一层缺失。

第三个根因是没有训练,经验只能靠个人碰到才长出来。修改方式是准备一份故障案例集,每一条记录三样东西:现象名、证据路径、根因与修复。项目里已有的 docs/35-troubleshooting-cases.md 就是承载这份案例集的文档位置,V2 的工作是把第 06 章这样的排查链按同一个格式补进去。原因是如果没有固定格式的案例,训练时会立刻退回到“听老同事讲故事”的模式,无法复现。新行为是任何人都能按现象名检索到历史路径,并按同一路径在测试集群里复现。

验证方案不能靠拍脑袋,我给的是可执行的检查项,而不是结论数字。第一,在测试集群里针对每一条案例人工制造对应现象,检查证据包中是否包含根因所在层的文件;第二,让两位没参与过该案例的同事按树各走一遍,比对两次的证据文件清单是否一致;第三,定期扫描故障树配置,检查是否存在现象名没有入口层、或者入口层不在五层之内的孤儿节点;第四,记录每次真实故障实际走了几层,用来发现哪些现象被归错了入口层。这些指标的具体阈值需要在真实运行一段时间后按当前官方文档和自身数据核验后再定。

改在证据后
不改在猜想前
复测同一路
才算真修完

08、演进

[同一输入]
    |
    +--[V1] 按症状查表 + 手工敲命令 / 代价:顺序靠自觉,判据口径混用
    |
    +--[V2] 分层证据包 + 判据来源白名单 + 案例训练 / 代价:初始耗时增加,树需要维护
    |
[Trade-off]
得到可复现与可交接 / 失去老手的跳跃式速度 / 适用边界是多人轮值、故障类型稳定的集群

正确性上,V1 和 V2 的差别不在命令本身,而在证据是否完整。V1 的正确性依赖于执行者当天记得什么,V2 的正确性依赖于五层是否都被采集,即使人判断错了,证据还在,第二个接手的人可以推翻前一个结论。

稳定性上,V1 的方差很大,同一个人在疲惫状态下会跳步,V2 用脚本的固定顺序把方差压平了一部分,代价是脚本本身成了新的故障点——它坏了,链路就断了。所以脚本必须足够简单,只用 kubectl,不引入额外依赖。

复杂度上,V1 的复杂度在人的脑子里,V2 的复杂度在配置文件和案例集里。从组织角度看这是好事,复杂度从不可管理的形态变成了可评审、可版本化的形态。

成本上,V2 有三个持续成本:树要跟着 Kubernetes 版本和 CNI 实现更新,案例集要有人维护,新人要花时间学这套顺序而不是直接上手。适用范围上,这套方式适合多人轮值、故障种类相对稳定的集群;对单人维护的小集群,跳跃式排查的效率更高。

遗留问题有三个没有解决:一是跨节点的网络与存储证据目前还要登录节点采集,没有自动化;二是证据包是事后快照,无法还原故障发生前一分钟的连续状态;三是树本身没有校验机制,会不会随着时间腐烂,只能靠人工巡检。

旧路求速度
新路求可复
得之在交接
失之在灵活

09、洞见

9.1 证据分层优先于根因猜测

反直觉判断:先分层、再猜测的顺序,比先有假设、再找证据更快,即使前者看起来多走了几步。原因在于人的假设会锚定后续观察,一旦锚定在错误的层上,后面看到的每条证据都会被解释成支持假设的样子。分层的作用不是限制思考,是给思考设定一个必须逐个通过的关卡。

9.2 事件是缓存,不是账本

反直觉判断:kubectl describe 和 kubectl get events 看起来最像“系统日志”,实际上它们是寿命最短的一层证据,默认只保留一小时。日志看起来更像一手材料,反而会跟着容器生命周期被重建覆盖。所以这两者的先后顺序,应该由数据的存活时间决定,而不是由它们看起来像不像权威记录决定。

9.3 用聚合指标推翻对象级结论是典型的越界

反直觉判断:节点很闲不代表能调度,这一点在监控面板上永远不会显示出来,因为面板展示的是结果态,调度依赖的是声明态。凡是拿结果态去否定声明态的判断,都先要问一句:这两个数字是不是在描述同一件事。

9.4 固定树是压缩,不是穷举

反直觉判断:二十类症状不需要二十条独立路径,它们只需要被压缩到五个层。压缩的价值在于,新人记住的是五层,而不是二十个现象;遇到没见过的新故障时,他仍然有地方下手。真正会失效的不是树的覆盖度,是维护树的人忘了它是一份需要持续修订的配置,而不是一次写完的文档。

树是压缩器
不是百科全书
穷举必失控
收敛才有用

10、系统落地

原来我们有什么:一份按组件组织的 runbook、一堆散在各人脑子里的排查直觉、一个每天被截图淹没的值班群。原来能做什么:单点问题的处理还算快,一旦涉及两个以上组件的交界处,速度就断崖式下降。

本篇新增了三样东西。第一是一份声明式的故障树配置,把现象名映射到入口层和必读判据,第 05 章的 fault-tree.yaml 是它的最小形态。第二是一个固定顺序的证据采集器,把五层证据落成带层号前缀的独立文件,并打包成本一致。第三是一套根因结论必须回指证据文件的要求,它把复盘从讲故事变成了对账。

现在能做什么:任意值班同学拿到告警,第一步不再是凭直觉敲命令,而是先确定现象名、再确定入口层;两位同学独立排查同一条故障,得到的证据清单应当一致;每条根因结论都能指回具体文件。这套能力的边界也很清楚,它给的是证据的完整性,不是判断的正确性——判断仍然由人做,但可被第二个人复核。

还缺什么:缺少测试集群上的故障注入能力,案例集目前只能靠真实故障累积;缺少对节点层证据的自动化采集,跨节点网络与存储问题仍然需要登录节点;缺少对树的定期自检。

下一步的演进方向有三个,按优先级排:先给故障树加自检,扫描孤儿节点和过期判据;再把节点层的 CNI、路由、挂载点采集做进脚本,让跨节点问题的证据也不用登录机器;最后做故障注入,把二十类现象做成可重复执行的注入脚本,用盲测来验证这棵树是否真的能被没参与过的人走通。

原有散兵勇
新增一条线
线成可交接
再谈自动化

11、小结

Q1 → 先看离对象最近、寿命最短的那层证据,对象层的状态与事件永远排第一
Q2 → 按证据的层级看,从对象层逐层下沉,不按组件、不按直觉跳步
Q3 → 站错层时,通常要等到下一个人复核证据包才会发现,所以固定顺序本身就是纠错机制
状态 → 一套可跑的故障树配置 + 分层证据采集器,能产出结构一致的证据包并支撑端到端复测

问在层上问
答在证上答
状态可交付
余下是训练

12、作业

12.1 理解题:为什么调度判定和实际使用率会给出相反的结论,而两者都不是错的?

参考答案:两者描述的是不同空间里的量。调度器做决策时比较的是 Pod 的 requests 与节点 allocatable 扣除已分配 requests 之后的余量,这是声明态;监控展示的是进程实际消耗,这是结果态。一个节点完全可能实际很闲但声明配额已满,也可能实际很忙但配额有余。用结果态去否定声明态的判定,属于跨语义比较,必然出错。

12.2 实战题:为 ImagePullBackOff 补一条故障树节点,要求写清入口层、必读判据和停止条件。

参考答案:入口层是容器层。必读判据包括 Pod 的 Events 原文(区分 ErrImagePull 与 manifest unknown 之类的具体错误)、容器镜像字段的完整地址(含仓库域名与 tag)、Pod 上的 imagePullSecrets、以及节点到镜像仓库的网络可达性。停止条件是:Events 中已出现明确的镜像拉取错误,且镜像地址与凭据配置已逐一核对。如果镜像名和凭据都正确,则应下沉到节点层,检查节点上的容器运行时配置与节点到仓库的链路。

12.3 排障题:一个 Service 的 EndpointSlice 里端点是存在的,但通过入口访问返回 502,你会按什么顺序排除?

参考答案:第一,检查 EndpointSlice 中端点的端口字段是否为空,这是最常见的隐性故障——命名端口在 Pod 中解析不到时端点仍然存在,但没有可用的转发端口。第二,如果端口正常,检查 Pod 的 Ready 条件与探针配置,探针通过才是被计入可用端点的前提。第三,检查 Service 的 targetPort 与容器端口的对应关系。第四,如果以上都正常,下沉到节点层检查 kube-proxy 或等价组件的转发规则是否与 EndpointSlice 一致。全程以对象层证据为准,不要用对 Pod IP 的直接访问结果代替 Service 链路的验证。

12.4 架构判断题:这套固定故障树是否应该直接扩展成自动修复系统?

参考答案:不应该,至少现在不应该。当前这套设计的定位是把证据收敛到人能下判断的粒度,它输出的是一份结构化的证据包,而不是一个确定性的修复决策。自动修复的前提是“现象到动作”的映射是充分确定的,而现实中同一条现象背后的根因分布很广,比如 Pod Pending 可能是配额、可能是 taint、可能是卷未绑定。在没有足够的案例统计支撑判断之前,把修复动作自动化,等于把误判的代价从“多花二十分钟”放大成“批量误操作”。更合理的路径是先把案例集做厚、把证据采集做全,让自动化的适用范围从数据里长出来,而不是从设计里假设出来。

题不必求难
求能复现路
手过一遍后
记忆才成树

13、思考

回到最初那个冲突:故障种类是有限的,排查路径却是每个人的个人记忆。这两件事本来就不该用同一种东西去承载。现象是有限的,可以枚举;路径必须是固定的,可以写下来;记忆是不可靠的,只适合用来做判断,不适合用来做流程。

那次凌晨的误判,真正的代价不是多花了二十分钟,是让我看清了一个事实:在一个人身上,经验是可以积累的;在一个团队里,经验如果不被固化成路径,就会被稀释。值班表上换一个人,整条链路就重新走一遍。

固定树的价值不在于它覆盖了多少种故障,而在于它把“下一步该看什么”这个问题从个人判断变成了共同约定。至于判断本身,永远还是人的事——但至少从今往后,人和人可以对着同一份证据说话。

现象千般变
层级不曾变
循层取证去
何惧故障多

posted @ 2026-10-12 09:27  李福春  阅读(2)  评论(0)    收藏  举报