大带宽服务器自建DDoS第一道闸:XDP吞掉800万pps的SYN,`ip -s link`却报零丢包

上个月帮一个做游戏登录网关的团队看一台25G独服,被8Mpps的SYN加UDP小包混着打。业务表现是:CPU总利用率只有18%,top看不到任何进程飙升,机器能ping通,但玩家就是连不上,ss -lnt看到80端口的Recv-Q常年贴着4096。

他们第一反应是上iptables -m hashlimit。我让他们先别写规则——8Mpps的包进netfilter,每个包都要建conntrack条目、算hash、走一遍钩子,一个核撑死100万pps,中途就会把softirq打满。真正该做的第一道闸是XDP,在驱动NAPI poll里就把包扔掉,不分配skb、不进协议栈。

但部署过程中踩到的坑,比SYN洪水本身恶心多了。这篇把过程和代码全交代清楚。

一、先确认包到底丢在哪一层,别一上来就写规则

90%的人跳过这一步,直接装XDP,然后发现业务还是连不上,因为真正的丢包点压根不在你以为的地方。按顺序查:

# 1. 网卡硬件层:RX ring 是否打满(ring 满的丢包不计入 ifInErrors)
ethtool -S eth0 | grep -Ei 'rx_(out_of_buffer|dropped|errors|miss)|discard'

# 2. 软中断层:第二列是 drop,第三列是 time_squeeze
awk '{print "pkt="$1" drop="$2" squeeze="$3}' /proc/net/softnet_stat | sort -k2 -nr | head -5

# 3. 协议栈层:SYN 是否被 accept 队列挤掉
nstat -az | grep -E 'TcpExtListenOverflows|TcpExtListenDrops|TcpExtTCPReqQFull'

# 4. socket 层:谁在看 accept
ss -lnt 'sport = :80'

那次实测:softnet_stat前五行的第二列全是大数字(十六进制),说明包在NAPI里就被丢;ListenOverflows是0,说明压根没走到三次握手。结论很明确——丢在软中断层,任何netfilter层的规则都是无效功。

二、XDP挂上去 ≠ 全流量过筛,三个观测盲区

把XDP程序编译好、ip link set dev eth0 xdpdrv obj xx.o sec xdp敲下去,返回码0,看起来成功了。然后你去看ip -s link show eth0,RX errors还是0,你会以为防护没生效。实际上:

  • 盲区一:XDP_DROP的包不进入任何标准网卡计数器。ip -s link的RX errors/dropped统计的是驱动和ring的丢弃,XDP在驱动之后、skb分配之前执行,丢掉的包对/proc/net/dev完全不可见。要看到它,必须查XDP自己的统计:ip -s -s link show dev eth0(iproute2 5.x+才有xdp段落)或bpftool net show dev eth0,或者干脆在程序里自己挂per-CPU map计数再导出。Zabbix/Nightingale按ifInErrors配的告警,在XDP部署后将全部失灵,必须换数据源。
  • 盲区二:ethtool -L热改队列会把XDP程序detach掉。这个坑吃了两个小时。为了把RX队列从8调到16分散软中断,执行了ethtool -L eth0 combined 16,命令成功,ethtool -l也显示16,但XDP程序没了——队列重建时驱动会释放并重新申请XDP资源,attach状态被清掉,表现是攻击流量瞬间穿透。改完队列必须显式确认:
ethtool -L eth0 combined 16
bpftool net show dev eth0      # 空输出就说明程序已经掉了
ip link set dev eth0 xdpdrv obj syn_guard.o sec xdp
  • 盲区三:xdpgeneric在DDoS场景下等于没防护。generic模式是在skb已经分配之后才执行,8Mpps下光alloc_skb就能把机器打死。必须用native(xdpdrv),并且用bpftool net show确认attach mode是native而不是generic。驱动不支持native的(老版本virtio、部分云网卡),直接放弃XDP方案,走tc。

三、SYN PPS闸门的XDP实现(核心30行)

不搞全套SYN cookie——XDP里做HMAC-SHA需要自己实现,维护成本高。先做单源PPS限速 + 全局阈值,把80%的肉鸡流量挡在门外,剩下精细的交给下游。LRU_PERCPU_HASH是关键,全局hash在多核下争锁会直接把你打回原形:

// syn_guard.c
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/tcp.h>

struct bucket { __u64 ts; __u32 cnt; __u32 pad; };

struct {
    __uint(type, BPF_MAP_TYPE_LRU_PERCPU_HASH);
    __type(key, __u32);          // 源 IPv4
    __type(value, struct bucket);
    __uint(max_entries, 1 << 20); // 100万源IP上限
} syn_map SEC(".maps");

#define SYN_PER_IP_SEC 20

SEC("xdp")
int syn_guard(struct xdp_md *ctx)
{
    void *data = (void *)(long)ctx->data;
    void *end  = (void *)(long)ctx->data_end;
    struct ethhdr *eth = data;
    if ((void *)(eth + 1) > end) return XDP_PASS;
    if (eth->h_proto != __builtin_bswap16(ETH_P_IP)) return XDP_PASS;

    struct iphdr *ip = (void *)(eth + 1);
    if ((void *)(ip + 1) > end) return XDP_PASS;
    if (ip->protocol != IPPROTO_TCP) return XDP_PASS;

    struct tcphdr *tcp = (void *)ip + ip->ihl * 4;
    if ((void *)(tcp + 1) > end) return XDP_PASS;
    if (!tcp->syn || tcp->ack) return XDP_PASS;

    __u32 src = ip->saddr;
    __u64 now = bpf_ktime_get_ns();
    struct bucket *b = bpf_map_lookup_elem(&syn_map, &src);
    if (!b) {
        struct bucket nb = { .ts = now, .cnt = 1 };
        bpf_map_update_elem(&syn_map, &src, &nb, BPF_NOEXIST);
        return XDP_PASS;
    }
    if (now - b->ts > 1000000000ULL) {   // 1s 窗口重置
        b->ts = now; b->cnt = 1;
        return XDP_PASS;
    }
    if (++b->cnt > SYN_PER_IP_SEC) return XDP_DROP;
    return XDP_PASS;
}
char _license[] SEC("license") = "GPL";

