上一篇 下一篇 分享链接 返回 返回顶部

如何在香港服务器的 RHEL 9 上用 BPF map 统计 CN2 带宽利用率并提前预警流量瓶颈

发布人:Minchunlin 发布时间:2025-09-04 17:12 阅读量:572
凌晨 2:40,香港机房 7 楼,ToR 交换机的指示灯像海面上的渔火一闪一闪。CN2 口的利用率在 Grafana 上突然拉了一条直线,我盯着那条线,心想:要不是上周把 eBPF 监控和预警做了,这会儿恐怕又得从 NAT 到 LVS 一层层扒日志找元凶了。
这篇文章,是那次彻夜排查之后我整理的“作战手册”。它不是一篇泛泛的入门教程,而是我在 RHEL 9 上用 eBPF + map 做 CN2 带宽实时统计、阈值预警、以及上线踩坑复盘的完整记录。你能直接照着打;新手能跑起来,老手能看出门道并按需改造。

目标与思路

目标:对“接 CN2 的出入口物理口/逻辑口”做秒级带宽采样,得到稳定的入口/出口bps与带宽利用率(Utilization),并在达到阈值时进行“短时拉高”和“持续拥挤”两类告警,避免真正的瓶颈到来时才发现。
思路:
用 tc-BPF(clsact 的 ingress/egress 钩子)在数据包经过 qdisc 层时低开销地累加字节数。
eBPF 里只做加法,不做复杂逻辑。
使用 PERCPU HASH map 减少热点与锁竞争。
用户态常驻进程每秒汇总 map、做增量求差得到速率(bps),再按端口带宽计算利用率并执行告警逻辑。
指标暴露为 Prometheus /metrics,Grafana 展示;同时通过 Webhook(Slack/飞书等)做即时预警。
机房与环境参数(实打实)
类别 规格/版本 说明
机房 香港将军澳机柜 x2 双上联,ToR 聚合
服务器 2× Intel Xeon Silver 4210R / 128GB RAM 10G SFP+ 双口
网卡 Intel X710-DA2(ixgbe/ice 家族) 单机 CN2 专用口 eth1
操作系统 RHEL 9.2(内核 5.14.x) 默认支持 BPF & BTF
上联 CN2 GIA 1 Gbps 物理口 目标口:eth1(或 bond1.392 等 VLAN 子接口)
编译工具 clang/llvm、bpftool、libbpf 通过 dnf 安装
监控 Prometheus + Grafana 10s 抓取/1s 采样
预警 Slack Webhook 双阈值策略
注:如果你的 CN2 是 VLAN 子接口(如 bond1.392),建议只在逻辑口挂 BPF,避免在从口重复计数。

前置检查与安装

# 1) 基础包
sudo dnf -y install clang llvm bpftool libbpf libbpf-devel \
  kernel-headers kernel-devel elfutils-libelf-devel make git \
  iproute-tc ethtool golang

# 2) 挂载 bpf 和 tracefs(通常已挂载)
sudo mount -t bpf bpf /sys/fs/bpf || true
sudo mount -t tracefs nodev /sys/kernel/tracing || true

# 3) 确认 BPF JIT(可选)
cat /proc/sys/net/core/bpf_jit_enable
# 若为 1/2 均可;不是 1 就:
echo 1 | sudo tee /proc/sys/net/core/bpf_jit_enable

# 4) 找到 CN2 接口 ifindex
cat /sys/class/net/eth1/ifindex

方案设计取舍(为何选 tc-BPF)

Ingress/egress 都要统计:XDP 只在 ingress;我们需要 egress → 选 tc。
精度 vs 开销:在 tc egress 统计字节时,TSO/GSO 可能导致“分段后开销”与 skb->len 不完全一致,但对利用率足够准确;必要时在用户态做1.02~1.05 的校准系数(下文会讲)。
map 结构:只按 (ifindex, dir) 维度累计总字节,用户态每秒取一次和上次差值做速率,无需在内核端按时间切桶,避免 map 膨胀。
 
eBPF 程序(tc ingress/egress 计数)
 
文件:cn2_bw.bpf.c
// SPDX-License-Identifier: GPL-2.0
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>


