高防CDN控制台明明显示“攻击已清洗”,回注只剩 3Gbps 业务却丢 15% 包:UDP 反射洪水下 nf_conntrack 的三个静默丢包点

一、现场:控制台全绿,业务全红

凌晨 2:14,一个跑自研 KCP 的游戏业务告警:UDP 4000-4100 端口,P99 从 42ms 干到 380ms,丢包率 12%~18%,玩家批量掉线。这套业务接在高防 CDN 上,边缘是三台 25G 口的清洗节点,平时峰值 3Gbps。

登上高防控制台一看:攻击峰值 187Gbps / 32Mpps,主要成分是 memcached(11211)、NTP(123)、CLDAP(389) 反射,掺了一部分 TCP SYN。清洗策略已经自动触发,回注带宽稳定在 3.1Gbps——跟平日业务峰值 3.0Gbps 基本吻合。

也就是说,从清洗中心的视角看,攻击已经被拦干净了。但业务侧还在丢包。这时候最容易犯的错是跑去问高防厂商“你们是不是没洗干净”,其实问题大概率在回注链路之后、在你自己的边缘节点上。

二、第一刀:先看 conntrack,但别只看 max

反射洪水最直接的杀伤对象不是带宽,是 conntrack 表。UDP 无连接,但 conntrack 会为每一个五元组建一条表项,源 IP 全伪造,32Mpps 打进来就是 32M 条/秒的建表请求。

conntrack -S
cat /proc/net/stat/nf_conntrack | awk '{print "invalid="$6" insert_failed="$10" drop="$11" early_drop="$12}'
sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max \
       net.netfilter.nf_conntrack_udp_timeout \
       net.netfilter.nf_conntrack_udp_timeout_stream

现场数据:count=183422、max=262144,没满。很多人看到这里就掉头去看带宽和 softirq 了。但 insert_failed 和 invalid 两个计数器在以每秒几万的速度涨,这才是真正的线索。

invalid 涨的含义是:报文进了 conntrack 的 hook,但被判为“不合法”直接丢了,连表都没建,所以 count 永远不涨、max 永远不满,你在容量曲线上什么都看不到——这就是“静默”的来源。

三、第二刀:invalid 是谁造的?checksum

抓包看回注报文:

tcpdump -ni eth0 -vv -c 20 'udp and portrange 4000-4100'

输出里大量出现:

... UDP, length 96
    [bad udp cksum 0x1f3a -> should be 0x8c21!]

原因很明确:清洗中心清洗完之后,回注走的是 GRE / VXLAN 封装,出口设备做了硬件转发但没做 UDP 校验和的增量重算。业务侧因为在 edge 节点上开了 nf_conntrack_checksum=1(默认值),conntrack 收到报文后会校验,校验失败 → 判 invalid → 静默丢。

sysctl net.netfilter.nf_conntrack_checksum
# net.netfilter.nf_conntrack_checksum = 1

解法:

sysctl -w net.netfilter.nf_conntrack_checksum=0
echo 'net.netfilter.nf_conntrack_checksum = 0' >> /etc/sysctl.d/99-ddos-edge.conf

改完 invalid 立刻归零,丢包率从 15% 掉到 6%。但只掉了一半——还有第二个坑。

这里补一句:别看到 checksum 关掉就完事了。要么让清洗厂商在回注口重算 checksum,要么在边缘节点关校验,二选一。两边都不做就是这次的下场。

四、第三刀:hashsize 是只读的,你调 max 没用

剩下的 6% 丢包,在 conntrack -S 里表现为 insert_failed 和 early_drop 仍在缓慢增长。看链长:

cat /sys/module/nf_conntrack/parameters/hashsize
# 65536

awk 'BEGIN{m=262144;h=65536;print "avg chain:", m/h}'   # 4

看起来还行,但这是一台跑着 KCP、有真实玩家长连接的节点。真实业务表项加上攻击残留,实际分布严重不均,热点 bucket 链长能到十几。32Mpps 的查表全部退化成链表遍历,单核 softirq 直接打满。

想调大 hashsize,第一个撞上的坑是:在 4.19 及更早内核上这个参数是只读的。

echo 524288 > /sys/module/nf_conntrack/parameters/hashsize
# bash: echo: write error: Permission denied

第二个坑更阴:某些内核版本的 nf_conntrack_max 写 proc 时会顺带把 hashsize 设成 max/4,某些不会。你在 A 机器上 sysctl -w nf_conntrack_max=1048576 之后 hashsize 自动变 262144,在 B 机器上就不变,两台机器表现完全不一致,排查时能把你折磨死。

正确姿势是在模块加载前就把参数写死:

# /etc/modprobe.d/nf_conntrack.conf
options nf_conntrack hashsize=524288

# 或者直接走内核启动参数,最稳
# grubby --update-kernel=ALL --args="nf_conntrack.hashsize=524288"

