高防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 或 Nginx backlog 不够;
  • TcpExtSyncookiesSent 大量增长 → SYN 队列被打满,说明你的 nftables 限速阈值给太松了,回去调 meter 的 rate;
  • TcpExtTCPAbortOnMemory 非零 → conntrack 内存已经吃紧,赶紧先扩 nf_conntrack_max,再回头收紧黑名单;
  • ksoftirqd 单核 100% → 检查网卡是否多队列、RSS 是否均匀、有没有开 RPS/RFS。

整套东西落地下来,单节点在 host 网络 + nftables 前置限速的架构下,日常承载几万 QPS 是稳的,关键是没有了 bridge 模式那一层隐藏的 NAT 开销和源 IP 丢失问题。真正需要做的不是堆参数,而是搞清楚报文在 netfilter 的哪一层被处理——顺序错了,你写再多的限速规则都是在给 conntrack 收尸。