高防CDN 用 Docker 部署:容器里 $remote_addr 永远 172.17.0.1、conntrack 比带宽先爆——host 网络 + nftables 前置限速的完整落地
一、别急着 docker run —— 默认 bridge 会把你的高防做废
先说一个我们踩过的坑。某次用 nginx:1.27-alpine 起了一个边缘缓存节点,-p 443:443 -p 80:80 打满,QPS 稍微冲上 3 万,后端日志里 $remote_addr 清一色是 172.17.0.1。
原因不复杂:-p 发布的端口,报文路径是 eth0 → PREROUTING DNAT → docker0 → 容器,容器回包要再走一次 MASQUERADE。SNAT 之后容器里看到的源地址就是网桥网关。高防CDN最核心的能力——按源 IP 做速率限制、黑名单、CC 挑战——在 bridge 模式下直接废掉。
顺带三个副作用:
- 每个连接多吃一次 conntrack NAT 查表,SYN flood 场景下 conntrack 表竞赛窗口提前到来;
- 报文多走一次
docker0软中断,高 PPS 下ksoftirqd单核先顶; - 回程 SNAT 要维护 NAT 会话,
nf_conntrack条目数直接翻倍。
快速验证你现在的容器是哪种情况:
# 宿主机上
ss -tn state established '( sport = :443 )' | head
# 容器里看监听地址,如果是 0.0.0.0 且容器 IP 是 172.17.x.x,说明是 bridge
docker exec edge ip -4 addr show eth0
# 看是不是走了 docker-proxy / DNAT
iptables -t nat -nvL DOCKER | head -20
结论很直接:高防CDN节点一律用 --network host。容器直接复用宿主机 netns,网卡、路由、conntrack、iptables/nftables 全部是宿主机那一套,源 IP 原样保留,零 NAT 开销。
二、host 网络怎么开,内核参数在哪调
2.1 daemon.json 先关掉用不着的东西
{
"iptables": true,
"userland-proxy": false,
"log-driver": "json-file",
"log-opts": { "max-size": "100m", "max-file": "3" },
"default-ulimits": {
"nofile": { "Name": "nofile", "Hard": 1048576, "Soft": 1048576 },
"nproc": { "Name": "nproc", "Hard": 65535, "Soft": 65535 }
},
"data-root": "/data/docker"
}
userland-proxy: false 只对 bridge 模式生效,但既然开着,顺手关掉,避免哪天误用 -p 时冒出一个用户态转发进程。
data-root 挪到独立盘是有原因的:镜像层 + 容器可写层写在系统盘上,一次 docker pull 大镜像能把 / 写满,把宿主机上跑的 nftables 规则文件都给挤没了。
2.2 内核参数:容器里改不了,必须在宿主机改
很多人第一反应是 docker run --sysctl net.core.somaxconn=65535。在 host 网络下这条会直接报 sysctl: invalid argument,因为 netns 是共享的,Docker 拒绝写非 netns 隔离的参数。老老实实写宿主机 /etc/sysctl.d/99-edge.conf:
# conntrack:按 200 万条目规划,单条约 300 字节,占用约 600MB 内存
net.netfilter.nf_conntrack_max = 2097152
net.netfilter.nf_conntrack_buckets = 524288
net.netfilter.nf_conntrack_tcp_timeout_syn_recv = 10
net.netfilter.nf_conntrack_tcp_timeout_established = 1800
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 30
net.netfilter.nf_conntrack_tcp_timeout_close_wait = 20
net.netfilter.nf_conntrack_tcp_loose = 0
# SYN 层
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.tcp_synack_retries = 2
net.ipv4.tcp_syn_retries = 3
net.ipv4.tcp_abort_on_overflow = 1
# 队列与缓冲
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 32768
net.core.optmem_max = 65536
net.ipv4.tcp_max_tw_buckets = 2000000
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.ip_forward = 1
两个细节:
nf_conntrack_buckets必须是 2 的幂,而且部分内核版本只允许模块加载时确定,改完用cat /sys/module/nf_conntrack/parameters/hashsize核对;tcp_loose = 0是关键。默认情况下 conntrack 遇到没有 SYN 的报文会"宽松"地建一条 ESTABLISHED 条目,攻击者拿无状态包就能把表刷满。关掉它,脏包直接丢。
这些参数改完 sysctl --system 之后,用 conntrack -C 和 cat /proc/sys/net/netfilter/nf_conntrack_count 对一下数,确认生效。我们在轻云互联的高防节点上跑这批参数时,宿主机是能直接改内核参数的裸金属形态,不存在嵌套虚拟化里 nf_conntrack 模块加载受限的问题,这点在选机器的时候要提前问清楚。
三、nftables 前置限速:要在 conntrack 之前下手
这是整套部署里最值钱的一段。很多人把限速规则写在 iptables -A INPUT 里,那是 priority filter (0),报文早就进过 conntrack 了。SYN flood 打过来,你丢包丢得再狠,conntrack 表也是先被刷爆的那一个。
netfilter hook 的优先级顺序是:raw(-300) → conntrack(-200) → mangle(-150) → dstnat(-100) → filter(0)。我们要把无状态限速塞在 -300,也就是 conntrack 查表之前。
为什么用 table inet 而不是碰 Docker 的 table ip filter?因为 Docker 每次重启都会往 ip filter 表和 FORWARD 链里插规则,你在同一张表里写的东西极容易被冲掉。用独立的 inet edge 表,互不干扰。
#!/usr/sbin/nft -f
# /etc/nftables.d/edge.nft —— 由 nftables.service 在 docker.service 之前加载
table inet edge {
set blacklist {
type ipv4_addr
flags dynamic, timeout
timeout 30m
size 262144
}
# ---------- 第一道:无状态,跑在 conntrack 之前 ----------
chain prerouting {
type filter hook prerouting priority raw; policy accept;
# 已在黑名单里的,直接丢,不给它建 conntrack 的机会
ip saddr @blacklist counter drop
# 老掉牙的无效 TCP 标志组合
tcp flags & (fin|syn) == fin|syn counter drop
tcp flags & (syn|rst) == syn|rst counter drop
tcp flags & (fin|rst) == fin|rst counter drop
# 按源 IP 做 SYN 限速,超过 20/s 的进黑名单 30 分钟
tcp flags & (fin|syn|rst|ack) == syn \
meter syn_meter size 262144 { ip saddr limit rate over 20/second } \
add @blacklist { ip saddr } \
counter drop
}
# ---------- 第二道:带状态,报文进了 conntrack 之后再判 ----------
chain input {
type filter hook input priority filter; policy drop;
iif lo accept
# 注意:到这里 conntrack 已经跑过了,ct state 才有意义
ct state invalid counter drop
ct state established,related accept
ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded } \
limit rate 10/second accept
tcp dport { 80, 443 } ct state new accept
# 运维口只放内网
ip saddr 10.0.0.0/8 tcp dport 22 accept
}
}
几个容易写错的地方:
ct state不能出现在priority raw那条链里。conntrack 还没跑,表达式取到的是未定义值,内核会静默按 false 处理,你的规则就形同虚设了。这也是为什么我把带状态的判断单独拆到input链;meter的语义是"匹配超限的那部分包",所以limit rate over 20/second配上后面的add @blacklist,才是"超了才拉黑";blacklist集合要带flags dynamic, timeout才能被add动态写入并自动过期;- 规则文件必须在 Docker 启动前加载。用
systemctl enable --now nftables,并在 unit 里Before=docker.service。
加载完立刻看计数:
nft -a list table inet edge
nft list set inet edge blacklist | head -20
# 被拉黑的源 IP 数量
nft list set inet edge blacklist | grep -c 'expires'
四、容器自身的四个坑
4.1 日志驱动:json-file 会把盘写爆
Nginx 的 access_log 已经落到挂载出来的卷里了,再让容器把 stdout 全量写给 Docker 守护进程,等于同一份日志写两遍,而且 json 序列化那一层是单线程的。高 QPS 下 dockerd 自己就能吃满一个核。
直接 --log-driver=none,日志从 Nginx 自己的文件走。要采集用 Filebeat 挂载卷读。(如果你确实需要 docker logs,至少把 max-size 和 max-file 配上。)
4.2 缓存目录绝对不能落在 overlayfs 上
CDN 的缓存目录是典型的大量小文件、频繁 rename、频繁 unlink。overlayfs 的 copy-up 和 redirect_dir 元数据会让 rename 变成随机写的灾难,压测时能明显看到 iowait 飙升、P99 抖动。
/var/cache/nginx 必须 bind mount 到宿主机文件系统上,最好是独立 NVMe + XFS(记得带 ftype=1),或者 ext4。挂载参数上 noatime,nodiratime 是底线。
4.3 ulimit 默认 1024,早就在报错只是你没看
worker_connections 写到 65535,结果进程 fd 上限是 1024,Nginx 会在 error.log 里刷 too many open files 然后开始丢连接。除了 daemon.json 里的 default-ulimits,还要确认宿主机 systemd 那边 LimitNOFILE 不小于这个值,否则容器继承了也上不去。
4.4 完整启动命令
docker run -d \
--name edge-cdn \
--network host \
--restart unless-stopped \
--ulimit nofile=1048576:1048576 \
--ulimit nproc=65535:65535 \
--log-driver=none \
--read-only \
--tmpfs /var/run:size=64m,mode=0755 \
--tmpfs /tmp:size=256m,mode=1777 \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
--cap-add CHOWN \
--cap-add SETGID \
--cap-add SETUID \
--security-opt no-new-privileges \
-v /data/nginx/conf/nginx.conf:/etc/nginx/nginx.conf:ro \
-v /data/nginx/conf/conf.d:/etc/nginx/conf.d:ro \
-v /data/nginx/cache:/var/cache/nginx \
-v /data/nginx/logs:/var/log/nginx \
nginx:1.27-alpine
--read-only 加上 tmpfs 是为了让容器根文件系统不可写。这样即使 Nginx 或者某个模块被打穿,也没有落盘写 webshell 的路径。
启动之后核对一遍:
docker exec edge-cdn cat /proc/1/limits | grep -i 'open files'
docker exec edge-cdn nginx -V 2>&1 | tr ' ' '\n' | grep -E 'proxy_protocol|realip'
docker exec edge-cdn ss -lntp | grep -E ':80|:443'
五、真实 IP 透传:两种场景分开处理
host 网络下,直连的流量 $remote_addr 就是真实客户端 IP,不用做任何事。真正要处理的是前面还挂了一层四层转发(比如高防 IP 清洗层)的情况。
场景 A:上游支持 PROXY protocol。Nginx 监听端口带 proxy_protocol 参数:
server {
listen 443 ssl proxy_protocol;
listen 80 proxy_protocol;
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
real_ip_header proxy_protocol;
real_ip_recursive on;
# 日志里现在拿到的是真实客户端 IP
log_format main '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" rt=$request_time';
}
场景 B:上游只透传 X-Forwarded-For。那就把 real_ip_header 换成 X-Forwarded-For,且 set_real_ip_from 里只写上游那几台 LB 的地址,别写 0.0.0.0/0。写全网段的话,客户端随手伪造一个 XFF 就能绕过你所有的 IP 限速规则。
还有一个坑:容器里如果跑的是 openresty 或带 Lua 的镜像,Lua 里取 ngx.var.remote_addr 拿到的是 realip 模块处理后的值,但 ngx.var.binary_remote_addr 拿到的是 TCP 层原始地址。做 IP 黑名单的时候要注意用的是哪个,用错了会出现"日志里是真实 IP,但限速规则匹配的是上游 IP"这种诡异现象。
六、上线前必须核对的指标
压测跑起来之后,别只看带宽和 QPS,真正要看的是这些内核计数器。任何一项非零都要停下来查:
nstat -az | grep -E \
'ListenOverflows|ListenDrops|TCPBacklogDrop|TCPReqQFullDrop|SyncookiesSent|TCPAbortOnMemory|TCPTimeWaitOverflow'
# conntrack 实际水位
conntrack -C
cat /proc/sys/net/netfilter/nf_conntrack_count
# 软中断是否集中在单核
mpstat -P ALL 1 3
# nftables 各条规则的命中计数
nft -a list chain inet edge prerouting
TcpExtListenOverflows增长 →somaxconn或 Nginxbacklog不够;TcpExtSyncookiesSent大量增长 → SYN 队列被打满,说明你的 nftables 限速阈值给太松了,回去调meter的 rate;TcpExtTCPAbortOnMemory非零 → conntrack 内存已经吃紧,赶紧先扩nf_conntrack_max,再回头收紧黑名单;ksoftirqd单核 100% → 检查网卡是否多队列、RSS 是否均匀、有没有开 RPS/RFS。
整套东西落地下来,单节点在 host 网络 + nftables 前置限速的架构下,日常承载几万 QPS 是稳的,关键是没有了 bridge 模式那一层隐藏的 NAT 开销和源 IP 丢失问题。真正需要做的不是堆参数,而是搞清楚报文在 netfilter 的哪一层被处理——顺序错了,你写再多的限速规则都是在给 conntrack 收尸。