高防CDN 的“无状态防护”是假象:SYN 洪水下内核 request_sock 的真实内存账,以及状态被推到 cookie / eBPF / 回注三处的代价

先看一个真实场景

某游戏业务,边缘节点 25G 网卡,SYN 洪水峰值 280 万 PPS。现象很怪:

  • 带宽只占 18%,网卡无丢包,ethtool -S 全绿
  • CPU 55%,没打满
  • 但健康检查开始飘,业务侧 5xx 涨,控制台一直显示“攻击已清洗,回注 4.2Gbps”

抓包看内核计数器:

nstat -az | grep -E 'TcpExtListen|TcpExtSyncookies|TcpExtTCPReqQFull'
TcpExtListenOverflows            184223
TcpExtListenDrops                184223
TcpExtSyncookiesSent            9120347
TcpExtTCPReqQFullDoCookies     4481026
TcpExtTCPReqQFullDrop                 0

TCPReqQFullDoCookies 四百万次——意思是 SYN_RECV 队列早就满了,内核全程在硬扛 cookie。带宽没被打爆,conntrack 也没爆,卡住的是“半开连接”这件事本身的状态成本。

这就是“清洗”这个词最容易被误解的地方:清洗设备处理的是带宽和包速率,状态成本被原封不动地推给了内核。下面拆三层,看这份状态到底被推到了哪。

一、半开连接的内存到底怎么算

不走 cookie 的路径,每来一个 SYN(tcp_conn_request → inet_reqsk_alloc),内核要做两件事:

  • 从 request_sock 的 slab cache 分配一个 struct inet_request_sock
  • 把这个 SYN 的 skb 挂到 req->skb 上,留着后续重传 SYN-ACK

别猜,直接量:

pahole -C inet_request_sock /usr/lib/debug/boot/vmlinux-$(uname -r)
pahole -C request_sock      /usr/lib/debug/boot/vmlinux-$(uname -r)
# 没有 debuginfo 就看 slab 实际占用
grep -E 'request_sock|skbuff_head_cache' /proc/slabinfo
slabtop -o | head -20

64 位系统上 inet_request_sock 量级在 200B 出头,挂着的 skb 的 truesize(含 skb_shared_info + slab 对齐)通常落在 768B 左右。合起来 单个半开连接 ≈ 1KB。

280 万 PPS × 1KB ≈ 2.8 GB/s 的内存分配速率。

注意这是“速率”不是“总量”。tcp_max_syn_backlog 会把总量压在几百 MB,真正出问题的是 分配/释放的争用:64 核上每个 CPU 的 slab per-cpu cache 被 SYN 洪水持续打满,回落到 node 层 zone->lock,这时候 SYN 处理路径开始排队——但带宽和 CPU 曲线都很漂亮,监控上什么都看不出来。

队列满时的判定在 tcp_conn_request 里:

if (inet_csk_reqsk_queue_is_full(sk)) {
    if (!IS_ENABLED(CONFIG_SYN_COOKIES))
        goto drop;
    if (sock_net(sk)->ipv4.sysctl_tcp_syncookies) {
        want_cookie = true;                 /* 转为无状态,硬扛 */
    } else {
        NET_INC_STATS(LINUX_MIB_TCPREQQFULLDROP);   /* TcpExtTCPReqQFullDrop */
        goto drop;
    }
}

所以 TCPReqQFullDoCookies 一涨,就等于“攻击已经超过你半开队列的承载能力”,而不是“防护生效了”。这个计数器在高防节点上必须单独做告警,它比带宽曲线诚实得多。

二、打开 cookie,你丢了什么

net.ipv4.tcp_syncookies 三个取值,行为完全不同:

  • 0:关,队列满直接丢
  • 1:仅 SYN_RECV 队列满时才走 cookie
  • 2:强制,连正常握手也走无状态路径

不少团队为了省内存直接上 2,这就是攻击过后“能连上了但特别慢”的源头。

原因在编码空间:cookie 是 secure_tcp_syn_cookie() 算出来的一个 32 位值,大部分位数用来放 hash,剩下的小部分只能塞一个 MSS 索引。塞不下的东西有:

  • 窗口缩放因子 WScale
  • SACK permitted
  • TCP Timestamps
  • TFO cookie

后果按严重程度排:

1. WScale 退化为 0 —— 吞吐直接塌

窗口缩放没了,接收窗口顶死在 64KB 量级。对 RTT 40ms 的跨境回源链路,单连接 BDP 上限直接被锁死。业务反馈“攻击结束了两小时还是慢”,八成是 cookie 还在生效。验:

ss -ti dst :443 | grep -o 'wscale:[0-9,-]*' | sort | uniq -c
# 正常:  8942 wscale:7,7
# cookie:1203 wscale:0,7      ← 或者干脆没有 wscale 字段

2. Timestamps 缺失 —— tcp_tw_reuse 静默失效

tcp_tw_reuse 的前提条件之一就是 tcp_timestamps=1,而且它只对主动发起连接生效。cookie 路径把 timestamps 干掉之后,回注侧的 TIME_WAIT 复用在完全不报错的情况下失效,ss -s 里的 TIME_WAIT 数字只涨不降,最后 conntrack 和 ephemeral port 一起见底。

3. SACK 缺失 —— 丢一个包退一大截

回注链路上只要有一点丢包,没有 SACK 就是一次只重传一个段,恢复速度差一个数量级。清洗期过后业务侧 P99 迟迟不回落,往往就是这个。

三、把状态推到 eBPF:这块内存基本都是拍脑袋估的

边缘节点自己那层 L4 限速/首包挑战,通常不会用内核的 synproxy(失效点太多,不展开),而是直接 XDP 层建表。表型选错,后面全是坑。

