Docker 为什么绕过 UFW,如何正确修复端口暴露
Docker 会通过 iptables 的 DNAT 规则绕过 UFW,导致 ufw status 显示默认拒绝但 8080 仍可从公网访问。本文解释 PREROUTING 与 DOCKER 链的机制,并介绍有效修复方法。
Docker 为何会绕过 UFW
Docker 会绕过 UFW,因为发布的容器端口不会经过 UFW 管理的防火墙规则。运行 docker run -p 8080:80 时,Docker 会在内核的 nat 表中,将 DNAT(目标网络地址转换)规则写入自己的 DOCKER 链,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 为发布端口设置的规则,位于DOCKER链中,并由PREROUTING跳转到该链,正是执行此操作。- 接下来进行路由决策。目标地址是主机本身的数据包会进入
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 的数据包。在 Docker Engine 28.0.0 及更高版本中,Docker 还会在 raw 表的 PREROUTING 链中添加规则,丢弃从非回环接口进入、目标为该回环端口的任何数据包。在这些版本中,该端口可从主机访问,其他位置均无法访问。对于早于 28.0.0 的版本,Docker 文档说明,同一 L2 网段中的主机(例如连接到同一网络交换机的计算机)仍可能访问发布到 localhost 的端口。因此,在依赖此行为前,请检查 docker version 并升级。验证绑定情况:
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 不会删除或修改其中的规则,包括 daemon 重启后也是如此。
该链属于 Docker 的默认 iptables 防火墙后端。Docker Engine 29 新增了实验性的 nftables 后端,您必须使用 firewall-backend daemon 选项启用它。启用后不存在 DOCKER-USER 链:自定义过滤规则应放在您自己的独立 nftables 表中,并在 forward hook 上配置 base chain。如果您未修改该选项,则使用的是 iptables,下面的内容全部适用。两种工具的区别见 iptables 与 nftables 指南。
运行命令前要注意:数据包到达 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 和隔离规则也会消失。Docker 文档警告,如果没有替代规则,局域网中的主机就可以访问容器端口。你需要破坏容器网络才能修复这一绕过问题,而且这些规则都将由你手动编写和维护。Docker 官方文档认为该设置不适合大多数用户,并说明事后创建这些规则会非常复杂。DOCKER-USER链的存在正是为了让任何人都不需要启用这个开关。
同一问题的 IPv6 部分
首先检查 IPv6 上已发布的端口是什么样:
sudo ss -tlnp | grep 8080从 Docker Engine 27 开始,Docker 默认管理 ip6tables。在启用 IPv6 的 Docker 网络上,已发布端口也会在 IPv6 表中执行相同的 DNAT,因此同样存在绕过问题,也适用相同的修复方法:DOCKER-USER链同样存在于 ip6tables 中,因此使用 sudo ip6tables -I DOCKER-USER ... 镜像配置规则,并从外部使用 curl 访问服务器的公网 IPv6 地址进行测试,例如 curl -6 http://[2001:db8:2a::1]:8080/。这适用于 Docker 的默认 iptables 后端;在实验性的 nftables 后端中,两套协议栈都没有 DOCKER-USER链,IPv6 过滤规则应改为写入您自己的 ip6 nftables 表。
在未启用 IPv6 的网络上,IPv6 客户端则由 docker-proxy处理。这是一个监听 [::]:8080并将流量通过 IPv4 转发到容器的普通用户空间进程。访问主机进程的流量确实会经过 INPUT,因此 UFW 可以过滤该路径,但前提是 UFW 正在管理 IPv6。UFW 是否管理 IPv6,以及 VPS 上 IPv6 通道出现缺口的其他方式,是UFW 和 IPv6 指南的主题。
在回环地址上发布端口可以避开整个问题:-p 127.0.0.1:8080:80仅绑定 IPv4 回环地址,因此不存在 IPv6 监听器;在 Docker Engine 28.0.0 及更高版本中,从外部也无法通过任一协议栈访问该端口。
可长期稳定使用的模式
- 在 Docker Engine 28.0.0 或更高版本中,将所有内部端口发布到
127.0.0.1,从源头避免端口暴露(较旧版本允许同一 L2 网段中的主机访问这些端口)。 - 让一个反向代理统一处理公网流量,并由它占用 80 和 443 端口。
- 对主机保持 UFW 默认拒绝,仅允许 SSH 和代理端口。
- 在 Docker 的默认 iptables 后端中,于
DOCKER-USER过滤确实需要对外开放的容器端口,并根据原始目标端口匹配,将规则持久化到/etc/ufw/after.rules。 - 保持 Docker 的 iptables 集成启用。
完成一次配置后,边界就清晰了:ufw status描述主机,DOCKER-USER描述容器。不会再有端口被意外发布;你输入的下一条 docker run -p 命令只会暴露你明确指定的端口。
FAQ
为什么 UFW 阻止端口后,我仍然可以访问 Docker 容器?
因为 Docker 在 nat 表的 DOCKER 链中,通过从 PREROUTING 进入的 DNAT 规则发布端口。该规则会在执行任何过滤前,将数据包的目标地址改写为容器地址。随后,数据包沿 FORWARD 路径传输,而 UFW 规则位于 INPUT 中,数据包不会进入该链。系统根本不会检查防火墙,因此其中的拒绝规则对已发布的容器端口不起作用。
如何让 UFW 阻止 Docker 发布的端口?
UFW 本身无法做到,因为其规则位于错误的链中。你可以停止暴露该端口:在 Docker Engine 28.0.0 及更高版本中,将端口发布为 127.0.0.1:8080:80,这样只有主机可以访问。也可以在 DOCKER-USER 链中添加 iptables 规则,通过 conntrack 匹配原始目标端口。将该规则持久化到 /etc/ufw/after.rules,这样重启后仍会保留,并执行 ufw reload。
是否应在 Docker 的 daemon.json 中设置 "iptables": false?
不应设置。该选项会移除 Docker 的大部分防火墙和 NAT 规则,造成的问题远不止绕过 UFW。由于伪装规则被移除,容器将失去出站 Internet 访问。Docker 还警告说,如果不添加替代规则,容器端口将可被本地网络中的主机访问。应改用回环地址发布和 DOCKER-USER 链。这样既能解决暴露问题,也不会破坏容器网络。
Docker 也会绕过 IPv6 上的 UFW 吗?
在 Docker Engine 27 及更高版本中,ip6tables 管理默认已启用。因此,在启用 IPv6 的 Docker 网络上发布的端口会像 IPv4 上一样绕过 UFW 被改写。在 Docker 默认的 iptables 后端中,还需要将相同的 DOCKER-USER 规则使用 ip6tables 镜像配置。在 Engine 29 的实验性 nftables 后端中,不存在 DOCKER-USER 链,因此应将该过滤规则放入你自己的 nftables 表中。对于未启用 IPv6 的网络,docker-proxy 进程会监听 [::],此类流量会经过 INPUT;如果 UFW 管理 IPv6,UFW 就可以对其进行过滤。发布到 127.0.0.1 可避免这两种情况,因为 IPv6 上根本不会有进程监听。