char LICENSE[] SEC("license") = "GPL";


struct key {
    __u32 ifindex;
    __u32 dir; // 0=ingress, 1=egress
};


// per-CPU hash,value 是累计字节数
struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_HASH);
    __uint(max_entries, 1024);
    __type(key, struct key);
    __type(value, __u64);
} bytes SEC(".maps");


// 统一的累加函数
static __always_inline int count_bytes(struct __sk_buff *skb, __u32 dir)
{
    struct key k = {
        .ifindex = skb->ifindex,
        .dir = dir,
    };
    __u64 initv = 0, *val = bpf_map_lookup_elem(&bytes, &k);
    __u64 len = (__u64)skb->len;


    if (!val) {
        // 第一次出现该 key,插入 0 后再取
        bpf_map_update_elem(&bytes, &k, &initv, BPF_NOEXIST);
        val = bpf_map_lookup_elem(&bytes, &k);
        if (!val) return BPF_OK;
    }
    // 累加字节
    __sync_fetch_and_add(val, len);
    return BPF_OK;
}


SEC("tc")
int tc_ingress(struct __sk_buff *skb)
{
    return count_bytes(skb, 0);
}


SEC("tc")
int tc_egress(struct __sk_buff *skb)
{
    return count_bytes(skb, 1);
}
编译:
 
clang -O2 -g -target bpf -D__TARGET_ARCH_x86 -c cn2_bw.bpf.c -o cn2_bw.bpf.o
 
加载与挂载(只在 CN2 口)
 
我们用 clsact:一个 qdisc 同时提供 ingress/egress 挂载点;全程 root/CAP_SYS_ADMIN。
DEV=eth1  # 你的 CN2 接口

# 1) 添加 clsact(幂等处理)
sudo tc qdisc del dev $DEV clsact 2>/dev/null || true
sudo tc qdisc add dev $DEV clsact

# 2) 挂 ingress/egress 的 eBPF 程序
sudo tc filter add dev $DEV ingress bpf direct-action obj cn2_bw.bpf.o sec tc_ingress
sudo tc filter add dev $DEV egress  bpf direct-action obj cn2_bw.bpf.o sec tc_egress

# 3) 把 map pin 到 bpffs,便于用户态读取
sudo bpftool prog show | grep cn2_bw | awk '{print $1,$2,$3,$4,$5,$6,$7,$8}'
# 找到 bytes map 的 id
MAP_ID=$(sudo bpftool map show | awk '/bytes/ {print $1}' | sed 's/id//')
sudo mkdir -p /sys/fs/bpf/cn2bw
sudo bpftool map pin id $MAP_ID /sys/fs/bpf/cn2bw/bytes

# 验证(查看当前 map)
sudo bpftool map dump pinned /sys/fs/bpf/cn2bw/bytes | head
如果你使用 VLAN 子接口(如 bond1.392),把 DEV 改成该逻辑口即可。不要同时在物理从口上再挂一遍,避免重复计数。

用户态 Exporter(Go)——计算速率与告警

我们写个轻量 Go 程序:
 
每 1 秒汇总 PERCPU 值,做增量差分拿到 bps_in / bps_out。
 
按配置中的 port_capacity_bps 算利用率;触发两种告警:

瞬时告警:过去 30s 内,任意 20s 有 util > 80%。

持续告警:过去 10min 的 p95(util) > 70%。
 
暴露 /metrics,并可通过 Webhook 推送。
 
配置:/etc/cn2-bw/config.yaml
 
listen_addr: ":9109"
interfaces:
  - name: "eth1"          # CN2 口
    ifindex: 0            # 0 表示自动从 /sys/class/net/name/ifindex 读取
    port_capacity_bps: 1000000000   # 1 Gbps
    tso_correction: 1.03  # egress 校准系数(TSO/GSO 情况下适当>1)
alerting:
  webhook: "https://hooks.slack.com/services/XXXX/XXXX/XXXX"
  instant_util_threshold: 0.80
  instant_window_sec: 30
  instant_required_high_secs: 20
  sustained_util_p95_threshold: 0.70
  sustained_window_sec: 600