struct flow_key {
    __be32 saddr;      /* 4B */
    __be16 sport;      /* 2B */
    __be16 _pad;       /* 2B → 对齐到 8B */
};

struct bucket {
    __u64 tokens;      /* 8B */
    __u64 last_ns;     /* 8B */
};

struct {
    __uint(type, BPF_MAP_TYPE_LRU_PERCPU_HASH);
    __uint(max_entries, 1000000);
    __type(key, struct flow_key);
    __type(value, struct bucket);
} rate_map SEC(".maps");

内存算法不是 max_entries × 24,而是:

per_cpu_slot = round_up(sizeof(key) + sizeof(value), 8)   // = 24
total_bytes  = max_entries * per_cpu_slot * nr_cpus
             = 1000000 * 24 * 64
             = 1,536,000,000    // ≈ 1.43 GiB

PERCPU 表每个核存一份完整拷贝,查询只走当前 CPU,快,但代价是内存乘了核数。核数从 32 换到 96 的时候,同一个配置文件内存直接翻三倍——这是第一个静默坑。

表污染型攻击:不耗你带宽,耗你的表

攻击源全是随机伪造的 saddr,每个 IP 只出现一次。LRU 表被撑到 max_entries,真实用户的老 entry 被 LRU 淘汰,下一个包进来又当新流做挑战——合法用户被反复挑战,攻击者一个包都没多花。

bpftool map show | grep -A2 rate_map
bpftool map dump id <map_id> | head -20
# key 全是乱序随机 IP,就是正在被打表

修法两条,生产上都用:

  • 降 key 维度:用 saddr & 0xffffff00 做 /24 聚合,容量掉三个数量级,代价是同一 C 段一起限速——对这个量级的僵尸网络来说,这个代价值得付。
  • 两级表:第一层只放 /24 前缀做粗限速,命中前缀且超阈值的才下钻到 /32 精确表。随机源 IP 根本进不了第二层,表污染这条路直接堵死。
/* 第一层:/24 聚合,max_entries 只需要 20 万量级 */
struct flow_key { __be32 prefix24; __u32 _pad; };  /* 16B 对齐 */

/* 第二层:/32 精确,只对第一层命中且超阈值的源建条目 */
struct exact_key { __be32 saddr; };

我们内部压 XDP 那层时用轻云互联的裸金属跑,主要图两点:网卡支持 XDP native 不用 fallback 到 generic 模式,中断也能自己绑,ethtool -k 里的 GRO 分档也是开的,省掉一堆 offload 对不齐的排查时间。

四、回注:最后一份状态被扔给了源站

清洗完要回注。不管是 GRE 隧道、IP_TRANSPARENT 直注,还是七层回源,源站都要重建一次完整状态,而这一层的成本几乎从来不计入“防护容量”。

源 IP 保持的两个必踩点

# 回注节点
sysctl -w net.ipv4.ip_nonlocal_bind=1
# 应用侧 setsockopt(IP_TRANSPARENT)
# 源站
sysctl -w net.ipv4.conf.all.rp_filter=0
sysctl -w net.ipv4.conf.eth0.rp_filter=0
ip rule add from 203.0.113.0/24 table 100
ip route add default via 10.0.0.1 table 100

rp_filter 没关干净,SYN 会被内核静默干掉——不回 TCP MIB 计数器,只在 netstat -s 的 IpExtInAddrErrors / InNoRoutes 里有迹可循。这类丢包排查起来最消耗人:

nstat -az | grep -E 'IpExtInAddrErrors|IpExtInNoRoutes'
# 只涨这两个、TCP 计数器全不动 → 直接查 rp_filter 和路由表

回注侧 TIME_WAIT:跟回注 QPS 同速累积

四层转发连接的生存周期极短,TIME_WAIT 的累积速度等于回注 QPS。这时候 tcp_tw_reuse=1 是必须的——但注意它只在 tcp_timestamps=1 且主动建连时生效。如果边缘还在扛 SYN 洪水、cookie 路径把 timestamps 干掉了,回注侧这个参数就是纯配置项,一点作用没有。

检查顺序别搞反:先确认 ss -s 里 TIME_WAIT 有没有在降,再回头查边缘的 cookie 状态,最后才动 tcp_max_tw_buckets。

五、排查顺序:按状态成本从低往高,别从带宽开始

  1. nstat -az | grep -E 'TcpExtListen|TcpExtSyncookies|TcpExtTCPReqQFull' —— 先分清是半开队列还是全连接队列的问题
  2. ss -lnt 看 Send-Q(实际生效的 backlog)和 Recv-Q;Recv-Q 贴住 Send-Q 就是 accept 侧处理不过来,跟半开队列无关
  3. slabtop -o | grep -E 'request_sock|skbuff' —— 判断是不是 slab 分配路径在排队
  4. bpftool map show —— 看 eBPF 表的 entry 数与容量比,> 80% 就是表污染预警
  5. ss -ti 抓 wscale —— 这一步决定“攻击已过但业务仍慢”是不是 cookie 造成的

最后

DDoS 防护在带宽维度是做减法,“清洗掉多少”看得见;但在状态维度永远只能做搬运——把它从内核的 request_sock 搬到 cookie 的 32 位里,再从 cookie 搬到 eBPF 的 map 里,最后从 map 搬回源站的 conntrack。每一层都有内存和时间成本,每一层的成本都能被攻击者定向利用。

高防 CDN 之间真正的差距不在“谁清洗得更干净”——清洗能力早就同质化了。差距在于这些状态成本被摊在什么位置、由谁记账、以及摊不下去的时候有没有降级策略。当你在控制台上看到“攻击已清洗”而业务还在掉的时候,先去量一下那 1KB 一个的半开连接,答案通常就在那里。