标签: 性能调优

  • 深入 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_rxskb(套接字缓冲区)压入特定 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 引入 macvlanSR-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 并手动绑核。

  • 深入 NVMe 队列阻塞排查:blk-mq 调度器误用引发的 XFS 元数据锁雪崩与 sys CPU 饱和实战

    高并发写入场景下,NVMe 盘配合 XFS 极易触发 sys CPU 满载与 IO 夯死。核心原因是 NVMe 误用了 mq-deadline 调度器,导致 blk-mq 软件队列自旋锁争用,进而引发 XFS 分配元数据时在 xfs_log_commit_cil 处发生锁雪崩。直接结论:NVMe 设备的 IO 调度器必须设为 none,同时对于高并发盘,需在格式化时调大 XFS 的 agcount 以打散锁粒度。

    故障现场:数据库写入 p99 突增与 sys CPU 飙升

    某次排查过程中,一套承载核心业务的 PostgreSQL 集群(内核版本 5.10.134-el8,底层存储为裸金属物理机的 PCIe Gen4 NVMe SSD)在高并发 COPY 导入数据时,QPS 出现周期性断崖式下跌。

    通过 top 观察,CPU sys 态长期飙升至 70% 以上,iowait 反而在 10% 左右波动。这极不寻常——对于一块标称 100万 IOPS 的 NVMe 盘,IO 没有跑满,CPU 却在内核态被榨干。

    抓取当时的 iostat -x 1 核心指标:

    Device:         r/s     w/s     rkB/s     wkB/s   rrqm/s   wrqm/s  %rrqm  %wrqm r_await w_await aqu-sz rareq-sz wareq-sz  svctm  %util
    nvme0n1        12.0 42351.0     192.0  680512.0     0.0     0.0    0.0    0.0    0.15   18.42   12.5   16.00    16.06   0.02  85.40
    

    注意 w_await 达到了惊人的 18.42ms,对于 NVMe 来说,这个延迟意味着底层已经严重阻塞。但 %util 只有 85%,设备并未完全饱和。

    使用 perf top -U 直接看内核态热点,现场如下:

      18.45%  [kernel]  [k] queued_spin_lock_slowpath
      12.31%  [kernel]  [k] dd_insert_requests
       8.52%  [kernel]  [k] xfs_log_commit_cil
       6.14%  [kernel]  [k] blk_mq_submit_bio
       5.33%  [kernel]  [k] _raw_spin_lock_irqsave
    

    热点非常集中:dd_insert_requestsxfs_log_commit_cil。这表明系统同时在块设备调度层和文件系统日志提交层发生了严重的锁争用。

    为什么 NVMe 设备使用 mq-deadline 会导致 IO 栈雪崩?

    问题出在 Linux blk-mq(Block Multi-Queue)架构的调度器选择上。

    在传统的单队列(Single Queue)时代,所有 IO 请求进入一个全局队列,需要 CFQ 或 Deadline 这种电梯算法(Elevator)进行合并和排序,以减少机械硬盘的磁头寻道。

    到了 NVMe 时代,硬件支持多达 64K 个提交/完成队列。Linux 为此重构了 blk-mq 架构,分为软件队列(Software Staging Queues,通常每个 CPU 核心一个)和硬件分发队列(Hardware Dispatch Queues)。

    排查发现,该服务器的 NVMe 被默认配置了 mq-deadline 调度器:

    $ cat /sys/block/nvme0n1/queue/scheduler
    [mq-deadline] kyber bfq none
    

    底层阻塞原理: 当调度器设置为 mq-deadline(甚至 bfq)时,IO 请求在进入硬件队列前,必须先挂载到电梯算法的软件队列中。dd_insert_requests 就是 mq-deadline 插入请求的内核函数。由于高并发下成千上万个线程试图向这个软件队列提交 BIO(Block I/O),这就不可避免地触发了自旋锁(queued_spin_lock_slowpath)。 NVMe 的纳秒级响应速度完全被软件队列的自旋锁开销抹平,导致 CPU 在 sys 态空转,IO 提交路径被硬生生卡住。

    剥茧抽丝:XFS 延迟分配与 AIL/CIL 阻塞

    块设备的延迟飙升,迅速引发了文件系统层的连锁反应,这也是为什么 perf 中出现了大量 xfs_log_commit_cil

    XFS 是一种强依赖 Allocation Group(AG)并发设计的日志文件系统(当前版本 V5)。当数据库执行大量写入时,XFS 会利用延迟分配(Delayed Allocation)机制,在内存中缓存数据,直到刷盘时才真正分配物理 Block 并更新元数据。

    1. CIL(Committed Item List)雪崩:元数据变更首先写入内存中的 CIL。当底层 NVMe 因为 mq-deadline 阻塞时,后台刷脏线程(xfsaild)将 AIL(Active Item List)刷入磁盘的速度骤降。

    2. AG 锁争用:CIL 空间被占满,前端业务线程在调用 xfs_alloc_vextent 申请新的空间块时,必须等待日志空间释放。大量 PostgreSQL 线程被迫在同一个 AG 的元数据锁上排队。

    3. 全局夯死:IO 栈的阻塞放大了 XFS 的锁临界区时间,最终导致原本并行的 IO 瀑布般退化为串行等待,形成死锁态势的雪崩。

    解决方案与防御性配置

    解决该问题不需要修改业务代码,纯属系统级架构调优,分为治标和治本两步。

    1. 立即剥离软件调度器(实时恢复)

    将 NVMe 设备的调度器强行切换为 none,绕过所有电梯算法,让 BIO 请求直接从软件多队列打入硬件队列。

    echo none > /sys/block/nvme0n1/queue/scheduler
    

    执行瞬间,sys CPU 从 70% 骤降至 8%,PostgreSQL QPS 恢复正常,w_await 回落至 0.05ms。

    为了防止重启失效,通过 udev 固化防御策略:

    # vim /etc/udev/rules.d/60-io-scheduler.rules
    ACTION=="add|change", KERNEL=="nvme[0-9]*", ATTR{queue/scheduler}="none"
    

    2. XFS AG 并发度调优(底层重构)

    默认情况下,mkfs.xfs 根据磁盘大小自动计算 agcount(通常是 4 或 8)。对于大容量、极高吞吐的 NVMe 盘和数据库场景,默认 AG 数量太少,容易发生并发分配碰撞。 在节点下线重装阶段,调整格式化参数,人为扩大 AG 数量打散锁粒度:

    # 格式化 XFS:强制启用 32 个 AG,对齐 512M 日志大小
    mkfs.xfs -f -K -d agcount=32 -l size=512m,version=2,su=256k /dev/nvme0n1
    

    注:agcount 并非越大越好,过大会增加 mount 时间和内存开销,通常 16-32 针对高端 NVMe 是甜点区间。

    常见问题

    Q1: io_uring 在遇到这种 XFS 锁争用时,会退化成同步阻塞吗? 会。这是很多人使用 io_uring 踩坑的地方。虽然 io_uring 是异步 IO,但如果在文件系统层发生 metadata 锁争用(比如 XFS 分配 block),底层的 IORING_OP_WRITE 且带有 RWF_NOWAIT 标志位时,内核会直接返回 -EAGAIN。随后 io_uring 只能将这个 IO 任务推入后台的 io_worker 线程池进行同步阻塞处理,纯异步链路被击穿,高并发下依然会导致线程池耗尽。

    Q2: 调度器设置为 none 后,系统还有 IO 合并能力吗? 有,但发生在不同层级。none 确实禁用了电梯算法层的合并,但 blk-mq 在软件队列层(Software Staging Queue)和块设备硬件驱动层依然会利用 scatter-gather list 进行有限的相邻物理段合并。对于 NVMe 而言,本身 4K 随机 IO 的性能极高,强行进行复杂的 IO 合并排序带来的 CPU 锁开销远大于其收益。

    Q3: 如何在生产环境无损监控 XFS 的 AG 锁争用情况? 极力推荐使用 eBPF/bpftrace 而不是 SystemTap。可以通过挂载 tracepoint 实时监控 CIL 提交延迟:

    bpftrace -e 'tracepoint:xfs:xfs_log_commit_cil { @start[tid] = nsecs; } tracepoint:xfs:xfs_log_commit_cil_wait { if(@start[tid]) { @usecs = hist((nsecs - @start[tid]) / 1000); delete(@start[tid]); } }'
    

    如果输出的直方图显示大量调用耗时超过 1000 微秒(1ms),说明文件系统日志提交已出现严重积压,需立即排查底层块设备延迟。

  • 深入 Apache Pulsar 写入雪崩排查:Journal/Ledger 磁盘混用引发的 IO 饱和与 Bookie 假死实战

    某次接手一个号称“完全按照官方最佳实践”部署的 Pulsar 集群,业务方反馈高并发场景下大量 Producer 频繁抛出 PulsarClientException$TimeoutException,P99 写入延迟从常态的 5ms 瞬间飙升至 8000ms+,集群吞吐呈断崖式下跌。直接抛出排查结论:这是典型的底层存储架构无知导致的惨案。部署人员将 BookKeeper 的 journalDirectories(写前日志)和 ledgerDirectories(数据与索引)挂载到了同一块物理磁盘(甚至是同一块云盘)。当 Ledger 触发后台垃圾回收(Garbage Collection)或 RocksDB 刷盘时,海量随机 IO 直接榨干了磁盘 IOPS,导致 Journal 的顺序 fsync 严重阻塞。Bookie 内部线程池大面积挂起,最终因 ZK 心跳超时被踢出集群,引发 NotEnoughBookiesException 全局写入雪崩。

    Pulsar 最大的卖点就是“计算与存储分离”(Broker 与 Bookie 分离),但很多人只停留在节点级别的隔离,完全无视了 BookKeeper 内部极其苛刻的 IO 路径分离要求。

    BookKeeper 的写入模型极其严谨且保守:一条消息到达 Bookie 后,必须强制 fsync 落盘到 Journal(类似 MySQL 的 Redo Log),才会向 Broker 返回 ACK。同时,消息会被写入内存(MemTable),随后异步批量刷入 Ledger 磁盘,并更新 RocksDB 中的索引。 这套设计的初衷非常明确:用 Journal 的极速顺序写保证低延迟和数据可靠性,用 Ledger 的大容量存储应对历史数据读取和高吞吐。

    把 Journal 和 Ledger 混在一块盘上,无异于在高速公路上摆地摊。

    排查期间,登陆故障 Bookie 节点,一条极其普通的 iostat 命令就让问题原形毕露:

    # iostat -dx 1
    Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
    nvme1n1           0.00     0.00  850.00 1200.00 10240.00 45000.00    53.89   145.20   70.83   90.50   56.90   0.49 100.00
    

    磁盘 %util 死死钉在 100%,avgqu-sz(请求队列长度)高达 145,await 飙到 70ms 以上(对于 NVMe 来说,超过 5ms 就已经是不及格了)。

    去翻看 Bookie 的 Prometheus 监控,核心指标 bookkeeper_journal_JOURNAL_SYNC_99_per(Journal 落盘 99 线)与磁盘 IO 延迟高度吻合,出现了巨幅毛刺。此时,Broker 的日志里已经尸横遍野:

    org.apache.bookkeeper.client.BKException$BKNotEnoughBookiesException: Not enough non-faulty bookies available
        at org.apache.bookkeeper.client.LedgerCreateOp.initiate(LedgerCreateOp.java:142)
        ...
    

    为什么会突然爆发?因为 BookKeeper 并非只有简单的追加写。当 Ledger 中的 EntryLog 文件里被删除(或过期)的数据达到一定比例时,Bookie 会触发后台 GC 线程(Minor/Major Compaction)。GC 的动作是读取旧文件、过滤有效数据、重写到新文件。这是一个极其暴力的重度随机读 + 顺序写过程。 如果 Journal 和 Ledger 共享物理 IO 设备,GC 产生的海量 IO 请求会瞬间塞满 OS 的 Block Layer 队列,Journal 线程哪怕只是想追加写入几 KB 数据并调用一次 fsync,也只能在队列里绝望地排队。

    不仅如此,由于 Journal 同步阻塞,Bookie 的 Netty Worker 线程被耗尽,导致 Bookie 连发往 ZooKeeper 的心跳都无法及时响应。ZK 判定 Bookie 宕机,Broker 发现 Ensemble 可用节点不足(例如配置了 3 副本,只剩下 2 个健康节点),直接拒绝写入。由于集群是均衡负载的,随着 GC 在各个节点轮番上演,整个 Pulsar 集群如同多米诺骨牌般倒塌。

    解决这种问题,不要去迷信什么神奇的 JVM 调优参数,核心就是尊重物理拓扑

    修复手段与防御性配置:

    1. 物理级别的 IO 隔离(最关键) 修改 bookkeeper.conf,强制分离 Journal 和 Ledger 目录到不同的物理磁盘。Journal 给一块极小但极快的高性能 NVMe SSD(几十G即可,写满会自动清理),Ledger 给大容量的普通 SSD 甚至 HDD。

    # 高速 NVMe 挂载点
    journalDirectories=/mnt/nvme_journal/bookkeeper/journal
    # 大容量 SSD/HDD 挂载点
    ledgerDirectories=/mnt/ssd_ledger/bookkeeper/ledgers
    

    2. 对后台 GC 进行冷酷的资源限流 不要让 GC 跑起来像脱缰的野马。在 bookkeeper.conf 中开启 GC 限速,严格控制其对磁盘带宽的占用:

    # 开启按字节限流
    isThrottleByBytes=true
    # 限制 Compaction 最大速率为 50MB/s (根据底层磁盘能力调整)
    compactionRateByBytes=52428800
    # 避免在高峰期触发 Major Compaction
    minorCompactionThreshold=0.2
    majorCompactionThreshold=0.8
    

    3. RocksDB 索引刷盘的平滑处理 Ledger 中的索引默认由 RocksDB 管理,RocksDB 的 MemTable Flush 同样会带来 IO 尖峰。确保配置了合理的 Write Buffer 和并发度:

    dbStorage_rockdb_writeBufferSizeMB=64
    dbStorage_rockdb_numLevels=6
    

    架构设计不是画几个方块就完事了。Pulsar 这种分布式中间件的性能底座,其实都建立在底层 Linux IO 调度和文件系统特性的基础之上。不理解数据的生命周期流转,不看磁盘的 IOPS 和延迟分布,一键部署出来的集群,最终都会在晚高峰教你做人。

    排查清单:BookKeeper IO 阻塞与假死速查

    1. 磁盘物理拓扑核对:执行 df -hlsblk,严格对照 bookkeeper.conf 中的 journalDirectoriesledgerDirectories,确认两者绝未落在同一块物理盘、同一个 LVM 卷或同一个共享云盘组上。

    2. Journal Sync 延迟监控:紧盯 bookkeeper_journal_JOURNAL_SYNC 的 P99 和 P999 指标,一旦常态超过 10ms,立刻排查底层的 IO 争抢或硬件寿命衰减问题。

    3. ZooKeeper 会话抖动排查:排查 Bookie 侧日志是否有 Expired session,以及 ZK 侧是否有 Closed socket connection for client。如果是 IO 夯死导致的 CPU 调度迟滞,考虑适当调大 zkTimeout(默认通常为 10s-30s),但治本仍在 IO 治理。

    4. GC 日志与速率审查:搜索 Bookie 日志中的 GarbageCollectorThread 关键字,观察 Compaction 触发频率和耗时。确认 isThrottleByBytes 是否开启并配置了合理的阈值,防止后台合并打挂前台写入。

    5. Direct Memory 泄漏挤压 OS Cache:检查 dbStorage_directIO_entryLogger 是否未正确分配,导致 Bookie OOM 或严重依赖 PageCache。确保为 Bookie 预留充足的 Direct Memory 给 RocksDB Block Cache 和 ReadAhead Cache。

  • 深入 NUMA 内存失衡排查:zone_reclaim_mode 引发的 THP 压缩阻塞与局域 OOM 击穿实战

    结论先行。针对 Elasticsearch/Kafka 等重度依赖 mmap 和 Page Cache 的应用,彻底关闭 THP(never)、设置 vm.zone_reclaim_mode=0 并强制 numactl --interleave=all 是规避 NUMA 局域 OOM 的铁律。跨 NUMA 访问的纳秒级延迟惩罚,远低于本地 Node 深度回收(Direct Reclaim)与大页压缩(Compaction)带来的秒级 I/O 夯死。

    现场还原:Load 飙升与诡异的毛刺

    某次排查中,业务反馈一个基于 Elasticsearch 7.17(底层系统为 Ubuntu 20.04,Kernel 5.4.0)的日志集群 P99 写入延迟出现极规律的剧烈抖动。平时延迟在 10ms 左右,但每隔几小时就会突发飙升至 2000ms+,伴随 Load Average 瞬间冲高到 80 以上。

    登录机器初步勘查,物理内存 256GB,JVM Heap 配置为 31GB(为了利用指针压缩),理论上剩余的 200GB+ 都会被 OS 用于 Page Cache 加速 mmap 读写。通过 free -g 查看,系统整体还有近 80GB 的 available 内存。

    然而,在查阅 /var/log/syslog 时,却发现了明确的 OOM Killer 介入日志:

    [51234.567890] java invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
    [51234.567895] CPU: 12 PID: 14532 Comm: java Tainted: G        W         5.4.0-122-generic #138-Ubuntu
    [51234.567901] Node 0 Normal free:45056kB min:45056kB low:56320kB high:67584kB
    [51234.567902] Node 0 Normal: 452*4kB (UME) 310*8kB (UME) ...
    [51234.567905] Node 1 Normal free: 83886080kB min:45056kB low:56320kB high:67584kB
    

    注意看日志里的致命细节:Node 0 的 free 内存已经触底(约 45MB,达到了 min watermark),而 Node 1 竟然还有 80GB 的空闲内存!

    性能观测:找出幕后黑手

    为了弄清为什么系统宁愿 OOM 也不用 Node 1 的内存,我拉起了常规的观测工具链。

    通过 numastat -m 查看 NUMA 节点的内存分布:

    $ numastat -m
                                 Node 0          Node 1           Total
                     --------------  --------------  --------------
    MemTotal                 128000          128000          256000
    MemFree                      43           81920           81963
    MemUsed                  127957           46080          174037
    Active                   110540           20480          131020
    Inactive                  12400           22500           34900
    

    Node 0 已经被彻底榨干。在延迟飙升期间,使用 perf top -p 抓取内核态调用栈,发现 CPU 极度密集地消耗在以下几个函数上:

    1. compaction_alloc

    2. isolate_freepages

    3. shrink_page_list

    同时,通过 /proc/vmstat 观察系统计数器,发现 compact_stallthp_fault_fallback 两个指标在毛刺期间呈现出几乎垂直的增长。

    为什么整体内存充足,却依然触发了局域 OOM 与 mmap 阻塞?

    这是一个典型的由 NUMA 架构默认分配策略、zone_reclaim_mode 回收机制以及 THP(透明大页)碎片整理共同酿成的惨剧。我们层层剖析。

    1. NUMA 的 Local Allocation 陷阱

    现代多路服务器默认开启 NUMA(Non-Uniform Memory Access)。Linux 内核默认的内存分配策略是 default,即优先在当前进程运行所在的 NUMA 节点上分配内存。 Elasticsearch 的主进程启动后,如果被调度器主要分配在 Node 0 的 CPU 上执行,它产生的大量 mmap 缺页中断(Page Faults)会疯狂吃掉 Node 0 的内存构建 Page Cache。最终,Node 0 被填满,而 Node 1 在旁边“看戏”。

    2. zone_reclaim_mode 引发的 Direct Reclaim 阻塞

    当 Node 0 的内存达到 low 水位线时,内核有两种选择:

    • A: 去 Node 1 借用空闲内存。

    • B: 强行在 Node 0 本地进行内存回收(驱逐 Page Cache 或 Swap)。

    内核如何决策?取决于 vm.zone_reclaim_mode 的值(以及节点间的距离 node_distance)。 在部分发行版或 BIOS 设置下,当 NUMA 节点距离较远时,系统倾向于在本地强行回收。此时如果业务正在高并发地写入,后台的 kswapd0 回收速度跟不上分配速度,内核就会挂起当前申请内存的用户态线程,进入Direct Reclaim(直接回收)路径。 shrink_page_list 就是在疯狂扫描和驱逐 Node 0 上的 Page Cache。这对于极度依赖 mmap 的 ES 和 Kafka 来说,相当于把热数据从内存里生生挖掉,下一次访问直接产生严重的磁盘 I/O 停顿。

    3. THP(Transparent Huge Pages)的致命一击

    如果只是缺内存,驱逐 Page Cache 最多带来 I/O 延迟。但 perf top 中的 compaction_alloc 揭示了更严重的问题:透明大页(THP)正在进行内存碎片压缩。 内核默认开启了 THP(madvisealways),试图为进程分配 2MB 的连续物理大页以减少 TLB Miss。当 Node 0 内存碎片化严重,没有连续的 2MB 空间时,内核的 khugepaged 或者触发 Direct Compaction 的线程会强行移动内存页面,试图“拼凑”出 2MB 的连续空间。 这个过程需要获取 Zone 级别的锁,会完全阻塞该 NUMA 节点上的其他内存分配请求。此时,业务看到的现象就是:机器负载瞬间飙到 80+,所有的写请求全部卡死(Hang),直到压缩超时或失败回退(thp_fault_fallback),随后由于 Node 0 实在挤不出哪怕 4KB 的内存,触发 OOM Killer 杀掉进程。

    核心调优实战与防御性配置

    不要迷信 OS 的默认配置,对于高吞吐的 DB/存储类应用,以下三步是必须落地的防御性基线:

    1. 强制 NUMA 内存交错分配(Interleave)

    通过 numactl 覆盖默认的本地分配策略,让应用在所有 NUMA 节点上均匀分配内存,彻底打散 Page Cache。 修改 ES 或 Kafka 的 systemd service 文件:

    [Service]
    # 将原来的 ExecStart 替换为带 numactl 的版本
    ExecStart=/usr/bin/numactl --interleave=all /usr/share/elasticsearch/bin/elasticsearch
    

    注:很多老鸟会担心 Interleave 带来的跨节点访问延迟(约增加 10~20 纳秒)。但在存储类系统中,因为局域内存耗尽引发的磁盘 I/O 阻塞(毫秒级甚至秒级),其代价是纳秒级跨节点延迟的 100,000 倍以上。

    2. 关闭 THP 与调整 zone_reclaim_mode

    透明大页对于 Redis/ES/Kafka 这类内存访问极度随机、频繁分配释放的应用,百害而无一利。必须在内核层彻底关闭,同时禁止本地激进回收。

    写入 /etc/sysctl.d/99-sysctl.conf

    # 优先去其他 Node 借用内存,绝不强行在本地发起深度回收
    vm.zone_reclaim_mode = 0
    # 降低 Swap 倾向,保护 Page Cache
    vm.swappiness = 1
    # 预留总内存的 1%-2% 给内核态,防止网络突发包导致网卡/内核分配内存失败触发直接回收
    # 256G 内存建议设置为 2G (2097152) 到 4G
    vm.min_free_kbytes = 2097152
    

    关闭 THP(不要只改 sysfs,建议写到 grub 引导参数里彻底干掉): 编辑 /etc/default/grub,在 GRUB_CMDLINE_LINUX 中追加: transparent_hugepage=never 执行 update-grub 并重启系统。若不重启,可通过以下命令即时生效:

    echo never > /sys/kernel/mm/transparent_hugepage/enabled
    echo never > /sys/kernel/mm/transparent_hugepage/defrag
    

    3. OOM Score 防御性保护

    对于关键存储进程,适当调低其 OOM Score,防止在极端情况下被内核误杀。可以在启动脚本中注入:

    echo -500 > /proc/$$/oom_score_adj
    

    常见问题 (FAQ)

    Q1:如何判断我现在的系统有没有受到 THP 的性能毒害? 查看 /proc/vmstat 中的关键计数器增量。执行 watch -n 1 "grep -e compact_stall -e thp_fault_fallback -e pgmigrate_success /proc/vmstat"。如果在你的业务高峰期,这几个指标在疯狂跳动,说明系统正在花费大量 CPU 周期进行内存整理,你的 P99 延迟绝对已经出问题了。

    Q2:vm.min_free_kbytes 设置得越大越好吗? 绝对不是。如果设置得太大(例如超过总内存的 5%),会导致系统提前触碰 low 甚至 high 水位线,触发后台 kswapd 极其频繁地唤醒,一直在做无用的 Page Cache 回收,反而降低了内存利用率并推高 CPU sys 使用率。一般 256G 内存给 2G~4G 足矣。

    Q3:除了 numactl --interleave=all,修改 BIOS 里的 Node Interleaving 有什么区别? BIOS 级别的 Node Interleaving 是从硬件层把 NUMA 给屏蔽掉(UMA 模式),OS 看到的只有一个大的 NUMA 节点。这种方式虽然简单粗暴,但所有进程都被迫交错访问。而使用 numactl 可以在 OS 保留 NUMA 感知的前提下,仅针对特定的吃内存大户(如 JVM / DB)进行交错,其他对 CPU 缓存敏感的轻量级计算进程(如 Nginx/Envoy)依然可以享受 NUMA 的本地访问加速,后者更加精细和灵活。

  • 深入 GitLab CI 阻塞排查:全局 Cache 滥用引发的 Runner IO 饱和与多级构建穿透实战

    某次代码合入高峰期,核心业务的 GitLab CI Pipeline 出现大面积排队,单次构建从 3 分钟恶化至 40 分钟。核心原因是全局 cache 滥用导致 Runner 节点磁盘 IO 打满(iowait > 65%),且 Docker 构建层缓存(BuildKit)被错误穿透。通过将缓存后端迁移至 MinIO S3、引入 BuildKit 挂载缓存,并严格分离 Cache 与 Artifacts,最终将 P99 构建耗时稳压在 2 分钟内。

    案发现场:Pipeline 假死与 IO 风暴

    排查过程中,研发反馈提交 PR 后 Pipeline 迟迟不执行。登录 GitLab Runner 宿主机(4C16G,普通 SSD),直接看系统负载:

    $ uptime
     14:22:10 up 45 days, 10:13,  2 users,  load average: 32.41, 28.14, 15.02
    
    $ iostat -dxz 1
    Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
    sda               0.00    12.40  145.00  380.00 45210.00 125044.0 648.59   14.20   25.30   15.10   29.20   1.90 100.00
    

    %util 持续 100%,load average 飙到 32。查看 Runner 容器的执行日志,发现大量 Job 卡在拉取和解压缓存阶段:

    Checking cache for default-3...
    Downloading cache.zip from https://storage.googleapis.com/gitlab-com-runners-cache/...
    WARNING: Downloaded cache is 3.2 GB
    Extracting cache is taking 12m 45s...
    

    一个包含前端 node_modules 和后端 .go/pkg/mod 的巨型 Cache 被挂载到了全局 default 阶段。当 10 个 Job 并发启动时,单台 Runner 需要瞬间下载并解压超过 30GB 的碎文件,直接把磁盘 IO 打穿。

    为什么全局 Cache 滥用会引发 Runner IO 雪崩?

    很多开发写 .gitlab-ci.yml 时,图省事喜欢把所有依赖丢进全局 cache,并且配置 paths 覆盖整个项目根目录。这在底层机制上是个灾难。

    GitLab Runner 处理 Cache 的默认逻辑是:基于 ZIP 压缩,在 Job 开始前下载解压,在 Job 结束后压缩上传。 如果配置不当(例如未配置分布式缓存,使用本地文件系统或远端低速 OSS),会产生以下连环爆炸:

    1. 网络与 CPU 双重瓶颈:每次 3GB 碎文件的 ZIP 压缩/解压,吃光 Runner 节点的 CPU 资源。

    2. 多 Job 踩踏:在同一个 Pipeline 中,buildtestlint 三个 Job 并发执行,会产生 3 次冗余的 3GB Cache 下载动作。

    3. 缓存穿透:只要有一个文件发生变化,缓存的 Hash Key 就会变动(或者使用 fallback key),导致全量重新上传。

    此外,Docker 构建环节也存在严重的缓存穿透。传统 docker build 遇到 COPY . . 时,只要源码树里任何一个无关文件(如 README.md)改动,其后的所有构建层(包括耗时的 go mod download)缓存全部失效。

    防御性流水线重构与多级缓存落地

    针对上述架构缺陷,实施分层缓存防御改造。环境基于 GitLab Runner 16.3 和 Docker 24.0.5。

    1. 阻断本地 IO 踩踏:引入 MinIO S3 分布式缓存

    放弃 Runner 本地文件缓存,在内网独立部署 MinIO 集群承接 Cache 流量,避免 Runner 磁盘成为瓶颈。修改 /etc/gitlab-runner/config.toml

    [[runners]]
      name = "high-perf-runner"
      url = "https://gitlab.example.com/"
      token = "glrt-xxxxxxxxxx"
      executor = "docker"
      [runners.docker]
        tls_verify = false
        image = "alpine:latest"
        privileged = true
        disable_entrypoint_overwrite = false
        oom_kill_disable = false
        disable_cache = false
        volumes = ["/cache"]
        shm_size = 0
      [runners.cache]
        Type = "s3"
        Path = "gitlab-runner-cache"
        Shared = true
        [runners.cache.s3]
          ServerAddress = "minio.internal.lan:9000"
          AccessKey = "admin"
          SecretKey = "StrongSecret123!"
          BucketName = "ci-cache"
          Insecure = true
    

    2. 精准外科手术:拆分 Cache 与 Artifacts,按需声明

    .gitlab-ci.yml 中,严禁使用全局 Cache。Cache 用于加速依赖下载,Artifacts 用于阶段间传递制品。

    stages:
      - deps
      - build
      - test
    
    # 只在依赖拉取阶段更新 Cache
    go-deps:
      stage: deps
      image: golang:1.21-alpine
      cache:
        key:
          files:
            - go.sum
        paths:
          - .go/pkg/mod/
        policy: pull-push # 唯一允许 push 的 Job
      script:
        - go mod download -x
    
    # 编译阶段:只读 Cache,通过 Artifacts 传递二进制
    go-build:
      stage: build
      image: golang:1.21-alpine
      cache:
        key:
          files:
            - go.sum
        paths:
          - .go/pkg/mod/
        policy: pull # 阻断重复压缩上传
      script:
        - go build -o myapp ./cmd/main.go
      artifacts:
        paths:
          - myapp
        expire_in: 1 hour # 防御制品磁盘打满
    

    3. 终极杀器:BuildKit 挂载缓存与内联镜像缓存

    针对 Docker 镜像构建,废弃旧版 docker build,全面启用 BuildKit (v0.12+) 的挂载缓存机制,将编译时的 .cache 独立持久化到 Runner 宿主机。

    修改 Dockerfile:

    # syntax=docker/dockerfile:1.4
    FROM golang:1.21-alpine AS builder
    WORKDIR /app
    COPY go.mod go.sum ./
    # 利用 BuildKit 挂载远端/本地模块缓存,避免每次下载
    RUN --mount=type=cache,target=/go/pkg/mod \
        go mod download
    
    COPY . .
    # 挂载构建缓存
    RUN --mount=type=cache,target=/go/pkg/mod \
        --mount=type=cache,target=/root/.cache/go-build \
        go build -ldflags="-s -w" -o server .
    
    FROM alpine:3.18
    COPY --from=builder /app/server /server
    CMD ["/server"]
    

    配合 CI 脚本中的内联缓存(Inline Cache),实现多分支复用:

    docker-pack:
      stage: build
      image: docker:24.0.5-dind
      variables:
        DOCKER_BUILDKIT: 1
      script:
        - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
        - >
          docker build
          --build-arg BUILDKIT_INLINE_CACHE=1
          --cache-from $CI_REGISTRY_IMAGE/cache:latest
          -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
          .
    

    常见问题

    Q1: Artifacts 和 Cache 到底怎么选?边界在哪? Cache 没有绝对保证,Runner 可能会清理它,它是为了速度(如 npm, go mod)。 Artifacts 是为了 Job 间的数据完整传递(如 build 产出的 .jar 传给 deploy)。绝对不要用 Cache 来传递编译产物,否则遇到并发 Pipeline 或者 Fallback Key 命中失败,会导致后续 Job 找不到文件而直接报错退出。

    Q2: DIND (Docker-in-Docker) 模式下,怎么复用宿主机的镜像层缓存? DIND 每次启动都是独立的守护进程,默认不共享宿主机 /var/lib/docker。如果非要复用,可以通过 Runner 的 volumes = ["/var/run/docker.sock:/var/run/docker.sock"] 改为 Docker-out-of-Docker (DOOD) 模式。但注意,这会带来严重的并发污染问题(多个 Job 同时强删镜像)。更推荐的做法是坚持 DIND,但引入 Registry Cache (--cache-from--cache-to=type=registry),将缓存推送到私有 Harbor。

    Q3: GitLab CI 并发太高导致 MinIO S3 节点网络打满怎么限流? 首先检查 Cache 包是否过大,剔除不必要的文件。其次,在 GitLab Runner 配置中使用 [runners.limit] 限制单 Runner 的并发 Job 数。最后,如果是特定大项目引发的,在 gitlab-ci.yml 里使用 rules 控制无关代码提交(如 Markdown 修改)不触发构建,或者引入 needs: (DAG 依赖) 取代按 Stage 批量阻塞,错开 Cache 拉取的时间峰值。

  • 深入 OpenTelemetry 追踪雪崩排查:全量采样引发的 Collector OOM 与无用 Span 泛洪实战

    某次微服务全链路追踪(OpenTelemetry)大范围铺开后,核心集群的 OTel Collector DaemonSet 出现大面积 OOMKilled,后端 Jaeger 和 ClickHouse 写入 QPS 瞬间跌零。业务线研发在排查线上故障时,发现应用日志与 TraceID 彻底脱节,排查陷入盲人摸象。最终结论:研发在 SDK 端开启了 100% 盲目全量采样,且未对 Kubelet 探针与 Redis 心跳做任何过滤;更有甚者,将动辄几百 KB 的 Base64 图片 Payload 强塞进 Span Attribute。由于 Collector 端未配置防御性的 memory_limiter 与背压处理,积压的脏数据瞬间打爆内存。

    解决思路很明确:SDK 端收敛采样率、Collector 端强行过滤高频无价值 Path 并截断超长 Attribute,同时修复异步线程池丢失 OTel Context 导致的 Trace-Log 关联断裂。

    案发现场与指标异常

    排查过程中,监控大盘发出刺耳的告警:otel-collector 的 Pod 频繁重启。登录节点查看系统日志,死因极其经典:

    $ dmesg -T | grep -i oom
    [xxx] Memory cgroup out of memory: Killed process 12345 (otelcol-contrib) total-vm:4250112kB, anon-rss:2097152kB
    

    此时去看 OTel Collector 本身的暴露指标(:8888/metrics),在每次 Pod 阵亡前,以下两个指标呈现出垂直拉升的态势:

    • otelcol_receiver_refused_spans{transport="grpc"}:暴增,说明 Receiver 端已经拒绝接收数据。

    • otelcol_processor_dropped_spans:激增,Processor 队列被打满。

    为了查清楚到底是什么垃圾数据塞满了 Collector,我临时开了一个 Debug Exporter 拦截了部分流量写入本地文件。打开一看,简直是灾难:

    1. 70% 的 Span 是无效心跳/healthz/metrics 以及海量的 Redis PING/PONG。一个 500 规模的 Pod 集群,每 10 秒一次的 Readiness/Liveness 探针,加上无脑的全量采样,一分钟就能造出几十万个毫无追踪价值的孤儿 Span。

    2. 把 Trace 当对象存储用:某几个核心业务的 Span 里面,http.request.body 居然包含了完整的 HTTP POST 数据,其中夹带了大量未经压缩的 JSON Array,单 Span 大小突破了 500KB。

    为什么说这是愚蠢的配置?

    全链路追踪的核心价值是系统拓扑呈现与分布式请求的瓶颈定位(Control Flow),绝不是用来存明细业务数据甚至二进制文件的。 把 500KB 的 Payload 塞进 Span,不仅极大消耗了应用的 CPU(序列化开销),还把网络带宽和 Collector 的内存当成了免费午餐。此外,对 /healthz 进行全量 Trace,相当于你在高速公路上给每一道测速探头拍个特写,除了把磁盘撑爆,没有任何排障意义。

    另外,排查业务日志时发现另一个低级失误:大量核心报错日志里没有 trace_id。查阅业务代码发现,研发在处理高并发请求时使用了 Java 的 @Async 或自建的 ThreadPoolExecutor,但完全没有做 MDC(Mapped Diagnostic Context)的跨线程传递。主线程的 OTel Context 一到子线程就丢失了,导致 Trace 和 Log 在最关键的异步执行环节强行脱钩。

    落地实战:防御性配置与采样重构

    针对这种乱象,必须在 Collector 层面实施强硬的“防御性编程”策略,不能指望所有业务线都能自觉写好 SDK 配置。

    1. Collector 内存兜底与背压(Memory Limiter)

    绝对不要在生产环境裸奔 Collector,必须配置 memory_limiter processor。它会在内存接近上限时主动拒绝新数据(触发背压限制),并强制执行 GC,宁可丢弃(Drop)部分监控数据,也不能让 Collector OOM 导致 DaemonSet 崩溃影响宿主机。

    processors:
      memory_limiter:
        check_interval: 1s
        limit_mib: 1500        # 硬限制,视 Pod Limit (如 2G) 预留 20%
        spike_limit_mib: 300   # 软限制阈值,超过 limit - spike 时开始 drop 数据
    

    2. 强行清洗无价值垃圾 Span (Filter Processor)

    使用 filter processor,在 Collector 入口处直接把探针和心跳干掉。

    processors:
      filter/drop_health:
        error_mode: ignore
        traces:
          span:
            - 'attributes["http.target"] == "/healthz"'
            - 'attributes["http.target"] == "/metrics"'
            - 'name == "PING"' # Redis Ping
    

    3. 截断超大 Attribute (Transform Processor)

    对于研发乱塞 Payload 的行为,使用 OTTL (OpenTelemetry Transformation Language) 强制截断,超过 2048 字节直接截断,保护后端存储引擎。

    processors:
      transform/truncate_payload:
        trace_statements:
          - context: span
            statements:
              - set(attributes["http.request.body"], substring(attributes["http.request.body"], 0, 2048)) where IsString(attributes["http.request.body"])
    

    4. 尾部采样(Tail-based Sampling)替换全量盲采

    在 SDK 端仅保留基础的 ParentBased(TraceIdRatioBased) 头采样策略(如 5%),将重头戏交给 Collector 层面的 Tail Sampling。对于状态码为 5xx 或延迟大于 2 秒的 Trace 实施 100% 抓取,而对正常的 200 OK 请求执行极低概率的采样。

    processors:
      tail_sampling:
        decision_wait: 10s # 等待 Trace 组装的时间
        num_traces: 50000
        policies:
          - name: always-sample-errors
            type: status_code
            status_code: {status_codes: [ERROR]}
          - name: slow-traces
            type: latency
            latency: {threshold_ms: 2000}
          - name: probabilistic-normal
            type: probabilistic
            probabilistic: {sampling_percentage: 5}
    

    5. 解决跨线程 Trace-Log 上下文丢失

    在业务代码中,严禁裸调线程池。如果是 Java + Spring Boot,推荐直接使用 OTel Java Agent,它对常见的线程池(ForkJoinPool, ThreadPoolExecutor)做了底层字节码增强。如果是手动传递,必须使用 Context.current().makeCurrent() 或包装 Runnable

    // 错误写法:Context 丢失
    executor.submit(() -> doSomething());
    
    // 正确写法:Context 传播
    Runnable instrumentedRunnable = Context.current().wrap(() -> doSomething());
    executor.submit(instrumentedRunnable);
    

    排查清单与同类问题速查

    1. Collector 频繁重启 / OOMKilled:第一时间检查 memory_limiter 是否配置正确。limit_mib 必须小于 K8S Pod 的 Memory Limit,通常预留 20%~25% 给非 Go 运行时开销。

    2. 存储后端(Elasticsearch/ClickHouse)CPU打满 / IO 瓶颈:大概率是 Span Payload 过大。通过 Collector 的 debug exporter 抽样排查 http.request.bodydb.statement 字段是否包含异常的巨型文本。

    3. Trace 找得到,但报错日志搜不到 TraceID:检查应用是否发生了跨线程/跨协程的异步调用,重点排查 OTel Context propagator 是否在线程切换时被正确传递。

    4. 无用 Span 泛洪排查:查询后端存储中 Span Name 排名前 10 的列表,如果 /health/pingSELECT 1 占据了大部分比例,立即在 Collector 侧追加 filter processor。

  • 深入 io_uring 延迟雪崩排查:O_DIRECT 缺失引发的 io-wq 线程池打满与 XFS 阻塞实战

    io_uring 并非异步 IO 银弹。在缺失 O_DIRECT 或执行 Append 写时,XFS 元数据锁会迫使 io_uring 降级至内核 io-wq 线程池。一旦线程池耗尽,主提交线程将陷入 D 状态,p99 延迟暴涨。核心解法:强制 Direct IO 并结合 fallocate 预分配文件块,彻底绕过元数据锁争用。

    某次排查一个基于 io_uring 重构的高并发存储网关(C++ 编写,运行于 Ubuntu 22.04,Kernel 5.15.0-82-generic,底层为 XFS v5 挂载)。该网关在压测阶段初期表现极佳,但当并发写入量达到 5000 QPS 时,系统 Load Average 瞬间飙升至 200+,p99 延迟从 2ms 劣化至惊人的 800ms。

    现场取证与指标异动

    首先看基础 IO 指标。通过 iostat 观察,磁盘的 %util 达到了 100%,但实际写入吞吐量(wMB/s)仅有可怜的 40MB/s,远未达到 NVMe SSD 的瓶颈。

    # iostat -x -d 1
    Device            r/s     w/s     rMB/s     wMB/s   rrqm/s   wrqm/s  %rrqm  %wrqm r_await w_await aqu-sz rareq-sz wareq-sz  svctm  %util
    nvme1n1          0.00 4820.00      0.00     38.50     0.00     0.00   0.00   0.00    0.00  182.50 142.10     0.00     8.18   0.21 100.00
    

    接着查看 CPU 状态,发现 iowait 极高,且存在大量的上下文切换(CS)。直接拉取进程状态,发现核心网关进程及其派生的内核线程大面积处于 D 状态(Uninterruptible Sleep)。

    # 查看 D 状态进程堆栈
    $ for pid in $(ps -eo pid,state | awk '$2=="D"{print $1}'); do echo "PID: $pid"; cat /proc/$pid/stack; done
    
    PID: 14205 (网关主线程)
    [<0>] io_sq_thread+0x28a/0x560
    [<0>] ret_from_fork+0x22/0x30
    
    PID: 14221 (io_wqe_worker_0)
    [<0>] xfs_ilock+0x105/0x220
    [<0>] xfs_file_buffered_aio_write+0x142/0x3a0
    [<0>] xfs_file_write_iter+0x7b/0xc0
    [<0>] io_write+0xe4/0x310
    [<0>] io_issue_sqe+0x39a/0x1e30
    [<0>] io_wq_submit_work+0x12d/0x3b0
    [<0>] io_worker_handle_work+0x153/0x290
    [<0>] io_wqe_worker+0x2cd/0x350
    [<0>] ret_from_fork+0x22/0x30
    

    内核堆栈直接暴露了致命问题:大量的 io_wqe_worker 线程阻塞在 xfs_ilock 上,且调用链明确显示走的是 xfs_file_buffered_aio_write(Buffered IO 路径)。

    为什么 io_uring 会在这个场景下退化为同步阻塞?

    很多研发对 io_uring 有个致命的误解,认为只要把 IO 丢进 SQE(Submission Queue Entry),内核就会纯异步处理。然而在 Linux VFS/文件系统层,真正的“非阻塞”是非常严苛的。

    io_uring 提交一个写请求时,它会默认带上 IOCB_NOWAIT 标志尝试“内联”(Inline)执行。

    1. 如果是 Direct IO (O_DIRECT) 且不改变文件大小(已分配块):XFS 能够无锁直接下发 BIO,请求立即返回 EIOCBQUEUED,这是最完美的 Fast Path。

    2. 如果是 Buffered IO 或者需要改变文件大小(Append 写)

    3. Buffered IO 需要分配 Page Cache,甚至触发内存回收,这在内核中是无法完全非阻塞的。
    4. Append 写需要分配新的磁盘 Block 并更新 Inode metadata,XFS 必须获取独占的 IOLOCK_EXCL 锁(即堆栈中的 xfs_ilock)。

    如果 XFS 发现无法 NOWAIT 完成,会向 io_uring 返回 -EAGAINio_uring 捕获到 -EAGAIN 后,会将这个 IO 任务打包,丢进内核后台的 io-wq 线程池(Slow Path)。

    在我们的高并发网关中,由于未设置 O_DIRECT,且业务在不断 Append 写新日志文件,导致:

    1. 所有的写请求都在 Fast Path 返回 -EAGAIN

    2. io_uring 疯狂创建 io_wqe_worker 线程来接管任务。

    3. 这些 Worker 线程在执行 XFS 元数据更新时,由于抢占同一个 Inode 的 xfs_ilock,发生严重的锁排队。

    4. 内核 io-wq 线程池有并发上限(受限于 RLIMIT_NPROC 和内部调度机制),当线程池被打满后,io_uring 的主提交线程(如果启用了 SQPOLL,则是 io_sq_thread,否则是用户态的 io_uring_enter 系统调用)也会被迫阻塞。

    这就形成了经典的雪崩链路:Buffered IO/元数据写 -> EAGAIN -> io-wq 线程池爆炸 -> XFS 锁争用打满 IO 栈 -> 核心线程 D 状态阻塞。

    源码级溯源:XFS 与 io-wq 的死亡缠绕

    翻开 Kernel 5.15 的源码,我们可以清晰地看到这个降级逻辑:

    // fs/io_uring.c
    static int io_issue_sqe(struct io_kiocb *req, unsigned int issue_flags)
    {
        // ...
        // 尝试执行写操作,带有 IOCB_NOWAIT
        ret = io_write(req, issue_flags); 
    
        // 如果底层文件系统返回 -EAGAIN,说明无法非阻塞完成
        if (ret == -EAGAIN && !(req->flags & REQ_F_NOWAIT)) {
            // 降级:将任务丢入 io-wq 后台线程池
            return io_queue_async_work(req, NULL);
        }
        // ...
    }
    

    而在 XFS 层:

    // fs/xfs/xfs_file.c
    STATIC ssize_t
    xfs_file_write_iter(struct kiocb *iocb, struct iov_iter *from)
    {
        // 如果是 NOWAIT 且需要更新元数据/加锁失败,直接返回 -EAGAIN
        if (iocb->ki_flags & IOCB_NOWAIT) {
            if (!xfs_ilock_nowait(ip, XFS_IOLOCK_EXCL))
                return -EAGAIN;
        }
        // ...
    }
    

    破局之道:防御性 IO 架构改造

    要让 io_uring 发挥真正的 100K+ IOPS 威力,必须严防死守 Slow Path 降级。针对该网关,我们实施了以下三板斧改造:

    1. 强制启用 O_DIRECT 并对齐内存

    修改文件打开标志,彻底绕过 Page Cache。注意,使用 O_DIRECT 要求用户态 buffer 的内存地址和写入长度必须是块设备逻辑扇区(通常是 512 或 4096 字节)的整数倍。可以使用 posix_memalign 分配内存。

    // 改造前
    int fd = open("data.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
    
    // 改造后 (移除 O_APPEND,加入 O_DIRECT)
    int fd = open("data.log", O_WRONLY | O_CREAT | O_DIRECT, 0644);
    

    2. 利用 fallocate 预分配击穿 XFS 元数据锁

    由于去掉了 O_APPEND,我们要自己维护写入 Offset。更重要的是,为了避免每次写入都触发 XFS 的块分配(Block Allocation)导致获取 IOLOCK_EXCL,必须在文件创建时预分配足够大的空间。

    // 预分配 1GB 空间,保持文件 size 不变 (FALLOC_FL_KEEP_SIZE)
    // 这样后续的 IO 都是纯数据覆写 (Overwrite),XFS 只需要 IOLOCK_SHARED 甚至无锁下发
    if (fallocate(fd, FALLOC_FL_KEEP_SIZE, 0, 1024 * 1024 * 1024) != 0) {
        perror("fallocate failed");
    }
    

    3. 约束 io_uring 的退化行为 (非必须,但推荐)

    在初始化 io_uring 时,可以在 SQE 中显式设置 IOSQE_ASYNC,但这会强制走 io-wq,并非我们想要的。正确做法是依赖系统的默认行为,但通过上述 1 和 2 的改造,确保所有的 IO 都能在 Fast Path 成功,彻底饿死 io_wqe_worker 线程。

    改造后再次压测,5000 QPS 下 Load Average 降至 2.5,iowait 趋近于 0,p99 延迟稳定在 1.2ms,通过 ps 命令再也看不到海量的 io_wqe_worker 线程,系统恢复丝滑。

    常见问题

    Q1:除了 XFS,在 ext4 上使用 io_uring 也会遇到这个问题吗? 会。无论 ext4 还是 btrfs,只要是 Buffered IO,或者涉及到文件 Append 写、打洞(Punch hole)、文件扩容,VFS 层都会面临元数据更新的锁保护。io_uring 遇到无法立即拿到的锁,统统会返回 -EAGAIN 并回退到 io-wq 线程池。这也是为什么高性能存储引擎(如 SPDK, ScyllaDB)坚决只用 O_DIRECT | O_DSYNC + AIO/io_uring 的原因。

    Q2:如何监控系统中 io_uring 的 io-wq 线程数量及退化情况? 可以通过 eBPF 挂载内核探测点。一个简单的 bpftrace 脚本可以统计每秒降级到 io-wq 的请求数:

    bpftrace -e 'kprobe:io_queue_async_work { @[comm] = count(); } interval:s:1 { time("%H:%M:%S\n"); print(@); clear(@); }'
    

    如果看到你的核心业务进程疯狂触发该探针,说明你的 IO 栈配置存在严重问题,正在大量退化。

    Q3:我使用了 O_DIRECT,为什么 io_uring 的 p99 延迟偶尔还是会抖动? 即使是 Direct IO,如果底层 NVMe 硬件队列打满,或者发生了 PCIe 链路层重传,依然会导致延迟上升。此外,XFS 默认开启了 speculative preallocation(推测性预分配),在某些碎片化严重的文件系统上,即便是对齐的覆写,也可能偶尔触发元数据刷新(Journaling),可以通过挂载参数 allocsize 进行微调,或者定期进行 xfs_fsr 碎片整理。

    Q4:启用 IORING_SETUP_SQPOLL 轮询模式能解决阻塞问题吗? 不能。SQPOLL 只是内核启动一个专门的 io_sq_thread 去轮询你的 SQ 队列,省去了你发起 io_uring_enter 系统调用的开销(减少 syscall 上下文切换)。但如果底层的 XFS 依然因为锁争用返回 -EAGAINio_sq_thread 同样会将任务丢给 io-wq,甚至如果 io-wq 阻塞,io_sq_thread 自身也会陷入 D 状态,导致整个提交队列停摆。架构设计不能用并发去掩盖底层的串行锁。

  • 深入 Zabbix 队列雪崩排查:Housekeeper 锁表引发的 MySQL IO 饱和与 History Syncer 堵塞实战

    排查某次监控系统大面积告警延迟事故,Zabbix Dashboard 显示待处理队列(Queue)堆积突破 20 万,告警通知 P99 延迟达到夸张的 2 小时。最终定位:业务团队滥用自定义模板,通过 log[] 键值将大段 Java 报错栈直接写入 Zabbix,导致 history_strhistory_text 表数据暴增;同时 Zabbix 原生 Housekeeper 清理过期数据时触发海量 DELETE 操作,彻底打爆 MySQL InnoDB 的 IOPS,引发 History Syncer 进程全部夯死。解决方案极其粗暴且有效:彻底关闭 Zabbix 原生 Housekeeper,将所有历史与趋势表改造成 MySQL 按天/按月分区表(Table Partitioning),用 DROP PARTITION 替代 DELETE

    故障现场极具讽刺意味:监控系统本身成了最需要被监控的系统。登录 Zabbix Server 节点,系统负载(Load Average)出奇的低,但查看 zabbix_server.log,满屏的红色告警:

    Zabbix server history syncer processes more than 75% busy
    Zabbix server trapper processes more than 75% busy
    [Z3005] query failed: [1205] Lock wait timeout exceeded; try restarting transaction [delete from history_text where itemid=19283 and clock<1698710400]
    

    History Syncer 是 Zabbix 将内存缓存数据刷入数据库的核心进程,它被堵死,意味着前端 Poller 和 Trapper 收集到的数据全憋在 Server 的 Shared Memory 里,最终导致采集停滞、队列爆炸。

    切到 MySQL 数据库节点,罪魁祸首立刻浮出水面。执行 iostat -dx 2 观察磁盘,数据盘的 %util 死死钉在 100%,w/s (每秒写 IO)达到磁盘物理极限,await 延迟飙升到 800ms 以上。 进入 MySQL 终端敲下 SHOW FULL PROCESSLIST;,看到几十个处于 updatingLocked 状态的 SQL 线程,全部是类似这样的语句:

    DELETE FROM history_str WHERE itemid=40281 AND clock < 1698710400 LIMIT 5000;
    DELETE FROM history_text WHERE itemid=40282 AND clock < 1698710400 LIMIT 5000;
    

    技术逻辑其实非常清晰。把关系型数据库当做时序数据库(TSDB)来用,本身就是一种架构妥协。在 InnoDB 引擎中,执行 DELETE 语句删除几百万行历史数据,简直是运维自杀。DELETE 并不是简单地抹掉磁盘空间,而是会产生海量的 Undo Log(用于回滚)和 Redo Log(用于崩溃恢复),同时需要更新 B+ 树索引,引发大量的数据页分裂与 Buffer Pool 内存淘汰(Churn)。 当这种重度 IO 操作遇上业务团队滥用 Zabbix 收集文本日志(把 Zabbix 当 ELK 用),history_text 表的一行数据可能高达几 KB。Housekeeper 一启动,瞬间的随机写 IO 洪峰直接把底层存储击穿。

    为什么说这种配置不可原谅?因为在超过万级 NVPS(每秒处理新值数)的中大型 Zabbix 架构中,依赖原生 Housekeeper 清理数据是标准的新手雷区。Zabbix 官方手册虽然提过分区表的替代方案,但默认安装依然开启 Housekeeper,这坑了无数没有经历过数据量毒打的运维。

    止血与根治方案:

    第一步,紧急止损。立刻修改 zabbix_server.conf,将 Housekeeping 的频率设置为 0,切断 IO 洪峰的源头,并重启 Zabbix Server,等待队列慢慢消化。

    # zabbix_server.conf
    HousekeepingFrequency=0
    

    第二步,架构重构,实施 MySQL 表分区(Table Partitioning)。 原理很简单:将时间序列数据按时间(clock 字段)分散到不同的物理文件中。当需要删除过期数据时,直接 ALTER TABLE ... DROP PARTITION。在文件系统层面,这等同于直接 rm -f 一个物理文件,时间复杂度为 O(1),瞬间释放空间,彻底零 IO 负担,不会产生任何 Undo Log。

    针对 Zabbix 历史表的改造核心 SQL 如下(以 history_uint 为例):

    -- 确保表结构没有外键,且时钟字段是主键/唯一键的一部分
    ALTER TABLE history_uint PARTITION BY RANGE (clock) (
        PARTITION p20231101 VALUES LESS THAN (UNIX_TIMESTAMP('2023-11-02 00:00:00')),
        PARTITION p20231102 VALUES LESS THAN (UNIX_TIMESTAMP('2023-11-03 00:00:00')),
        -- 预先建好未来的分区
        PARTITION p_future VALUES LESS THAN MAXVALUE
    );
    

    配合一个定时执行的 Shell 或 Python 脚本(业内常用 zabbix-mysql-partitioning.pl 脚本),每天零点自动检查并 DROP 掉过期的历史分区,同时 ADD 未来的新分区。

    最后,强烈建议剥离 Zabbix 的文本日志收集职能。Zabbix 核心是数值型时序监控(float/uint),日志型(str/text)数据一律丢给 Filebeat + Elasticsearch 或 Promtail + Loki 去处理。在监控架构里,强行让一个工具做所有事,最后通常是什么都做不好。

    同类问题排查清单(Zabbix 性能雪崩速查)

    1. Zabbix 内部队列指标:在 Zabbix Frontend 检查 Administration -> Queue。如果延迟集中在 10 分钟以上,且大部分是 Zabbix agent (active) 或 Trapper,大概率是 Server 性能瓶颈而非网络问题。

    2. 底层数据库 IOPS 饱和度:通过 iostat -dx 1 或 Node Exporter 的 node_disk_io_time_seconds_total 指标,排查底层数据盘 %util 是否长期处于 90%+。如果是,立即停止 Zabbix Server 进程以保护 DB。

    3. Housekeeper 配置确认:检查 Zabbix Server 配置中的 HousekeepingFrequencyMaxHousekeeperDelete。大规模场景下必须置 0 关闭,改用 DB 原生表分区或直接使用 TimescaleDB/ClickHouse 作为后端。

    4. 滥用监控项审查:在 MySQL 执行 SELECT itemid, COUNT(*) FROM history_text GROUP BY itemid ORDER BY COUNT(*) DESC LIMIT 10;,揪出产生大量文本型历史数据的 Top 10 监控项(Items),并在 Zabbix 页面中直接 Disable,切断污染源。

    5. 缓存击穿指标:查看 Dashboard 中的 Zabbix cache usage, % free。如果 History index cacheValue cache 经常跌破 5%,说明 CacheSizeHistoryCacheSize 配置过小,或者存在大量低频的长周期聚合查询在刷缓存。

  • 深入 Zabbix 监控雪崩排查:LLD 发现风暴引发的 Proxy 缓存积压与 History Syncer 夯死实战

    近期处理了一起 Zabbix 6.0 LTS 集群雪崩事故。根因是某业务线引入劣质自定义 LLD 模板,单机生成逾万监控项,引发 Proxy 缓存打满与 History Syncer 进程 100% 繁忙,最终压垮后端 DB IO 导致全局断连。核心解法:阻断异常 LLD 发现、调优 Zabbix 核心缓存参数,并将底层存储彻底迁移至 PostgreSQL + TimescaleDB 解决写入墙问题。

    故障现场:Queue 积压与 Poller 满载

    排查过程中,监控大屏首先报警的是 Zabbix Queue 严重积压,延迟超过 10 分钟的 item 数量直线飙升破 5 万。登录 Zabbix Server 核心节点,top 命令显示 Load Average 飙升至 80+,系统 iowait 长期盘踞在 40% 以上。

    查看 Zabbix Server 日志 /var/log/zabbix/zabbix_server.log,满屏都是极其致命的告警:

    Zabbix server history syncer processes more than 75% busy
    Zabbix server history syncer processes more than 100% busy
    server is out of memory: Out of memory (data: 256M, index: 64M)
    cannot accept connection from proxy "cn-sh-proxy-01": max number of Trapper processes reached
    

    切到前端分布式 Proxy 节点 /var/log/zabbix/zabbix_proxy.log,同样处于崩溃边缘:

    cannot send proxy data to server at "10.0.0.10": Zabbix server connection failed
    history cache is full, sleeping for 1 second
    

    表象很清晰:数据写不进数据库,导致 Zabbix Server 的 History Syncer(负责将内存数据刷入 DB 的核心进程)全部夯死。Server 端 Trapper 进程耗尽,导致 Proxy 无法上报数据,Proxy 本地的 HistoryCache 被打爆,最终整个监控链路瘫痪。

    为什么一个简单的自定义模板能搞垮整个监控集群?

    很多开发在写 Zabbix 监控脚本时,缺乏“防御性编程”思维。抓取故障现场的 Proxy sqlite3 库(或本地临时文件),发现罪魁祸首是一个名为 Custom_K8s_Pod_Discovery 的 LLD (Low-Level Discovery) 脚本。

    该脚本通过 Python 遍历全量 Pod 状态,但没有做任何 Limit 限制和状态机过滤。单台 Kubernetes Node 上的脚本直接返回了近 5MB 的 JSON Array:

    {
      "data": [
        {"{#PODNAME}": "web-api-7b89f...", "{#NAMESPACE}": "prod", "{#CONTAINER}": "nginx"},
        // ... 往下还有 15000+ 个对象
      ]
    }
    

    Zabbix LLD 引擎在处理这个宏大 JSON 时,会为每一个 {#PODNAME} 动态生成 5 个 Item(CPU、内存、网络 IO 等)。 算一笔账:1 台机器抛出 15000 个实体 $\times$ 5 个 Item = 75000 个监控项。 如果是 100 台节点的集群,瞬间生成 750 万个新监控项

    这些海量监控项每 30 秒采集一次数据,疯狂涌入 Zabbix Proxy。 Proxy 的默认 HistoryCacheSize 仅有区区 16M,瞬间被打满。随后 Proxy 将庞大的 Payload 塞给 Zabbix Server,Server 端的 History Syncer 试图将这几百万条并发写入后端的 MySQL history_uint 表。MySQL InnoDB 面对这种毫无规律的极高频并发 Insert,B+ 树页分裂严重,NVMe 磁盘的 IOPS 直接打满,写延迟达到 500ms 以上,彻底堵死。

    架构级改造:从 MySQL 到 PG+TimescaleDB

    在千万级 Item 的企业监控场景下,MySQL 表分区脚本(如常用的 partitioning.sql 存储过程)不仅维护极其痛苦,且对历史数据的清理依然会产生锁争用。

    解决写入瓶颈的最终态方案,是利用原生时序数据库。Zabbix 从 5.0 开始深度支持 PostgreSQL + TimescaleDB 扩展,将 history 相关的表转化为 hypertable,实现按时间维度的透明 Chunk 分片。

    迁移与落地步骤:

    1. 部署 PostgreSQL 14 与 TimescaleDB 插件。

    2. 导入 Zabbix 基础 Schema 后,务必执行 TimescaleDB 转换脚本:

    # Zabbix 6.0 环境下开启 TimescaleDB 支持
    zcat /usr/share/doc/zabbix-sql-scripts/postgresql/timescaledb.sql | sudo -u zabbix psql zabbix
    
    1. 在 Zabbix Server 开启内部历史数据压缩(极大降低磁盘 IO 并节省 70% 空间):
    -- 连接到 zabbix 库
    UPDATE config SET db_extension='timescaledb', history_compression_status=1, history_compress_older='7d';
    

    切换到 TimescaleDB 后,Zabbix History Syncer 的写操作变成了针对内存中最新 Chunk 的顺序追加写(Append-only),避开了全表扫描和巨型 B-Tree 维护,单机轻松抗住 10万+ QPS 的监控项写入。

    调优与防御性配置落地

    底层存储问题解决后,必须对 Zabbix 核心配置进行防御性加固,防止类似 LLD 风暴再次冲垮服务。

    1. Zabbix Server 核心参数重调

    编辑 /etc/zabbix/zabbix_server.conf

    # 根据物理内存,大幅提高历史缓存,作为 DB 抖动时的缓冲池
    HistoryCacheSize=2G
    HistoryIndexCacheSize=256M
    ValueCacheSize=1G
    
    # 增加数据刷盘进程数(需结合 DB 最大连接数考量)
    StartHistorySyncers=30
    
    # 增加处理 Proxy 和 Agent 主动上报的 Trapper 进程
    StartTrappers=100
    
    # 禁用 Server 端轮询,强制全部走 Proxy 分布式采集
    StartPollers=0
    

    2. Zabbix Proxy 缓冲防御

    编辑 /etc/zabbix/zabbix_proxy.conf

    # 提高 Proxy 侧的缓存,容忍更长时间的 Server 端断连
    HistoryCacheSize=1G
    HistoryIndexCacheSize=128M
    
    # 严格控制外部脚本超时时间,防止进程卡死(默认3秒,最大不超过10秒)
    Timeout=10
    

    3. 数据预处理(Pre-processing)截流

    针对自定义监控项,强制要求在 Zabbix Web UI 的 Item Preprocessing 中配置以下规则:

    • Discard unchanged with heartbeat (心跳抑制): 如果监控值没有变化,直接在 Proxy/Server 端丢弃,只在达到 heartbeat(如 1 小时)时强制写入一次。这能削减 60% 以上的无用状态写入。

    • 正则表达式过滤: 对 LLD 发现的文本进行白名单截断,丢弃非核心进程的数据。

    常见问题

    Q1: Proxy 报错 “Zabbix server connection failed”,但网络 Ping 和 Telnet 都通,如何排查? 通常不是网络问题,而是 Zabbix Server 端的 Trapper 进程全忙。检查 Zabbix Server 监控大屏上的 Zabbix server trapper processes busy 指标是否达到 100%。若是,需调大 StartTrappers,或检查是否有超大 Payload 正在阻塞网络层解析。

    Q2: 监控项经常出现断点,日志提示 “first network error, wait for 15 seconds”,如何优化? 这是 Poller 进程在执行某些慢请求(如大文本抓取、远端 API 调用)时超时了。Zabbix 默认超时 Timeout=3 秒。建议将耗时任务改成 Agent 端的异步 Crontab 写入本地文件,Zabbix 只做简单的 vfs.file.contents 读取;或者将 Timeout 谨慎上调至 10。

    Q3: 迁移到 TimescaleDB 后,Zabbix 的 Housekeeper 还需要开启吗? 绝对不需要对历史表开启。开启 TimescaleDB 后,应在 Zabbix UI 的 “Administration -> General -> Housekeeping” 中,勾选 Override item history period 并启用内部机制。旧数据的清理会由 DB 原生的 drop_chunks() 函数瞬间完成,而不是 Housekeeper 一行行执行极度耗 IO 的 DELETE 语句。

    Q4: 怎样防止自定义 LLD 脚本再次引发灾难? 运维必须剥夺业务组直接创建 LLD Template 的权限。通过 CI/CD 管道扫描业务侧提交的脚本,限制 LLD 返回的 JSON 最大数组长度(如不超过 200)。此外,在 Zabbix 中利用 “LLD overrides” 功能,强制要求匹配特定正则的对象才能触发 Item 发现。

  • 深入 Jenkins 动态构建雪崩排查:Kubernetes 插件 QPS 限流引发的 JNLP 断连与 Pod 孤儿风暴实战

    Jenkins 动态 Agent 架构在处理高并发构建时极易触发系统雪崩。核心元凶通常是 kubernetes-plugin 默认极低的 Client-Go QPS 限制引发 API 节流与 Pod 调度积压,叠加 NAT 网关静默丢弃 JNLP 空闲连接导致断连风暴。破局的关键在于:切换 Agent 通信至 WebSocket 协议,利用底层 System Properties 强行拉高 K8S 客户端 QPS/Burst 阈值,并通过 JCasC 实施防御性的超时与重试固化配置。

    故障现场:几百个 Pipeline 瞬间卡死,Master 线程池耗尽

    某次在应对业务大版本集中发布时,Jenkins(版本 2.426.1 LTSkubernetes-plugin 版本 4136.v7233)出现突发性大面积卡顿。

    现场症状:

    1. 构建积压:超过 300 个 Pipeline 任务处于 pending 状态,卡在 Jenkins doesn’t have label XXX

    2. 僵尸 Pod 泛滥:K8S 集群中存在大量状态为 TerminatingRunning 但未在执行任务的 Jenkins-Agent Pod。

    3. Master 假死:Jenkins Web UI 响应极其缓慢,Load Average 飙升至 80+,JVM 老年代内存使用率长期处于 95% 以上,频繁触发 Full GC。

    通过 jstack 抓取 Jenkins Master 的线程快照,发现大量线程阻塞在 Kubernetes 客户端的 HTTP 请求调度上,同时伴随疯狂报错的系统日志:

    # 报错一:JNLP Ping 超时风暴
    WARNING: Ping thread for channel JNLP4-connect connection from 10.244.5.122:38912 failed.
    java.util.concurrent.TimeoutException: Ping started at 171xxxxxxx hasn't completed by 171xxxxxxx+240000
        at hudson.remoting.PingThread.ping(PingThread.java:132)
    
    # 报错二:Kubernetes Plugin API 限流
    WARNING: Failed to provision a new node. 
    io.fabric8.kubernetes.client.KubernetesClientException: too many requests (429)
        at io.fabric8.kubernetes.client.dsl.internal.OperationSupport.requestFailure(OperationSupport.java:694)
    

    为什么 Jenkins Master 会被 K8S 动态 Agent 拖垮?

    表象是 Jenkins 性能不足,底层其实是通信协议缺陷与默认配置短板在并发场景下的集中爆发。

    1. K8S 插件 Client-Go QPS 限流导致的调度饥饿

    Jenkins Kubernetes 插件底层依赖 fabric8io/kubernetes-client。在缺乏显式配置的情况下,该客户端继承了极低的默认流控阈值(早期版本 QPS=5,Burst=10)。 当瞬间涌入几百个动态 Agent 申请时,Jenkins 向 Kube-APIServer 发起大量的 Pod Create/Watch 请求。触发限流(HTTP 429)后,客户端会指数退避重试。这不仅导致 Pod 迟迟无法拉起,还会使 Master 端负责 Provisioning 的专属线程被长时间挂起,最终耗尽线程池资源,引发 Web UI 卡死。

    2. NAT 网关静默丢弃引发 JNLP 断连风暴

    传统的 JNLP 代理协议基于 TCP长连接(默认端口 50000)。在容器化部署中,Agent Pod 通常经过 NodePort、Ingress 或云厂商的 NAT 网关与 Master 通信。 许多 NAT 网关/防火墙对空闲 TCP 连接有严格的存活期限制(如 5 分钟或更短),若无数据传输会静默丢弃(Drop)连接,且不发送 RST。 Jenkins 默认的 PingThread 检测周期是 4 分钟。当构建任务处于长时间的纯本地编译(如 make -j16)且没有向 Master 输出日志时,TCP 连接会被 NAT 掐断。此时 Master 仍在等待 Ping 回应,直到超时报错终止构建。随后 Master 尝试销毁 Pod,但由于上述的 API 限流,Delete 请求失败,直接产生大量“孤儿 Pod”。

    3. Pipeline CPS 转换引发的 Master CPU 燃烧

    部分研发在 Pipeline 的共享库(Shared Library)中编写了复杂的 for/while 循环或对大体积 JSON 进行了反序列化,且未加 @NonCPS 注解。Jenkins Pipeline 的 Continuation Passing Style (CPS) 引擎会将这些逻辑转换成成百上千个小的状态机对象存储到 Heap 中。大量的状态变更叠加 Agent 断连引发的异常处理逻辑,导致 Master 的 CPU 被 GC 线程和 CPS 引擎彻底吃光。

    极客实战:防御性配置与底层调优

    拒绝修修补补,直接从网络协议、K8S 客户端参数和不可变基础设施层面彻底重构。

    调优 1:废弃 TCP JNLP,全面启用 WebSocket 通道

    WebSocket 基于 HTTP/HTTPS 进行协议升级,复用 80/443 端口。标准 L7 Ingress/LB 对 WebSocket 的保活支持远好于裸 TCP 端口,有效穿透各类严格的防火墙。

    需要在 Jenkins System 中开启 WebSocket 并在 K8S Agent 模板中强制指定。通过 JCasC (Jenkins Configuration as Code) 固化配置如下:

    jenkins:
      cloud:
        kubernetes:
          name: "kubernetes"
          serverUrl: "https://kubernetes.default"
          # 开启 WebSocket 连接
          webSocket: true
          containerCapStr: "200" # 限制最大并发 Pod 数,防止打爆集群
          templates:
            - name: "base-agent"
              label: "base-agent"
              nodeUsageMode: EXCLUSIVE
              containers:
                - name: "jnlp"
                  image: "jenkins/inbound-agent:3148.v532a_7e715ee3-1"
                  # JNLP 容器的防御性资源限制
                  resourceRequestCpu: "500m"
                  resourceLimitCpu: "1000m"
                  resourceRequestMemory: "512Mi"
                  resourceLimitMemory: "1024Mi"
    

    调优 2:暴力破解 K8S 客户端并发限制

    直接通过 JVM 启动参数(System Properties),向 Kubernetes 客户端注入高并发阈值配置,并缩短 JNLP 的 Ping 超时窗口以尽早发现死连接。

    在 Jenkins Master 的 Deployment/StatefulSet 中注入以下 JAVA_OPTS

    # 提升 fabric8 k8s client 并发上限 (根据 API Server 承载能力调整)
    -Dorg.csanchez.jenkins.plugins.kubernetes.clients.Qps=50
    -Dorg.csanchez.jenkins.plugins.kubernetes.clients.Burst=100
    
    # 优化 JNLP Ping 机制:2分钟 Ping 一次,超时时间设为 1 分钟 (默认 4 分钟太迟钝)
    -Dhudson.remoting.PingThread.pingIntervalSecs=120
    -Dhudson.remoting.PingThread.pingTimeoutSecs=60
    
    # 优化 GC:大内存下启用 G1GC 并开启字符串去重 (缓解 CPS 转换导致的字符串常量泛滥)
    -XX:+UseG1GC -XX:+UseStringDeduplication -Xms8g -Xmx8g
    

    调优 3:Pipeline 共享库死锁的防御拦截

    针对耗时的 JSON 解析和复杂的集合遍历,强制在共享库代码层面引入 @NonCPS 注解,将计算任务剥离出 Jenkins Master 的状态机保存机制,交由原生 JVM 栈执行:

    import groovy.json.JsonSlurper
    import com.cloudbees.groovy.cps.NonCPS
    
    // 错误示范:在 CPS 块中解析大 JSON,极易导致 Master OOM 或 CPU 100%
    // def parseJson(String text) { return new JsonSlurper().parseText(text) }
    
    // 正确实战:防御性声明,计算完毕后直接返回结果,不保留中间状态
    @NonCPS
    def parseJsonFast(String text) {
        def slurper = new JsonSlurper()
        return slurper.parseText(text)
    }
    

    常见问题 (FAQ)

    Q1:Pipeline 卡在 “Waiting for next available executor”,但 K8S 集群明明有充足的 CPU/Memory 资源? A: 检查 Jenkins Master 是否达到了 containerCap 上限(默认 100)。即使集群有资源,Jenkins Kubernetes 插件也会拒绝发起新的 Pod 创建请求。另外,确认 Agent 模板中的 label 是否与 Pipeline 中声明的一致,拼写错误会导致无限期等待。

    Q2:通过 JCasC 更新了共享库 (Shared Library) 的分支,为什么重新构建时没有立刻生效? A: Jenkins 针对 Shared Library 默认开启了基于 Workspace 的缓存机制。如果在短时间内连续触发构建,可能会复用上一次 clone 的旧版本代码。可以在共享库配置中勾选 Include @Library changes in job recent changes 或在 JCasC 中显式关闭库的深度缓存(调整 retriever 的 timeout 策略),同时确认 Jenkins 服务器本地时间与 Git 仓库时间没有出现钟摆漂移。

    Q3:Pipeline 运行中抛出 java.io.NotSerializableException: java.util.regex.Matcher 报错,如何排查? A: 这是极其典型的 CPS 污染问题。Jenkins Pipeline 遇到 shsleep 等步骤时,会将当前所有的局部变量序列化保存到磁盘。如果在上述步骤前定义了不可序列化的对象(如 Regex Matcher、Socket 连接、I/O 流),序列化就会崩溃。 解法: 将对 Matcher 的操作封装到一个使用 @NonCPS 修饰的函数中执行,或者在使用完该对象后立即将其设为 null,确保其在跨越 Node/Agent 边界或进入挂起状态前被抛弃。