惊天大坑:Docker 会绕过 UFW 防火墙!0.0.0.0 端口裸露风险与安全绑定配置

你在 UFW 封了 8080,Docker 映射后公网照样能访问!本文揭秘 Docker 直接改 iptables 跳过 UFW 的原因,并给出 127.0.0.1 安全绑定 + 反向代理、以及修改 daemon.json / ufw-docker 补丁两种解法。

很多人在自己的国外 VPS 上装好 Docker,开开心心地跑起数据库、Redis、后台面板,然后顺手用 UFW 把"非必要"的端口封得死死的,以为万事大吉。结果某天随手用手机流量扫了一眼端口,发现 5432、6379、8080 全都在公网"敞着门"——可 UFW 的状态明明写着 denied。这不是 UFW 坏了,也不是你配置错了,而是 Docker 的一个"特性"在背后悄悄绕过了它。

这篇文章就把这个坑一次讲透:它是怎么发生的、为什么 UFW 拦不住、以及最稳妥的两种修法。全篇命令都可以直接抄,建议边看边在你的测试机上敲一遍。

延伸阅读

更多相关攻略推荐:泛域名 SSL 证书自动化避坑:Acme.sh + DNS API BGP 是互联网的"高德地图":打开网页时,数据是这样找路的【优化线路 02】BGP / AS4837 常规线路年付 10 美元高级玩家玩出花:如何在 VPS 上广播自己的 IP 地址?BYOIP计算机科学两大难题之一:缓存失效与 CDN 瞬时刷新奥秘

一、先把坑复现出来:你以为封住了,其实没封住

假设你有一台 Ubuntu/Debian 的 VPS,刚做完基础加固:默认拒绝所有入站,只放行 22(SSH)、80、443。命令大概是这样:

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose

此时 ufw status 干干净净,只有三行 allow。你心里很踏实。接着你起了个容器,比如一个不想让外人碰的 PostgreSQL:

docker run -d --name db -p 5432:5432 postgres:16

注意这里没写监听地址,Docker 默认就会把它绑到 0.0.0.0,也就是"所有网卡、所有 IP、公网也能进"。你心想:没关系,UFW 把 5432 挡在外面了。但现实是——从你自家电脑、或者用 nmap 一扫,5432 赫然是 open。更可怕的是数据库、Redis、Adminer、phpMyAdmin 这类东西一旦裸露,几小时内就会被 Shodan 之类扫描器收录,接着就是被勒索、被当成肉鸡、流量被刷爆。

一条命令就能自查你是不是也中招:

docker ps --format "table {{.Names}}\t{{.Ports}}"

只要哪一行的端口显示成 0.0.0.0:5432->5432/tcp,那就意味着公网可达。如果是数据库、缓存、内网 API,立刻有风险。

二、原因揭秘:Docker 直接改 iptables,绕开了 UFW 的 INPUT 链

要理解这个坑,得先知道 UFW 到底是什么。UFW(Uncomplicated Firewall)只是 iptables 的一个"人话前端",它最终落地执行的,还是 Linux 内核的 iptables 规则。UFW 主要往 filter 表的 INPUT 链里写规则,来拦截"打到宿主机自身"的流量。

问题出在 Docker 身上。当容器做端口映射(-p)时,Docker 为了做 NAT 转发,会自己动手往 iptables 里塞规则,而且塞的位置非常"靠前":

  • nat 表PREROUTING 链里,Docker 插入了一个跳到 DOCKER 链的规则;
  • DOCKER 链(nat 表)里,它会写一条 DNAT 规则:把访问宿主机 5432 的包,改目的地到容器内部 IP(比如 172.17.0.2:5432);
  • filter 表FORWARD 链里,Docker 又插入规则放行这条转发,并建了一条 DOCKER 链(filter 表)来 ACCEPT 容器相关流量。

关键就在"转发"二字。容器流量不是"打到宿主机自己"的,而是被转发进容器的,所以它走的是 PREROUTING(nat)→ FORWARD(filter)这条链路,根本不经过 UFW 守护的 INPUT 链。而且 Docker 在 FORWARD 链里插入的 ACCEPT 规则,排在 UFW 的过滤规则之前就先匹配生效了,于是 UFW 等于被"架空"。用 Docker 官方文档的原话说:容器流量在 nat 表就被分流了,包在抵达 UFW 使用的 INPUT/OUTPUT 链之前就已经被改道,防火墙配置被直接无视。

想亲眼看看这些规则,可以跑:

sudo iptables -t nat -L DOCKER -n --line-numbers
sudo iptables -L DOCKER-USER -n
sudo iptables -L FORWARD -n --line-numbers

你会看到 nat 表里那条 DNAT ... tcp dpt:5432 to:172.17.0.2:5432,以及 FORWARD 链里 Docker 塞进来的 ACCEPT 规则。这就是"封了 UFW 却还裸露"的真相。需要说明的是:这不是 bug,是 Docker 的设计行为,从 moby 项目(Docker 引擎)诞生起就是如此,至今未改,因为它要保证端口映射能正常工作。

三、解法 1(最推荐):端口安全绑定 127.0.0.1 + 反向代理

最干净、最不容易翻车的做法只有一个思路:容器只监听本机回环地址 127.0.0.1,绝不直接对公网开放。需要对外暴露的服务,统一交给一个反向代理(Nginx / Caddy)去"门口接客",再由代理在内部网络把请求转发给容器。

启动容器时,把监听地址显式写成 127.0.0.1:

docker run -d --name db -p 127.0.0.1:5432:5432 postgres:16

