下午两点半,正是业务流量相对平稳的时段。我正在梳理下个季度的多集群架构方案,监控系统的告警电话直接打破了这种平静。
告警显示:K8S 控制平面出现大面积 504 Gateway Timeout,紧接着,三个 Master 节点上的 kube-apiserver 容器接连发生 OOMKilled。同时,etcd 集群的 CPU 使用率飙升至 600%,Read 延迟从平时的 2-3 毫秒直接打到了 15 秒以上。
控制平面雪崩了。
现场排查:谁在谋杀 API Server?
遇到 etcd 延迟飙升,第一直觉通常是底层的磁盘 IO 出了问题。我立即查看了 etcd 的磁盘同步指标:
histogram_quantile(0.99, rate(etcd_disk_wal_fsync_duration_seconds_bucket[5m]))
指标显示 P99 依然稳定在 10ms 以内,磁盘完全没问题。接着看写入 QPS,etcd_server_proposals_committed_total 并没有明显突增。这说明不是写风暴,而是一场读风暴。
我切到 API Server 的 Prometheus 监控面板,查询请求速率:
sum(rate(apiserver_request_total{code=~"2.."}[2m])) by (resource, verb)
图表上的数据令人发指:对 pods 和 nodes 的 LIST 请求速率达到了每秒 300 多次。
进一步拉取 Audit Log(审计日志),抓取了其中一个导致延迟极高的请求:
{
"stage": "ResponseComplete",
"requestURI": "/api/v1/pods",
"verb": "list",
"user": {
"username": "system:serviceaccount:data-ops:node-cost-agent",
"groups": ["system:serviceaccounts", "system:serviceaccounts:data-ops", "system:masters"]
},
"sourceIPs": ["10.244.15.22"],
"userAgent": "OpenAPI-Generator/1.0.0/python",
"responseStatus": { "code": 200 }
}
问题锁定了:数据团队在午休前刚发布了一个名为 node-cost-agent 的组件,用 Python 写的。我把那个容器的镜像拉下来,反编译看了一下他们的核心逻辑。代码大概长这样:
while True:
# 获取全集群的所有 Pod 和 Node
pods = v1.list_pod_for_all_namespaces()
nodes = v1.list_node()
calculate_cost(pods, nodes)
time.sleep(5)
看到这段代码的瞬间,我血压有点上来。在一个拥有近万个 Pod、上千个 Node 的生产集群里,用一个 while True 每 5 秒做一次无参数的全量 LIST。这种操作,等同于对 K8S 控制平面发起了一次精准的 DDoS 攻击。
底层逻辑:为什么一个全量 LIST 会导致 OOM?
很多刚接触 K8S 客户端开发的人不理解,不就是查个数据吗?API Server 内存为什么会爆?这背后暴露的是对 K8S 存储与缓存机制的严重认知缺失。
在 K8S 中,LIST 操作的开销是极大的,核心在于 ResourceVersion 这个参数的语义。
当客户端发起 LIST 请求时,如果没有显式指定 resourceVersion(即 ResourceVersion=""),API Server 会怎么处理?
根据 K8S storage.go 的底层逻辑,ResourceVersion="" 意味着客户端要求强一致性读(Quorum Read)。
-
击穿缓存:API Server 内部维护了一个 Watch Cache(
cacher),原本是用来缓解 etcd 压力的。但强一致性读会直接绕过这个缓存,把请求透传给底层的 etcd 集群。 -
etcd 序列化开销:etcd 收到请求后,需要从 BoltDB 中遍历拉取几万条 Key-Value,并将其打包成 Protobuf 格式发给 API Server。这就是为什么 etcd 的 CPU 和 Read 延迟会瞬间飙升。
-
API Server 的内存爆炸:这是最致命的一环。API Server 从 etcd 拿到庞大的 Protobuf 数据后,需要先反序列化为内部版本(Internal Version),然后转换成客户端请求的外部版本(如
v1),最后因为这个 Python 客户端请求的是 JSON 格式,API Server 还需要进行庞大的 JSON 序列化。
在 Go 语言中,处理这种几十 MB 甚至上百 MB 结构体的深拷贝和 JSON 序列化,会产生海量的临时对象。几百个并发请求瞬间涌入,垃圾回收器(GC)根本来不及清理,内存直接呈 90 度直线飙升,直到触发 cgroup 的 OOMKilled。
为什么 API Priority and Fairness (APF) 没有拦截?
正常情况下,K8S 的 APF(API 优先级与公平性)机制应该会限流这种恶意的请求。为什么 API Server 还是挂了?
回头看一眼那条审计日志,注意 user.groups 字段:
"groups": ["system:serviceaccounts", "system:serviceaccounts:data-ops", "system:masters"]
开发人员为了图省事,直接把这个 Agent 的 ServiceAccount 绑定了 cluster-admin 的 ClusterRole。
在 K8S 的 APF 默认配置中,属于 system:masters 组的请求会匹配到内置的 exempt FlowSchema:
apiVersion: flowcontrol.apiserver.k8s.io/v1beta2
kind: FlowSchema
metadata:
name: exempt
spec:
matchingPrecedence: 1
rules:
- subjects:
- group:
name: system:masters
priorityLevelConfiguration:
name: exempt
exempt 级别的优先级配置是不受任何并发限制的(不受限流器约束)。这就是为什么这些恶意的 LIST 请求能够畅通无阻地打满 API Server 的资源。为了图省事给的特权,最终成了压垮集群的最后一根稻草。
止血与技术结论
定位到问题后,处理就很简单了。直接 kubectl scale deploy node-cost-agent --replicas=0。断开连接后,API Server 的负载在两分钟内回落到正常水平,集群恢复。
事后我给相关团队发了事故复盘报告,并强制推行了控制平面的调用规范。在此提炼几个关于 K8S 客户端开发与 API Server 调优的硬核结论:
1. 坚决抛弃轮询 LIST,拥抱 Informer 机制
如果你需要持续获取集群状态,请使用 Client-go 提供的 SharedInformer。它在启动时只会做一次带分页的 LIST 请求,随后通过 WATCH 机制与 API Server 保持长连接,仅仅接收增量事件(Add/Update/Delete)。这不仅能在本地维持一个完整的状态缓存,还几乎不消耗 API Server 额外的 CPU 和内存。
2. 必须 LIST 时,合理利用缓存与分页 如果你受限于语言(比如必须用 Python/Java)且没有好用的 Informer 库,必须主动发起 LIST:
-
命中缓存:设置
ResourceVersion="0"。这会告诉 API Server:“我不需要强一致性,给我 Watch Cache 里的数据就行”。这能直接挡掉对 etcd 的强一致性查询。 -
开启分页(Chunking):设置
Limit参数(例如limit=500),配合返回结果中的Continuetoken 进行分批拉取。这极大降低了 API Server 单次序列化的内存峰值。
3. 严格遵循最小权限原则(RBAC)与 APF 隔离
永远不要在业务组件上挂载 cluster-admin 角色。这不仅是安全漏洞,更是稳定性的灾难。APF 只有在请求被正确分类到普通业务层级(如 workload-low 或自建的 FlowSchema)时,才能在集群雪崩前精准丢弃超载请求。
K8S 的控制平面很强大,但也极其脆弱。把单机时代的 while True 和全表扫描思维带进分布式调度系统的核心链路上,是对系统架构的不尊重。任何绕过缓存的强一致性全量拉取,都必须经过严格的架构审视。