bpf:
  pinned_map: "/sys/fs/bpf/cn2bw/bytes"
  sample_interval_sec: 1
 
Go 程序:cmd/cn2-bw-exporter/main.go(使用 cilium/ebpf)
 
下面代码可直接用;它不依赖 bpf skeleton,只读 pinned map 的原始 key/value。
 
package main


import (
"bytes"
"encoding/binary"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"


"github.com/cilium/ebpf"
"gopkg.in/yaml.v3"
)


type IfCfg struct {
Name             string  `yaml:"name"`
Ifindex          int     `yaml:"ifindex"`
PortCapacityBps  uint64  `yaml:"port_capacity_bps"`
TsoCorrection    float64 `yaml:"tso_correction"`
}


type AlertCfg struct {
Webhook                    string  `yaml:"webhook"`
InstantUtilThreshold       float64 `yaml:"instant_util_threshold"`
InstantWindowSec           int     `yaml:"instant_window_sec"`
InstantRequiredHighSecs    int     `yaml:"instant_required_high_secs"`
SustainedUtilP95Threshold  float64 `yaml:"sustained_util_p95_threshold"`
SustainedWindowSec         int     `yaml:"sustained_window_sec"`
}


type Cfg struct {
ListenAddr string  `yaml:"listen_addr"`
Interfaces []IfCfg `yaml:"interfaces"`
BPF struct {
PinnedMap          string `yaml:"pinned_map"`
SampleIntervalSec  int    `yaml:"sample_interval_sec"`
} `yaml:"bpf"`
Alerting AlertCfg `yaml:"alerting"`
}


type key struct {
Ifindex uint32
Dir     uint32 // 0 ingress, 1 egress
}


type perIfStats struct {
mu sync.Mutex
// 累计字节
totalIn, totalOut uint64
// 上一次读到的累计字节
lastIn, lastOut uint64
// 当前速率(bps)
bpsIn, bpsOut float64
// 最近窗口的利用率缓存
utilHist []float64
}


func mustIfindex(name string) int {
if idx, err := os.ReadFile("/sys/class/net/" + name + "/ifindex"); err == nil {
i, _ := strconv.Atoi(strings.TrimSpace(string(idx)))
return i
}
log.Fatalf("cannot read ifindex for %s", name)
return 0
}


func p95(xs []float64) float64 {
if len(xs) == 0 {
return 0
}
cp := append([]float64(nil), xs...)
// 简单选择算法
n := int(math.Ceil(0.95 * float64(len(cp))))
for i := 0; i < n; i++ {
minIdx := i
for j := i + 1; j < len(cp); j++ {
if cp[j] < cp[minIdx] {
minIdx = j
}
}
cp[i], cp[minIdx] = cp[minIdx], cp[i]
}
return cp[n-1]
}


func postWebhook(url, text string) {
if url == "" { return }
body := `{"text":` + strconv.Quote(text) + `}`
http.Post(url, "application/json", bytes.NewBufferString(body))
}


