.

iT邦幫忙

3

從 Linux 基礎實現 Docker Bridge 網路:一步步理解容器通訊 (6)

  • 分享至 

  • xImage
  •  

部落格好讀版


在上一章中,我們認識了 Docker 對 iptables 的修改,成功讓容器之間以及容器與外網的流量順利傳輸。然而,還有一個重要的情境需要解決:外部網路如何存取容器

經常使用 Docker 的朋友可能已經知道答案了 - Docker Container 可以透過 Port Forwarding 來實現此功能。雖然它看起來像是一項新技術,但本質上也是 Network Address Translation (NAT) 的一種實現方式。

深入研究 NAT:認識常見的 NAT 型態

在深入討論之前,讓我們先來了解 NAT 的類型。在 iptables 中,可以靈活地進行各種 Network Address Translation (NAT) 操作,主要分為兩種:Source NAT (SNAT)Destination NAT (DNAT)

SNAT:Source Network Address Translation

SNAT 會改寫封包的來源 IP 位址,讓外部看到的「Source IP」已被替換。舉個例子,當多台 PC 透過一台 ADSL Router 共享上網時,每台 PC 都配置了 Private IP。當這些 PC 連線至外部網路時,Router 會將 packet header 裡的 Source IP 改成 Router 本身的 Public IP。如此一來,外部網站(例如 Web Server)所記錄到的 Source IP 就是 Router 的 IP,而非 PC 的 Private IP。

SNAT 常用於以下場景:

Internet Sharing
一個公司或家庭網路內的多台設備分享同一個 Public IP 上網時,Router 使用 SNAT 技術把內部 Source IP(如 192.168.x.x)改為 Public IP,以便外網 Server 能正確回應。

Container Network 和 Hairpin NAT
如 Docker 中,當 Container 需要通過映射的 Port 訪問自己或其他 Container 時,SNAT(或 MASQUERADE,它是一種特殊形式的 SNAT)用來改寫 Source Address,使得回應路由可以正確返回。

DNAT:Destination Network Address Translation (目標網路地址轉換)

DNAT(Destination Network Address Translation)則改寫封包的目的地 IP 位址,典型的應用場景是把位於內網的 Web 伺服器透過防火牆的公網 IP 對外發布。例如,一台 Web 伺服器只擁有內網 IP,卻希望讓網際網路使用者能直接使用公網 IP 來存取。當外部客戶端發送封包時,目的地為該防火牆的公網 IP,而防火牆會將封包的目的地址動態改寫成 Web 伺服器的內網 IP,並把封包轉發至內網。最終,外部訪問就能穿透防火牆,抵達實際的內網伺服器,達成 DNAT 所謂的「目標地址」轉換。

DNAT 常用於以下場景:

Port Forwarding(連接埠轉發)
在家庭或企業網路中,外部訪問某個公共 IP 和埠號的流量可以經過 NAT 設備轉發到內部網路的特定伺服器。例如:外部訪問公網 IP 的 8080 埠流量會被轉發到內部 192.168.1.100 的 80 埠上。這裡就使用了 DNAT 改寫封包的目的地址與埠號。

Load Balancing(負載平衡)
DNAT 可用於將到達某個入口點的流量分配到多個後端伺服器,以平衡負載。

MASQUERADE:地址偽裝

MASQUERADE 其實是 SNAT 的一種特例,能夠自動完成 SNAT。雖然在 iptables 中可以使用與 SNAT 類似的方式來實現轉換,但仍存在一些差異。最主要的不同點在於,使用 SNAT 時必須明確指定要轉換成的 IP,這個 IP 可以是一個或多個範圍。但如果系統採用 ADSL 這類動態撥號方式,每次撥號後出口 IP 都可能改變,便無法使用事先寫死的 SNAT 規則,因為規則裡的 IP 不會隨著撥號取得的新 IP 自動變更,導致我們必須頻繁手動修改 iptables,非常不方便。

正因如此,MASQUERADE 便應運而生。它會自動偵測網卡(例如 eth0)的當前動態 IP,無論 IP 變動多頻繁,都能在出站時自動將封包的來源 IP 改為網卡的最新 IP,如此就能大幅簡化維運工作。

