探索Docker預設網路NAT映射的分配與過濾行為
在《WebRTC第一課:網路架構與NAT工作原理》一文中,我們對WebRTC的網路架構進行說明,瞭解到了NAT的工作原理、RFC 3489[2]對NAT的四種傳統分類以及較新的RFC 4787[3]中按分配行為和過濾行為對NAT行為的分類。
不過,「紙上得來終覺淺,絕知此事要躬行」,在這篇文章中,我打算選取一個具體的NAT實現進行案例研究(Case Study)。 在市面上的NAT實現中,Docker容器的網路NAT絕對是最容易獲得的一種實現。 因此,我們將把Docker預設網路[4]的NAT實現機制作為本篇的研究物件,探索該NAT的分配行為和過濾行為,以確定Docker預設網路的NAT類型。
為了這次探索,我們首選需要構建實驗網路環境。
1. 構建實驗環境
Docker預設網路使用NAT(網路位址轉換)來允許容器訪問外部網路。 創建容器時,如果未指定網路設置,容器會連接到預設的“bridge”網络,並分配一個內部IP位址(通常在172.17.0.0/16範圍內)。 Docker在宿主機上創建一個虛擬網橋(docker0),作為容器與外部網路的介面。 當容器嘗試訪問外部網路時,使用源網路位址轉換(SNAT),將內部IP和埠轉換為宿主機的IP和一個隨機高位埠,以便與外部網路通信。 Docker通過配置iptables規則來實現這些NAT功能,處理數據包的轉發、地址轉換和過濾。
基於上述描述,我們用兩台主機來構建一個實驗環境,拓撲圖如下:
圖片
從上圖可以看到:我們的實驗環境有兩台主機:192.168.0.124和192.168.0.125。 在124上,我們基於docker默認網络啟動一個容器,在該容器中放置一個用於NAT打洞驗證的nat-hole-puncher程式,該程式通過訪問192.168.0.125上的udp-client-addr-display程式在Docker的NAT上留下一個“洞”,然後我們在125上使用nc(natcat)工具[5]驗證是否可以通過這個洞向容器發送數據。
我們要確定Docker默認網路NAT的具體類型,需要進行一些測試來觀察其行為。 具體來說,主要需要關注兩個方面:
- 埠分配行為:觀察NAT是如何為內部主機(容器)分配外部埠的。
- 過濾行為:檢查NAT如何處理和過濾入站數據的,是否與源IP、源Port有關等。
接下來,我們來準備一下驗證NAT類型需要的兩個程式:nat-hole-puncher和udp-client-addr-display。
2. 準備nat-hole-puncher程式和udp-client-addr-display程式
下圖描述了nat-hole-puncher、udp-client-addr-display以及nc命令的交互流程:
圖片
三者的交互流程在圖中已經用文字標記的十分清楚了。
根據該圖中的邏輯,我們分別實現一下nat-hole-puncher和udp-client-addr-display。
下面是nat-hole-puncher的源碼:
// docker-default-nat/nat-hole-puncher/main.go
package main
import (
"fmt"
"net"
"os"
"strconv"
)
func main() {
if len(os.Args) != 5 {
fmt.Println("Usage: nat-hole-puncher <local_ip> <local_port> <target_ip> <target_port>")
return
}
localIP := os.Args[1]
localPort := os.Args[2]
targetIP := os.Args[3]
targetPort := os.Args[4]
// 向target_ip:target_port发送数据
err := sendUDPMessage("Hello, World!", localIP, localPort, targetIP+":"+targetPort)
if err != nil {
fmt.Println("Error sending message:", err)
return
}
fmt.Println("sending message to", targetIP+":"+targetPort, "ok")
// 向target_ip:target_port+1发送数据
p, _ := strconv.Atoi(targetPort)
nextTargetPort := fmt.Sprintf("%d", p+1)
err = sendUDPMessage("Hello, World!", localIP, localPort, targetIP+":"+nextTargetPort)
if err != nil {
fmt.Println("Error sending message:", err)
return
}
fmt.Println("sending message to", targetIP+":"+nextTargetPort, "ok")
// 重新监听local addr
startUDPReceiver(localIP, localPort)
}
func sendUDPMessage(message, localIP, localPort, target string) error {
addr, err := net.ResolveUDPAddr("udp", target)
if err != nil {
return err
}
lport, _ := strconv.Atoi(localPort)
conn, err := net.DialUDP("udp", &net.UDPAddr{
IP: net.ParseIP(localIP),
Port: lport,
}, addr)
if err != nil {
return err
}
defer conn.Close()
// 发送数据
_, err = conn.Write([]byte(message))
if err != nil {
return err
}
return nil
}
func startUDPReceiver(ip, port string) {
addr, err := net.ResolveUDPAddr("udp", ip+":"+port)
if err != nil {
fmt.Println("Error resolving address:", err)
return
}
conn, err := net.ListenUDP("udp", addr)
if err != nil {
fmt.Println("Error listening:", err)
return
}
defer conn.Close()
fmt.Println("listen address:", ip+":"+port, "ok")
buf := make([]byte, 1024)
for {
n, senderAddr, err := conn.ReadFromUDP(buf)
if err != nil {
fmt.Println("Error reading:", err)
return
}
fmt.Printf("Received message: %s from %s\n", string(buf[:n]), senderAddr.String())
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
- 42.
- 43.
- 44.
- 45.
- 46.
- 47.
- 48.
- 49.
- 50.
- 51.
- 52.
- 53.
- 54.
- 55.
- 56.
- 57.
- 58.
- 59.
- 60.
- 61.
- 62.
- 63.
- 64.
- 65.
- 66.
- 67.
- 68.
- 69.
- 70.
- 71.
- 72.
- 73.
- 74.
- 75.
- 76.
- 77.
- 78.
- 79.
- 80.
- 81.
- 82.
- 83.
- 84.
- 85.
- 86.
- 87.
- 88.
- 89.
- 90.
- 91.
- 92.
- 93.
- 94.
我們將其編譯完打到鏡像中去,Makefile和Dockerfile如下:
// docker-default-nat/nat-hole-puncher/Makefile
all:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o nat-hole-puncher main.go
image:
docker build -t nat-hole-puncher .
// docker-default-nat/nat-hole-puncher/Dockerfile
# 使用 Alpine 作为基础镜像
FROM alpine:latest
# 创建工作目录
WORKDIR /app
# 复制已编译的可执行文件到镜像中
COPY nat-hole-puncher .
# 设置文件权限
RUN chmod +x nat-hole-puncher
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
執行編譯與打鏡像命令:
$ make
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o nat-hole-puncher main.go
$ make image
docker build -t nat-hole-puncher .
[+] Building 0.7s (9/9) FINISHED docker:default
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 265B 0.0s
=> [internal] load metadata for docker.io/library/alpine:latest 0.0s
=> [1/4] FROM docker.io/library/alpine:latest 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 2.70MB 0.0s
=> CACHED [2/4] WORKDIR /app 0.0s
=> [3/4] COPY nat-hole-puncher . 0.2s
=> [4/4] RUN chmod +x nat-hole-puncher 0.3s
=> exporting to image 0.1s
=> => exporting layers 0.1s
=> => writing image sha256:fec6c105f36b1acce5e3b0a5fb173f3cac5c700c2b07d1dc0422a5917f934530 0.0s
=> => naming to docker.io/library/nat-hole-puncher 0.0s
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
接下來,我們再來看看udp-client-addr-display源碼:
// docker-default-nat/udp-client-addr-display/main.go
package main
import (
"fmt"
"net"
"os"
"strconv"
"sync"
)
func main() {
if len(os.Args) != 3 {
fmt.Println("Usage: udp-client-addr-display <local_ip> <local_port>")
return
}
localIP := os.Args[1]
localPort := os.Args[2]
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
startUDPReceiver(localIP, localPort)
}()
go func() {
defer wg.Done()
p, _ := strconv.Atoi(localPort)
nextLocalPort := fmt.Sprintf("%d", p+1)
startUDPReceiver(localIP, nextLocalPort)
}()
wg.Wait()
}
func startUDPReceiver(localIP, localPort string) {
addr, err := net.ResolveUDPAddr("udp", localIP+":"+localPort)
if err != nil {
fmt.Println("Error:", err)
return
}
conn, err := net.ListenUDP("udp", addr)
if err != nil {
fmt.Println("Error:", err)
return
}
defer conn.Close()
buf := make([]byte, 1024)
n, clientAddr, err := conn.ReadFromUDP(buf)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Printf("Received message: %s from %s\n", string(buf[:n]), clientAddr.String())
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
- 42.
- 43.
- 44.
- 45.
- 46.
- 47.
- 48.
- 49.
- 50.
- 51.
- 52.
- 53.
- 54.
- 55.
- 56.
- 57.
- 58.
- 59.
- 60.
- 61.
- 62.
現在兩個程式都就緒了,接下來我們就開始我們的探索。
3. 探索步驟
我們先在192.168.0.125上啟動udp-client-addr-display,監聽6000和6001 UDP埠:
// 在192.168.0.125上执行
$./udp-client-addr-display 192.168.0.125 6000
- 1.
- 2.
- 3.
然後在192.168.0.124上創建client1容器:
// 在192.168.0.124上执行
$docker run -d --name client1 nat-hole-puncher:latest sleep infinity
eeebc0fbe3c7d56e7f43cd5af19a18e65a703b3f987115c521e81bb8cdc6c0be
- 1.
- 2.
- 3.
取得client1容器的IP位址:
// 在192.168.0.124上执行
$docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' client1
172.17.0.5
- 1.
- 2.
- 3.
啟動client1容器中的nat-hole-puncher程序,綁定本地5000埠,然後向192.168.0.125的6000和6001埠發送數據包:
$ docker exec client1 /app/nat-hole-puncher 172.17.0.5 5000 192.168.0.125 6000
sending message to 192.168.0.125:6000 ok
sending message to 192.168.0.125:6001 ok
listen address: 172.17.0.5:5000 ok
- 1.
- 2.
- 3.
- 4.
之後,我們會在125的udp-client-addr-display輸出中看到如下結果:
./udp-client-addr-display 192.168.0.125 6000
Received message: Hello, World! from 192.168.0.124:5000
Received message: Hello, World! from 192.168.0.124:5000
- 1.
- 2.
- 3.
通過這個結果我們得到了NAT映射后的源位址和埠:192.168.0.124:5000。
現在我們在125上用nc程式向該映射后的地址發送三個UDP包:
$ echo "hello from 192.168.0.125:6000" | nc -u -p 6000 -v 192.168.0.124 5000
Ncat: Version 7.50 ( https://nmap.org/ncat )
Ncat: Connected to 192.168.0.124:5000.
Ncat: 30 bytes sent, 0 bytes received in 0.01 seconds.
$ echo "hello from 192.168.0.125:6001" | nc -u -p 6001 -v 192.168.0.124 5000
Ncat: Version 7.50 ( https://nmap.org/ncat )
Ncat: Connected to 192.168.0.124:5000.
Ncat: 30 bytes sent, 0 bytes received in 0.01 seconds.
$ echo "hello from 192.168.0.125:6002" | nc -u -p 6002 -v 192.168.0.124 5000
Ncat: Version 7.50 ( https://nmap.org/ncat )
Ncat: Connected to 192.168.0.124:5000.
Ncat: 30 bytes sent, 0 bytes received in 0.01 seconds.
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
在124上,我们看到nat-hole-puncher程序输出如下结果:
Received message: hello from 192.168.0.125:6000
from 192.168.0.125:6000
Received message: hello from 192.168.0.125:6001
from 192.168.0.125:6001
- 1.
- 2.
- 3.
- 4.
4. 探索后的结论
通过上面的执行步骤以及输出的结果,我们从端口分配行为和过滤行为这两方面分析一下Docker默认网络NAT的行为特征。
首先,我们先来看端口分配行为。
在上面的探索步骤中,我们先后执行了:
- 172.17.0.5:5000 -> 192.168.0.125:6000
- 172.17.0.5:5000 -> 192.168.0.125:6001
但从udp-client-addr-display的输出来看:
Received message: Hello, World! from 192.168.0.124:5000
Received message: Hello, World! from 192.168.0.124:5000
- 1.
- 2.
Docker默认网络的NAT的端口分配行为肯定不是Address and Port-Dependent Mapping,那么到底是不是Address-Dependent Mapping的呢?你可以将nat-hole-puncher/main.go中的startUDPReceiver调用注释掉,然后再在另外一台机器192.168.0.126上启动一个udp-client-addr-display(监听7000和7001),然后在124上分别执行:
$ docker exec client1 /app/nat-hole-puncher 172.17.0.5 5000 192.168.0.125 6000
sending message to 192.168.0.125:6000 ok
sending message to 192.168.0.125:6001 ok
$ docker exec client1 /app/nat-hole-puncher 172.17.0.4 5000 192.168.0.126 7000
sending message to 192.168.0.126:7000 ok
sending message to 192.168.0.126:7001 ok
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
而从125和126上的udp-client-addr-display的输出来看:
//125:
./udp-client-addr-display 192.168.0.125 6000
Received message: Hello, World! from 192.168.0.124:5000
Received message: Hello, World! from 192.168.0.124:5000
//126:
./udp-client-addr-display 192.168.0.126 7000
Received message: Hello, World! from 192.168.0.124:5000
Received message: Hello, World! from 192.168.0.124:5000
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
可以看出:即便是target ip不同,只要源ip+port一致,NAT也只会分配同一个端口(这里是5000),显然在端口分配行为上,Docker默认网络的NAT是Endpoint-Independent Mapping类型的!
我們再來看過濾行為。 nat-hole-puncher在NAT打洞後,我們在125上使用nc工具向該“洞”發UDP包,結果是只有nat-hole-puncher發過的目的ip和埠(比如6000和6001)才可以成功將數據通過“洞”發給nat-hole-puncher。 換個埠(比如6002),數據都會被丟棄掉。 即便我們沒有測試從不同IP向「洞」發送udp數據,但上述過濾行為已經足夠讓我們判定Docker預設網路的NAT過濾行為屬於Address and Port-Dependent Filtering。
綜合上述兩個行為特徵,如果按照傳統NAT類型劃分,Docker默認網路的NAT應該屬於埠受限錐形。
5. 小結
本文探討了Docker預設網路的NAT(網路位址轉換)行為。 我們通過構建實驗環境,使用兩個自製程式(nat-hole-puncher和udp-client-addr-display)以及nc工具,來測試和分析Docker NAT的埠分配行為和過濾行為。
主要的探索結論如下:
- 埠分配行為:Docker默認網路的NAT表現為Endpoint-Independent Mapping類型。 即無論目標IP和埠如何變化,只要源IP和埠相同,NAT就會分配相同的外部埠。
- 過濾行為:Docker默認網路的NAT表現為Address and Port-Dependent Filtering類型。 只有之前通信過的特定IP和埠組合才能成功穿透NAT發送數據包到內部網路。
基於這兩種行為特徵,我們可以得出結論:按照傳統NAT類型劃分,Docker默認網路的NAT屬於埠受限錐形(Port Restricted Cone)NAT。
不過,在真正實踐中判斷一個NAT的類型無需如此費勁,RFC3489給出檢測NAT類型(傳統四種類別)的流程圖[6]:
圖片
github上也有上述演算法的開源的實現,比如:pystun3[7]。 下面是利用pystun3檢測網路NAT類型的方法:
$docker run -it python:3-alpine /bin/sh
/ # pip install pystun3
/ # pystun3
NAT Type: Symmetric NAT
External IP: xxx.xxx.xxx.xxx
External Port: yyyy
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
注:這裡pystun3的檢測結果是多層NAT的結果,並非單純的Docker默認網路的NAT類型。
本文涉及的源碼可以在這裡[8]下載 - https://github.com/bigwhite/experiments/blob/master/docker-default-nat
參考資料
[1] WebRTC第一課:網络架構與NAT工作原理: https://tonybai.com/2024/11/27/webrtc-first-lesson-network-architecture-and-how-nat-work/
[2] RFC 3489: https://datatracker.ietf.org/doc/html/rfc3489
[3] RFC 4787: https://datatracker.ietf.org/doc/html/rfc4787
[4] Docker預設網络: https://tonybai.com/2016/01/15/understanding-container-networking-on-single-host/
[5] nc(natcat)工具: https://man.openbsd.org/nc.1
[6] RFC3489給出檢測NAT類型(傳統四種類別)的流程圖: https://www.rfc-editor.org/rfc/rfc3489#section-10.2
[7] pystun3: https://github.com/talkiq/pystun3
[8] 這裡: https://github.com/bigwhite/experiments/blob/master/docker-default-nat