反過來的問題#

到目前為止解決的都是「裡面想連出去」。但容器跑的多半是 server,會有人從外部要連進來。namespace 裡的 IP(例如 172.17.0.2)是私有位址,外網看不到,需要 host 出面當代理人。

這件事情靠 DNAT(Destination NAT)達成,使用者層的指令是 docker run -p 或 docker-compose 裡的 ports:,叫做 port publishing(埠口公開)或 port mapping(埠口對應)。

三種 IP 角色先分清楚#

外部連線進來時,會經過幾種不同性質的 IP:

  • Loopback IP:127.0.0.0/8,只在本機內部有效,外部看不見
  • 私有 IP(private IP):10.0.0.0/8172.16.0.0/12192.168.0.0/16,內部網段使用,公網不可路由
  • 公開 IP(public IP):可由公網路由的 IP,host 對外那張介面綁的就是這種

容器自己拿的是私有 IP,外部要連進來只能透過 host 在某張介面上監聽的 port,再由 host 把流量轉給容器。

DNAT 規則的位置#

DNAT 必須在路由判斷「這個封包是不是要給我自己」之前發生,所以放在 PREROUTING chain:

sudo iptables -t nat -L PREROUTING -n -v

寫一條手動 DNAT 看看,把連到 host eth0 的 8080 port 全部轉給 10.0.0.1:80

sudo iptables -t nat -A PREROUTING \
  -i eth0 -p tcp --dport 8080 \
  -j DNAT --to-destination 10.0.0.1:80

加上 FORWARD chain 的允許:

sudo iptables -A FORWARD -p tcp -d 10.0.0.1 --dport 80 \
  -i eth0 -o br0 -j ACCEPT

別忘了上一章已經補過 conntrack 的 RELATED,ESTABLISHED 規則,回程封包靠它放行。

外部連 host_public_ip:8080 時,封包進來的瞬間目的 IP 被改寫成 10.0.0.1:80,再走轉送流程進到 namespace。

Docker 怎麼做這件事#

執行:

docker run -d -p 8080:80 nginx

這條指令會發生:

  • docker0 對應子網(如 172.17.0.0/16)配一個 IP 給容器
  • 在 iptables 加上一條 PREROUTING DNAT,把進入 host 0.0.0.0:8080 的 TCP 流量改寫到 172.17.0.X:80
  • 在 FORWARD chain 對應的 DOCKER chain 開一條允許

檢查:

sudo iptables -t nat -L DOCKER -n -v
sudo iptables -L DOCKER -n -v

DOCKER chain 是 Docker 自動建立的 user-defined chain,PREROUTING 會 jump 進來。所有 -p 開出來的 port 都會在這裡留下對應規則。

三種 publish 寫法的差別#

-p 旗標支援不同寫法:

  • -p 8080:80:在 host 所有介面(0.0.0.0:8080)publish;外部與本機都連得到
  • -p 127.0.0.1:8080:80:只 bind 在 Loopback,外部完全連不到,只能本機自己連
  • -p 192.168.1.10:8080:80:只 bind 在 host 某張介面的特定 IP

差別反映在 iptables 規則的 -d 欄位上。docker port <container> 可以查實際對應。

檢查 listen 狀態#

sudo ss -tlnp | grep -E ':8080|docker-proxy'

可能會看到 docker-proxy 程序在監聽。docker-proxy 是 Docker 用來補足某些 iptables 環境(例如 Loopback 連線)的 user-space 代理;多數情況下 iptables DNAT 才是主路徑。

用 tcpdump 確認封包改寫#

從外部機器連 host_public_ip:8080,在 host 上抓 eth0docker0

sudo tcpdump -i eth0 -nn 'tcp port 8080'
sudo tcpdump -i docker0 -nn 'tcp port 80'

預期看到的樣貌:

  • eth0 上的封包目的是 host_public_ip:8080
  • 同一個連線在 docker0 上看到的目的已經是 172.17.0.X:80
  • 來源 IP 仍是外部那台機器的 IP(DNAT 不改來源)

回程封包透過 conntrack 自動還原,所以外部看到的回應來源仍是 host_public_ip:8080

安全提醒#

  • -p 8080:80 等同於把容器 expose 到網路上,記得搭配防火牆策略
  • 內部測試用 -p 127.0.0.1:8080:80 限制在 Loopback 比較安全
  • DOCKER-USER chain 是 Docker 留給使用者額外加規則的位置,會在所有 Docker 自動規則之前評估

全圖串起來#

回頭看完整路徑:

  • 容器互通:veth pair + Linux bridge(docker0)
  • 容器看得到 host:bridge IP 當 default gateway
  • 容器出網:ip_forward + POSTROUTING MASQUERADE
  • 外部連回容器:PREROUTING DNAT + port publishing

CNI(Container Network Interface)在 Kubernetes 等環境只是把這套邏輯換成另一組 plugin 來實作,本質上仍然是 namespace、veth、bridge、iptables 這幾塊積木。

延伸閱讀#

  • man 8 iptables
  • Docker 文件「Networking overview」
  • Kubernetes 文件「Cluster Networking」與 CNI 規範