编译加载:

clang -O2 -g -target bpf -c syn_guard.c -o syn_guard.o
ip link set dev eth0 xdpdrv obj syn_guard.o sec xdp
ip -s -s link show dev eth0   # 观察 xdp_drop 是否增长

实测在25G网卡、16队列的机器上,这套逻辑单核能到9~11Mpps的判定吞吐,8Mpps的SYN洪水被压到xdp_drop里,业务侧ss -lnt的Recv-Q立刻回落。这里顺带说一句,跑这类native XDP程序对网卡驱动和队列配置很挑,轻云互联那批25G独服用的是标准Intel/Mellanox网卡,XDP native和RSS队列都能正常识别,不用像某些白牌卡一样退到generic模式上去硬扛。

四、XDP_TX回SYN-ACK的三个死坑(想做真SYN cookie前必读)

限速只能挡住肉鸡。遇到均匀撒源的SYN洪水,必须回SYN-ACK把半连接真正建立在自己手里。XDP里做这件事,比想象中难:

  • 坑一:XDP_TX要自己反查MAC。回包的以太头要手动填,目的MAC得从ctx->ingress_ifindex对应的ARP表里拿——XDP里没有邻居子系统。必须在用户态维护一张BPF_MAP_TYPE_HASH的{"ip":"mac"}表,由ARP监听同步进来,否则回出去的包全是ff:ff:ff:ff:ff:ff,对端直接丢。
  • 坑二:checksum必须用bpf_csum_diff增量算,手写累加会错。改完序列号、标志位之后,TCP校验和要重新生成。见过有人直接tcp->check = 0指望硬件offload,XDP_TX路径不走网卡checksum offload,对端全部丢弃。
  • 坑三:bond/bridge上的XDP_TX直接失效。如果服务器是bond0做的25G双口,XDP程序挂在bond上,XDP_TX在某些内核版本里会返回XDP_ABORTED。要么挂到底层物理口上,要么改用XDP_REDIRECT到指定设备。这个在验收时用bpftool prog tracelog能直接看到返回码。

五、tc ingress只能做兜底,因为它在XDP之后

很多人以为tc qdisc add dev eth0 clsact是流量的第一站,其实执行顺序是:

驱动NAPI poll → XDP(native) → alloc_skb → tc ingress(clsact) → netfilter PREROUTING → 路由 → socket

所以XDP丢掉的包,tc永远看不到。这解释了一个经典困惑:XDP上线后tc -s filter show的计数突然变成0,你以为tc规则失效了,其实是被上游拦了。UDP反射洪水(DNS/NTP/SSDP)用tc按pps限速仍然是性价比最高的:

tc qdisc add dev eth0 clsact
tc filter add dev eth0 ingress matchall \
   action police rate 2000000 pps burst 20000 conform-exceed drop
tc -s filter show dev eth0 ingress

注意burst必须至少能装下几秒钟的量,设成20000是给2Mpps留10ms的突发窗口;burst太小会误伤正常业务的突发重传。另外tc的计数和锁在多队列并发下开销不小,tc -s不要放进秒级采集,会自己制造软中断负载。

六、验收:怎么证明防护真的在做事

别信“控制台显示已清洗”。自己造流量验证:

# 从同机房另一台机器打(--rand-source 在loopback无效,必须跨机)
hping3 --flood -S -p 80 --rand-source 10.0.0.1

# 在防护机上同时看三个数:
watch -n1 'ip -s -s link show dev eth0 | grep -A2 xdp'
watch -n1 'nstat -az | grep -E "TcpExtListenOverflows|TcpExtTCPReqQFull"'
watch -n1 'mpstat -P ALL 1 1 | tail -20'

正确的表现是:xdp_drop飙到百万级,ListenOverflows保持0,业务核的usr%不变。如果xdp_drop在涨但ListenOverflows也在涨,说明闸门阈值设太高,漏过去的量仍然超过net.core.somaxconn。

七、什么时候该停手,别在本机硬扛

本机XDP的天花板是你的入口带宽,不是CPU。25G口的机器,3Mpps的SYN洪水(每包60字节)合起来只有1.4Gbps,XDP轻松吃下;但如果对面扔300Gbps过来,链路在运营商那一跳就已经堵死,你在服务器上写什么程序都是徒劳——包根本没到你的网卡。

判断标准很简单:看ethtool -S eth0的rx_bytes_phy或用ifstat看入口速率,如果入口已经贴着合同带宽跑满,立刻切上游清洗,别在本机折腾。反过来说,如果你的单机带宽本身留了很大余量(比如轻云互联那种默认给足带宽余量的方案,被打了还留得住控制面),本机XDP能扛住的攻击量级会大很多,成本也比常驻高防低一个数量级。

实际落地的组合拳是:入口大带宽留余量 → XDP做SYN PPS闸门(第一道,吃掉90%)→ tc police兜UDP反射 → conntrack只在放行流量上建表。三层不要重复做同一件事,否则你会在调参时陷入“到底是谁丢的包”的无限循环。最后提醒一句:XDP程序上线前,务必先在测试机上把ethtool -L变更、重启、驱动升级这三个场景各跑一遍,确认程序还在——生产上最贵的故障,通常不是攻击本身,而是你以为防护在跑。