别拿 frp 默认配置给高防CDN回源:tcpMux 的 TCP-over-TCP 队头阻塞把 P99 顶到 2.3 秒,proxy_protocol 断链让 WAF 误封整个回源网段

一、先把链路画清楚,不然调优全是瞎调

高防CDN 的常见部署是:源站托管在某个只给内网 IP 的机柜,或者源站自己有公网但不想暴露(怕被打到打穿)。于是前置一层高防CDN,回源走内网穿透隧道。真实链路是这样的:

用户 → 高防CDN边缘节点 → 回源(CDN回源IP段)
     → frps 公网中转机(单IP, 暴露 443)
     → frpc(源站内网) → 源站 Nginx(127.0.0.1:8443)

很多人以为"高防CDN 把攻击挡在外面了",实际上高防保护的是入向链路,隧道这一段的出向是完全裸奔的。更麻烦的是,CDN 回源节点是分布式的、几十上百个 IP 并发回源,隧道出口却只有一个 remotePort。三个坑全在这里埋着。

二、坑一:tcpMux 让所有回源连接挤进一条 TCP

frp 默认 transport.tcpMux = true,底层用 yamux 把 N 条逻辑流复用到一条物理 TCP 上。问题在于:底层只有一条 TCP 的发送窗口和重传队列。一旦这条连接上任何一个段丢失,TCP 进入重传等待,yamux 上所有流全部阻塞——这就是教科书级的 TCP-over-TCP 队头阻塞。

实测环境:源站到中转机跨省 RTT 30ms,丢包率 0.3%,CDN 回源并发 200。

配置组合                         | P50     | P99      | 底层隧道重传/分钟
tcpMux=true  (默认)              | 45ms    | 2340ms   | 1800+
tcpMux=false                     | 38ms    | 180ms    | 210
tcpMux=false + kcp               | 35ms    | 95ms     | N/A(自己重传)

P99 差了一个数量级,纯粹是被一条 TCP 的队头阻塞拖死的。

确认方法,直接扒底层隧道的 TCP 状态:

ss -tin dst 中转机IP:7000
# 重点看三列:retrans、cwnd、rtt
# retrans 持续增长 + cwnd 塌到个位数 = 典型队头阻塞现场

# 或者在 frps 机器上数重传
nstat -az | grep -E 'TcpRetransSegs|TcpExtTCPLostRetransmit'

改法很直接,frpc.toml:

[common]
# 关掉多路复用,每条回源连接独立建 TCP
transport.tcpMux = false
transport.tcpKeepalive = 30
transport.dialServerTimeout = 10
transport.heartbeatInterval = 10
transport.heartbeatTimeout = 30

# 关闭压缩和加密(TLS 已经是加密的,再压一层纯属浪费 CPU 和延迟)
transport.useCompression = false
transport.useEncryption = false

代价是连接数暴涨。关掉 tcpMux 之后我们这台中转机 ESTABLISHED 从 300 涨到 4000+,这时候 fd 限制、conntrack、本地端口范围全部要跟着调,见第四节。

关于 KCP / QUIC 的取舍

frp 支持 transport.protocol = "kcp" 和 "quic",都能绕开 TCP-over-TCP。但这里有个和高防CDN 强耦合的坑:KCP 和 QUIC 都走 UDP,而高防清洗设备对 UDP 的策略通常比 TCP 激进得多(UDP 反射放大攻击是重灾区),隧道流量容易被误判清洗,表现就是"时不时断几秒"。

如果非要用 KCP,两个前提:中转机的 KCP 端口用非标端口(别用 443/80),并且在 CDN 侧把回源协议锁定为 TCP,避免 QUIC 回源和隧道 UDP 撞在一起。我们的选择是老老实实裸 TCP + 关 tcpMux,稳定性优先。

三、坑二:proxy_protocol 断链,WAF 把整个回源段封了

这个坑比性能坑更致命,因为它会直接造成全站 5xx/403。

