UFW 管不到 Docker 发布的端口——原理与补法
容器发布端口走的是 FORWARD 而不是 INPUT,所以 ufw 的 deny incoming 对它完全无效。给出 DOCKER-USER 守卫的写法、验证因果的实验方法,以及三个已经踩过的坑。
现象
ufw status 显示 deny (incoming),但容器用 ports: 发布的端口从公网照样能连上。看起来防火墙开着,实际上守的是一张别的门。
原理:DNAT 发生在 INPUT 之前
外部连接到达 eth0
│
├─ nat/PREROUTING ── DNAT 改写目的地址为容器 IP:端口
│ │
│ ▼
└────────────────────► FORWARD 链 ──► DOCKER-USER ──► DOCKER-FORWARD ──► ACCEPT
(全程不经过 INPUT)
目的地址被改写之后,这个包对内核来说就是"转发给另一个地址"的包,走 FORWARD。而 UFW 的入站过滤全部挂在 INPUT 上。两者根本不在同一条路径上。
所以单开 UFW 会给人虚假的安全感——它保护的是宿主自己监听的服务,不包括任何容器发布的端口。
补法:在 FORWARD 最早的一跳上守卫
Docker 保留了 DOCKER-USER 链作为用户自定义入口,它在 Docker 自己的规则之前被求值:
# 白名单:私网与回环
-A DOCKER-USER -m conntrack --ctstate NEW -s 10.0.0.0/8 -j RETURN
-A DOCKER-USER -m conntrack --ctstate NEW -s 172.16.0.0/12 -j RETURN
-A DOCKER-USER -m conntrack --ctstate NEW -s 192.168.0.0/16 -j RETURN
-A DOCKER-USER -m conntrack --ctstate NEW -s 127.0.0.0/8 -j RETURN
# 兜底:其余来源的新连接一律丢弃
-A DOCKER-USER -m conntrack --ctstate NEW -j DROP
三个设计点:
为什么按来源而不是按端口。 按端口写就得在每次新增服务时回来改规则,漏一次就是一个敞口。按来源写是"只有私网能进容器",与暴露了哪些端口无关——新增服务天然安全。
为什么必须带 --ctstate NEW。 容器出网请求的回程包是 ESTABLISHED。不带这个条件,容器自己就再也连不回来了。只要新连接受控,已经建立的连接不受影响。
为什么必须放行回环。 宿主上用 localhost 访问已发布端口时,源地址是 127.0.0.1,而这个包经 DNAT 之后同样走 FORWARD。不放行的话,本机调试会莫名其妙失败,而公网行为反而"正常"——这种反差很容易带偏排查方向。
验证:要做出因果对照,不是只看现象
"公网连不上"这个现象本身不能证明是这条规则起的作用——可能是别的东西挡的。做一个能证否的实验:
# 用 network namespace + veth 伪造一个公网来源,源地址取文档保留段
ip netns add probe
ip link add veth-p type veth peer name veth-h
ip link set veth-p netns probe
ip addr add 203.0.113.1/24 dev veth-h && ip link set veth-h up
ip netns exec probe ip addr add 203.0.113.5/24 dev veth-p
ip netns exec probe ip link set veth-p up
ip netns exec probe ip route add default via 203.0.113.1
# 对照组 1:宿主经公网 IP 访问已发布端口 → 期望放行
# 对照组 2:公网来源访问同一端口 → 期望阻断,且 DROP 计数 +1
iptables -L DOCKER-USER -n -v | grep DROP
# 对照组 3:移除守卫后,同一来源重试 → 期望放行
第三组是关键的因果证明:不是别的原因挡住的,就是这条守卫。计数器的增量则是第二重证据——拦截真的发生在这个链上。
三个已经踩过的坑
1. ufw enable ≠ 开机自启
ufw enable 输出的那句 "enabled on system startup" 只是把 ENABLED=yes 写进 /etc/ufw/ufw.conf。systemd 的 ufw.service 可能是 disabled 的,重启后规则不会应用。
systemctl is-enabled ufw # 要确认这里是 enabled
此时 ufw status 仍会显示 active(因为规则已经加载在内核里了),所以这个状态无法用来判断开机时会不会生效。要判断得看单元状态,或者更彻底一点——重启一次看规则还在不在。后者才是最终证据。
2. dockerd 重启会清空 DOCKER-USER
Docker 守护进程重启时会重建自己的一套链,DOCKER-USER 里的自定义内容随之消失。用一个跟随 docker 的单元来兜住:
[Unit]
Description=Restore DOCKER-USER ingress guard
After=docker.service
PartOf=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/docker-ufw-guard.sh
PartOf=docker.service 让这个单元在 docker 重启时跟着重跑。验证方式是直接重启 docker 再看规则是否回来——不要假设。
3. 守卫脚本必须幂等且静默
脚本会被 systemd 和定时看护任务反复调用。用 iptables -C 先判断再 -A,避免规则堆积。
一个容易漏的细节:iptables -C 在某些版本会把规则文本写到 stdout。如果这个脚本的约定是"正常时无输出"(供 cron 判断异常),就必须 >/dev/null 2>&1,否则每次正常调用都会产生输出,看护逻辑要么误报要么被噪声淹没。
fail2ban 与 UFW 的链顺序
这条容易静默失效,单独说:
fail2ban 用 iptables -I 把 f2b-sshd 插到 INPUT 的第 1 位。如果 UFW 的链排在前面并已经 ACCEPT 了该端口,被封的 IP 就永远走不到封禁链——封禁看起来在生效(fail2ban-client status 里 ban 列表有它),实际完全没用。
本机实测的顺序是对的:
1 f2b-sshd ← fail2ban,先判封禁
2 ufw-before-logging-input
3 ufw-before-input
...
原因是 ufw.service 在 network-pre.target 之前运行,早于 fail2ban;而 UFW 的跳转规则是追加(-A)而不是插入,所以后启动的 fail2ban 反而占据了第 1 位。这个顺序不是设计出来的,是启动顺序的副产品——所以它也会因为启动顺序变化而失效。检查方法:
iptables -S INPUT | head -3
顺序不对时 systemctl restart fail2ban 可以恢复。
顺带清掉的一类死规则
raw PREROUTING 里曾有若干形如下的规则:
iifname != "br-xxxxxxxxxxxx" ip daddr 172.18.0.2 counter packets 0 bytes 0 drop
双重问题:写死了会被重新分配的容器 IP(容器重建后就对不上号),而且匹配不到任何流量——raw 表在 conntrack 之前求值,那时回程包的目的地址还是宿主公网 IP,DNAT 尚未发生。实测计数器恒为 0。
这类规则最消耗时间的地方在于:它看起来在做一件正确的事,注释也写得很有道理。判断它是否真的在工作,只能靠计数器。
小结
| 层 | 负责 | 实现位置 |
|---|---|---|
| UFW | 宿主自身监听的服务的入站过滤 | INPUT |
| DOCKER-USER 守卫 | 容器流量的入站过滤 | FORWARD 最早一跳 |
| fail2ban | 来源封禁(必须排在 UFW 之前) | INPUT 第 1 位 |
三样都要有,且都要确认开机后仍然存在。防火墙配置的失效方式很安静:状态命令说 active,功能却已经不在。