SNAT 與 MASQUERADE 的用法範例

  1. SNAT(單一 IP)

    iptables -t nat -A POSTROUTING -s 10.8.0.0/255.255.255.0 -o eth0 -j SNAT --to-source 192.168.5.3
    

    此命令表示:將所有來源為 10.8.0.0/24 的封包,在透過 eth0 介面傳送出去時,將其來源 IP 改為 192.168.5.3

  2. SNAT(多個 IP 範圍)

    iptables -t nat -A POSTROUTING -s 10.8.0.0/255.255.255.0 -o eth0 -j SNAT --to-source 192.168.5.3-192.168.5.5
    

    此規則表示:將 10.8.0.0/24 網段的流量,根據特定演算法或機制,改寫為 192.168.5.3192.168.5.4192.168.5.5 等多個 IP 之一。

  3. MASQUERADE(自動抓取動態 IP)

    iptables -t nat -A POSTROUTING -s 10.8.0.0/255.255.255.0 -o eth0 -j MASQUERADE
    

    使用此命令,無需指定來源 IP,MASQUERADE 會自動取得 eth0 的當前 IP,讓封包傳送出去時統一使用該 IP 作為來源 IP。


總結:

  • SNATDNAT 分別針對封包的「Source」或「Destination」進行轉換。在實際應用中,我們常需要同時處理內網 Server 對外以及外網 Client 對內的流量,因此 SNAT 與 DNAT 經常搭配使用。
  • MASQUERADE 作為 SNAT 的一種特例,特別適合用於出口 IP 不固定、動態頻繁變更的環境,能為管理者省去手動修改規則的麻煩,讓網路地址轉換更自動化、更輕鬆。

分析:建立 Docker Container 前後的 iptables 與 NAT 變化

掌握必要的知識後,讓我們實際分析 iptables 和 NAT 的變化。首先,查看 Docker Network:

docker network ls
# output
NETWORK ID     NAME      DRIVER    SCOPE
469fd7ab0fbd   bridge    bridge    local
a2b456636301   host      host      local
e5238737e4cb   none      null      local

這三個是 Docker 的預設網路。接著,使用以下指令建立一個 nginx container:

docker container run --name my-nginx-container -d -p 8080:80 nginx

讓我們觀察 iptables 和 nat 的變化。首先看 filter table:

iptables -t filter -nvL --line-number
# output
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
num   pkts bytes target     prot opt in     out     source               destination

Chain FORWARD (policy DROP 0 packets, 0 bytes)
num   pkts bytes target     prot opt in     out     source               destination
1        0     0 DOCKER-USER  all  --  *      *       0.0.0.0/0            0.0.0.0/0
2        0     0 DOCKER-ISOLATION-STAGE-1  all  --  *      *       0.0.0.0/0            0.0.0.0/0
3        0     0 ACCEPT     all  --  *      docker0  0.0.0.0/0            0.0.0.0/0            ctstate RELATED,ESTABLISHED
4        0     0 DOCKER     all  --  *      docker0  0.0.0.0/0            0.0.0.0/0
5        0     0 ACCEPT     all  --  docker0 !docker0  0.0.0.0/0            0.0.0.0/0
6        0     0 ACCEPT     all  --  docker0 docker0  0.0.0.0/0            0.0.0.0/0

Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
num   pkts bytes target     prot opt in     out     source               destination

Chain DOCKER (1 references)
num   pkts bytes target     prot opt in     out     source               destination
+1        0     0 ACCEPT     tcp  --  !docker0 docker0  0.0.0.0/0            172.17.0.2           tcp dpt:80

Chain DOCKER-ISOLATION-STAGE-1 (1 references)
num   pkts bytes target     prot opt in     out     source               destination
1        0     0 DOCKER-ISOLATION-STAGE-2  all  --  docker0 !docker0  0.0.0.0/0            0.0.0.0/0
2        0     0 RETURN     all  --  *      *       0.0.0.0/0            0.0.0.0/0

Chain DOCKER-ISOLATION-STAGE-2 (1 references)
num   pkts bytes target     prot opt in     out     source               destination
1        0     0 DROP       all  --  *      docker0  0.0.0.0/0            0.0.0.0/0
2        0     0 RETURN     all  --  *      *       0.0.0.0/0            0.0.0.0/0

Chain DOCKER-USER (1 references)
num   pkts bytes target     prot opt in     out     source               destination
1        0     0 RETURN     all  --  *      *       0.0.0.0/0            0.0.0.0/0

###

iptables -t nat -nvL --line-number
# output
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
num   pkts bytes target     prot opt in     out     source               destination
1        0     0 DOCKER     all  --  *      *       0.0.0.0/0            0.0.0.0/0            ADDRTYPE match dst-type LOCAL

Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
num   pkts bytes target     prot opt in     out     source               destination

Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
num   pkts bytes target     prot opt in     out     source               destination
-1       69  5003 DOCKER     all  --  *      *       0.0.0.0/0           !127.0.0.0/8          ADDRTYPE match dst-type LOCAL
+1      218 16475 DOCKER     all  --  *      *       0.0.0.0/0           !127.0.0.0/8          ADDRTYPE match dst-type LOCAL

