深入 K8S 控制平面陷阱排查:海量 CRD List/Watch 引发的 etcd WAL 阻塞与 API Server 延迟雪崩实战

结论先行:当 K8s 集群控制平面发生雪崩时,根因通常不是 API Server 算力不足,而是写得极烂的 Controller 发起了海量无 ResourceVersion 的全量 List 请求,直接击穿 API Server 缓存。这会导致 etcd WAL fsync 延迟突增(>10ms),进而引发全集群组件(Scheduler、Kubelet)上下文超时。核心解法:强制分页(Limit/Continue)、开启 WatchCache、分离 etcd 事件集群,以及利用 ionice 保障 etcd 磁盘 IO 优先级。

故障现场:504 Gateway Timeout 与 P99 延迟飙升

排查过程中,某套基于 K8s v1.27.3 和 etcd v3.5.9 的核心生产集群频繁出现 Pod 无法调度、状态更新停滞的问题。通过 kubectl 执行简单的 get pods 命令,响应时间偶尔超过 10 秒,甚至直接返回 504 Gateway Timeout。

查看监控大盘,发现三个致命指标异动:

  1. apiserver_request_duration_seconds 的 P99 延迟从平时的 50ms 飙升至 15s 以上。

  2. etcd 的 etcd_disk_wal_fsync_duration_seconds P99 突破 100ms(正常必须控制在 10ms 以内)。

  3. kube-controller-manager 日志被大量 context deadline exceeded 淹没。

登录 etcd 节点,执行压测与状态检查:

# 检查集群健康状态及延迟
ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/healthcheck-client.crt \
  --key=/etc/kubernetes/pki/etcd/healthcheck-client.key \
  endpoint status -w table

# 使用 iostat 确认磁盘瓶颈
iostat -dx 1 5

iostat 显示 etcd 所在的数据盘 %util 长时间处于 100%,await 高达 120ms。磁盘 IOPS 显然被耗尽了。但这块盘是企业级 NVMe SSD,单盘随机写标称在 50k IOPS 以上,日常 K8s 的元数据变更不可能打满。必然有异常查询放大了底层读写。

追踪溯源:审计日志里的“内鬼”

为了抓出打爆 API Server 的元凶,不能靠猜,直接上 PromQL 分析 API Server 的请求速率:

topk(5, sum(rate(apiserver_request_total{code=~"2.."}[2m])) by (resource, verb, user))

结果显示,某个内部自研的 GitOps Controller 正在以每秒 500 次的频率,对集群中一个包含 10 万条记录的 CRD(Custom Resource Definition)发起全量 LIST 请求。

查看 API Server 审计日志(Audit Log):

{
  "kind": "Event",
  "apiVersion": "audit.k8s.io/v1",
  "level": "Metadata",
  "verb": "list",
  "requestURI": "/apis/custom.company.com/v1/massivecrds",
  "userAgent": "gitops-controller/v1.0.0",
  "user": {"username": "system:serviceaccount:gitops:gitops-sa"}
}

注意这里的 requestURI,没有带任何 resourceVersion 或 limit 参数。

为什么无限制的 List 查询会击穿 etcd 集群?

这里必须搞清楚 API Server 的缓存机制(Cacher)与 etcd Quorum Read 的关系。

当客户端调用 K8s API 进行 LIST 操作时,行为取决于请求参数中的 resourceVersion:

  1. resourceVersion="0":API Server 会直接从内存中的 WatchCache 返回数据,对 etcd 零压力。

  2. resourceVersion="<特定版本号>":API Server 也会尽量从 WatchCache 满足,如果 Cache 太旧,会等待更新。

  3. 不传 resourceVersion(或传 “”):这是最致命的。API Server 被要求提供强一致性数据,它会绕过本地 Cache,直接向 etcd 发起 Quorum Read 请求。

