手工模擬實(shí)現(xiàn) Docker 容器網(wǎng)絡(luò)!
大家好,我是飛哥!
如今服務(wù)器虛擬化技術(shù)已經(jīng)發(fā)展到了深水區(qū)。現(xiàn)在業(yè)界已經(jīng)有很多公司都遷移到容器上了。我們的開發(fā)寫出來的代碼大概率是要運(yùn)行在容器上的。因此深刻理解容器網(wǎng)絡(luò)的工作原理非常的重要。這有這樣將來遇到問題的時(shí)候才知道該如何下手處理。
網(wǎng)絡(luò)虛擬化,其實(shí)用一句話來概括就是用軟件來模擬實(shí)現(xiàn)真實(shí)的物理網(wǎng)絡(luò)連接。比如 Docker 就是用純軟件的方式在宿主機(jī)上模擬出來的獨(dú)立網(wǎng)絡(luò)環(huán)境。我們今天來徒手打造一個(gè)虛擬網(wǎng)絡(luò),實(shí)現(xiàn)在這個(gè)網(wǎng)絡(luò)里訪問外網(wǎng)資源,同時(shí)監(jiān)聽端口提供對(duì)外服務(wù)的功能。
看完這一篇后,相信你對(duì) Docker 虛擬網(wǎng)絡(luò)能有進(jìn)一步的理解。好了,我們開始!
一、基礎(chǔ)知識(shí)回顧
1.1 veth、bridge 與 namespace
Linux 下的 veth 是一對(duì)兒虛擬網(wǎng)卡設(shè)備,和我們常見的 lo 很類似。在這兒設(shè)備里,從一端發(fā)送數(shù)據(jù)后,內(nèi)核會(huì)尋找該設(shè)備的另一半,所以在另外一端就能收到。不過 veth 只能解決一對(duì)一通信的問題。詳情參見輕松理解 Docker 網(wǎng)絡(luò)虛擬化基礎(chǔ)之 veth 設(shè)備!
如果有很多對(duì)兒 veth 需要互相通信的話,就需要引入 bridge 這個(gè)虛擬交換機(jī)。各個(gè) veth 對(duì)兒可以把一頭連接在 bridge 的接口上,bridge 可以和交換機(jī)一樣在端口之間轉(zhuǎn)發(fā)數(shù)據(jù),使得各個(gè)端口上的 veth 都可以互相通信。參見
Namespace 解決的是隔離性的問題。每個(gè)虛擬網(wǎng)卡設(shè)備、進(jìn)程、socket、路由表等等網(wǎng)絡(luò)棧相關(guān)的對(duì)象默認(rèn)都是歸屬在 init_net 這個(gè)缺省的 namespace 中的。不過我們希望不同的虛擬化環(huán)境之間是隔離的,用 Docker 來舉例,那就是不能讓 A 容器用到 B 容器的設(shè)備、路由表、socket 等資源,甚至連看一眼都不可以。只有這樣才能保證不同的容器之間復(fù)用資源的同時(shí),還不會(huì)影響其它容器的正常運(yùn)行。參見
通過 veth、namespace 和 bridge 我們?cè)谝慌_(tái) Linux 上就能虛擬多個(gè)網(wǎng)絡(luò)環(huán)境出來。而且它們之間、和宿主機(jī)之間都可以互相通信。
但是這三篇文章過后,我們還剩下一個(gè)問題沒有解決,那就是虛擬出來的網(wǎng)絡(luò)環(huán)境和外部網(wǎng)絡(luò)的通信。還拿 Docker 容器來舉例,你啟動(dòng)的容器里的服務(wù)肯定是需要訪問外部的數(shù)據(jù)庫的。還有就是可能需要暴露比如 80 端口對(duì)外提供服務(wù)。例如在 Docker 中我們通過下面的命令將容器的 80 端口上的 web 服務(wù)要能被外網(wǎng)訪問的到。
我們今天的文章主要就是解決這兩個(gè)問題的,一是從虛擬網(wǎng)絡(luò)中訪問外網(wǎng),二是在虛擬網(wǎng)絡(luò)中提供服務(wù)供外網(wǎng)使用。解決它們需要用到路由和 nat 技術(shù)。
1.2 路由選擇
Linux 是在發(fā)送數(shù)據(jù)包的時(shí)候,會(huì)涉及到路由過程。這個(gè)發(fā)送數(shù)據(jù)包既包括本機(jī)發(fā)送數(shù)據(jù)包,也包括途徑當(dāng)前機(jī)器的數(shù)據(jù)包的轉(zhuǎn)發(fā)。
先來看本機(jī)發(fā)送數(shù)據(jù)包。其中本機(jī)發(fā)送在25 張圖,一萬字,拆解 Linux 網(wǎng)絡(luò)包發(fā)送過程這一篇我們討論過。
所謂路由其實(shí)很簡單,就是該選擇哪張網(wǎng)卡(虛擬網(wǎng)卡設(shè)備也算)將數(shù)據(jù)寫進(jìn)去。到底該選擇哪張網(wǎng)卡呢,規(guī)則都是在路由表中指定的。Linux 中可以有多張路由表,最重要和常用的是 local 和 main。
local 路由表中統(tǒng)一記錄本地,確切的說是本網(wǎng)絡(luò)命名空間中的網(wǎng)卡設(shè)備 IP 的路由規(guī)則。
- #ip route list table local
- local 10.143.x.y dev eth0 proto kernel scope host src 10.143.x.y
- local 127.0.0.1 dev lo proto kernel scope host src 127.0.0.1
其它的路由規(guī)則,一般都是在 main 路由表中記錄著的??梢杂?ip route list table local 查看,也可以用更簡短的 route -n
再看途徑當(dāng)前機(jī)器的數(shù)據(jù)包的轉(zhuǎn)發(fā)。除了本機(jī)發(fā)送以外,轉(zhuǎn)發(fā)也會(huì)涉及路由過程。如果 Linux 收到數(shù)據(jù)包以后發(fā)現(xiàn)目的地址并不是本地的地址的話,就可以選擇把這個(gè)數(shù)據(jù)包從自己的某個(gè)網(wǎng)卡設(shè)備上轉(zhuǎn)發(fā)出去。這個(gè)時(shí)候和本機(jī)發(fā)送一樣,也需要讀取路由表。根據(jù)路由表的配置來選擇從哪個(gè)設(shè)備將包轉(zhuǎn)走。
不過值得注意的是,Linux 上轉(zhuǎn)發(fā)功能默認(rèn)是關(guān)閉的。也就是發(fā)現(xiàn)目的地址不是本機(jī) IP 地址默認(rèn)是將包直接丟棄。需要做一些簡單的配置,然后 Linux 才可以干像路由器一樣的活兒,實(shí)現(xiàn)數(shù)據(jù)包的轉(zhuǎn)發(fā)。
1.3 iptables 與 NAT
Linux 內(nèi)核網(wǎng)絡(luò)棧在運(yùn)行上基本上是一個(gè)純內(nèi)核態(tài)的東西,但為了迎合各種各樣用戶層不同的需求,內(nèi)核開放了一些口子出來供用戶層來干預(yù)。其中 iptables 就是一個(gè)非常常用的干預(yù)內(nèi)核行為的工具,它在內(nèi)核里埋下了五個(gè)鉤子入口,這就是俗稱的五鏈。
Linux 在接收數(shù)據(jù)的時(shí)候,在 IP 層進(jìn)入 ip_rcv 中處理。再執(zhí)行路由判斷,發(fā)現(xiàn)是本機(jī)的話就進(jìn)入 ip_local_deliver 進(jìn)行本機(jī)接收,最后送往 TCP 協(xié)議層。在這個(gè)過程中,埋了兩個(gè) HOOK,第一個(gè)是 PRE_ROUTING。這段代碼會(huì)執(zhí)行到 iptables 中 pre_routing 里的各種表。發(fā)現(xiàn)是本地接收后接著又會(huì)執(zhí)行到 LOCAL_IN,這會(huì)執(zhí)行到 iptables 中配置的 input 規(guī)則。
在發(fā)送數(shù)據(jù)的時(shí)候,查找路由表找到出口設(shè)備后,依次通過 __ip_local_out、 ip_output 等函數(shù)將包送到設(shè)備層。在這兩個(gè)函數(shù)中分別過了 OUTPUT 和 PREROUTING 開的各種規(guī)則。
如果是轉(zhuǎn)發(fā)過程,Linux 收到數(shù)據(jù)包發(fā)現(xiàn)不是本機(jī)的包可以通過查找自己的路由表找到合適的設(shè)備把它轉(zhuǎn)發(fā)出去。那就先是在 ip_rcv 中將包送到 ip_forward 函數(shù)中處理,最后在 ip_output 函數(shù)中將包轉(zhuǎn)發(fā)出去。在這個(gè)過程中分別過了 PREROUTING、FORWARD 和 POSTROUTING 三個(gè)規(guī)則。
綜上所述,iptables 里的五個(gè)鏈在內(nèi)核網(wǎng)絡(luò)模塊中的位置就可以歸納成如下這幅圖。
數(shù)據(jù)接收過程走的是 1 和 2,發(fā)送過程走的是 4 、5,轉(zhuǎn)發(fā)過程是 1、3、5。有了這張圖,我們能更清楚地理解 iptable 和內(nèi)核的關(guān)系。
在 iptables 中,根據(jù)實(shí)現(xiàn)的功能的不同,又分成了四張表。分別是 raw、mangle、nat 和 filter。其中 nat 表實(shí)現(xiàn)我們常說的 NAT(Network AddressTranslation) 功能。其中 nat 又分成 SNAT(Source NAT)和 DNAT(Destination NAT)兩種。
SNAT 解決的是內(nèi)網(wǎng)地址訪問外部網(wǎng)絡(luò)的問題。它是通過在 POSTROUTING 里修改來源 IP 來實(shí)現(xiàn)的。
DNAT 解決的是內(nèi)網(wǎng)的服務(wù)要能夠被外部訪問到的問題。它在通過 PREROUTING 修改目標(biāo) IP 實(shí)現(xiàn)的。
二、 實(shí)現(xiàn)虛擬網(wǎng)絡(luò)外網(wǎng)通信
基于以上的基礎(chǔ)知識(shí),我們用純手工的方式搭建一個(gè)可以和 Docker 類似的虛擬網(wǎng)絡(luò)。而且要實(shí)現(xiàn)和外網(wǎng)通信的功能。
1. 實(shí)驗(yàn)環(huán)境準(zhǔn)備
我們先來創(chuàng)建一個(gè)虛擬的網(wǎng)絡(luò)環(huán)境出來,其命名空間為 net1。宿主機(jī)的 IP 是 10.162 的網(wǎng)段,可以訪問外部機(jī)器。虛擬網(wǎng)絡(luò)為其分配 192.168.0 的網(wǎng)段,這個(gè)網(wǎng)段是私有的,外部機(jī)器無法識(shí)別。
這個(gè)虛擬網(wǎng)絡(luò)的搭建過程如下。先創(chuàng)建一個(gè) netns 出來,命名為 net1。
- # ip netns add net1
創(chuàng)建一個(gè) veth 對(duì)兒(veth1 - veth1_p),把其中的一頭 veth1 放在 net1 中,給它配置上 IP,并把它啟動(dòng)起來。
- # ip link add veth1 type veth peer name veth1_p
- # ip link set veth1 netns net1
- # ip netns exec net1 ip addr add 192.168.0.2/24 dev veth1 # IP
- # ip netns exec net1 ip link set veth1 up
創(chuàng)建一個(gè) bridge,給它也設(shè)置上 ip。接下來把 veth 的另外一端 veth1_p 插到 bridge 上面。最后把網(wǎng)橋和 veth1_p 都啟動(dòng)起來。
- # brctl addbr br0
- # ip addr add 192.168.0.1/24 dev br0
- # ip link set dev veth1_p master br0
- # ip link set veth1_p up
- # ip link set br0 up
這樣我們就在 Linux 上創(chuàng)建出了一個(gè)虛擬的網(wǎng)絡(luò)。創(chuàng)建過程和 聊聊 Linux 上軟件實(shí)現(xiàn)的“交換機(jī)” - Bridge! 中一樣,只不過今天為了省事,只創(chuàng)建了一個(gè)網(wǎng)絡(luò)出來,上一篇中創(chuàng)建出來了兩個(gè)。
2. 請(qǐng)求外網(wǎng)資源
現(xiàn)在假設(shè)我們上面的 net1 這個(gè)網(wǎng)絡(luò)環(huán)境中想訪問外網(wǎng)。這里的外網(wǎng)是指的虛擬網(wǎng)絡(luò)宿主機(jī)外部的網(wǎng)絡(luò)。
我們假設(shè)它要訪問的另外一臺(tái)機(jī)器 IP 是 10.153.*.* ,這個(gè) 10.153.*.* 后面兩段由于是我的內(nèi)部網(wǎng)絡(luò),所以隱藏起來了。你在實(shí)驗(yàn)的過程中,用自己的 IP 代替即可。
我們直接來訪問一下試試
- # ip netns exec net1 ping 10.153.*.*
- connect: Network is unreachable
提示網(wǎng)絡(luò)不通,這是怎么回事?用這段報(bào)錯(cuò)關(guān)鍵字在內(nèi)核源碼里搜索一下:
- //file: arch/parisc/include/uapi/asm/errno.h
- #define ENETUNREACH 229 /* Network is unreachable */
- //file: net/ipv4/ping.c
- static int ping_sendmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
- size_t len)
- {
- ...
- rt = ip_route_output_flow(net, &fl4, sk);
- if (IS_ERR(rt)) {
- err = PTR_ERR(rt);
- rt = NULL;
- if (err == -ENETUNREACH)
- IP_INC_STATS_BH(net, IPSTATS_MIB_OUTNOROUTES);
- goto out;
- }
- ...
- out:
- return err;
- }
在 ip_route_output_flow 這里的返回值判斷如果是 ENETUNREACH 就退出了。這個(gè)宏定義注釋上來看報(bào)錯(cuò)的信息就是 “Network is unreachable”。
這個(gè) ip_route_output_flow 主要是執(zhí)行路由選路。所以我們推斷可能是路由出問題了,看一下這個(gè)命名空間的路由表。
- # ip netns exec net1 route -n
- Kernel IP routing table
- Destination Gateway Genmask Flags Metric Ref Use Iface
- 192.168.0.0 0.0.0.0 255.255.255.0 U 0 0 0 veth1
怪不得,原來 net1 這個(gè) namespace 下默認(rèn)只有 192.168.0.* 這個(gè)網(wǎng)段的路由規(guī)則。我們 ping 的 IP 是 10.153.*.* ,根據(jù)這個(gè)路由表里找不到出口。自然就發(fā)送失敗了。
我們來給 net 添加上默認(rèn)路由規(guī)則,只要匹配不到其它規(guī)則就默認(rèn)送到 veth1 上,同時(shí)指定下一條是它所連接的 bridge(192.168.0.1)。
- # ip netns exec net1 route add default gw 192.168.0.1 veth1
再 ping 一下試試。
- # ip netns exec net1 ping 10.153.*.* -c 2
- PING 10.153.*.* (10.153.*.*) 56(84) bytes of data.
- --- 10.153.*.* ping statistics ---
- 2 packets transmitted, 0 received, 100% packet loss, time 999ms
額好吧,仍然不通。上面路由幫我們把數(shù)據(jù)包從 veth 正確送到了 bridge 這個(gè)網(wǎng)橋上。接下來網(wǎng)橋還需要 bridge 轉(zhuǎn)發(fā)到 eth0 網(wǎng)卡上。所以我們得打開下面這兩個(gè)轉(zhuǎn)發(fā)相關(guān)的配置
- # sysctl net.ipv4.conf.all.forwarding=1
- # iptables -P FORWARD ACCEPT
不過這個(gè)時(shí)候,還存在一個(gè)問題。那就是外部的機(jī)器并不認(rèn)識(shí) 192.168.0.* 這個(gè)網(wǎng)段的 ip。它們之間都是通過 10.153.*.* 來進(jìn)行通信的。設(shè)想下我們工作中的電腦上沒有外網(wǎng) IP 的時(shí)候是如何正常上網(wǎng)的呢?外部的網(wǎng)絡(luò)只認(rèn)識(shí)外網(wǎng) IP。沒錯(cuò),那就是我們上面說的 NAT 技術(shù)。
我們這次的需求是實(shí)現(xiàn)內(nèi)部虛擬網(wǎng)絡(luò)訪問外網(wǎng),所以需要使用的是 SNAT。它將 namespace 請(qǐng)求中的 IP(192.168.0.2)換成外部網(wǎng)絡(luò)認(rèn)識(shí)的 10.153.*.*,進(jìn)而達(dá)到正常訪問外部網(wǎng)絡(luò)的效果。
- # iptables -t nat -A POSTROUTING -s 192.168.0.0/24 ! -o br0 -j MASQUERADE
來再 ping 一下試試,歐耶,通了!
- # ip netns exec net1 ping 10.153.*.*
- PING 10.153.*.* (10.153.*.*) 56(84) bytes of data.
- 64 bytes from 10.153.*.*: icmp_seq=1 ttl=57 time=1.70 ms
- 64 bytes from 10.153.*.*: icmp_seq=2 ttl=57 time=1.68 ms
這時(shí)候我們可以開啟 tcpdump 抓包查看一下,在 bridge 上抓到的包我們能看到還是原始的源 IP 和 目的 IP。
再到 eth0 上查看的話,源 IP 已經(jīng)被替換成可和外網(wǎng)通信的 eth0 上的 IP 了。
至此,容器就可以通過宿主機(jī)的網(wǎng)卡來訪問外部網(wǎng)絡(luò)上的資源了。我們來總結(jié)一下這個(gè)發(fā)送過程
3. 開放容器端口
我們?cè)倏紤]另外一個(gè)需求,那就是把在這個(gè)命名空間內(nèi)的服務(wù)提供給外部網(wǎng)絡(luò)來使用。
和上面的問題一樣,我們的虛擬網(wǎng)絡(luò)環(huán)境中 192.168.0.2 這個(gè) IP 外界是不認(rèn)識(shí)它的。只有這個(gè)宿主機(jī)知道它是誰。所以我們同樣還需要 NAT 功能。
這次我們是要實(shí)現(xiàn)外部網(wǎng)絡(luò)訪問內(nèi)部地址,所以需要的是 DNAT 配置。DNAT 和 SNAT 配置中有一個(gè)不一樣的地方就是需要明確指定容器中的端口在宿主機(jī)上是對(duì)應(yīng)哪個(gè)。比如在 docker 的使用中,是通過 -p 來指定端口的對(duì)應(yīng)關(guān)系。
- # docker run -p 8000:80 ...
我們通過如下這個(gè)命令來配置 DNAT 規(guī)則
- # iptables -t nat -A PREROUTING ! -i br0 -p tcp -m tcp --dport 8088 -j DNAT --to-destination 192.168.0.2:80
這里表示的是宿主機(jī)在路由之前判斷一下如果流量不是來自 br0,并且是訪問 tcp 的 8088 的話,那就轉(zhuǎn)發(fā)到 192.168.0.2:80 。
在 net1 環(huán)境中啟動(dòng)一個(gè) Server
- # ip netns exec net1 nc -lp 80
外部選一個(gè)ip,比如 10.143.*.*, telnet 連一下 10.162.*.* 8088 試試,通了!
- # telnet 10.162.*.* 8088
- Trying 10.162.*.*...
- Connected to 10.162.*.*.
- Escape character is '^]'.
開啟抓包, # tcpdump -i eth0 host 10.143.*.*??梢娫谡?qǐng)求的時(shí)候,目的是宿主機(jī)的 IP 的端口。
但數(shù)據(jù)包到宿主機(jī)協(xié)議棧以后命中了我們配置的 DNAT 規(guī)則,宿主機(jī)把它轉(zhuǎn)發(fā)到了 br0 上。在 bridge 上由于沒有那么多的網(wǎng)絡(luò)流量包,所以不用過濾直接抓包就行,# tcpdump -i br0。
在 br0 上抓到的目的 IP 和端口是已經(jīng)替換過的了。
bridge 當(dāng)然知道 192.168.0.2 是 veth 1。于是,在 veth1 上監(jiān)聽 80 的服務(wù)就能收到來自外界的請(qǐng)求了!我們來總結(jié)一下這個(gè)接收過程
三、總結(jié)
現(xiàn)在業(yè)界已經(jīng)有很多公司都遷移到容器上了。我們的開發(fā)寫出來的代碼大概率是要運(yùn)行在容器上的。因此深刻理解容器網(wǎng)絡(luò)的工作原理非常的重要。這有這樣將來遇到問題的時(shí)候才知道該如何下手處理。
本文開頭我們先是簡單介紹了 veth、bridge、namespace、路由、iptables 等基礎(chǔ)知識(shí)。Veth 實(shí)現(xiàn)連接,bridge 實(shí)現(xiàn)轉(zhuǎn)發(fā),namespace 實(shí)現(xiàn)隔離,路由表控制發(fā)送時(shí)的設(shè)備選擇,iptables 實(shí)現(xiàn) nat 等功能。
接著基于以上基礎(chǔ)知識(shí),我們采用純手工的方式搭建了一個(gè)虛擬網(wǎng)絡(luò)環(huán)境。
這個(gè)虛擬網(wǎng)絡(luò)可以訪問外網(wǎng)資源,也可以提供端口服務(wù)供外網(wǎng)來調(diào)用。這就是 Docker 容器網(wǎng)絡(luò)工作的基本原理。
整個(gè)實(shí)驗(yàn)我打包寫成一個(gè) Makefile,放到了這里:https://github.com/yanfeizhang/coder-kung-fu/tree/main/tests/network/test07
最后,我們?cè)贁U(kuò)展一下。今天我們討論的問題是 Docker 網(wǎng)絡(luò)通信的問題。Docker 容器通過端口映射的方式提供對(duì)外服務(wù)。外部機(jī)器訪問容器服務(wù)的時(shí)候,仍然需要通過容器的宿主機(jī) IP 來訪問。
在 Kubernets 中,對(duì)跨主網(wǎng)絡(luò)通信有更高的要求,要不同宿主機(jī)之間的容器可以直接互聯(lián)互通。所以 Kubernets 的網(wǎng)絡(luò)模型也更為復(fù)雜。