Chain POSTROUTING (policy ACCEPT 0 packets, 0 bytes)
num   pkts bytes target     prot opt in     out     source               destination
1        0     0 MASQUERADE  all  --  *      !docker0  172.17.0.0/16        0.0.0.0/0
+2        0     0 MASQUERADE  tcp  --  *      *       172.17.0.2           172.17.0.2           tcp dpt:80

Chain DOCKER (2 references)
num   pkts bytes target     prot opt in     out     source               destination
1        0     0 RETURN     all  --  docker0 *       0.0.0.0/0            0.0.0.0/0
+2        0     0 DNAT       tcp  --  !docker0 *       0.0.0.0/0            0.0.0.0/0            tcp dpt:8080 to:172.17.0.2:80

整理一下,只留下新增的規則:

*filter
Chain DOCKER (1 references)
num   pkts bytes target     prot opt in     out     source               destination
1        0     0 ACCEPT     tcp  --  !docker0 docker0  0.0.0.0/0            172.17.0.2           tcp dpt:80

*nat
Chain POSTROUTING (policy ACCEPT 0 packets, 0 bytes)
num   pkts bytes target     prot opt in     out     source               destination
2        0     0 MASQUERADE  tcp  --  *      *       172.17.0.2           172.17.0.2           tcp dpt:80

Chain DOCKER (2 references)
num   pkts bytes target     prot opt in     out     source               destination
2        0     0 DNAT       tcp  --  !docker0 *       0.0.0.0/0            0.0.0.0/0            tcp dpt:8080 to:172.17.0.2:80

使用 iptables-save 找到對應的項目:

*filter
-A DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 80 -j ACCEPT

*nat
-A POSTROUTING -s 172.17.0.2/32 -d 172.17.0.2/32 -p tcp -m tcp --dport 80 -j MASQUERADE

-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80

解釋:filter 表的規則

-A DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 80 -j ACCEPT

解讀:

  • -A DOCKER:在 filter 表的自訂 Chain DOCKER 中新增一條規則。
  • -d 172.17.0.2/32:目的 IP 為 172.17.0.2(容器在 docker0 bridge 上的虛擬網卡 IP)。
  • ! -i docker0:封包的輸入介面不是 docker0,表示該封包來自外部。
  • -o docker0:封包的輸出介面是 docker0,表示封包要傳送到 docker0 介面。
  • -p tcp -m tcp --dport 80:協定為 TCP,目的埠為 80。
  • -j ACCEPT:允許符合條件的封包通過。

目的:
此規則允許外部流量傳送到容器的 80 埠。

驗證步驟

開始之前,我們先備份 iptables:

sudo iptables-save > /tmp/iptables-backup.txt

接著使用 iptables -D 刪除規則,此時該條規則暫時失效

# iptables -D <CHAIN> <行號>
sudo iptables -D DOCKER 1

測試從 Host 外連線到 nginx 容器對應的 HOST_IP:PORT。你應該會看到連線失敗的訊息。

最後,記得恢復 iptables:

sudo iptables-restore < /tmp/iptables-backup.txt


解釋: nat 表的規則

-A POSTROUTING -s 172.17.0.2/32 -d 172.17.0.2/32 -p tcp -m tcp --dport 80 -j MASQUERADE

解讀:

  • -A POSTROUTING:在 nat 表的 POSTROUTING Chain 中新增規則。
  • -s 172.17.0.2/32 -d 172.17.0.2/32:來源 IP 和目的 IP 同樣是 172.17.0.2,表示同一個容器自己連到自己(loopback/hairpin)的情境。
  • -p tcp -m tcp --dport 80:協定為 TCP,目的埠為 80。
  • -j MASQUERADE:對此流量進行 NAT 轉換(MASQUERADE 會動態改寫來源 IP/Port),使其能正常「環迴」回到同一個容器。

目的:
這是 Docker 用來處理「容器透過主機映射的 Port 存取自己」的情境(俗稱 Hairpin NAT)。舉例來說,當容器嘗試連到 host_ip:8080 時,最終需要轉回自己的 80 Port,此時就需要 MASQUERADE 來實現「自我存取」的連線。若沒有這個機制,封包在傳輸過程中可能會出現路由或 ARP 的錯亂。


-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80