此时 docker ps 会显示 127.0.0.1:5432->5432/tcp,意味着只有这台 VPS 自己能访问,公网连不进来。数据库、Redis、消息队列、内部 API、后台管理面板,凡是"不该被外人碰"的,全部用这个写法。

如果用 docker-compose,写法是在 ports 里加前缀:

services:
  db:
    image: postgres:16
    ports:
      # 只监听本机,公网不可达
      - "127.0.0.1:5432:5432"
  web:
    image: nginx:alpine
    ports:
      # 真正要对外的是反代,才绑定 0.0.0.0
      - "80:80"
      - "443:443"

然后让需要公网访问的 Web 应用,通过 Nginx 反代出去。比如你有个容器跑在 127.0.0.1:8080,Nginx 配置可以这样写:

server {
    listen 80;
    server_name app.example.com;
    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Caddy 更省事,一行就能反代:reverse_proxy 127.0.0.1:8080。这样,公网唯一能摸到的只有 80/443(由反代和 UFW 双重把关),容器端口全缩在内网。这是生产环境最稳的架构,强烈建议默认采用。

四、解法 2:DOCKER-USER 链 / ufw-docker 补丁 / daemon.json

有时候你就是想精细控制"某个容器端口,只允许特定 IP 访问",或者图省事想一把锁死 Docker 的转发。这时有三个层次的办法,按安全度从高到低排列。

4.1 用 DOCKER-USER 链做白名单(官方推荐做法)

Docker 预留了一个 DOCKER-USER 链,它位于 Docker 自己规则之前被评估。只要往这里写规则,就能在 Docker 放行之前先拦一道。例如"默认拒绝所有进容器的流量,只允许某个 IP 访问 5432":

# 先拒绝所有经过 Docker 的外部流量
sudo iptables -I DOCKER-USER -i eth0 -j DROP
# 再放行白名单 IP(把 eth0 换成你的公网网卡,用 ip a 看)
sudo iptables -I DOCKER-USER -i eth0 -s 203.0.113.50 -p tcp --dport 5432 -j ACCEPT

但要注意:iptables 命令写的规则重启会丢。要让它持久化,要么装 iptables-persistentnetfilter-persistent save),要么把规则写进 UFW 的 /etc/ufw/after.rules 文件末尾(放在 *filter 段里),再 sudo ufw reload。这种"一把 DROP"的方式比较粗暴,会同时把你的 Nginx 反代也挡了,所以只适合"Docker 里全是不该外露的内部服务"的场景。

4.2 用 ufw-docker 脚本(半自动,最省心)

chaifeng/ufw-docker 这个开源脚本,本质就是把上面那套 DOCKER-USER 规则封装好,让你能用接近 UFW 的命令去管理容器端口。安装很简单:

sudo wget -O /usr/local/bin/ufw-docker https://github.com/chaifeng/ufw-docker/raw/master/ufw-docker
sudo chmod +x /usr/local/bin/ufw-docker
sudo ufw-docker install   # 备份并改写 /etc/ufw/after.rules,重启后生效
sudo systemctl restart ufw

装好之后,思路就反过来了:默认所有容器端口都不对外,你想开哪个再用命令开:

ufw-docker status            # 看当前允许了哪些
ufw-docker allow web 80      # 暴露容器 web 的 80 端口
ufw-docker allow web 443/tcp # 暴露 443,指定协议
ufw-docker delete allow web  # 收回 web 的所有规则

它支持 Docker Swarm,也会自动处理 IPv6 的 after6.rules,还提供 ufw-docker check 自检。对于不想自己手写 iptables 的人,这是最实用的补丁。

4.3 动 daemon.json(高风险,谨慎)

网上常有人教你在 /etc/docker/daemon.json 里加 "iptables": false,让 Docker 别碰 iptables。Docker 官方明确说:这个选项不适合大多数人,极可能把容器网络搞崩。一旦关掉,容器做不了 NAT 出网、端口映射全乱,你得自己手写一整套转发规则,工作量巨大且极易出错。所以除非你真的精通 iptables,否则别碰它。

如果你只是想要"默认拒绝转发"的更稳内置选项,较新的 Docker 引擎有 ip-forward-no-drop 之类的高级开关,但同样属于进阶玩法。对我们普通玩家而言,正解是 127.0.0.1 绑定 + 反代,或 ufw-docker,而不是去关 Docker 的 iptables 管理

补充一个改动提醒:无论你改 daemon.json 还是 after.rules,改完都要重启相关服务(systemctl restart docker / systemctl restart ufw)才能让规则生效;而且已经运行中的旧容器可能不会自动套用新规则,必要时 docker stop 再 docker start,甚至重建容器。

五、别忘最外层:云厂商安全组才是第一道防线

最后提醒一个很多新手忽略的层次问题。UFW 是宿主机层面的防火墙,而你的 VPS 在云厂商那里还有一道安全组(Security Group),它是网络层面、在流量到达你的系统之前就生效的。

正确姿势是三层叠防:

  • 第一层:云安全组。只放行 22/80/443,数据库、Redis 等端口一律不对外开。这是成本最低、最有效的一层。
  • 第二层:UFW。主机内再兜一道,默认 deny,习惯成自然。
  • 第三层:绑定地址。容器端口尽量绑 127.0.0.1,从根源上不裸露。

即便某一层配置失误,还有另外两层兜底。安全组 + UFW + 安全绑定,三者结合才是真的安全,别把宝全押在 UFW 上。

#VPS #Docker #UFW #防火墙 #安全 #端口映射 #iptables