现象:源站 WAF 日志里所有请求的 remote_addr 都是 127.0.0.1,或者更糟——是 frps 出口 IP。某天 CC 策略触发,WAF 把那个出口 IP 拉黑,结果所有用户的请求全部 403,因为对 WAF 来说它们长得一模一样。

根因:frp 的 tcp 类型代理本质是裸转发,源站看到的对端就是 frpc 本地回连的地址。X-Forwarded-For 依赖 CDN 回源时带,但如果中间有任何一层重写了 header(比如用了 frp 的 type = "http" 代理),链路就断了。

正解:全链路 PROXY protocol v2 透传,不要用 frp 的 http 代理类型。

frpc 侧注入 PROXY 头

# frpc.toml
[[proxies]]
name = "cdn-origin-443"
type = "tcp"
localIP = "127.0.0.1"
localPort = 8443
remotePort = 443
# 关键:向本地 8443 发送 PROXY protocol v2 头
transport.proxyProtocolVersion = "v2"

源站 Nginx 接收(直连 HTTP 场景)

# 注意 listen 后面必须带 proxy_protocol
server {
    listen 8443 ssl proxy_protocol;
    server_name origin.example.com;

    # 只信任 frpc 本地回连 + CDN 回源网段,多一个都不行
    set_real_ip_from 127.0.0.1;
    set_real_ip_from 10.0.0.0/8;
    real_ip_header proxy_protocol;
    real_ip_recursive on;

    ssl_certificate     /etc/nginx/ssl/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/privkey.pem;

    location / {
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_pass http://127.0.0.1:8080;
    }
}

注意 real_ip_recursive on 这个开关:它意味着 Nginx 会从右往左逐跳剥离受信 IP。如果 CDN 没有清理客户端伪造的 XFF,而你又信任了过宽的网段,客户端可以自己塞一个 X-Forwarded-For: 1.2.3.4 把自己伪装成任意 IP,直接绕过 WAF 的 CC 策略。所以 set_real_ip_from 必须精确到 CDN 公布的回源网段 + frpc 本机地址。

源站是 gRPC / 非 HTTP 怎么办

走 Nginx stream 层透传,PROXY 头一进一出:

stream {
    log_format proxy '$proxy_protocol_addr [$time_local] '
                     '$protocol $status $bytes_sent $bytes_received '
                     '$session_time upstream=$upstream_addr';
    access_log /var/log/nginx/stream.log proxy;

    upstream grpc_backend {
        server 127.0.0.1:50051;
        keepalive 128;
        keepalive_timeout 60s;
    }

    server {
        listen 8443 proxy_protocol so_keepalive=on backlog=8192;
        proxy_pass grpc_backend;
        # 出向继续带 PROXY 头,交给后端应用识别真实来源
        proxy_protocol on;
        proxy_timeout 300s;
        proxy_connect_timeout 5s;
    }
}

验证是否真的透传到了,别猜:

# 源站直接看 stream 日志里的 proxy_protocol_addr
tail -f /var/log/nginx/stream.log | awk '{print $1}' | sort | uniq -c | sort -rn | head

# 如果全是 127.0.0.1,说明 frpc 的 proxyProtocolVersion 没生效
# 检查 frpc 版本,v0.52 之后是 TOML 配置下的 transport.proxyProtocolVersion

四、坑三:关掉 tcpMux 之后,conntrack 和端口先炸

关掉多路复用的当天晚上,中转机报了 nf_conntrack: table full, dropping packet。CDN 回源本身是短连接为主,关掉 tcpMux 后每条回源连接在中转机上都占一条 conntrack 表项,几万并发直接把默认的 262144 打满。

# 现场取证三件套
conntrack -C                          # 当前表项数
sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
dmesg -T | grep -i conntrack | tail   # 有没有 dropping
ss -s                                 # TIME_WAIT / ESTAB 分布

中转机 /etc/sysctl.d/99-tunnel.conf:

# ---- conntrack ----
net.netfilter.nf_conntrack_max = 2097152
net.netfilter.nf_conntrack_buckets = 524288
net.netfilter.nf_conntrack_tcp_timeout_established = 3600
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 30
net.netfilter.nf_conntrack_tcp_timeout_close_wait = 15

# ---- 端口与 TIME_WAIT ----
net.ipv4.ip_local_port_range = 10240 65535
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_max_tw_buckets = 262144
net.ipv4.tcp_fin_timeout = 15

# ---- 队列 ----
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 65535
net.ipv4.tcp_max_syn_backlog = 65535

# ---- 别乱开的两个 ----
# net.ipv4.tcp_tw_recycle 已在 4.12 内核删除,网上老文章别照抄
# tcp_syncookies 保持 1,隧道口会挨扫
net.ipv4.tcp_syncookies = 1

同时 frps 的 systemd 必须放开 fd:

# /etc/systemd/system/frps.service.d/override.conf
[Service]
LimitNOFILE=1048576
LimitNPROC=1048576

改完 sysctl --system + systemctl daemon-reload && systemctl restart frps,然后确认:

cat /proc/$(pidof frps)/limits | grep -i 'open files'
# 期望 Max open files  1048576

五、回源口加固:别让隧道端口裸奔在公网

frps 的 443 暴露在公网,意味着任何人扫到都能打。只放行 CDN 的回源 IP 段,用 ipset 做 O(1) 匹配,比几百条 iptables 规则干净得多:

ipset create cdn_origin hash:net maxelem 65536

# CDN 控制台会公布回源 IP 段,全部灌进去
for cidr in 203.0.113.0/24 198.51.100.0/24 192.0.2.0/24; do
    ipset add cdn_origin $cidr
done

iptables -N TUNNEL_GUARD
iptables -A TUNNEL_GUARD -m set --match-set cdn_origin src -j RETURN
iptables -A TUNNEL_GUARD -j LOG --log-prefix "tunnel-drop: " --log-level 4
iptables -A TUNNEL_GUARD -j DROP

iptables -I INPUT -p tcp --dport 443 -j TUNNEL_GUARD
iptables -I INPUT -p tcp --dport 7000 -m set --match-set cdn_origin src -j ACCEPT
iptables -I INPUT -p tcp --dport 7000 -j DROP

注意 7000 那个 bindPort 千万别学某些教程直接 0.0.0.0 开放——那等于把你的 frpc 注册接口公开了。建议 frpc 主动连 frps 时走 WireGuard 或者干脆把 7000 只监听在私有网卡上,源站侧连过去再建隧道。

顺带一提,中转机我们放在轻云互联的节点上,选它主要就一个原因:隧道中转机最怕的不是 CPU 而是线路抖动和 IP 干净度。中转机延迟抖动 10ms,经过隧道放大到业务侧可能就是 P99 翻三倍。带宽标称值和实际回程质量是两回事,这个自己压测一遍心里就有数了。

六、最终生产配置与实测结果

完整跑下来的组合:

  • frpc:tcpMux=false、protocol=tcp、proxyProtocolVersion=v2、关闭 compression/encryption
  • 源站 Nginx:listen 8443 ssl proxy_protocol + real_ip_recursive on + 精确的 set_real_ip_from
  • 中转机:conntrack 2M、fd 1M、ipset 白名单、tcp_tw_reuse 打开
  • CDN 侧:回源协议锁 TCP,配置多源站 + 主动健康探测(frpc 掉线时秒切)
指标                        调优前        调优后
回源 P99                    2340ms       180ms
源站日志真实IP占比           0%           100%
WAF误封导致的403              有(全站)     0
中转机conntrack峰值          表满丢包      41%
单机可承载回源QPS            约 800       约 6200

最后一句话总结:高防CDN 的内网穿透,性能瓶颈从来不在 CDN,也不在 frp 的"高级特性"上,而在 tcpMux 这一行默认配置和 PROXY protocol 有没有真正打通。这两件事没做对,后面所有的内核参数调优都是白费功夫。