func main() {
// 读取配置
cfgData, err := os.ReadFile("/etc/cn2-bw/config.yaml")
if err != nil { log.Fatal(err) }
var cfg Cfg
if err := yaml.Unmarshal(cfgData, &cfg); err != nil { log.Fatal(err) }


// 打开 pinned map
m, err := ebpf.LoadPinnedMap(cfg.BPF.PinnedMap, nil)
if err != nil { log.Fatalf("open pinned map: %v", err) }
defer m.Close()


// 接口状态结构
stats := map[int]*perIfStats{}
for i := range cfg.Interfaces {
if cfg.Interfaces[i].Ifindex == 0 {
cfg.Interfaces[i].Ifindex = mustIfindex(cfg.Interfaces[i].Name)
}
stats[cfg.Interfaces[i].Ifindex] = &perIfStats{
utilHist: make([]float64, 0, cfg.Alerting.SustainedWindowSec),
}
}


// 抓取协程
go func() {
ticker := time.NewTicker(time.Duration(cfg.BPF.SampleIntervalSec) * time.Second)
defer ticker.Stop()


for range ticker.C {
// 遍历 map
itr := m.Iterate()
var rawK, rawV []byte
for {
if ok := itr.Next(&rawK, &rawV); !ok {
if err := itr.Err(); err != nil && err != io.EOF {
log.Printf("iterate err: %v", err)
}
break
}
if len(rawK) < 8 || len(rawV) < 8 { continue }
var k key
var v uint64
binary.Read(bytes.NewReader(rawK[:8]), binary.LittleEndian, &k)
binary.Read(bytes.NewReader(rawV[:8]), binary.LittleEndian, &v)


if st, ok := stats[int(k.Ifindex)]; ok {
st.mu.Lock()
if k.Dir == 0 {
st.totalIn = v
} else {
st.totalOut = v
}
st.mu.Unlock()
}
}
// 计算 bps 和利用率
for _, ic := range cfg.Interfaces {
st := stats[ic.Ifindex]
st.mu.Lock()
dIn := st.totalIn - st.lastIn
dOut := st.totalOut - st.lastOut
st.lastIn = st.totalIn
st.lastOut = st.totalOut


interval := float64(cfg.BPF.SampleIntervalSec)
st.bpsIn = float64(dIn)*8.0/interval
st.bpsOut = float64(dOut)*8.0/interval * ic.TsoCorrection


util := math.Max(st.bpsIn, st.bpsOut) / float64(ic.PortCapacityBps)
if len(st.utilHist) >= cfg.Alerting.SustainedWindowSec {
st.utilHist = st.utilHist[1:]
}
st.utilHist = append(st.utilHist, util)
st.mu.Unlock()
}
}
}()


// 告警协程
go func() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for range ticker.C {
for _, ic := range cfg.Interfaces {
st := stats[ic.Ifindex]
st.mu.Lock()
h := append([]float64(nil), st.utilHist...)
st.mu.Unlock()


// 即时告警(过去 N 秒有 M 秒超过阈值)
if len(h) >= cfg.Alerting.InstantWindowSec {
win := h[len(h)-cfg.Alerting.InstantWindowSec:]
countHigh := 0
for _, u := range win {
if u >= cfg.Alerting.InstantUtilThreshold { countHigh++ }
}
if countHigh >= cfg.Alerting.InstantRequiredHighSecs {
postWebhook(cfg.Alerting.Webhook,
fmt.Sprintf("[CN2拥塞-瞬时] iface=%s util>=%.0f%% %d/%ds",
ic.Name, cfg.Alerting.InstantUtilThreshold*100,
countHigh, cfg.Alerting.InstantWindowSec))
}
}


// 持续拥塞(p95 超阈)
if len(h) >= cfg.Alerting.SustainedWindowSec {
p := p95(h[len(h)-cfg.Alerting.SustainedWindowSec:])
if p >= cfg.Alerting.SustainedUtilP95Threshold {
postWebhook(cfg.Alerting.Webhook,
fmt.Sprintf("[CN2拥塞-持续] iface=%s p95(util)=%.1f%% in last %ds",
ic.Name, p*100, cfg.Alerting.SustainedWindowSec))
}
}
}
}
}()


// Prometheus /metrics
http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
var b strings.Builder
b.WriteString("# HELP cn2_bps_in Ingress bits per second\n")
b.WriteString("# TYPE cn2_bps_in gauge\n")
b.WriteString("# HELP cn2_bps_out Egress bits per second\n")
b.WriteString("# TYPE cn2_bps_out gauge\n")
b.WriteString("# HELP cn2_utilization Link utilization (0-1)\n")
b.WriteString("# TYPE cn2_utilization gauge\n")


for _, ic := range cfg.Interfaces {
st := stats[ic.Ifindex]
st.mu.Lock()
b.WriteString(fmt.Sprintf("cn2_bps_in{iface=%q} %f\n", ic.Name, st.bpsIn))
b.WriteString(fmt.Sprintf("cn2_bps_out{iface=%q} %f\n", ic.Name, st.bpsOut))
util := math.Max(st.bpsIn, st.bpsOut) / float64(ic.PortCapacityBps)
b.WriteString(fmt.Sprintf("cn2_utilization{iface=%q} %f\n", ic.Name, util))
st.mu.Unlock()
}
io.WriteString(w, b.String())
})
log.Printf("listening on %s", cfg.ListenAddr)
log.Fatal(http.ListenAndServe(cfg.ListenAddr, nil))
}
 
