SSD Nodes Learn
指南 Matt Connor作者: Matt Connor · 更新于 2026-07-19

Docker 绕过 UFW:原因与修复方法

Docker 用 iptables 规则发布容器端口,绕开了 UFW,因此被拒绝的端口仍然对外网开放。本文讲解其原理,以及真正有效的修复方法。

为什么 Docker 会绕过 UFW

Docker 之所以绕过 UFW,是因为发布出去的容器端口从不经过 UFW 所管理的防火墙规则。当您运行 docker run -p 8080:80 时,Docker 会在内核 nat 表的 PREROUTING 链中写入一条 DNAT(目标网络地址转换,destination network address translation)规则。这条规则会在内核决定数据包去向之前,就把每个数据包的目标地址改写为容器的私有地址。改写后的数据包随后经由 Docker 控制的 FORWARD 链被转发进容器。而 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-alpine

ufw 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 之前运行

内核按照固定的顺序处理一个入站数据包,而整个问题就出在这个顺序上。

  1. PREROUTING 最先运行。这里的规则可以改写数据包的目标地址,而 Docker 为已发布端口写的规则正是这么做的。
  2. 接下来是路由决策。目标为主机自身的数据包进入 INPUT 链,目标为其他任何机器的数据包进入 FORWARD 链。
  3. UFW 的规则位于 INPUT,Docker 的规则位于 FORWARD

看看 Docker 为您刚启动的容器写下的规则:

sudo iptables -t nat -L DOCKER -n
Chain 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:80

那条 DNAT 就是全部答案。任何到达端口 8080 的数据包,目标地址都会被改写为 172.17.0.2:80,也就是该容器在 Docker 私有网桥网络上的地址。改写之后,数据包的目标就不再是主机,于是路由决策把它送上 FORWARD 路径,而 Docker 已经在那里加好了接受流量进入自己网络的规则。您那条 deny 8080/tcp 规则则在 INPUT 里苦等一个永远不会到来的数据包。

在 Ubuntu 24.04 上,iptables 命令只是 nftables 之上的一层前端,但链的顺序和最终结果完全相同。UFW 和 Docker 都写入同一条内核数据包流水线,而 Docker 的入口更靠前。

日常修复:把端口发布到 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 就回到了它的本职工作:守护主机自身提供服务的端口。在这里生成那套规则,然后按顺序运行这些命令:

ToolUFW rule generator

真正的过滤:DOCKER-USER 链

有时一个容器端口必须保持对网络发布,但又要加以限制,比如一个只允许某个办公室地址访问的数据库副本端口。为此,Docker 提供了 DOCKER-USER 链。每个前往任何容器的数据包,都会在 Docker 自己的接受规则之前先经过 DOCKER-USER,而 Docker 从不往这条链里写规则。这条链是为您准备的,并且 Docker 在守护进程重启后也不会改动其中的内容。

运行命令前有一个陷阱:当数据包到达 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 会成功,而从其他任何地方连接都会超时。

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 的防火墙规则远不止发布端口这一件事。正是那条 masquerade(地址伪装)规则让容器能够通过主机的地址访问外网,所以一旦关闭集成,容器就无法拉取镜像、无法访问软件包镜像源,也无法调用任何外部 API(应用程序编程接口,application programming interface)。DNAT 规则则是让 -p 能够工作的根本,所以已发布的端口会彻底失效。那些把不同 Compose 网络彼此隔离的隔离规则也会消失。您等于是用破坏容器网络的方式去修复这个绕过问题,而上述每一条规则都将变成需要您亲手编写和维护的东西。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/

在一个没有 IPv6 的网络上,IPv6 客户端改由 docker-proxy 处理,这是一个普通的用户态进程,它监听 [::]:8080 并通过 IPv4 把流量转发进容器。前往主机进程的流量确实会经过 INPUT,所以 UFW 可以过滤这条路径,但前提是 UFW 确实在管理 IPv6。它是否在管理,以及 VPS 上 IPv6 缺口打开的其他方式,正是 UFW 与 IPv6 指南 所讨论的主题。

发布到回环地址可以彻底绕开整个问题:-p 127.0.0.1:8080:80 只绑定 IPv4 回环,因此没有 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 规则,其破坏范围远超这个绕过问题本身。容器会失去出站上网能力,因为 masquerade 规则没了;已发布的端口会停止工作,因为 DNAT 规则没了。请改用回环发布和 DOCKER-USER 链,它们能在不破坏容器网络的前提下修复暴露问题。

Docker 在 IPv6 上也会绕过 UFW 吗?

在 Docker Engine 27 及更高版本上,ip6tables 管理默认开启,所以在一个启用了 IPv6 的 Docker 网络上发布的端口,会像在 IPv4 上一样被改写绕过 UFW,并且需要用 ip6tables 如法炮制同一条 DOCKER-USER 规则。在没有 IPv6 的网络上,docker-proxy 进程监听 [::],这类流量确实会经过 INPUT,如果 UFW 管理 IPv6,就能在那里过滤它。发布到 127.0.0.1 可以避免这两种情况,因为根本没有任何东西在 IPv6 上监听。