解讀:

  • -A DOCKER:在 nat 表裡的自訂 Chain DOCKER 新增規則。
  • ! -i docker0:封包的輸入介面不是 docker0(意即從外部或主機進來)。
  • -p tcp -m tcp --dport 8080:協定為 TCP,目的埠為 8080。
  • -j DNAT --to-destination 172.17.0.2:80:進行目地位址轉換 (Destination NAT),將目的 IP/Port 改寫成 172.17.0.2:80

目的:
這正是 Port Mapping 的關鍵規則:

  • 當外界或本機對「Host 的 8080 Port」發起 TCP 連線,iptables 會把流量 DNAT 到容器的 172.17.0.2:80
  • 也就是說,外部連到 host:8080,最後轉到容器 IP:80(nginx)上,使你可以從外部直接以 http://HOST_IP:8080 連到容器裡面的服務。

補充: 解釋 DOCKER Chain 的來源

NAT 轉跳到 DOCKER 的條件有以下兩個:

-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER

我們來看看簡單版 iptables 流程圖:

簡單解釋一下兩個 Chain 的差別:

PREROUTING

  • 主要處理外部網絡進入的封包(經過網卡的流量)。
  • 本地主機生成的封包不經過 PREROUTING,因為它們從來沒有經過網卡的輸入路徑。

OUTPUT

  • 處理所有本地主機生成的封包,包括發往本機的封包。
  • 如果 Docker 中需要處理本地主機生成的流量(例如連接 localhost:9091),則必須在 OUTPUT Chain中設置

其中規則的 --dst-type LOCAL 中的 LOCAL 代表"本地主機",是指運行 iptables 的那台機器(例如 EC2 實例)的網路接口地址。相同的 network namespace 都算是 LOCAL 的範疇。
而使用 Docker Bridge 時,由於本質上是建立了另一個 network namespace,所以從容器 IP 對於 Host 的 iptables 來說屬於外網,會走 PREROUTING chain。

小結

使用 Docker,使用 bridge network 建立容器,並且指定 port forwarding 從容器 80 port 到 host 主機 8080 port (-p 8080:80)。會對 iptables 建立以下規則:

  1. 當外界或本機對「Host 的 8080 Port」發起 TCP 連線,iptables 會把流量 DNAT 到容器的 172.17.0.2:80
  2. 允許同一個容器透過主機映射的 Port 再回來連自己 (Hairpin NAT)
  3. 允許外部(非 docker0 Interface)進來、要轉送到容器 IP:80 的封包順利通行。

至於"允許容器對外部發起連線" 的規則,在第五章時已經做過了,規則如下:

# 允許: 所有預設 bridge 網段對外部連線
-A FORWARD -i docker0 ! -o docker0 -j ACCEPT
# 所有預設 bridge 網段連線到外部,做 SNAT 轉換
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
# 允許: 所有預設 bridge 網段,連線建立後返回的封包或與該連線相關的封包
-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT

大家可以用上面學習到的知識,試著解讀這些規則。

實作:Docker Style Port Forwarding

接下來我們要實作自己的 Container port forwarding,目標如下:

  1. 在 bridge 網路內,啟動 HTTP server,監聽位址 0.0.0.0:8081(等於在容器內啟動)
  2. 在 host 網路內,port forwarding Host 網路的 9091 port 到 bridge 網路的 8081 port
  3. 確保在 Host 網路內,可以透過 HOST_IP:9091 連線到 HTTP server
  4. 確保在另一個 EC2 網路,可以透過 HOST_IP:9091 連線到 HTTP server

製作 HTTP Server 執行檔

首先建立 Golang 專案,添加以下程式碼:

package main

import (
	"fmt"
	"net/http"
	"os"
	"strconv"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/cors"
)

type Config struct{}

func main() {
	app := Config{}

	port := ":8080" // Default port
	if len(os.Args) > 1 {
		p, err := strconv.Atoi(os.Args[1])
		if err != nil {
			fmt.Println("Invalid port number:", err)
			return
		}
		port = fmt.Sprintf(":%d", p)
	}
	srv := http.Server{
		Addr:    port,
		Handler: app.routes(),
	}
	fmt.Printf("Server is listening on %s\n", port)
	if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		fmt.Println("Error starting server:", err)
	}
}

func (app *Config) routes() http.Handler {
	mux := chi.NewRouter()

	mux.Use(cors.Handler(cors.Options{
		AllowedOrigins:   []string{"https://*", "http://*"},
		AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
		AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
		ExposedHeaders:   []string{"Link"},
		AllowCredentials: true,
		MaxAge:           300,
	}))

	mux.Get("/", app.HelloWorld)

	return mux
}

