Docker 为什么绕过 UFW,如何正确修复端口暴露
Docker 会通过 iptables 的 DNAT 规则绕过 UFW,导致 ufw status 显示默认拒绝但 8080 仍可从公网访问。本文解释 PREROUTING 与 DOCKER 链的机制,并介绍有效修复方法。
Docker 为什么会绕过 UFW
Docker 会绕过 UFW,因为发布的容器端口不会经过 UFW 管理的防火墙规则。运行 docker run -p 8080:80 时,Docker 会将 DNAT(目标网络地址转换)规则写入内核 nat 表的 PREROUTING 链。内核决定数据包的去向之前,该规则会先将每个数据包的目标地址改写为容器的私有地址。改写后的数据包随后通过 FORWARD 链转发到容器,该链由 Docker 控制。UFW 的规则位于 INPUT 链中,而数据包根本不会进入该链。因此,ufw status 显示默认拒绝,sudo ufw deny 8080 报告操作成功,但 8080 端口仍然可以响应整个互联网的请求。
这不是 Docker 的缺陷,UFW 也没有损坏。两个工具操作的是同一个内核防火墙。Docker 的规则只是在数据包路径中更早的位置生效,因此不会触发 UFW 处理。本指南会演示这种绕过行为,解释其机制,然后介绍两种有效的修复方法:在 127.0.0.1 上发布端口,以及在 DOCKER-USER 链中进行过滤。如果您还不熟悉 UFW,请先参阅UFW 防火墙基础指南进行配置,因为默认拒绝的防火墙仍然是服务器上其他安全配置的正确基础。
在您自己的服务器上验证绕过
先准备一台已启用 UFW、并对入站流量采用默认拒绝策略的 VPS。运行一个发布端口的 Web 容器:
sudo ufw status verbose
docker run -d --name web -p 8080:80 nginx:1.29-alpineufw status verbose 显示 Default: deny (incoming), allow (outgoing),且没有针对端口 8080 的规则。根据防火墙自身的报告,该端口处于关闭状态。现在从另一台机器测试,不要在服务器本机上测试:
curl -I http://your-vps-ip:8080/HTTP/1.1 200 OK容器可以响应请求。添加一条显式拒绝规则,然后再次测试:
sudo ufw deny 8080/tcp该端口仍然可以响应,因为这条拒绝规则位于数据包不会经过的链中。UFW 没有失效,而是根本没有被检查。这也解释了问题为何很难发现:任何地方都不会输出错误,部署可以正常完成,防火墙状态输出看起来也与正常锁定的服务器完全相同。
机制:PREROUTING 在 INPUT 之前运行
内核会按固定顺序处理传入数据包,整个问题都由这个顺序引起。
PREROUTING首先运行。此处的规则可以改写数据包的目标地址,Docker 为发布端口添加的规则正是这样做的。- 接下来进行路由决策。目标地址是主机自身的数据包会进入
INPUT链。目标地址是其他机器的数据包会进入FORWARD链。 - UFW 的规则位于
INPUT。Docker 的规则位于FORWARD。
查看刚刚启动的容器对应的 Docker 规则:
sudo iptables -t nat -L DOCKER -nChain DOCKER (2 references)
target prot opt source destination
RETURN 0 -- 0.0.0.0/0 0.0.0.0/0
DNAT 6 -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80DNAT 这一行说明了全部原因。所有到达端口 8080 的数据包都会将目标地址改写为 172.17.0.2:80,也就是该容器在 Docker 私有网桥网络中的地址。改写后,数据包的目标地址不再是主机自身,因此路由决策会将其发送到 FORWARD 路径。在该路径中,Docker 已经添加了允许流量进入其网络的规则。您的 deny 8080/tcp 规则位于 INPUT 中,但它等待的数据包永远不会到达。
在 Ubuntu 24.04 上,iptables 命令是 nftables 的前端,但链的顺序和结果完全相同。UFW 和 Docker 都会写入同一条内核数据包处理链路,而 Docker 的入口更早。这个问题并不只与 UFW 有关:Rocky 或 AlmaLinux VPS 上的 firewalld 会在该链路的同一位置进行过滤,也会被相同的 DNAT 规则绕过,因此下面的修复方法同样适用于这种环境。
日常修复:在 127.0.0.1 上发布端口
大多数容器一开始就不需要对公网开放。数据库、反向代理后的应用服务器、管理面板和指标端点,都不应直接响应互联网请求。将它们发布到回环地址:
docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpine或者在 Compose 文件中:
services:
web:
image: nginx:1.29-alpine
ports:
- "127.0.0.1:8080:80"这是因为 Docker 的 DNAT 规则现在只匹配目标地址为 127.0.0.1 的数据包,而来自互联网的数据包不可能合法地携带该目标地址,因此内核会在执行任何防火墙规则前将其丢弃。该端口可从主机访问,其他位置都无法访问。验证绑定地址:
sudo ss -tlnp | grep 8080输出中应为 127.0.0.1:8080,而不是 0.0.0.0:8080 或 [::]:8080。然后从另一台机器确认 curl http://your-vps-ip:8080/ 的连接会被拒绝。
对于需要面向互联网的服务,运行一个独立的反向代理,由它占用 80 和 443 端口并按主机名路由,不要发布其他端口。这正是 Traefik 反向代理指南介绍的模式,也是自托管应用 VPS 上的 Nextcloud 只能通过代理访问的原因。如何声明 ports: 条目,以及其余 Compose 工作流,请参阅 Docker Compose 基础指南。
所有内部容器都使用回环地址后,UFW 就能恢复正常职责:保护主机自身提供服务的端口。在此处构建规则集,然后按顺序运行命令:
实际过滤:DOCKER-USER 链
有时容器端口必须继续发布到网络,但需要限制访问。例如,数据库副本端口可能只能由一个办公地址访问。为此,Docker 提供了 DOCKER-USER 链。所有发往任意容器的数据包都会在 Docker 自身的接受规则之前经过 DOCKER-USER,而 Docker 不会向其中写入规则。该链供您使用,Docker daemon 重启时也不会修改其中的内容。
执行命令前需要注意:数据包到达 DOCKER-USER 时,DNAT 重写已经完成。数据包的目标端口是容器端口(本例中为 80),而不是发布端口(8080)。因此,匹配 --dport 8080 的规则不会匹配任何数据包。可靠的方法是匹配客户端最初连接的端口,内核的连接跟踪器会记录该端口:
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP这条规则的含义是:对于从 eth0 进入、且所属连接的原始目标端口为 8080 的数据包,丢弃所有不是从 10.0.0.10 发出的数据包。--ctdir ORIGINAL 匹配项将规则限制在客户端到容器的方向,避免错误匹配回复数据包。将 eth0 替换为公网接口;ip route | grep default 可显示该接口名称。按照前面相同的方式测试:从允许的地址执行 curl 应成功,而从其他位置执行时连接会超时。连接挂起表示 DROP 规则正在生效,而不是后端没有服务;被拒绝的连接与超时连接的区别,是区分端口被过滤和服务未监听的最快方法。
使用 iptables 命令添加的规则会在重启后消失。由于 UFW 已经管理此防火墙,持久化规则的合适位置是 /etc/ufw/after.rules。在文件末尾追加以下代码块:
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP
COMMIT然后运行 sudo ufw reload。UFW 会在每次重新加载和每次启动时重新应用该文件。因此,容器过滤规则与防火墙的其他规则位于同一处,并且在系统重启和 Docker 升级后仍会保留。
为什么不应禁用 Docker 的 iptables 集成
针对这个问题,较早的回答建议在 /etc/docker/daemon.json 中设置 { "iptables": false }。不要这样做。Docker 的防火墙规则远不只是发布端口。伪装规则让容器可以通过主机的地址访问外部网络。禁用该集成后,容器无法拉取镜像、访问软件包镜像站或调用任何外部 API(应用程序编程接口)。DNAT 规则是 -p 正常工作的基础,因此已发布的端口会完全失效。用于隔离不同 Compose 网络的规则也会消失。这样虽然可以修复绕过问题,却会破坏容器网络;上述每条规则都需要您手动编写和维护。Docker 官方文档说明,此设置适用于确实打算自行完成这些工作的用户。DOCKER-USER 链的存在正是为了让任何人都不需要启用此开关。
同一问题在 IPv6 中的情况
首先检查 IPv6 上发布的端口是什么样:
sudo ss -tlnp | grep 8080从 Docker Engine 27 开始,Docker 默认管理 ip6tables。在启用 IPv6 的 Docker 网络中,发布的端口会在 IPv6 表中执行相同的 DNAT 处理,因此同样存在绕过问题,也适用相同的修复方法:ip6tables 中也存在 DOCKER-USER 链,因此使用 sudo ip6tables -I DOCKER-USER ... 镜像配置规则,并从外部使用 curl 访问服务器的公网 IPv6 地址进行测试,例如 curl -6 http://[2001:db8:2a::1]:8080/。
在未启用 IPv6 的网络中,IPv6 客户端会改由 docker-proxy 处理。它是一个普通的用户空间进程,监听 [::]:8080,再通过 IPv4 将流量转发到容器。访问主机进程的流量确实会经过 INPUT,因此 UFW 可以过滤这一路径,但前提是 UFW 正在管理 IPv6。UFW 是否管理 IPv6,以及 VPS 上 IPv6 暴露风险的其他来源,是UFW 和 IPv6 指南的主题。
绑定到 loopback 可以完全绕开这个问题:-p 127.0.0.1:8080:80 仅绑定 IPv4 loopback,因此不存在 IPv6 监听器,外部也无法通过任一协议栈访问它。
可长期稳定使用的模式
- 将所有内部端口发布到
127.0.0.1,从源头上避免其暴露。 - 让一个反向代理统一处理公网流量,并独占 80 和 443 端口。
- 在主机上保持 UFW 的默认拒绝策略,仅允许 SSH 和代理端口。
- 在
DOCKER-USER中过滤确实需要公开的容器端口,按原始目标端口匹配,并将规则持久化到/etc/ufw/after.rules。 - 保持 Docker 的 iptables 集成启用。
完成一次设置后,端口暴露情况就不会再令人意外:ufw status描述主机,DOCKER-USER描述容器。不会有端口被意外发布;下一条输入的 docker run -p 将只暴露你明确指定的端口。
FAQ
为什么 UFW 阻止端口后,我仍然可以访问 Docker 容器?
因为 Docker 会在 PREROUTING 链中通过 DNAT 规则发布端口。该规则会在任何过滤发生前,将数据包的目标地址改写为容器地址。随后,数据包会经过 FORWARD 路径,而 UFW 规则位于 INPUT 中,数据包不会进入该链。防火墙根本不会检查这些数据包,因此其拒绝规则对已发布的容器端口不起作用。
如何让 UFW 阻止 Docker 发布的端口?
UFW 本身无法做到,因为其规则位于错误的链中。可以停止暴露该端口,将其发布为 127.0.0.1:8080:80,这样只有主机可以访问;也可以在 DOCKER-USER 链中添加 iptables 规则,通过 conntrack 匹配原始目标端口。将该规则持久化到 /etc/ufw/after.rules,使其在重启和 ufw reload 后仍然生效。
是否应在 Docker 的 daemon.json 中设置 "iptables": false?
不应设置。该选项会移除 Docker 的所有防火墙和 NAT 规则,造成的问题远不止绕过防火墙。由于伪装规则被移除,容器会失去出站 Internet 访问;由于 DNAT 规则被移除,已发布的端口也会停止工作。应改用回环地址发布和 DOCKER-USER 链;这两种方式可以解决暴露问题,同时不会破坏容器网络。
Docker 也会绕过 IPv6 上的 UFW 吗?
在 Docker Engine 27 及更高版本中,ip6tables 管理默认启用。因此,在启用 IPv6 的 Docker 网络上发布的端口会像 IPv4 中一样绕过 UFW 被改写,并且需要使用 DOCKER-USER 规则,同时通过 ip6tables 镜像该规则。在未启用 IPv6 的网络中,docker-proxy 进程会监听 [::],这些流量会经过 INPUT;如果 UFW 管理 IPv6,UFW 就可以对其进行过滤。发布到 127.0.0.1 可以避免这两种情况,因为 IPv6 上根本没有进程监听。