聊聊容器網絡實現原理
基本原理
Linux 容器假如使用了 network namespace,那么容器將會有自己的一個網絡棧,而這個網絡棧,就包括了:網卡、回環設備、路由表和 iptables 規則等。這些要素,其實就構成了進程發起和響應網絡請求的基本環境,擁有了屬于自己的 IP 地址和端口。雖然容器可以直接使用宿主機網絡棧的方式,從而為容器提供良好的網絡性能,但是這樣也會不可避免地引入共享網絡資源的問題,比如端口沖突。
但是被隔離在自己 network namespace 中的容器如何跟同一宿主機上但處于不同 network namespace 里的容器進程進行通信呢?如何跟容器所在宿主機進行通信呢?如何訪問外部主機的?或者提供外部可訪問的服務呢?
圖片
把每一個容器都看作一臺主機,它們有一套獨立的“網絡棧”。那么容器與同一臺上宿主機的通信,或者與宿主機的通信,其實就相當于兩臺主機之間的通信。而實現兩臺主機之間通信最直接的方式,就是用一根網線將兩臺主機連接起來。如果想要實現多臺主機之間的通信,那就需要用網線,把它們連接在一臺交換機上。
Linux 提供了 bridge 虛擬網絡設備(類似交換機的能力),并且提供了 Veth Pair 虛擬設備(Veth Pair 的虛擬設備被創建出來之后,總是以兩張虛擬網卡(Veth Peer)的形式成對出現。并且從其中一個網卡發出的數據包,可以直接出現在與它對應的另一張網卡上,哪怕這兩張網卡在不同的 network namespace 中)。
因此,只需要通過上述虛擬設備,將 Veth Pair 一端連在 Container 上,作為 Container 的 eth0,另外一端連接在網橋上,同時給網橋分配一個 IP 地址(此時網橋也作為了宿主機的一個網絡接口),就可以實現容器與同一臺上宿主機的通信,或者與宿主機的通信。如圖所示,
之后在上述的基礎之上,在宿主機上通過路由和 NAT 的方式,就可以實現容器訪問外部服務,或者為外部提供服務。
圖片
除了 bridge 的方式之外,還可以通過其他方式來實現,但是目前普遍的方式是采用上述的方式。比如,Docker 項目默認就是使用上述方式實現的。除了 bridge+veth pair 的方式之外,容器還可以選擇其他的網絡配置方式。
- 比如 Docker 的文檔中就提到了 macvlan 的配置方式,和 macvlan 很類似的方式還有 ipvlan。它們的相同點就是都可以在一個物理的網絡接口上再配置幾個虛擬的網絡接口。在這些虛擬的網絡接口上,都可以配置獨立的 IP,并且這些 IP 可以屬于不同的 Namespace。而不同點是,對于 macvlan,每個虛擬網絡接口都有自己獨立的 mac 地址;而 ipvlan 的虛擬網絡接口是和物理網絡接口共享同一個 mac 地址。由于 ipvlan/macvlan 網絡接口直接掛載在物理網絡接口上,此時數據包發送的過程中只會經過容器內的協議棧配置,比如容器自己的 iptables 規則,而不會經過宿主機的 iptables 規則。因此,對于會用到宿主機 iptables 規則的容器,比如 Kubernetes 里使用 service 的容器,就不能工作了。
圖片
bridge 網絡模型通信舉例
容器->容器(同一宿主機)
- 在容器 container1 中查看相應的路由:這個容器里有一張叫作 eth0 的網卡,它正是一個 Veth Pair 設備在容器里的這一端。所有對 172.17.0.0/16 網段的請求,都會被交給 eth0 來處理(第二條 172.17.0.0 路由規則)。
$ route
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
default 172.17.0.1 0.0.0.0 UG 0 0 0 eth0
172.17.0.0 0.0.0.0 255.255.0.0 U 0 0 0 eth0
而這個 Veth Pair 設備的另一端,則在宿主機上。你可以通過查看宿主機的網絡設備看到它,如下所示。可以看到,container1 對應的 Veth Pair 設備,在宿主機上是一張虛擬網卡。它的名字叫作 veth9c02e56。并且,通過 brctl show 的輸出,你可以看到這張網卡被“插”在了 docker0 上。
# 在宿主機上
$ ifconfig
...
docker0 Link encap:Ethernet HWaddr 02:42:d8:e4:df:c1
inet addr:172.17.0.1 Bcast:0.0.0.0 Mask:255.255.0.0
inet6 addr: fe80::42:d8ff:fee4:dfc1/64 Scope:Link
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:309 errors:0 dropped:0 overruns:0 frame:0
TX packets:372 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0
RX bytes:18944 (18.9 KB) TX bytes:8137789 (8.1 MB)
veth9c02e56 Link encap:Ethernet HWaddr 52:81:0b:24:3d:da
inet6 addr: fe80::5081:bff:fe24:3dda/64 Scope:Link
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:288 errors:0 dropped:0 overruns:0 frame:0
TX packets:371 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0
RX bytes:21608 (21.6 KB) TX bytes:8137719 (8.1 MB)
$ brctl show
bridge name bridge id STP enabled interfaces
docker0 8000.0242d8e4dfc1 no veth9c02e56
- 當在 container1 容器里訪問 container2 容器的 IP 地址(比如 ping 172.17.0.3)的時候,這個目的 IP 地址會匹配到 container1 容器里的路由規則,相應的路由規則的網關(gateway)是 0.0.0.0,這就意味著這是一條直連規則:即凡是匹配到這條規則的 IP 包,應該經過本機的 eth0 網卡,通過二層網絡直接發往目的主機。
此時則需要知道 172.17.0.3 這個 IP 地址對應的 MAC 地址,因此 container1 容器的網絡協議棧,會通過 eth0 網卡發送一個 ARP 廣播,來通過 IP 地址查找對應的 MAC 地址。由于一張虛擬網卡被“插”在網橋上后,它就會變成該網橋的“從設備”。從設備會被“剝奪”調用網絡協議棧處理數據包的資格,從而“降級”成為網橋上的一個端口。這個端口唯一的作用,就是接收流入的數據包,而這些數據包的轉發或者丟棄則全交由對應的網橋處理(container1 中的 eth0 網卡對應的 Veth Pair 其實就相當于一個網絡端口,而 eth0 和它對應的 veth pari 的連接就相當于一根網線,這根“網線”將 containerd 連到了網橋上。
所以,在收到這些 ARP 請求之后,docker0 網橋就會扮演二層交換機的角色,把 ARP 廣播轉發到其他被“插”在 docker0 上的虛擬網卡上。因此,container2 容器的 eth0 就會收到這個 ARP 請求,從而將 172.17.0.3 所對應的 MAC 地址回復給 container1 容器。 - 有了這個目的 MAC 地址,container1 容器的 eth0 網卡就可以將數據包發出去。docker0 處理轉發的過程,則繼續扮演二層交換機的角色。此時,docker0 網橋根據數據包的目的 MAC 地址(也就是 container2 容器的 MAC 地址),在它的 CAM 表(即交換機通過 MAC 地址學習維護的端口和 MAC 地址的對應表)里查到對應的端口(Port)為:vethb4963f3,然后把數據包發往這個端口。這個端口對應著 container2 中的 eth0 網卡(相當于有根網線將他們連起來)。因此數據包就進入到了 container2 容器的 Network Namespace 里。所以,container2 容器看到的情況是,它自己的 eth0 網卡上出現了流入的數據包。這樣,container2 的網絡協議棧就會對請求進行處理,最后將響應(Pong)返回到 containerd1。
圖片
需要注意的是,在實際的數據傳遞時,上述數據的傳遞過程在網絡協議棧的不同層次,都有 Linux 內核 Netfilter 參與其中。所以,如果感興趣的話,你可以通過打開 iptables 的 TRACE 功能查看到數據包的傳輸過程,具體方法如下所示,通過上述設置,你就可以在 /var/log/syslog 里看到數據包傳輸的日志了。
# 在宿主機上執行
$ iptables -t raw -A OUTPUT -p icmp -j TRACE
$ iptables -t raw -A PREROUTING -p icmp -j TRACE
宿主機->容器
當在主機上訪問容器的時候,訪問請求的數據包,
- 首先根據宿主機的路由規則到達 docker0 網橋。
$ route -n
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
172.17.0.0 0.0.0.0 255.255.0.0 U 0 0 0 docker0
- 然后再被轉發到對應的 Veth Pair 設備,最后出現在容器里。
圖片
容器->外部主機
- 當一個容器試圖連接到另外一個宿主機時,比如:ping 10.168.0.3,它發出的請求數據包,首先經過 docker0 網橋出現在宿主機上。
- 之后根據宿主機的路由表里的路由規則(10.168.0.0/24 via eth0)),將對 10.168.0.3 的訪問請求交給宿主機的 eth0 處理。
- 同時對數據包進行源地址替換(SNAT)。這是因為容器通常使用的是一個私有的、非路由的 IP 地址(如172.17.0.2),這些地址在宿主機的網絡之外是不可見的。宿主機網絡之外用于通信的地址,還得是宿主機。如果不進行 SNAT 的話,那么在外部網絡中無法進行正確的路由。下面是 netfilter POSTROUTING 階段的規則,包含了一條規則:source 如果是 172.17.0.0/16,但是出口用的網絡接口不是 docker 的話,則進行 SNAT。
Chain POSTROUTING (policy ACCEPT 2469K packets, 176M bytes)
pkts bytes target prot opt in out source destination
0 0 MASQUERADE all -- * !docker0 172.17.0.0/16 0.0.0.0/0
- 接下來的數據包就會經宿主機的 eth0 網卡轉發到宿主機網絡上,最終到達 10.168.0.3 對應的宿主機上(當然這兩臺宿主機本身是要連通的)。
當遇到容器連不通“外網”的時候,你都應該先試試 docker0 網橋能不能 ping 通,然后查看一下跟 docker0 和 Veth Pair 設備相關的 iptables 規則是不是有異常,往往就能夠找到問題的答案了。
圖片
外部主機->容器
如果一個容器想要對外提供服務的話,此時需要使用 NAT 的能力,將提供服務的端口映射到 Docker 宿主機端口上。在實現原理上,其實就是在 netfilter PREROUTING 階段進行目的地址轉換(DNAT)。
- 當外部主機訪問宿主機的 8090 端口,也就是訪問 10.168.0.2:8090 的時候。
- 訪問數據包會先到宿主機網絡,先經過 netfilter PREROUTING 階段。PREROUTING 中有一條規則是對于目的端口是 8090 的數據包來說,它的目的地址和端口會被替換為 172.17.0.2:8090。因此,任何訪問宿主機 8090 端口的數據包,都會被轉發到容器 172.17.0.2:8090 上。
Chain PREROUTING (policy ACCEPT 2587K packets, 122M bytes)
pkts bytes target prot opt in out source destination
2637K 125M DOCKER all -- * * 0.0.0.0/0 0.0.0.0/0 ADDRTYPE match dst-type LOCAL
Chain DOCKER (2 references)
pkts bytes target prot opt in out source destination
0 0 RETURN all -- docker0 * 0.0.0.0/0 0.0.0.0/0
37821 2032K DNAT tcp -- !docker0 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:8090 to:172.17.0.2:8090
bridge 網絡模擬創建
當我們在使用容器的時候,容器引擎比如 Docker,會自動搭建好上述的網絡拓撲。下面從頭開始搭建上述的網絡拓撲。主要用到的是 ip netns 這個命令,通過它來對 network namespace 進行操作。
- 首先,我們先啟動一個不帶網絡配置的容器。可以看到,在啟動的容器中,Network Namespace 里就只有 loopback 一個網絡設備,而沒有了 eth0 網絡設備了。
# docker run -d --name if-test --network none centos:8.1.1911 sleep 36000
cf3d3105b11512658a025f5b401a09c888ed3495205f31e0a0d78a2036729472
# docker exec -it if-test ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
- 完成剛才的設置以后,在這個容器的 Network Namespace 里建立 veth。可以執行一下這個腳本完成 veth 的創建和通信。
pid=$(ps -ef | grep "sleep 36000" | grep -v grep | awk '{print $2}')
echo $pid
ln -s /proc/$pid/ns/net /var/run/netns/$pid
# Create a pair of veth interfaces
ip link add name veth_host type veth peer name veth_container
# Put one of them in the new net ns
ip link set veth_container netns $pid
# In the container, setup veth_container
ip netns exec $pid ip link set veth_container name eth0
ip netns exec $pid ip addr add 172.17.1.2/16 dev eth0
ip netns exec $pid ip link set eth0 up
ip netns exec $pid ip route add default via 172.17.0.1
# In the host, set veth_host up
ip link set veth_host up
完成這些操作之后,就創建了一對 veth 虛擬設備接口,示意圖如下所示。
假如 veth_host 上加上一個 IP,172.17.1.1/16,就可以從容器里就可以 ping 通這個 IP 了。這也證明了從容器到宿主機可以利用這對 veth 接口來通訊了。
首先呢,我們先找到這個容器里運行的進程"sleep 36000"的 pid,通過 "/proc/$pid/ns/net"這個文件得到 Network Namespace 的 ID,這個 Network Namespace ID 既是這個進程的,也同時屬于這個容器。
然后我們在"/var/run/netns/"的目錄下建立一個符號鏈接,指向這個容器的 Network Namespace。完成這步操作之后,在后面的"ip netns"操作里,就可以用 pid 的值作為這個容器的 Network Namesapce 的標識了。
接下來呢,我們用 ip link 命令來建立一對 veth 的虛擬設備接口,分別是 veth_container 和 veth_host。從名字就可以看出來,veth_container 這個接口會被放在容器 Network Namespace 里,而 veth_host 會放在宿主機的 Host Network Namespace。
之后,用 ip link set veth_container netns $pid 把 veth_container 這個接口放入到容器的 Network Namespace 中。
再然后我們要把 veth_container 重新命名為 eth0,因為這時候接口已經在容器的 Network Namesapce 里了,eth0 就不會和宿主機上的 eth0 沖突了。
最后對容器內的 eht0,我們還要做基本的網絡 IP 和缺省路由配置。因為 veth_host 已經在宿主機的 Host Network Namespace 了,就不需要我們做什么了,這時我們只需要 up 一下這個接口就可以了。
- 完成上述的 veth 設備創建之后,就可以讓數據包從容器的 network namespace 發送到 host network namespace 了。到了 Host Network Namespace 之后就需要考慮,如何將數據包繼續從 eth0 接口發送出去。
- 首先將第一步中建立的 veth_host 這個設備,接入到 docker0 這個 bridge 上即可。如果之前你在 veth_host 上設置了 IP 的,就需先運行一下"ip addr delete 172.17.1.1/16 dev veth_host",把 IP 從 veth_host 上刪除。
# ip addr delete 172.17.1.1/16 dev veth_host
ip link set veth_host master docker0
上述完成之后,網絡拓撲如下所示。容器和 docker0 組成了一個子網,docker0 上的 IP 就是這個子網的網關 IP。
- 此時,數據包是在宿主機網絡中。由于要訪問外網的一個 IP 地址,因此此時宿主機要做的工作是轉發。因此,需要打開宿主機上的轉發功能。
$ iptables -P FORWARD ACCEPT
$ cat /proc/sys/net/ipv4/ip_forward
0
$ echo 1 > /proc/sys/net/ipv4/ip_forward
- 完成上述的配置之后,可以從容器中 ping 通外網的 IP 地址了。
# docker exec -it if-test ping 39.106.233.176
PING 39.106.233.176 (39.106.233.176) 56(84) bytes of data.
64 bytes from 39.106.233.176: icmp_seq=1 ttl=77 time=359 ms
64 bytes from 39.106.233.176: icmp_seq=2 ttl=77 time=346 ms
^C
--- 39.106.233.176 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1ms
rtt min/avg/max/mdev = 345.889/352.482/359.075/6.593 ms
相關鏈接
- The Layers of the OSI Model Illustrated:https://www.lifewire.com/layers-of-the-osi-model-illustrated-818017
- 極客時間.張磊.《深入剖析Kubernetes》
- 容器網絡(一):https://morven.life/posts/networking-4-docker-sigle-host/