Docker 端口为什么绕过防火墙?讲透 iptables 链路与 5 种收口方案
一、先说现象
很多人在云主机上遇到过同一幕:
- 装好
firewalld或ufw,只放行了 22 和 443; firewall-cmd --list-ports里确实没有 8080;docker run -p 8080:80 nginx起来之后,从公网直接就能访问 8080。
防火墙像是被"穿透"了。于是开始怀疑 Docker 有后门、怀疑 firewalld 没生效、怀疑云厂商安全组。
其实三者都没坏。真正的原因是:你配的规则和 Docker 的包,走的根本不是同一条链路。
二、根因:DNAT 发生在防火墙之前
一个从公网进来的包,在 Linux 协议栈里的顺序是这样的:
网卡 → raw PREROUTING → nat PREROUTING(DNAT 在这里发生)
→ 路由决策
→ filter FORWARD(转发过滤)
→ 到达容器
Docker 发布端口时,会在 nat 表的 PREROUTING 里挂一条规则,把发往宿主机 8080 的包在进入过滤之前改写目的地址:
# 示例:Docker 写入 nat 表的端口映射规则
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A DOCKER -d 172.17.0.2/32 -p tcp --dport 80 -j DNAT --to-destination 172.17.0.2:80
关键在于:包在 PREROUTING 阶段就已经"不是给宿主机"的了,它的目的地变成了容器 IP。因此后续走的是 FORWARD,而不是 INPUT。
而 ufw 和 firewalld 的端口放行规则,绝大多数都挂在 INPUT 上(ufw-before-input、zone 的 input 链)。包压根不会经过那里。
Docker 官方文档对这件事的表述很直接:Docker 和 ufw 在规则的使用方式上互不兼容;发布端口时流量在
nat表就被转发,早于INPUT/OUTPUT链,实际上忽略了你的防火墙配置。
所以不是防火墙失效,是它被"绕过去"了——合法地、按设计地绕过去。
三、那我把规则加到 FORWARD 上行不行?
也不行,原因在链的顺序。
Docker 启动时会在 filter 表的 FORWARD 链里插入若干跳转,且插在最前面。典型结构(Docker 28+ 的 iptables 后端):
FORWARD
├─ -j DOCKER-USER ← 唯一留给用户的钩子,位置最前
├─ -j DOCKER-FORWARD ← Docker 自己的转发处理
│ ├─ 已建立连接直接 ACCEPT
│ └─ -j DOCKER-BRIDGE → -j DOCKER(按端口放行)
└─ -j DOCKER-INGRESS ← Swarm 相关
你在 FORWARD 末尾追加的 -A FORWARD -p tcp --dport 8080 -j DROP,永远轮不到执行——包在 DOCKER-USER 或 DOCKER 链里就已经被 ACCEPT 掉了。
这里有个容易踩的细节:DOCKER-USER 里的规则,看到的是 DNAT 之后的包。也就是说 --dport 匹配的是容器端口(80),不是宿主机端口(8080)。想按宿主机端口匹配,得用 conntrack:
# 按"原始目的端口"匹配,即用户实际访问的 8080
iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport 8080 -j DROP
这个坑我见过太多次:规则写了 --dport 8080 却完全不生效,因为包到这里时端口已经被改写成 80 了。
四、五种收口方案,按推荐度排序
### 方案 1:只绑回环地址(最简单,优先考虑)
如果容器只需要被本机的 Nginx / Caddy 反代,根本不需要对外暴露:
docker run -d -p 127.0.0.1:8080:80 nginx
Docker 生成的 DNAT 规则会带上目的地址限制,只有访问 127.0.0.1:8080 的包才会被转发,外部流量连匹配都匹配不上。
Compose 里同理:
services:
web:
image: nginx
ports:
- "127.0.0.1:8080:80"
优点:零防火墙知识、零维护、不会因为 Docker 重启丢规则。
限制:必须有反代或本机客户端;纯 TCP 服务(如数据库)也能用,但要注意反代层要支持对应协议。
### 方案 2:DOCKER-USER 链(最灵活,生产推荐)
DOCKER-USER 是 Docker 明确保留给用户的链,位置在 FORWARD 最前,且不会被 Docker 重建时清空(Docker 只保证这条链存在,不碰你的规则)。
只允许办公网访问 8080:
iptables -I DOCKER-USER -i eth0 -p tcp --dport 80 \
! -s 203.0.113.0/24 -j DROP
允许已建立连接回流(否则响应包会被自己拦掉):
iptables -I DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
只允许特定源 IP 段访问某个"原始端口":
iptables -I DOCKER-USER -i eth0 -m conntrack --ctorigdstport 8080 \
! --ctorigsrc 203.0.113.0/24 -j DROP
注意顺序:-I 是插入到链首,多条规则时后插入的先生效。建议先写 ACCEPT 类规则,再写兜底 DROP,或者用 -I DOCKER-USER <序号> 精确控制。
持久化是这套方案唯一的麻烦:iptables 规则重启即失。别用 iptables-persistent 保存——它会把 Docker 动态生成的 DOCKER、DOCKER-FORWARD 链一起存下来,下次开机 Docker 还没建链,恢复就会失败。
干净的写法是挂一个 systemd 单元,等 Docker 起来之后再灌规则:
# /etc/systemd/system/docker-firewall.service
[Unit]
Description=Apply custom DOCKER-USER rules
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/docker-firewall.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
#!/bin/sh
# /usr/local/sbin/docker-firewall.sh
set -e
iptables -I DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -I DOCKER-USER -i eth0 -p tcp --dport 80 ! -s 203.0.113.0/24 -j DROP
chmod +x /usr/local/sbin/docker-firewall.sh
systemctl daemon-reload
systemctl enable --now docker-firewall.service
### 方案 3:firewalld 的 docker zone 与 direct 规则
新版 Docker 在检测到 firewalld 时,会自动创建一个 target 为 ACCEPT 的 docker zone,并把 docker0 等网桥接口塞进去,同时建立 docker-forwarding 策略。
这带来一个副作用:容器相关的流量在 firewalld 层面被整体放行了。你在 public zone 里删端口、加 rich rule,都管不到已经发布出去的容器端口。
想在 firewalld 体系里拦,可以用 direct 规则直接落到 DOCKER-USER:
firewall-cmd --permanent --direct --add-rule ipv4 filter DOCKER-USER 0 \
-i eth0 -p tcp --dport 80 -j DROP
firewall-cmd --reload
坑:direct 规则在 firewalld 启动时下发,如果此时 DOCKER-USER 链还没被 Docker 创建,规则会下发失败。要么保证 docker.service 先于 firewalld 启动,要么接受"重载一次 firewalld"的操作习惯。相比之下,方案 2 的 systemd 单元更稳。
### 方案 4:ufw 的 after.rules
Ubuntu 上用 ufw 的话,得在 /etc/ufw/after.rules 的 *filter 段末尾补一条链声明和规则:
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -i eth0 -p tcp --dport 80 ! -s 203.0.113.0/24 -j DROP
-A DOCKER-USER -j RETURN
COMMIT
然后 ufw reload。注意 -A DOCKER-USER -j RETURN 一定要放在最后,否则链尾默认策略是 DROP,会把容器的正常出网也一起掐了。
同样存在"链未创建"的时序问题,且 ufw 与 Docker 的兼容性官方并不承诺,属于能跑但别指望长期稳定的方案。
### 方案 5:关掉 Docker 的 iptables 管理(不推荐,除非你很懂)
在 /etc/docker/daemon.json 里:
{
"iptables": false
}
关掉之后 Docker 不再写规则,端口映射由你自己用 NAT 实现。但代价是:容器默认上不了外网(没有 MASQUERADE),容器间隔离没了,-p 参数基本失效,还得自己处理 DNS。
官方对这件事的评价是:这选项对大多数用户不合适,很可能直接搞坏容器网络;而且也没法完全阻止 Docker 建规则。
除非你在做 SDN、需要完全接管转发面,否则别碰。
五、方案对比
| 方案 | 生效位置 | 维护成本 | 适用场景 | 主要风险 |
|---|---|---|---|---|
| 绑定 127.0.0.1 | nat DNAT 目的地址 | 极低 | 有反代、只需本机访问 | 必须改造调用链路 |
| DOCKER-USER | filter FORWARD 最前 | 中(需持久化) | 生产环境精细控制 | 规则顺序、conntrack 匹配 |
| firewalld direct | filter DOCKER-USER | 中 | 已统一用 firewalld | 启动时序 |
| ufw after.rules | filter DOCKER-USER | 中 | Ubuntu 单机 | 官方不承诺兼容 |
| 关闭 iptables | — | 高 | 特殊网络架构 | 容器断网、隔离失效 |
六、上线前的自检清单
1. 看谁在监听
ss -lntp | grep -E ':(8080|80)\s'
如果看到 docker-proxy 监听在 0.0.0.0:8080,说明端口是对全网开放的。
2. 看 DNAT 规则
iptables -t nat -S DOCKER | grep 8080
没有 -d 127.0.0.1 之类的限制,就是全量转发。
3. 看用户链是否真的生效
iptables -S DOCKER-USER
iptables -L DOCKER-USER -n -v --line-numbers
-v 的包计数是判断规则有没有被命中的最直接证据。计数一直是 0,说明规则位置不对或者匹配条件写错了。
4. 从外部验证,不要只看配置
# 在另一台机器上执行
nc -zv <公网IP> 8080
curl -m 3 -I http://<公网IP>:8080
配置对不对,以外部实际能否连通为准。
5. 别忘了 IPv6 和云安全组
- 如果 daemon 开了 IPv6,
ip6tables的DOCKER-USER要单独配一遍,两套规则互不影响。 - 主机防火墙是最后一道,不是唯一一道。云上务必同时收紧安全组,两层都拦才是真的收口。
七、几个常见误解
"关掉 userland-proxy 就安全了"
不成立。--userland-proxy=false 只是不启 docker-proxy 进程,DNAT 规则照旧存在,端口依旧可达。它不是安全开关。
"firewalld 里把 docker0 加进 trusted zone 就解决了"
方向反了。那是放开容器流量,不是限制。
"容器没发布端口就一定安全"
也未必。--network host 的容器直接用宿主网络栈,端口监听等同于宿主机进程;macvlan/ipvlan 的容器在二层直接暴露,同样绕开 Docker 的转发规则。
"重启 Docker 后规则还在"
DOCKER-USER 里的规则通常能活下来,但如果你用 iptables-save/restore 做持久化,很可能在 Docker 重建链时被冲掉或者恢复失败。用 systemd 单元按依赖顺序下发,是省心的做法。
八、小结
一句话总结这套机制:Docker 在 nat 表把包改道了,防火墙的 INPUT 规则从设计上就看不到这些包;而 FORWARD 里 Docker 的链又排在你的规则前面。
所以正确的姿势只有三个方向:
- 不让它暴露——绑
127.0.0.1,用反代对外; - 在
DOCKER-USER里拦——这是 Docker 留给你的唯一官方钩子,注意 DNAT 后的端口语义; - 在云安全组再兜一层——主机规则会写错,安全组不会跟着一起错。
把这三层叠起来,容器端口才算真正"收口"。
- 点赞
- 收藏
- 关注作者
评论(0)