分类: 云原生与容器化

  • 深入 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。

  • 深入 K8S CSI 存储拓扑:从 Pod 跨可用区调度死锁看 WaitForFirstConsumer 机制

    在跨可用区(Multi-AZ)部署有状态服务时,默认的 Immediate 存储绑定模式极易导致 Pod 调度死锁(存储卷在 AZ-A 被提前创建,而该区无可用计算资源,导致 Pod 卡在 Pending)。本文直接给出核心解法:生产环境的多可用区集群中,StorageClass 必须强制启用 volumeBindingMode: WaitForFirstConsumer 与 CSI 拓扑感知,这也是解决存储与计算资源错配的唯一正解。

    排查某次线上 Elasticsearch 集群扩容故障时,发现新建的 Pod 持续处于 Pending 状态。 通过 kubectl describe pod 查看 Events,输出了非常典型的报错:

    Warning  FailedScheduling  3m22s  default-scheduler  0/15 nodes are available: 3 node(s) had volume node affinity conflict, 12 node(s) didn't match Pod's node affinity/selector.
    

    集群环境是 K8S v1.28.2,底层使用 AWS EBS CSI Driver (v1.30.0)。报错明确指出了 volume node affinity conflict(存储卷节点亲和性冲突)。

    顺着线索排查 PVC 和 PV:

    # kubectl get pvc data-es-cluster-3
    NAME                STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
    data-es-cluster-3   Bound    pvc-8f9a2c1b-4d3e-4f5a-9b1c-2a3b4c5d6e7f   500Gi      RWO            gp3-default    15m
    
    # kubectl get pv pvc-8f9a2c1b-4d3e-4f5a-9b1c-2a3b4c5d6e7f -o yaml | grep -A 5 nodeAffinity
        nodeAffinity:
          required:
            nodeSelectorTerms:
            - matchExpressions:
              - key: topology.ebs.csi.aws.com/zone
                operator: In
                values:
                - ap-southeast-1a
    

    问题已经水落石出:PVC 绑定到了一个位于 ap-southeast-1a 可用区的 PV,但此时 ap-southeast-1a 的所有 Node 资源(CPU/Memory)已经耗尽。kube-scheduler 试图把 Pod 调度到有充裕资源的 ap-southeast-1b 或 1c,却被 PV 的拓扑亲和性(NodeAffinity)硬生生卡住,引发调度死锁。

    产生这种惨剧的根本原因,在于对应的 StorageClass 采用了 volumeBindingMode: Immediate。

    危险的 Immediate 模式

    在 Immediate 模式下,PVC 的生命周期独立于 Pod。流程如下:

    1. StatefulSet 控制器创建 PVC。

    2. CSI 的 external-provisioner 监听到 PVC 创建,立刻调用后端存储接口(如 AWS API)创建底层卷(EBS)。由于此时没有 Pod 的调度上下文,CSI Driver 只能基于 StorageClass 配置或随机选择一个 AZ 创建卷。

    3. 卷创建完毕,K8S 生成 PV 并与 PVC 绑定。PV 被打上了所在 AZ 的亲和性标签。

    4. kube-scheduler 开始调度 Pod,发现 PVC 已经死死绑定在了 AZ-A,只能强行往 AZ-A 调度。如果 AZ-A 计算资源不足,调度彻底失败。

    为什么 WaitForFirstConsumer 能终结跨可用区调度死锁?

    要打破这个僵局,必须把“先建卷再调度”改为“先计算调度,再按节点建卷”。这正是 WaitForFirstConsumer 机制存在的原因。

    修改 StorageClass 配置:

    apiVersion: storage.k8s.io/v1
    kind: StorageClass
    metadata:
      name: gp3-topology-aware
    provisioner: ebs.csi.aws.com
    volumeBindingMode: WaitForFirstConsumer # 核心配置
    allowedTopologies:
    - matchLabelExpressions:
      - key: topology.ebs.csi.aws.com/zone
        values:
        - ap-southeast-1a
        - ap-southeast-1b
        - ap-southeast-1c
    parameters:
      type: gp3
      fsType: ext4
    

    当模式切换为 WaitForFirstConsumer 时,底层的控制流发生了彻底的翻转:

    1. PVC 处于 Pending:StatefulSet 创建 PVC 后,external-provisioner 会忽略这个 PVC,不发起任何创建卷的动作,PVC 保持 Pending。

    2. 调度器介入 (VolumeBinding Plugin):Pod 进入调度队列。kube-scheduler 的 VolumeBinding 插件不仅会计算 CPU/Mem,还会评估各个 Node 的存储拓扑(Storage Topology)。

    3. 模拟绑定与预检:调度器假设将 Pod 放到 Node-B(位于 AZ-B,资源充足),并检查该 Node 是否满足 StorageClass 的拓扑限制。

    4. 注入 Annotation:确认无误后,调度器会在 PVC 上打上一个关键的注解 volume.kubernetes.io/selected-node: Node-B。

    5. 按图索骥 (Provisioning):此时 external-provisioner 监听到 PVC 上出现了 selected-node 注解,立即去查询 Node-B 对应的 CSINode 对象,提取其拓扑标签(topology.ebs.csi.aws.com/zone=ap-southeast-1b)。

    6. 精准建卷:CSI Driver 带着确切的 AZ 信息调用云厂商 API,在 AZ-B 精准创建出 EBS 卷。

    7. 大功告成:PV 创建,PVC 变为 Bound,Pod 被正式调度到 Node-B,挂载启动。

    整个过程,kube-scheduler 掌握了绝对的主动权,存储的创建被迫向计算资源的分布妥协,从根源上消灭了调度死锁。

    CSI 拓扑感知底座:CSINode 机制

    你可能会问:external-provisioner 怎么知道某个 Node 到底属于哪个存储拓扑域? 这就涉及 K8S CSI 架构中的 csi-node-driver-registrar Sidecar。在每个 Node 上运行的 CSI 节点组件,启动时会向 kubelet 注册自己,并上报节点级别的存储拓扑信息。K8S 会将这些信息持久化在 CSINode 资源中。

    我们可以直接抓取一个线上的 CSINode 对象来佐证:

    # kubectl get csinode ip-10-0-12-34.ap-southeast-1.compute.internal -o yaml
    apiVersion: storage.k8s.io/v1
    kind: CSINode
    metadata:
      name: ip-10-0-12-34.ap-southeast-1.compute.internal
    spec:
      drivers:
      - allocatable:
          count: 39
        name: ebs.csi.aws.com
        nodeID: i-0abcd1234efgh5678
        topologyKeys:
        - topology.ebs.csi.aws.com/zone
    

    这里的 topologyKeys 明确告知了上层组件,如果要往这台机器挂载 EBS 卷,必须参照 topology.ebs.csi.aws.com/zone 这个 Key 去匹配资源。

    常见问题 (FAQ)

    Q1:配置了 WaitForFirstConsumer,为什么集群扩容时 PVC 依然一直卡在 Pending? 这是生产环境常踩的坑。PVC Pending 说明调度器连第一步“找到合适的 Node”都没过去。这通常是因为:

    1. 集群内没有任何 Node 的 CPU/内存资源能满足 Pod 的 request。

    2. 调度器找到了有资源的 Node,但这台 Node 的拓扑标签与 StorageClass 中的 allowedTopologies 冲突。 检查手段:直接 kubectl describe pod ,看调度失败的 Event 是 Insufficient cpu 还是 volume node affinity。

    Q2:StatefulSet 的 Pod 跨可用区调度时,如果原有的 Node 挂了,Pod 漂移到其他 AZ 会发生什么? 会持续处于 Pending。因为 PV 一旦创建,其 NodeAffinity 就已经被写死(例如限定在 AZ-A)。Pod 虽然可以因故障漂移,但 kube-scheduler 评估时发现 AZ-B 的计算资源无法满足这个 PV 的 AZ-A 亲和性,拒绝调度。 对于块存储(如 EBS、阿里云云盘)这是物理限制;如果是分布式文件系统(NFS/CephFS),可以通过多 AZ 共享解决。块存储场景下的容灾,必须依赖应用层的高可用(如 ES/Kafka 的多副本同步),而不是指望 K8S 底层的云盘跨区漂移。

    Q3:Pod 一直处于 Terminating,kubelet 日志狂刷 Unmounted failed 或 Multi-Attach error,怎么处理? 这通常是 CSI 卸载流程卡死。K8S 的卸载顺序是:kubelet 发起 UnpublishVolume(解除宿主机挂载) -> Controller 发起 DetachVolume(解除云 API 绑定)。 遇到此类问题,绝对不要手贱强删 Pod(--force --grace-period=0),否则会导致云盘仍在 Node 上残留,最终耗尽机器的最大挂载数(allocatable count)。 正确排查步骤:

    1. 登录挂载 Node,执行 mount | grep 确认挂载点状态,必要时手动 umount -f 或清除挂死的僵尸进程(如 lsof | grep 找出的进程)。

    2. 查看对应的 csi-node DaemonSet Pod 的日志,确认是否调用底层 API 超时。

    Q4:本地存储(Local Persistent Volume)必须用 WaitForFirstConsumer 吗? 必须。本地盘与特定 Node 物理绑定,如果不延迟绑定,PVC 一旦被随机分配到某个节点的 Local PV 上,Pod 将永远被锁定在那台物理机上。一旦该机器无 CPU 资源,Pod 调度直接暴毙。所以在所有 Node-Local 存储(TopoLVM, OpenEBS LocalPV, 原生 Local Volume)的配置中,这是强制的铁律。