顺手算一下内存代价,别拍脑袋:单条 conntrack 含扩展约 320~350 字节,max=1048576 大约 336MB,hash bucket 数组 524288 × 8 字节 ≈ 4MB(指针)。对 256G 内存的机器无压力,但如果你在 8G 的小边缘节点上照抄,就是给自己埋雷。

五、第四刀:UDP rcvbuf 和 port-unreachable 回环

5.1 应用层收不动

ss -uanm | grep -E ':(4000|4100)'
# Recv-Q 长期贴着自己的 rcvbuf 上限

netstat -su | grep -Ei 'receive buffer errors|packet receive errors'

RcvbufErrors 在涨,说明内核已经把包收到了 socket 队列,但应用进程读得慢、或者缓冲太小被丢。默认 net.core.rmem_max 只有 208KB,而 setsockopt(SO_RCVBUF) 会被内核翻倍后与 rmem_max 比较取小——也就是说你在代码里写 1MB,实际只给你 208KB×2=416KB 的上限。

sysctl -w net.core.rmem_max=16777216
sysctl -w net.core.rmem_default=1048576

按每包 100 字节算,16MB 缓冲约 16 万个包,KCP 的一个 20ms tick 完全能吃下。这个值对游戏这种小包高频业务是必须调的。

5.2 一个被忽略的二次伤害:ICMP port unreachable

反射洪水里必然有大量打到你没监听端口的 UDP。内核默认会为每个这样的包回一个 ICMP port unreachable。源 IP 是伪造的,这些 ICMP 全打到了真实受害者身上——等于你帮着攻击者做了二次反射。

sar -n UDP 1 5
# 关注 noport 这一列

# 兜底:直接掐掉
nft add rule inet edge output icmp type destination-unreachable drop
# 或者按速率限
sysctl -w net.ipv4.icmp_ratelimit=100
sysctl -w net.ipv4.icmp_ratemask=6168

六、最终收敛:把丢包点前移到 raw 链

上面四刀都是“出了事怎么修”。真正让节点扛住 32Mpps 的是别让垃圾进 conntrack。反射攻击的端口特征极其固定,业务端口白名单一卡,99% 的包在 prerouting raw 阶段就没了,conntrack 一条表都不用建。

nft add table inet edge
nft add set inet edge udp_allow '{ type inet_service; flags interval; elements = { 4000-4100 }; }'

nft 'add chain inet edge preraw { type filter hook prerouting priority raw; policy accept; }'
nft add rule inet edge preraw udp dport != @udp_allow counter drop

# 反射端口单独再补一刀,防止业务端口被人拿来放大
nft add rule inet edge preraw udp sport '{ 19, 53, 123, 389, 1900, 11211 }' counter drop

顺序是关键:priority raw 是 -300,conntrack 的 hook 在 -200。挂在 raw 链上 drop,包根本走不到 conntrack 的建表逻辑;你要是图省事写在 priority filter(0) 上,那等于每条反射包都先建了一遍表再丢——32Mpps 照样能把节点干趴。

另外记住 drop 而不是 reject:reject 会生成回包,源 IP 伪造时回包全进黑洞还占发送队列。

七、事后复盘:几条踩出来的经验

  • “清洗成功”和“业务可用”是两个指标。控制台只看到回注带宽单侧数值,后续链路的问题(checksum、conntrack、rcvbuf)它一概看不见。验收高防时一定要带业务侧的 P99 和丢包率一起看。
  • conntrack 的 invalid 和 insert_failed 比 count/max 重要得多。前者涨说明有包在悄悄死,后者涨才是真容量问题。把 conntrack -S 进监控,阈值设在每秒 >100 就告警。
  • UDP 业务的 conntrack 时间参数要单独调。默认 nf_conntrack_udp_timeout=30,攻击流量无回包,就按 30 秒存。32Mpps × 30s = 9.6 亿条,远超任何 max 值——所以白名单必须在 raw 层做,调 max 调 hashsize 都是在给错误的方向加内存。
  • 高防节点的内核参数能不能改,本身就是选型指标。这次迁移时特意确认了一点:轻云互联的高防线路是给到物理节点维度的 sysctl 和 /sys/module/nf_conntrack/parameters/ 写权限的,hashsize、nf_conntrack_max 这些能在模块加载阶段按业务量级写死。之前试过几家交付成容器化节点的高防,/proc/sys/net/netfilter 整片只读挂载,只能被动等厂商调度改参数——出故障那个晚上,等一轮就是几十分钟的玩家流失。
  • 反射攻击的端口特征库要定期更新。memcached、CLDAP、SSDP、Chargen 是老面孔,但新的反射源(各种默认开放 UDP 的 IoT 服务)每年都在冒。白名单模式下这个维护成本很低,黑名单模式迟早会被新协议打穿。