编译与运行:
 
cd /opt && sudo mkdir -p cn2-bw && cd cn2-bw
sudo mkdir -p cmd/cn2-bw-exporter
# 将 main.go 放到 cmd/cn2-bw-exporter/ 下
go mod init cn2-bw
go get github.com/cilium/ebpf gopkg.in/yaml.v3
go build -o /usr/local/bin/cn2-bw-exporter ./cmd/cn2-bw-exporter


# 配置文件
sudo mkdir -p /etc/cn2-bw
sudo tee /etc/cn2-bw/config.yaml >/dev/null <<'YAML'
# (粘贴上面的 YAML)
YAML


# systemd 服务
sudo tee /etc/systemd/system/cn2-bw-exporter.service >/dev/null <<'UNIT'
[Unit]
Description=CN2 bandwidth exporter (eBPF)
After=network-online.target


[Service]
ExecStart=/usr/local/bin/cn2-bw-exporter
Restart=always
RestartSec=2
AmbientCapabilities=CAP_BPF CAP_NET_ADMIN CAP_PERFMON
CapabilityBoundingSet=CAP_BPF CAP_NET_ADMIN CAP_PERFMON
NoNewPrivileges=true


[Install]
WantedBy=multi-user.target
UNIT


sudo systemctl daemon-reload
sudo systemctl enable --now cn2-bw-exporter
 
小贴士:AmbientCapabilities 在较新 systemd 可用;否则直接以 root 跑即可。
 

验证与标定(用 iperf3 + ethtool 做对齐)

 
拉流/推流压测(另一台机器对测):
 
# server 端(对端)
iperf3 -s

# 本机:上行 800Mbps 持续 120s
iperf3 -c <server-ip> -u -b 800M -l 1400 -t 120
 
查看 Exporter 指标(本机):
 
curl -s http://127.0.0.1:9109/metrics | egrep 'cn2_bps_|cn2_utilization'
 
对比网卡硬件计数器:
 
ethtool -S eth1 | egrep 'tx_bytes|rx_bytes'
 
若发现 tc egress 的 bps 与 ethtool tx_bytes 的推导值存在**2~5%**差异,多半来自 TSO/GSO。把 tso_correction 调成 1.02~1.05,以贴近硬件值。
 

一段真实的 1 分钟数据(5 秒整点采样)

时间(壁钟) Ingress bps Egress bps Util(Max(in,out)/1Gbps)
02:31:00 120,531,000 225,004,000 0.225
02:31:05 118,220,000 802,114,000 0.802
02:31:10 115,908,000 840,321,000 0.840
02:31:15 121,777,000 876,992,000 0.877
02:31:20 119,304,000 901,210,000 0.901
02:31:25 117,998,000 915,433,000 0.915
02:31:30 118,412,000 934,551,000 0.935
02:31:35 120,083,000 951,002,000 0.951
02:31:40 121,775,000 968,331,000 0.968
02:31:45 119,990,000 983,114,000 0.983
02:31:50 118,207,000 995,442,000 0.995
02:31:55 118,331,000 999,882,000 1.000
 
上面这个窗口里,Exporter 会先触发瞬时告警(>80% 占 20/30 秒),随后持续告警在 10 分钟窗口 p95>70% 时出现。夜里那条拉成“刀片”的曲线,就是这样被提前捕捉到的。
 
Grafana 展示与 Prometheus 抓取
 
Prometheus scrape_config:
 
- job_name: 'cn2-bw'
  scrape_interval: 10s
  static_configs:
    - targets: ['10.0.0.12:9109']
 
Grafana 面板建议三条曲线:cn2_bps_in、cn2_bps_out、cn2_utilization(右轴 0~1),再放两条水平阈值线(0.8 和 0.7 的 p95)。

现场踩坑与解决过程

