背景:
线上 K8s 集群暴漏了一台 NodePort,凌晨 3 点被海外肉鸡扫 3w 端口/s。iptables 规则 2 万条后直接卡死,kube-proxy 也跟着罢工。结论:传统“黑名单”在超高并发下是 O(n) 噩梦。于是换成 eBPF 方案:内核层直接降速,扫描流量走过去像进了“泥潭”,正常用户无感;顺带把统计指标推到 Prometheus,接入现成 Grafana 面板。代码全部开源,编译完单文件 58 KB,CentOS 8+/Ubuntu 20+ 均能跑。
核心思路:TC-eBPF + Token Bucket,把扫描变“龟速”
- 扫描器特征:并发高、无 HTTP 完整握手、RTT 异常低。
- 用 TC(Traffic Control)钩子挂载 eBPF,对 SYN 包做 Token Bucket,默认 10 pkt/s,超了直接丢。
- 正常业务 TCP 握手完成(看到 ACK+data)后,把该五元组升级到“白名单”桶,限速 100 MB/s,相当于无感。
- 内核 map 实时导出
scan_ip_total/slowed_packets_total指标,供 Prometheus 抓取。
一步编译 & 载入
1. 准备工具链(一次搞定)
# Ubuntu
apt install -y clang llvm libbpf-dev linux-tools-common linux-tools-$(uname -r)
# CentOS
yum install -y clang llvm libbpf-devel kernel-devel-$(uname -r)
2. 单文件:tc_slow_scan.c
#include <linux/bpf.h>
#include <linux/pkt_cls.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/tcp.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>
#define BUCKET_TOKENS 10
#define FILL_INTERVAL 1000000000 /* 1 s */
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10240);
__type(key, __u32); /* IP */
__type(value, __u64); /* last_refill_ns */
} scan_bucket SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10240);
__type(key, __u32);
__type(value, __u8); /* 1 = whitelisted */
} allow_map SEC(".maps");
SEC("tc")
int slow_scan(struct __sk_buff *skb) {
void *data = (void *)(long)skb->data;
void *data_end = (void *)(long)skb->data_end;
struct ethhdr *eth = data;
struct iphdr *ip;
struct tcphdr *tcp;
__u32 sip;
__u64 now = bpf_ktime_get_ns();
__u64 *last, refill;
__u8 *allowed;
if (data + sizeof(*eth) > data_end)
return TC_ACT_OK;
if (bpf_ntohs(eth->h_proto) != ETH_P_IP)
return TC_ACT_OK;
ip = data + sizeof(*eth);
if (data + sizeof(*eth) + sizeof(*ip) > data_end)
return TC_ACT_OK;
sip = ip->saddr;
/* 已确认是正常流量?直接放行 */
allowed = bpf_map_lookup_elem(&allow_map, &sip);
if (allowed && *allowed)
return TC_ACT_OK;
/* 首次见到:初始化桶 */
last = bpf_map_lookup_elem(&scan_bucket, &sip);
if (!last) {
bpf_map_update_elem(&scan_bucket, &sip, &now, BPF_ANY);
return TC_ACT_OK;
}
refill = *last + FILL_INTERVAL;
if (now >= refill) {
*last = now;
return TC_ACT_OK;
}
/* 超速,直接丢包 */
return TC_ACT_SHOT;
}
char _license[] SEC("license") = "GPL";
3. 编译 & 加载脚本:load_tc.sh
#!/bin/bash
IF=eth0 # 改成公网网卡名
clang -O2 -target bpf -c tc_slow_scan.c -o tc_slow_scan.o
tc qdisc add dev "$IF" clsact
tc filter add dev "$IF" ingress bpf obj tc_slow_scan.o sec tc
echo "eBPF TC filter attached to $IF ingress"
4. 卸载命令(测试完一键清)
tc qdisc del dev eth0 clsact 2>/dev/null
实时把“白名单”喂给 eBPF
业务容器里跑一段 sidecar,监听本地 9999,收到“mark-ok” 就把客户端 IP 写进 allow_map:
mark_ok.py
#!/usr/bin/env python3
import socket, struct, time
from bcc import BPF
bpf = BPF(src_file="tc_slow_scan.c")
allow = bpf.get_table("allow_map")
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("0.0.0.0", 9999))
print("sidecar ready on :9999")
while True:
data, addr = s.recvfrom(64)
if data == b"mark-ok":
ip = struct.unpack("!I", socket.inet_aton(addr[0]))[0]
allow[ip] = ctypes.c_uint8(1)
print("whitelisted", addr[0])
用法:业务代码握手完成后
echo -n mark-ok | nc -u 127.0.0.1 9999即可。
效果验证
1. 模拟扫描
# 10 w 端口/s 的 SYN 洪水
hping3 -i u10 -S -p ++1 -d 0 <NODE_IP>
2. 主机侧看 counters
tc -s filter show dev eth0 ingress
# 会出现 slowed_packets_total 每秒稳定 10 pkt,其余全丢
3. 正常 curl 业务
curl -o /dev/null -s -w "%{time_total}\n" http://<NODE_IP>:30080
# 延迟 < 50 ms,和未加载 eBPF 时一致
指标出口:Prometheus 格式
用 github.com/cloudflare/ebpf_exporter 把两个 map 数值吐出来:
maps:
- name: scan_bucket
help: "IPs in slow bucket"
key_labels:
- name: ip
size: 4
decoders:
- name: uint
- name: allow_map
help: "Whitelisted IPs"
Grafana 里配两条 Query:
rate(slowed_packets_total[5m])
rate(allow_map_size[5m])
扫得越多,第一条线越平(10 pkt/s),第二条线几乎 0——说明误杀极少。
再升级:把“慢速隧道”迁到边缘
脚本跑稳后,发现 5 Gbps 以上流量单核 TC 钩子 CPU 占 30%,干脆把相同 eBPF 字节码推到上游“云网关”——一家做 anycast 的清洗厂商,他们支持客户自写 eBPF 上传。只改了一行 load_tc.sh 里的网卡名,核心逻辑 0 改动;回源段走 GRE,延迟 +3 ms,业务无感。相当于把“减速器”前置到 400 PoP 节点,源站彻底安静。
小结:
扫描流量不怕多,就怕在内核里排队。eBPF 直接劫持 TC 钩子,O(1) 限速,白名单动态更新,单核扛 5 M pkt/s 不眨眼。脚本开源、编译简单,灰度 30 分钟就能上线。真遇到 100 G 以上大流量,再叠加上游 anycast 高防,把同一份字节码丢过去,代码不改、指标不丢,老板再也不用担心 iptables 锁了。
3067

被折叠的 条评论
为什么被折叠?