func (app *Config) HelloWorld(w http.ResponseWriter, r *http.Request) {
	_, err := w.Write([]byte("Hello World!"))
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

編譯執行檔:

$env:GOOS = "linux"
$env:GOARCH = "amd64"
go build -o simpleServer cmd/api/main.go

執行 simpleServer,指定 8081 port,就可以啟動一個簡單的 Server:

./simpleServer 8081
# output
Server is listening on :8081

EC2 設定

為了方便施作,在既有的 Security Group 允許 TCP inbound:

simpleServer 執行檔上傳到 Host1 EC2:

scp -i </path/to/your-key.pem> </path/to/local-file> ec2-user@<EC2_IP或DNS>:</path/to/remote/directory>

測試連線:

curl localhost:8081
# output
Hello World!

在 ns1 運行 HTTP Server

在 ns1 開一個新的 bash shell:

sudo ip netns exec ns1 bash

使用 simpleServer 啟動 HTTP server:

# in ns1
./simpleServer 8081
# output
Server is listening on :8081

在 ns1 再開一個新的 bash shell,檢查 8081 Port 有沒有被佔用:

# in ns1
netstat -ano | grep 8081
# output
tcp        0      0 0.0.0.0:8081            0.0.0.0:*               LISTEN      off (0.00/0/0)

回到 root ns 的 bash shell,檢查 8081 Port 有沒有被佔用:

netstat -ano | grep 8081
# output
$

在 root ns 測試 HTTP server 連線:

curl 172.18.0.3:8081
# output
Hello World!

在 root ns 啟動 ns1 的 lo:

ip netns exec net1 ip link set lo up

在 ns1 的 bash shell,重新啟動 HTTP server,測試訪問自己:

# in ns1
curl localhost:8081
# output
Hello World!

準備測試環境

首先,我們將 Terminal 視窗切分,並準備好四個 Session,對應如下:

接著,測試 root ns -> ns1 以及 host2 -> host1 的連線是否暢通:

確認連線正常後,我們就可以開始添加規則了。

讓本地主機(Host1)可以訪問容器的 HTTP Server 服務

OUTPUT DNAT 規則

將本地主機到達本地主機 9091 Port 的 TCP 流量轉發到容器的 8081 Port。

完整的 DNAT 規則如下:

-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80

不透過 Docker Chain 處理,我們可以進一步簡化規則:

-A OUTPUT \
  ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL \
  -p tcp --dport 8080 \
  -j DNAT --to-destination 172.17.0.2:80

再將規則中的 bridge, dport 和 dnat 修改成我們的案例:

sudo iptables -t nat -A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL \
  -p tcp -m tcp --dport 9091 -j DNAT --to-destination 172.18.0.3:8081

操作結果如下:

讓外網 (Host2) 可以訪問 Host1 容器的 HTTP Server 服務

我們需要同時設置 SNAT, DNAT 才能形成完整的 Port Forwarding

PREROUTING DNAT 規則

將外部到達本地主機 9091 Port 的 TCP 流量轉發到容器的 8081 Port。

完整的 DNAT 規則如下:

-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80

不透過 Docker Chain 處理,我們可以進一步簡化規則:

-A PREROUTING \
  -m addrtype --dst-type LOCAL \
  ! -i docker0 \
  -p tcp --dport 8080 \
  -j DNAT --to-destination 172.17.0.2:80

再將規則中的 bridge, dport 和 dnat 修改成我們的案例:

sudo iptables -t nat -A PREROUTING -m addrtype --dst-type LOCAL \
  ! -i docker1 -p tcp -m tcp --dport 9091 -j DNAT --to-destination 172.18.0.3:8081

Filter 規則

允許外網封包轉送到 HTTP Server

sudo iptables -t filter -A FORWARD -d 172.18.0.3/32 ! -i docker1 -o docker1 -p tcp -m tcp --dport 8081 -j ACCEPT

操作結果如下:

結論

在本章中,我們深入探討了 Docker 容器的 Port Forwarding 機制,並實際動手實作。主要學習到以下幾點:

NAT 的基本概念與應用

  • 了解 SNAT、DNAT 和 MASQUERADE 的差異與使用場景
  • 掌握 Docker 如何運用 NAT 實現容器網路的連通性

Docker Port Forwarding 的實現原理

  • Docker 透過 iptables 的 DNAT 規則實現 Port Mapping
  • 使用 MASQUERADE 處理 Hairpin NAT(容器自訪問)的情況
  • Filter 表的規則確保封包能順利通過

到這篇為止,我們差不多了解了 Docker Bridge 網路的全貌。之後可能會出個補充篇,再寫一篇這個系列的總整理篇做個結尾。


.
圖片
  直播研討會

尚未有邦友留言

立即登入留言