未设 Limits 的致命代价:Prometheus 节点崩塌与 StatefulSet 29 分钟停机全景复盘

20 阅读12分钟

在分布式系统的可观测性体系中,Prometheus 一向被视作整个集群的“守望之眼”。但你是否想过:当这个守望者自身发生失控时,它不仅会闭上双眼,甚至能以一己之力击垮宿主机,并将整个集群拖入长达半小时的盲区黑洞?

在某公有云生产 Kubernetes 集群(AKS)中,我们经历了一起极具代表性的高可用事故:

  • 事故现象:承载核心监控组件的专用节点池突发内存 100% 满载,宿主机直接冻结假死,Kubelet 心跳中断;
  • 业务影响:Prometheus、Grafana 与 Alertmanager 全体瘫痪整整 29 分钟,集群陷入零指标、零告警状态;
  • 最大谜团:无状态应用(Deployment)几秒内即可在备用节点拉起,为何监控三件套却悬挂在 Terminating 状态长达近半小时,直到云平台底层的虚拟机被强行注销才得以恢复?

深入排查后,我们发现这场停机并非简单的“内存不足”,而是一场由配置疏漏、内核机制、Kubernetes 调度算法与云平台双自动化竞态共同交织的完美风暴。


一、 致命的四重连锁:未配资源声明如何蒙蔽调度器与内核?

这场事故的导火索,仅仅是 Helm Chart 中遗漏了一行配置:Prometheus 容器未声明任何 resources.requests 与 resources.limits。

在许多开发者的认知里,“不配 Limits 只是让容器自由使用内存,避免被频繁 OOMKill”。但在生产环境中,无声明运行的高负载容器直接在四个控制层面埋下了毁灭性的盲区:

image.png

1. 调度器失明(Scheduler Blindspot)

Kubernetes 调度器在为 Pod 寻找节点时,计算的是已分配 Request 的累加和,而不是宿主机实时的物理内存使用量。

  • 当 Prometheus 已经在节点上消耗了 11.6 GB 物理内存(宿主机总可用配额 14.5 GB,实际已用 85%)时,因为未配 requests,调度器账面上计入它的内存占用恒为 0。
  • 调度器判定该节点分配率仅为 29%(4.3 GB),依然非常空闲。
  • 结果:调度器毫不犹豫地将集群内 35 个临时证书签发 Pod(cm-acme-http-solver)全部塞进了该节点,直接将节点的 Pod 数量打满到了 50/50(maxPods 物理上限)。

2. 弹性伸缩器失明(Cluster Autoscaler Blindspot)

云原生集群的自动扩容(Cluster Autoscaler)依赖的关键信号是是否存在因资源不足而处于 Pending 状态的“无法调度 Pod”(Unschedulable Pods)。

  • 既然调度器认为该节点余量充沛,没有任何 Pod 被挂起,那么在整个故障孕育期内,云平台的 cluster_autoscaler_unschedulable_pods_count 指标恒等于 0。
  • Autoscaler 完全感知不到这台承载监控核心的节点早已不堪重负,错失了提前弹出一台新节点进行分流转移的黄金自救窗口。

3. 爆炸半径失控(Blast Radius Escalation)

容器技术的核心是利用 Linux cgroup 实现资源配额隔离。

  • 如果配置了 limits.memory,当容器内存超标时,内核 cgroup 会精准向该容器的主进程发送 SIGKILL(即产生可预期的 OOMKilled),仅重启 Prometheus 容器,整机依然稳如泰山。
  • 但由于没有 Limit,容器冲破了一切软硬件边界,将宿主机物理内存彻底吃空到 100%。Linux 内核的全局内存分配失败,直接诱发整机假死(Kernel Freeze / Lockup),本应局限在单容器的故障,被瞬间放大成了整台宿主机的物理瘫痪。

4. Kubelet 驱逐机制猝死(Eviction Impotence)

Kubelet 本身具备节点级驱逐保护机制(Node Eviction),会在内存紧张时将节点状态置为 MemoryPressure=True 并开始淘汰非关键 Pod。

  • 然而在本次事件中,内存从 85% 跃升到 100% 发生得极快,直接在一个监控采集周期内穿透。
  • 宿主机内核瞬间挂起,Kubelet 根本来不及执行任何驱逐策略或更新 Node 条件,甚至连最后的心跳都没能发出,便随着系统彻底失联。

二、 29 分钟停机之谜:StatefulSet 的“单活枷锁”与双自动化竞态

事故发生后,负责协调控制面的 prometheus-operator(Deployment 部署)在数秒内就被 ReplicaSet 在其它节点重新拉起。

但为什么核心的 Prometheus、Grafana 与 Alertmanager 却在深渊中挣扎了整整 29 分钟?

image.png

1. StatefulSet 的 At-Most-One(至多存在一个)单活语义

与无状态的 Deployment 不同,Prometheus 和监控核心由于挂载了本地持久化存储(PVC),采用了 StatefulSet 控制器。