在这个场景中,Controller 每次全量获取 10 万个 CRD 对象,不仅在 API Server 中产生巨大的 JSON/Protobuf 序列化开销(直接导致 API Server 内存暴涨,触发 GC 停顿),更直接将数百 MB 的读取压力穿透到 etcd。 etcd 是基于 Raft 和 bbolt 的,底层是 B+ 树。海量的大事务读取锁定了树的读写,导致 etcd 处理正常的 WAL (Write-Ahead Log) fsync 被阻塞。WAL 落盘一旦超过心跳超时时间(默认 100ms),Raft 就会认为 Leader 发生故障,触发 Leader 选举,整个控制平面瞬间瘫痪。

防御性调优:从 API Server 到 Controller 的加固

面对这种烂代码,运维架构师不能指望研发自觉,必须在基础设施层实施防御性配置。

1. 开启并调优 API Server WatchCache

确保 kube-apiserver.yaml 中启用了缓存,并为特定大体积资源分配足够的 Cache 大小(K8s 默认会根据可用内存自适应分配,但可以强制干预):

# /etc/kubernetes/manifests/kube-apiserver.yaml
spec:
  containers:
  - command:
    - kube-apiserver
    - --watch-cache=true
    - --watch-cache-sizes=custom.company.com#v1#massivecrds=50000
    - --default-watch-cache-size=1500

2. 强制使用 Informer 机制与分页 (Pagination)

将 GitOps Controller 的代码打回重做。禁止在 Reconcile 逻辑中直接调用原生 Client.List(),必须使用 client-go 提供的 SharedIndexInformer。Informer 启动时,只在全量同步时发起一次带 limit 和 continue 的分页请求,后续完全依赖 Watch 增量更新。

修正后的请求在日志中应该长这样(带有 limit 和 continue 游标):

/apis/custom.company.com/v1/massivecrds?limit=500
/apis/custom.company.com/v1/massivecrds?continue=eyJ2...&limit=500

3. etcd 的磁盘 IO 与内存碎片防御

针对 etcd 节点,为了防止非关键进程抢占 IO,使用 ionice 将 etcd 的 IO 优先级提至最高:

# 在 systemd unit 文件中添加
[Service]
IOSchedulingClass=realtime
IOSchedulingPriority=2

同时,修改 etcd 启动参数,缩短历史版本压缩周期,防止 MVCC 历史版本撑爆 bboltdb(默认配额 2G)。

# etcd.service
--auto-compaction-retention=1
--quota-backend-bytes=8589934592 # 提升至 8GB

并设置 CronJob 每天低峰期执行碎片整理(Defrag):

etcdctl defrag --cluster

常见问题 (FAQ)

Q1: etcd 报错 mvcc: database space exceeded,但实际数据只有几百兆,为什么? A1: 这是由于 etcd 的 MVCC 机制导致大量修改/删除产生的旧版本数据没有被压缩释放。bboltdb 的空间不会自动缩容。处理步骤:1. 确认已开启 --auto-compaction-retention;2. 立刻通过 etcdctl compact 压缩历史版本;3. 执行 etcdctl defrag 整理碎片,释放磁盘空间。

Q2: 为什么 etcd 的监控指标 wal_fsync_duration_seconds 很低,但 K8s Scheduler 调度 Pod 依然极慢(Binding 超时)? A2: 极可能是 API Server 实例与 Scheduler 所在的节点之间的网络延迟,或者 API Server 本身的 CPU 被限流(Throttling)。另外,检查 Scheduler 的 APIServer QPS 配置(默认 --kube-api-qps=50),如果并发调度的 Pod 规模上千,必须在 Scheduler 配置中将 QPS 提升至 300,Burst 提升至 500。

Q3: 如何快速定位是哪个 Pod/客户端在向 API Server 发送恶意的高频请求? A3: 最直接的做法是使用 K8s 的 API Priority and Fairness (APF) 机制查看拒绝日志,或者通过 Prometheus 聚合指标定位: sum(rate(apiserver_request_total[5m])) by (client, user, verb, resource) > 10 通过抓取 client 和 user 标识,就能精准定位到具体的 ServiceAccount,进而查到失控的 Controller Pod。