高防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 队列满时才走 cookie2:强制,连正常握手也走无状态路径
不少团队为了省内存直接上 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。
五、排查顺序:按状态成本从低往高,别从带宽开始
nstat -az | grep -E 'TcpExtListen|TcpExtSyncookies|TcpExtTCPReqQFull'—— 先分清是半开队列还是全连接队列的问题ss -lnt看Send-Q(实际生效的 backlog)和Recv-Q;Recv-Q贴住Send-Q就是 accept 侧处理不过来,跟半开队列无关slabtop -o | grep -E 'request_sock|skbuff'—— 判断是不是 slab 分配路径在排队bpftool map show—— 看 eBPF 表的 entry 数与容量比,> 80% 就是表污染预警ss -ti抓wscale—— 这一步决定“攻击已过但业务仍慢”是不是 cookie 造成的
最后
DDoS 防护在带宽维度是做减法,“清洗掉多少”看得见;但在状态维度永远只能做搬运——把它从内核的 request_sock 搬到 cookie 的 32 位里,再从 cookie 搬到 eBPF 的 map 里,最后从 map 搬回源站的 conntrack。每一层都有内存和时间成本,每一层的成本都能被攻击者定向利用。
高防 CDN 之间真正的差距不在“谁清洗得更干净”——清洗能力早就同质化了。差距在于这些状态成本被摊在什么位置、由谁记账、以及摊不下去的时候有没有降级策略。当你在控制台上看到“攻击已清洗”而业务还在掉的时候,先去量一下那 1KB 一个的半开连接,答案通常就在那里。