为了绝对防止多个相同编号的实例同时读写同一份底层数据导致存储损坏,Kubernetes 为 StatefulSet 设计了严苛的 At-Most-One 语义:

  • 10:37,节点判定为 NotReady;5 分钟容忍时间过后(10:42),控制面的 Taint Manager 开始将节点上的 Pod 标记为驱逐,Pod 状态变为 Terminating;
  • 死锁形成:Pod 要想真正完成终止并从集群 API 中注销,必须等待该节点上的 Kubelet 返回“容器已成功停止”的确认回执;
  • 但此时该节点的操作系统已彻底死锁,Kubelet 早已不复存在。
  • StatefulSet 控制器死守单活铁律:只要旧的 Pod 对象没有在 etcd 中彻底消失,新 Pod 就绝对不允许被调度创建! 三大监控核心全部卡死在 0/1 就绪状态。

2. 云平台两个自动化的“左右互搏”

既然 Kubelet 无法回复,死锁是如何被打破的?这就牵涉到公有云底层两个互不知情的自动化组件之间的戏剧性竞态:

  • 自动化 A(节点自动修复 Node Auto-Repair):10:44,云平台的节点健康探针检测到该节点处于 NotReady 超过 5 分钟,自动触发了原地的 NodeRebootStart(试图重启虚拟机救活);
  • 自动化 B(集群弹性伸缩 Cluster Autoscaler):同在 10:44,Autoscaler 看到该节点所有非 DaemonSet Pod 都已被标记驱逐,将其识别为“空节点”(Empty Node),同时加上了 scaleDownUnreadyTime: 20m 的倒计时;
  • 戏剧性竞态(11:05 碰撞):11:05 分,Node Auto-Repair 的虚拟机重启流程终于跑完(NodeRebootEnd);然而就在这同一分钟,Autoscaler 的 20 分钟等待超时触发,下发了 ScaleDownEmpty 指令,直接在云平台底层将这台刚重启好的虚拟机物理销毁并移出 VMSS(虚拟机规模集)!

直到 11:10,Kubernetes 控制面的 cloud-node-controller 发现该 Node 在云平台底层已经不存在,才正式将 Node 对象从集群中移除(RemovingNode)。

节点对象消失后,悬挂了 28 分钟的 Pod 终于被级联清理,StatefulSet 控制器解除封印,在空闲节点拉起全新实例,集群监控全景才在 11:13 重新点亮。


三、 基数剖析:214 万时间序列是如何吃掉 12.5 GB 内存的?

很多团队在遭遇 Prometheus OOM 时,第一反应往往是“是不是有内存泄漏?”。但实际度量证明,Prometheus 的内存占用是完全合法的,它完全符合 TSDB 时序基数的数学模型。

image.png

1. 内存经验法则:每活跃时序 5 KB

Prometheus 官方与工程实践中有一个通用的基数内存估算公式:

内存估算公式:Head RAM ≈ 活跃时间序列数 × 5 KB

排查中我们调取了崩溃前夕 Prometheus 的内部度量:

  • 活跃 Head Series(prometheus_tsdb_head_series):2,136,671 条;
  • 头部数据块(prometheus_tsdb_head_chunks):5,911,492 个;
  • 指标名称总数:10,367 个。

按照经验公式代入计算:

2,136,671 × 5 KB ≈ 10.68 GB

这与我们实测的稳定常驻内存 11.6 GB 几乎完全吻合。而在每 2 小时触发一次的 TSDB 内存块向磁盘刷写压缩(Compaction)过程中,内存峰值会自然冲高至 12.5 GB。

节点配置为 Standard_D4s_v4(16 GB 物理内存,Kubernetes 实际可用 Allocatable 仅 14.5 GB)。Prometheus 单个容器就吞食了整机 86% 以上的可用空间,系统没有任何喘息的缓冲垫。

2. 基数爆炸的罪魁祸首:两大直方图吞食 71% 空间

进一步下钻基数发现,在全量 214 万条时间序列中,有 153 万条(占比高达 71.6%)仅仅来自两个业务源:

  • 业务消息推送服务(Push Worker):贡献 87.3 万条(41%)。

    • 根因:在 notification_delivery_latency_seconds_bucket 和 batch_size_bucket 等直方图中,开发人员打上了高基数动态标签(如包含数千种变化的业务模板标识 template_code),导致直方图 Bucket 与维度笛卡尔积爆炸。
  • Ingress-Nginx Controller:贡献 65.6 万条(31%)。

    • 根因:默认开启了全量路径与 Host 粒度的耗时直方图,6 个 Bucket 指标在海量外部路由碰撞下迅速膨胀。

3. 根治之道:Drop Relabeling 无缝瘦身

要消除 Prometheus 的内存压力,并不一定要盲目采购更大规格的宿主机。通过在 ServiceMonitor 或 Scrape 配置中添加 metric_relabel_configs,直接在拉取时将无意义的高维桶丢弃:

metricRelabelings:
  - sourceLabels: [__name__]
    regex: "(notification_delivery_latency_seconds_bucket|nginx_ingress_controller_.*_bucket)"
    action: drop
  • 预期效果:活跃时间序列将从 214 万 骤降至 61 万(下降 71%);
  • 内存收益:Prometheus 稳态内存将从 12.5 GB 直接压降至 ~3.6 GB,原 16 GB 节点即可轻松承载,无需升级节点规格。

四、 监控自绝时的救命稻草:云平台宿主机指标

这起故障给 SRE 团队带来的最大警示,是在极端断网与监控崩溃场景下的可观测性断层。

当集群内的 Prometheus、Grafana 全部停机,且控制面日志尚未外发到集中审计日志时,工程师几乎处于“全盲”状态。宿主机由于被云弹性伸缩器快速销毁,dmesg 与本地 kubelet.log 也随之彻底湮灭。

最终挽救排障视线的,是公有云平台直接暴露在托管集群资源上的宿主机物理度量(如 Azure Monitor Host Metrics / AWS CloudWatch Host Metrics)。

这些指标运行在虚拟机外部的管理平面,不依赖集群内任何 Agent 或 DaemonSet 的健康状态。通过云 CLI 抓取的节点最大内存使用率,成为了锁定 100% 内存打满与宿主机假死的关键证据:

# 跨过集群内部,直接从云监控 API 提取节点硬件历史指标
az monitor metrics list --resource "$CLUSTER_RESOURCE_ID" \
  --metric node_memory_working_set_percentage \
  --filter "node eq 'aks-systempool-vmss00000d'" \
  --start-time 2026-09-11T09:00:00Z --end-time 2026-09-11T11:05:00Z \
  --interval PT5M --aggregation Maximum

架构建议: 绝不要把“监控监控系统的责任”完全托付给监控系统自身。在外部监控面板中(或独立的轻量级探针中),引入云厂商原生的宿主机与节点状态指标,是防止监控全黑的最后一道防线。


五、 规约与避坑指南:给生产环境的四条硬核建议

1. 资源配额铁律:CPU 严禁配 Limit,内存必须配 Limit

在 Kubernetes 生产治理中,推荐坚守以下配额准则:

  • CPU:只配置 Request,绝对不要配置 Limit。配置 CPU Limit 会触发 Linux 内核 CFS 配额节流(CFS Throttling),即使 CPU 整体利用率不足 30%,也会因为突发请求打满周期配额而导致应用响应严重毛刺;
  • Memory:必须同时声明 Request 与 Limit。
    • Request:根据稳态基数按 5 KB / Series 计算,满足调度预留;
    • Limit:至少保留 Compaction 峰值的 1.2 ~ 1.5 倍冗余。Limit 的核心价值不仅是保护容器,更是通过主动牺牲单 Pod,保全整台物理节点。

2. Helm Chart 隐藏巨坑:错误的 Values 键名会静默失效

在修复 Prometheus Operator 的 Admission Webhook 资源时,团队曾尝试在 values 文件中定义:

# ❌ 错误配置:kube-prometheus-stack 61.3.2 存在该 key 但无 resources 字段,渲染静默忽略
prometheusOperator:
  admissionWebhooks:
    createSecretJob:
      resources: ...
    patchWebhookJob:
      resources: ...

上述配置在 Helm 中不会抛出任何报错,但渲染出的 YAML 完全不包含任何资源定义!正确的驱动键名是统一的 patch.resources:

# ✅ 正确配置:统管两个 admission 任务
prometheusOperator:
  admissionWebhooks:
    patch:
      resources:
        requests:
          cpu: 50m
          memory: 64Mi
        limits:
          memory: 128Mi

实操教训:永远不要仅通过肉眼检查 Helm values,任何生产变更上线前,必须执行 helm template 审查真实生成的 Kubernetes 资源清单。

3. 破除单副本 StatefulSet 的停机诅咒

对于承载核心观测能力的 StatefulSet,如果业务无法承受节点宕机时 20+ 分钟的被动等待:

  • 方案 A(多副本高可用):将 Prometheus 部署为双副本(replicas: 2),并在外部通过 Thanos Query 或 VictoriaMetrics 进行去重合并;
  • 方案 B(调优缩容容忍窗口):评估降低 Cluster Autoscaler 的 scaleDownUnreadyTime(默认 20 分钟),加速底层坏节点的回收注销。

4. 专用节点池的容量规划:按峰值规划,而非按均值

为系统关键组件分配 Dedicated 节点池时,容量计算必须覆盖周期性 Compaction 峰值 + 集群 DaemonSets 累加开销。

当发现节点 Allocatable 仅剩 14.5 GB,而单个核心负载峰值已达 12.5 GB 时,系统已经处于随时可能失稳的走钢丝状态。提早推进机型热轮换,才是守护系统底线的唯一长效解法。