Secure Boot / Lockdown
一台 DELL 主机开启了 Secure Boot,导致加载 BPF 程序报权限问题。
解决:
临时:在该台服务器 BIOS 关闭 Secure Boot。
长期:使用签名内核或按照企业安全策略评估 eBPF 加载策略;确保 bpf_jit_enable=1。
 
重复添加 clsact
CI 自动化脚本重复 tc qdisc add 报错。
解决:先 tc qdisc del ... || true 再 add;或者使用 tc qdisc replace(部分版本不可用时仍用 del/add)。
 
Bonding 双口重复计数
早期误把程序挂在 bond1 和从口 ens3f0/ens3f1 上,导致统计翻倍。
解决:只挂在上层逻辑口(或只在承载 CN2 VLAN 的子接口上)。
 
TSO/GSO 误差
egress 计数与硬件字节有 2~5% 差距。
解决:增加 tso_correction 配置做系数校正;或在业务低谷短时关闭 TSO 校准后再打开(不推荐长期关闭,影响吞吐)。
 
map 容量与热点
开始时把 key 设计成“ifindex+秒级时间桶”,map entry 激增、CPU 开销抖动。
解决:简化为总字节累加,用户态做差分;PERCPU HASH 大幅降低锁竞争。
 
VLAN 场景
CN2 在 bond1.392,但我首发挂在 bond1,NAT/路由导致与期望口不完全一致。
解决:就近挂载在真实承载的 VLAN 子接口上,路径最清晰。
 
libbpf 版本/内核 BTF 对不齐
另一批机器装了奇怪版本的 bpftool/libbpf,加载报 BTF 错。
解决:RHEL 9 统一用官方仓库包;必要时用 CO-RE(本文直接用 tc + pinned map,不依赖 skeleton,兼容性更强)。

回滚与卸载(最好也写进 SOP)

DEV=eth1
sudo tc filter del dev $DEV ingress
sudo tc filter del dev $DEV egress
sudo tc qdisc del dev $DEV clsact
sudo rm -f /sys/fs/bpf/cn2bw/bytes
sudo systemctl disable --now cn2-bw-exporter

FAQ:几个常见问题

Q:为什么不用 XDP?
A:我们需要 egress 统计;XDP 只有 ingress。也可 ingress 用 XDP、egress 用 tc,但维护两套代码对团队不必要。
 
Q:能不能做“目的 ASN=电信”的精细维度?
A:可以,但要在 eBPF 里做 L3/L4 解析与五元组散列,开销与 map 维度会飙升。生产里我只在待压测的短窗口开细粒度采样,平时仍用粗粒度总量 + 路由级别的聚合分析(如 sFlow/NetFlow)。
 
Q:RHEL 9 的稳定性如何?
A:5.14 LTS 回溯补丁稳定,BTF 完整,配合官方 bpftool 基本“开箱即用”。

最后的故事:灯重新暗下来时

那晚 3:10,预警从“红”退回“黄”,CN2 出口利用率从 99% 稳到 65%。
我在 Grafana 上把那段“刀片”截了图,贴到 Slack,下面的回帖是 CDN 的突发回源流量已限速成功。
 
过去我们总在“峰值发生后”复盘,现在我们能在“瓶颈到来前”预判。这套 eBPF + map 的监控和预警,让带宽这个最硬的“水管子”第一次变得有弹性、有边界、有温度。
 
如果你的 CN2 也时不时“拉满”,不妨照着这篇做——先让监控说话,再让预警站出来。等下次指示灯又亮起来,你会庆幸今晚不是孤身一人。

附:一份极简上线清单(Checklist)

 明确 CN2 接口(物理口或 VLAN 子接口)
 
 安装 clang/llvm、bpftool、libbpf、iproute-tc
 
 编译并加载 cn2_bw.bpf.o 到 clsact ingress/egress
 
 pin bytes map 到 /sys/fs/bpf/cn2bw/bytes
 
 部署 Exporter 与 /metrics
 
 Prometheus 抓取 + Grafana 看板
 
 Webhook 告警阈值调优(瞬时 & 持续)
 
 TSO/GSO 校准对齐到硬件计数
 
 编写回滚 SOP 并演练一次
 
祝顺利,把“瓶颈”留在图上,把“余量”留给业务。
目录结构
全文