分类: 运维与架构

  • 深入 Prometheus 告警陷阱排查:静态阈值引发的 On-Call 疲劳雪崩与 Multi-Window 燃烧率 SLO 降噪实战

    抛弃所有基于 CPU、内存利用率的静态阈值告警。基础设施指标飙升不代表业务受损,海量无意义的 Warning 只会带来 On-Call 疲劳,最终导致真实的 P0 故障被淹没。本文直接给出基于 Prometheus (v2.45.0) 的多时间窗口(Multi-Window)Error Budget 燃烧率告警落地代码,将告警噪音降低 80% 以上,实现仅在用户体验真实受损时才触发 Page。

    为什么静态阈值与基础设施告警一定会摧毁 On-Call 体系?

    在近期的某次系统重构排查中,我看到业务线的 Prometheus 里堆满了这类告警规则:

    - alert: NodeCPUUsageHigh
      expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 85
      for: 3m
    

    这种告警逻辑在生产环境简直是一场灾难。Go Runtime GC、大促期间的瞬时突发流量、甚至是某个后台定时批处理任务,都会导致 CPU 短暂飙升。结果就是 On-Call 工程师在深夜被频繁唤醒,看一眼面板发现“已经恢复了”,默默点个 Ack 接着睡。 狼来了的故事上演三次后,墨菲定律就会生效: 当 CPU 飙升是因为死锁或真实的流量雪崩引起时,疲惫的工程师习惯性地认为“等 5 分钟自己就降下去了”,从而错失最佳止血时机。

    监控的本质是度量系统提供服务的质量,而不是度量系统的健康状态。用户根本不在乎你的 CPU 是 90% 还是 10%,他们在乎的是:请求有没有报错(Error Rate)?响应够不够快(Latency)?

    告别直觉:SLI 建模与 Error Budget 燃烧率(Burn Rate)

    真正的防御性告警设计,必须围绕 SLO(服务等级目标)展开。假设我们定了一个核心 API 的 SLO 为 99.9%(30 天为一个周期),这意味着我们在 30 天内允许的错误预算(Error Budget)为 0.1%。

    如果错误率恰好保持在 0.1%,那么预算将在 30 天后耗尽,此时的燃烧率(Burn Rate)就是 1。 如果错误率飙升到 1%,是允许值的 10 倍,燃烧率就是 10。预算将在 30天 / 10 = 3天 内耗尽。

    为了兼顾告警的灵敏度(快速发现大范围故障)与精确度(不被瞬时抖动误导),业界标准做法是引入 Multi-Window(多时间窗口)判断逻辑: 必须同时满足长窗口(评估故障规模)和短窗口(确认故障正在持续)的燃烧率阈值,才触发告警。

    Multi-Window 燃烧率告警在 Prometheus 的落地实战

    不要在告警规则里写几百个字符的复杂 PromQL,这会导致 Prometheus 每次评估告警时产生严重的 CPU 开销和查询超时。必须通过 Recording Rules 提前聚合 SLI。

    1. 定义 SLI 聚合规则 (Recording Rules)

    基于 Prometheus v2.45.0,我们为核心服务配置以下 Recording Rules。这里以 5m, 30m, 1h, 2h, 6h 五个窗口的错误率为例:

    groups:
    - name: sli_http_error_rate
      interval: 1m
      rules:
      # 预聚合 5 分钟窗口错误率
      - record: job:http_requests:error_rate5m
        expr: >
          sum by(job) (rate(http_requests_total{status=~"5.."}[5m]))
          /
          sum by(job) (rate(http_requests_total[5m]))
    
      # 预聚合 1 小时窗口错误率
      - record: job:http_requests:error_rate1h
        expr: >
          sum by(job) (rate(http_requests_total{status=~"5.."}[1h]))
          /
          sum by(job) (rate(http_requests_total[1h]))
    
      # (30m, 2h, 6h 的规则同理,此处省略以保持排版整洁)
    

    2. 构建多窗口燃烧率告警 (Alerting Rules)

    目标:SLO = 99.9%(允许错误率 0.001)。 级别分类:

    • P0 (Page, 电话呼叫): 14.4x 燃烧率(预算将在 2 天内耗尽),长窗口 1h,短窗口 5m。

    • P1 (Ticket, 企微/钉钉/Jira): 6x 燃烧率(预算将在 5 天内耗尽),长窗口 6h,短窗口 30m。

    groups:
    - name: slo_burn_rate_alerts
      rules:
      # P0 级别告警:短时间大量燃烧,必须立即人工介入 (14.4x)
      # 条件:(1h错误率 > 0.001 * 14.4) AND (5m错误率 > 0.001 * 14.4)
      - alert: HighErrorRate_Page
        expr: >
          (
            job:http_requests:error_rate1h > (1 - 0.999) * 14.4
          and
            job:http_requests:error_rate5m > (1 - 0.999) * 14.4
          )
        labels:
          severity: page
        annotations:
          summary: "High Error Budget Burn Rate (14.4x)"
          description: "Service {{ $labels.job }} error budget is burning rapidly. 2% of 30d budget consumed in 1h."
    
      # P1 级别告警:慢性燃烧,不需要半夜起床,但上班后需首要处理 (6x)
      # 条件:(6h错误率 > 0.001 * 6) AND (30m错误率 > 0.001 * 6)
      - alert: HighErrorRate_Ticket
        expr: >
          (
            job:http_requests:error_rate6h > (1 - 0.999) * 6
          and
            job:http_requests:error_rate30m > (1 - 0.999) * 6
          )
        labels:
          severity: ticket
        annotations:
          summary: "Significant Error Budget Burn Rate (6x)"
    

    3. Alertmanager (v0.25.0) 的防御性路由配置

    有了精准的标签打标(severity: page vs severity: ticket),Alertmanager 的路由配置就能彻底隔离噪音,保护 On-Call 人员的睡眠。

    route:
      receiver: 'default_slack'
      group_by: ['job', 'alertname']
      group_wait: 30s
      group_interval: 5m
      repeat_interval: 4h
      routes:
        # 彻底静默没有任何业务影响的基础设施 Warning
        - matchers:
            - severity="warning"
          receiver: 'blackhole'
    
        # 真实的 P0,触发 PagerDuty/电话
        - matchers:
            - severity="page"
          receiver: 'pagerduty_voice'
          group_wait: 10s   # P0 不等聚合,立刻发
          repeat_interval: 1h
    
        # P1 生成 Jira 工单或发工作群,不弹窗、不打电话
        - matchers:
            - severity="ticket"
          receiver: 'jira_and_slack'
    

    经过这套重构,系统告警量通常能直接断崖式下跌。你不再需要去猜测“MySQL Load 飙到 50 到底要不要起夜”,只要 HighErrorRate_Page 没响,业务吞吐和正确率就是稳的,天塌下来也等天亮再排查。

    常见问题

    Q1: 对于异步消费 MQ 的后台任务,没有 HTTP 状态码,怎么定义 SLI 和燃烧率? 不能用队列深度(Queue Depth)作为核心 SLI,因为消费速率和产生速率是动态变化的,队列深不一定影响业务最终时效。 应使用 端到端延迟 (End-to-End Latency) 或 处理成功率 (Pipeline Success Rate)。在入口处记录消息产生的时间戳,在消费者端计算 CurrentTime - ProduceTime。SLI 定义为:“99% 的消息必须在 5 分钟内处理完毕”。用 Histogram 抓取延迟,计算超出 5 分钟的比例作为“Error Rate”来计算燃烧率。

    Q2: 触发了 Page,排查后发现是因为下游第三方 API 挂了导致的,我的 SLO 受损但不是我的锅怎么办? 这体现了容错架构的缺失。你的 SLI 衡量的是你对客户提供的服务质量,下游挂了导致你的用户报错,这就是你的问题。正确的做法不是把告警转给下游,而是进行防御性编程:为下游依赖增加 Circuit Breaker(断路器),并在降级策略中返回默认值或缓存值。只要成功执行了降级返回 200 OK,你的系统就守住了 SLO。

    Q3: 每次发版发布(Deployment)时,Pod 重启总会引发瞬时的 5xx 错误,导致 5m 短窗口的燃烧率报警,怎么处理这种误报? 这是 Multi-Window 的典型副作用。解法有二:

    1. 联动 CI/CD 抑制: 部署流水线在开始前调用 Alertmanager API 注入一个带 matcher: service=XXX 的 Silence,部署结束后删除。

    2. 调优短窗口容忍度: 如果系统实在做不到无损发布(比如没配置 K8s preStop hook 和优雅下线),可以将针对短窗口 5m 的判断改为 job:http_requests:error_rate5m_offset5m,或者直接在 PromQL 中附加部署事件的规避逻辑。但这治标不治本,根本解法还是治理应用层的优雅启停机制。

  • 深入 K8S veth pair 丢包排查:高 PPS 触发的 SoftIRQ 单核瓶颈与 macvlan 卸载实战

    在 K8S 容器网络中,高并发(PPS > 30万)场景下 veth pair 极易因单队列架构触发宿主机单核 SoftIRQ (NET_RX) 100% 饱和,导致严重丢包与网络抖动。临时止血方案需在宿主机端开启 RPS(Receive Packet Steering)将软中断打散;而彻底解决该类 I/O 密集型业务瓶颈,应引入 macvlan 或 SR-IOV 进行网络栈卸载,直接旁路宿主机内核的复杂转发路径。

    故障现场:Redis 容器的神秘丢包与 99 线飙升

    近期排查了一起 K8S 集群内 Redis 响应毛刺问题。环境基础信息如下:

    • OS: Ubuntu 22.04 (Kernel 5.15.0-76-generic)

    • K8S 版本: v1.25.9

    • CNI: Calico v3.25.0 (BGP 路由模式)

    • 业务表现: 压测期间 Redis 实例的 QPS 达到 8 万时,p99 延迟从 2ms 突变至 150ms 以上,客户端频繁报 Read timed out。

    首先登入 Redis 所在宿主机,直接通过 mpstat 查看中断分布:

    # 每秒输出所有 CPU 核状态
    mpstat -P ALL 1
    
    09:41:01 AM  CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %guest  %gnice   %idle
    09:41:02 AM  all    8.23    0.00    6.11    0.00    0.00   12.15    0.00    0.00    0.00   73.51
    09:41:02 AM    0    4.00    0.00    3.00    0.00    0.00    1.00    0.00    0.00    0.00   92.00
    ...
    09:41:02 AM   12    2.00    0.00   10.00    0.00    0.00  100.00    0.00    0.00    0.00    0.00
    

    如上所示,CPU 12 的 %soft 已经被彻底打满(100%)。进一步通过 /proc/softirqs 定位具体的中断类型:

    watch -d -n 1 "cat /proc/softirqs | grep NET_RX"
    

    确认是 NET_RX 软中断风暴。接着查看容器对应的宿主机端 veth 网卡(假设为 cali9a3b2c1)的丢包统计:

    # 确认网卡 rx_dropped 指标疯狂上涨
    ip -s link show cali9a3b2c1
    

    现象明确:宿主机单核处理软中断能力达到极限,导致网卡接收队列(Backlog)溢出,底层协议栈开始大面积丢弃数据包。

    为什么 veth pair 会成为高吞吐场景的性能毒药?

    要搞清楚这个问题,必须深入 veth pair 在内核中的数据流转机制。

    veth pair 是一对虚拟以太网设备。在 Calico 网络下,数据包从物理网卡(如 eth0)进入宿主机,经过内核路由判决后,发往对应的宿主机端 veth 设备(caliXXX),然后再进入容器的网络命名空间。

    对于物理网卡,现代网卡均支持多队列(RSS, Receive Side Scaling),可以通过 Hash 算法将不同数据流的硬件中断(HardIRQ)分发到多个 CPU 核上,进而触发多核并发处理 NET_RX 软中断。

    但 veth pair 是纯软件模拟的虚拟网卡,默认只有单队列(rx-0/tx-0)。 当数据包从物理网卡路由到 caliXXX 时,内核调用 dev_forward_skb,最终触发 netif_rx 将 skb(套接字缓冲区)压入特定 CPU 的 softnet_data->input_pkt_queue 中。 由于 veth 没有硬件多队列支撑,所有发往该容器的数据包,其软中断处理逻辑通常只能由单核(通常是触发调用的源 CPU,或者被网卡中断绑定的固定 CPU)串行执行。当流量达到几十万 PPS 时,这个单 CPU 很快就会触及 100% 的瓶颈,导致后续包因为 Backlog 队满而被丢弃。

    实战破局:从软件调优到硬件卸载

    针对上述瓶颈,我们在实战中通常采用两个阶段的方案:快速止血与架构重构。

    第一阶段:软件层面开启 RPS 打散软中断

    RPS(Receive Packet Steering)是 RSS 的软件实现。它能在 netif_rx 接收到包后,利用四元组 Hash 软计算,将包投递到其他 CPU 的积压队列中,强制触发跨核的软中断处理。

    找到 Redis 对应的宿主机网卡 cali9a3b2c1,为其配置 RPS(假设宿主机为 16 核,我们将掩码设为 ffff,允许打散到所有核):

    # 将 16 进制掩码写入对应接收队列的 rps_cpus 中
    echo ffff > /sys/class/net/cali9a3b2c1/queues/rx-0/rps_cpus
    
    # 同步调大内核层面的 backlog 队列深度,防止缓冲击穿
    sysctl -w net.core.netdev_max_backlog=10000
    

    开启后,再次观察 mpstat,CPU 12 的 %soft 迅速下降至 30% 左右,其他 CPU 的 %soft 开始均衡上升,Redis 响应延迟立刻恢复到 2ms 的水平。

    注意: 这种方案有代价。RPS 带来了额外的 CPU 周期消耗(计算 Hash、跨核 Cache Miss),整体 CPU 负载(Load Average)会显著升高。这是典型的“空间换时间”策略。

    第二阶段:引入 macvlan / SR-IOV 卸载网络栈

    对于此类极致 I/O 的业务,经过多次踩坑,最终的防线必须是绕过复杂的宿主机网络栈。通过 Multus CNI 引入 macvlan 或 SR-IOV,是当前主流的解法。

    以 macvlan 桥接模式为例,它的底层原理是直接在宿主机物理网卡(eth0)上虚拟出一个具有独立 MAC 地址的子接口。数据包到达物理网卡后,底层驱动通过匹配 MAC 地址,直接将包送入容器的 Network Namespace,彻底跳过了宿主机内核的路由查找、Netfilter (iptables/IPVS) 过滤以及 veth pair 的设备中转。 且 macvlan 继承了物理主网卡的 RSS 特性,天然支持多核并发接收。

    在 K8S 中配置 Multus 与 Macvlan 混合网络示例 (NetworkAttachmentDefinition):

    apiVersion: "k8s.cni.cncf.io/v1"
    kind: NetworkAttachmentDefinition
    metadata:
      name: macvlan-conf
      namespace: default
    spec:
      config: '{
          "cniVersion": "0.3.1",
          "type": "macvlan",
          "master": "eth0",
          "mode": "bridge",
          "ipam": {
            "type": "host-local",
            "subnet": "192.168.100.0/24",
            "rangeStart": "192.168.100.100",
            "rangeEnd": "192.168.100.200",
            "routes": [
              { "dst": "0.0.0.0/0" }
            ],
            "gateway": "192.168.100.1"
          }
        }'
    

    随后在 Redis Pod 中声明注解:

    metadata:
      annotations:
        k8s.v1.cni.cncf.io/networks: macvlan-conf
    

    改造后,Redis Pod 获得了直通物理网络的 eth1 网卡,单机压测极限 PPS 提升了近 3 倍,且宿主机的 CPU sys/soft 占用极低。

    常见问题 (FAQ)

    Q1:为什么使用 macvlan (bridge 模式) 后,宿主机反而 ping 不通该容器了? 这是 macvlan 驱动设计的经典防线。macvlan 拦截了进出物理网卡的流量,但根据 802.1q 规范,从物理网卡发出的包默认不会回流到自己。宿主机发送的报文直接从底层网卡出去了,无法通过 MAC 匹配路由回该网卡上的 macvlan 子接口。 解法: 在宿主机上再创建一个同网段的 macvlan 接口(例如叫 macvlan-host),将宿主机对该网段的路由指向 macvlan-host,利用 bridge 模式下的内部交换机制实现通信。

    Q2:SR-IOV 与 macvlan 相比优势在哪里,什么时候必须上 SR-IOV? macvlan 仍经过宿主机的物理网卡驱动和内核协议栈底层;而 SR-IOV(Single Root I/O Virtualization)是 PCIe 硬件级别的虚拟化。它通过 PF(Physical Function)虚拟出多个 VF(Virtual Function),VF 直接映射给容器。 如果是搞 DPDK 等用户态网络协议栈,或者极低延迟(微秒级)的 HFT (高频交易) 场景,必须用 SR-IOV 彻底 Bypass 内核。普通的高性能 Redis/MySQL,macvlan 已经足够。

    Q3:开启了 RPS,但有些网卡的 rps_cpus 修改后提示 “Permission denied” 或无效? 如果是针对容器内的 veth 设备修改,受限于 NetNS 权限,需在宿主机端的对端网卡(如 Calico 的 calixxx、Flannel 的 vethxxx)操作。另外,务必确保宿主机系统服务(如 irqbalance)不要与你手动的 RPS 掩码逻辑发生冲突,排查过程中发现两者打架是常态,针对极端优化的节点,通常建议关闭 irqbalance 并手动绑核。