Docker 繞過 UFW 的原因與修正方法
Docker 透過 iptables 規則繞過 UFW,導致 deny 8080 後連接埠仍回應網際網路。了解 PREROUTING 機制,以及使用 DOCKER-USER 或發布設定的有效修正方式。
Docker 為何會繞過 UFW
Docker 會繞過 UFW,因為已發布的容器連接埠不會經過 UFW 管理的防火牆規則。執行 docker run -p 8080:80 時,Docker 會將 DNAT(目的地網路位址轉譯)規則寫入核心 nat 表中的自有 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,並將傳入流量的預設政策設為 deny。使用已發布連接埠的方式啟動 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容器會回應。加入明確的 deny 規則後再次測試:
sudo ufw deny 8080/tcp該連接埠仍會回應,因為 deny 規則位於封包不會經過的 chain 中。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 私有 bridge network 上的位址。改寫後,封包的目的地已不是本機,因此路由決策會將它送往 FORWARD 路徑;Docker 早已在該路徑加入允許流量進入自有 network 的規則。你的 deny 8080/tcp 規則位於 INPUT,但封包根本不會抵達。
在 Ubuntu 24.04 上,iptables 指令是 nftables 的前端,但鏈的順序與結果完全相同。UFW 與 Docker 都會寫入同一條核心封包處理管線,而 Docker 的進入點更早。這不只適用於 UFW:Rocky 或 AlmaLinux VPS 上的 firewalld 也會在該管線的相同位置進行篩選,並受到相同 DNAT 規則的繞過,因此以下修正方式同樣適用於該環境。
日常修正:在 127.0.0.1 上發布連接埠
多數容器原本就不需要對外公開。資料庫、反向代理後方的應用程式伺服器、管理面板及 metrics endpoint,都不應直接回應網際網路請求。請將它們發布到 loopback 位址:
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 chain 中加入規則,丟棄任何從非 loopback 介面進入、目的為該 loopback 連接埠的封包。在這些版本中,該連接埠可由主機存取,其他來源則無法存取。早於 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/ 不會收到回應。傳送至公開位址的封包不會再由 Docker 改寫,因此會抵達 INPUT;UFW 的預設拒絕政策與前文的 deny 8080/tcp 規則會靜默丟棄封包,而不是拒絕連線。因此,curl 會持續等待,最後以連線逾時結束,而不是顯示「connection refused」。
對於應該面向網際網路的服務,請執行一個獨立的反向代理,讓它獨占 80 和 443 連接埠,並依主機名稱進行路由,不要發布其他連接埠。Traefik 反向代理指南採用的就是這種模式;自架應用程式例如 VPS 上的 Nextcloud,也能藉此維持只能透過代理存取的狀態。如何宣告 ports: 項目,以及其餘 Compose 工作流程,請參閱 Docker Compose 基礎指南。
讓所有內部容器都使用 loopback 後,UFW 就能恢復正常用途:保護由主機本身提供服務的連接埠。請在此建立規則集,然後依序執行下列命令:
實際過濾:DOCKER-USER chain
有時容器連接埠必須持續對網路發布,但仍需限制存取。例如,資料庫 replica 連接埠可能只能由一個辦公室 IP 位址存取。對此,Docker 提供 DOCKER-USER chain。所有前往任何容器的封包,都會先通過 DOCKER-USER,再套用 Docker 自己的允許規則。此 chain 專供你加入規則使用,Docker 不會刪除或修改其中的規則,包括 daemon 重新啟動時也不會。
此 chain 屬於 Docker 預設的 iptables 防火牆後端。Docker Engine 29 新增了實驗性的 nftables 後端,必須使用 firewall-backend daemon 選項啟用。啟用後不會有 DOCKER-USER chain;自訂過濾規則必須放在你自行建立的 nftables table,並使用掛載於 forward hook 的 base chain。如果你尚未變更該選項,目前使用的就是 iptables,以下內容全部適用。兩種工具的差異請參閱iptables 與 nftables 指南。
執行命令前要注意一個陷阱:封包到達 DOCKER-USER 時,DNAT 改寫已經完成。封包的目的地連接埠是容器連接埠(本例為 80),不是發布的連接埠(8080)。因此,使用 --dport 8080 比對的規則不會比對到任何封包。可靠的方法是比對用戶端最初連線的連接埠;kernel 的 connection tracker 會記住這個資訊:
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 command 新增的規則會在重新開機後消失。由於 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(application programming interface)。DNAT 與隔離規則也會一併消失。Docker 文件警告,若沒有替代規則,區域網路上的主機便可存取容器的連接埠。你會以破壞容器網路的方式修補這項繞過問題,而上述每一條規則都必須由你自行撰寫與維護。Docker 官方文件指出,這項設定不適合大多數使用者,並說明事後建立這些規則的工作極為繁複。DOCKER-USER chain 的存在正是為了讓使用者不需要啟用這個開關。
相同問題的 IPv6 部分
先確認已發布的連接埠在 IPv6 上呈現的狀態:
sudo ss -tlnp | grep 8080自 Docker Engine 27 起,Docker 預設會管理 ip6tables。在啟用 IPv6 的 Docker network 上,已發布的連接埠會在 IPv6 表中套用相同的 DNAT 處理,因此同樣存在這個繞過問題,也適用相同的修正方式:DOCKER-USER chain 也會存在於 ip6tables 中,因此使用 sudo ip6tables -I DOCKER-USER ... 複製規則,並以 curl 從外部對伺服器的公開 IPv6 位址進行測試,例如 curl -6 http://[2001:db8:2a::1]:8080/。這適用於 Docker 的預設 iptables backend;在實驗性的 nftables backend 上,兩個 stack 都沒有 DOCKER-USER chain,IPv6 過濾規則則應加入自有的 ip6 nftables table。
在未啟用 IPv6 的 network 上,IPv6 用戶端會改由 docker-proxy 處理。這是一般的 user-space process,會監聽 [::]:8080,再透過 IPv4 將流量轉送至 container。前往 host process 的流量確實會經過 INPUT,因此 UFW 可以過濾這條路徑,但前提是 UFW 本身有管理 IPv6。UFW 是否管理 IPv6,以及 VPS 上其他可能產生 IPv6 防護缺口的方式,請參閱 UFW 與 IPv6 指南。
在 loopback 上發布連接埠可以避開整個問題:-p 127.0.0.1:8080:80 只會繫結 IPv4 loopback,因此不會有 IPv6 listener;在 Docker Engine 28.0.0 及後續版本上,外部也無法從任一 stack 連入。
能長期維持的模式
- 在 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 鏈中建立 DNAT 規則,而封包會先從 PREROUTING 到達該鏈。這會在任何篩選發生前,將封包的目的地改寫為容器的位址。接著封包會經過 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。由於 masquerade 規則消失,容器會失去對外網際網路的存取能力;Docker 也警告,若沒有替代規則,區域網路上的主機將可以存取容器連接埠。請改用 loopback 發布與 DOCKER-USER 鏈。這樣可以解決公開暴露問題,同時不會破壞容器網路功能。
Docker 也會在 IPv6 上繞過 UFW 嗎?
在 Docker Engine 27 及更新版本中,ip6tables 管理預設為啟用。因此,在支援 IPv6 的 Docker network 上發布的連接埠,會如同 IPv4 一樣繞過 UFW 進行改寫;使用 Docker 預設的 iptables backend 時,還需要搭配 ip6tables,鏡像設定相同的 DOCKER-USER 規則。在 Engine 29 的實驗性 nftables backend 中沒有 DOCKER-USER 鏈,因此應將篩選規則放在你自己的 nftables table 中。在未啟用 IPv6 的 network 上,docker-proxy 程序會監聽 [::],該流量確實會經過 INPUT;如果 UFW 管理 IPv6,UFW 就能進行篩選。發布到 127.0.0.1 可避開這兩種情況,因為 IPv6 根本不會有任何監聽程序。