本系列教學主要是為了弄清楚容器化的原理,紙上得來終覺淺,絕知此事要躬行,理論始終不及動手實踐來的深刻,所以這個系列會用go語言實現一個類似docker的容器化功能,最終能夠容器化的執行一個程序。
本章的原始碼已經上傳到github,地址如下:
https://github.com/HobbyBear/tinydocker/tree/chapter4
前文我們已經為容器替換了新的根檔案系統,但是由於我們啟動容器的時候是在一個新的網路名稱空間,目前的容器還不能存取外部網路,我們需要在這一節,讓容器能夠存取外部網路,並且能夠實現同一個主機上的容器能夠網路互通。
在正式開始編碼之前,我將基於最簡單的情況,則同一個主機上的容器能夠通過ip互相存取的情況,簡單的介紹下,容器網路互聯的原理,我們是在一個新的網路名稱空間 啟動的子程序,不同網路名稱空間擁有自己的防火牆,路由表,網路裝置,所以需要對新生成的網路名稱空間進行設定。讓網路名稱空間內部的網路包能夠從網路名稱空間內部出去到達主機上。
在linux上,可以用veth虛擬網路裝置去連線兩個不同網路名稱空間,veth裝置是成隊出現,分別連線到不同的名稱空間中, 從veth裝置一端進入的網路包能夠到達veth裝置的另一端, 但在設定容器網路時並不是將veth裝置直接連線在另一端的網路名稱空間內,因為如果主機上容器過多的話,採用直接兩兩相連的方式,將會讓網路拓撲過於複雜。所以一般是將veth裝置連線到一個叫做網橋bridge的虛擬網路裝置上,通過它對網路包進行轉發。
關於veth裝置和bridge的原理和使用,我之前出過一期視訊講解,可以去哪裡深入的學習下:
之前也出過許多對容器網路講解的系列視訊,如果有對容器網路不熟悉的同學,請看這裡:
現在,來讓我們實現下關於容器網路設定的邏輯,首先容器在建立的時候,得先為它分配一個ip地址,本質上就是為它內部的veth裝置分配一個ip地址。這就涉及到如何分配ip地址的問題,這裡有兩個問題需要解決:
1,當知道一個網路的網段後,如何知道網段內部哪個ip進行了分配,哪個ip沒有進行分配。
2,如果知道了這個網段內某個ip沒有被分配,如何根據偏移量計算最終沒有被分配的ip,比如我知道第8個ip沒有被分配,網路網段為192.168.0.0/24 ,那麼第8個ip是多少?
首先來看下ip儲存的問題,也就是看哪些ip進行了分配,哪些沒有進行分配。
這是一個看某個值是否存在的問題,可以通過bitmap去儲存,這在快速判斷ip是否存在的前提下,也能極大的降低儲存成本。
如下所示,如果第一個位元組的第1位和第3位被置為1了,說明在這個網段內,第一個ip和第3個ip都被佔用了。
一個byte是8個bit,也就可以表示8個ip是否被佔用,而一個網段中的ip個數=2的N次方個,其中N=32-網段位數。
用一個實際的例子舉例,比如子網掩碼是255.255.0.0,說明網段是前面的16位元,那麼ip個數就是由後16位元bit數表示,排除掉其中主機號全為0的網路號和主機號全為1的廣播號,可用ip數=2的16次方-2 ,要表示那麼多的ip數就需要 (2的16次方-2)/8 大小的位元組 約等於8kb,轉換成位元組陣列長度就是8192。
具體實現如下:
一個bitmap用一個byte陣列表示
type bitMap struct {
Bitmap []byte
}
bitmap的方法也就3個,
1, 檢視第n個ip是不是被分配。
func (b *bitMap) BitExist(pos int) bool {
aIndex := arrIndex(pos)
bIndex := bytePos(pos)
return 1 == 1&(b.Bitmap[aIndex]>>bIndex)
}
arrIndex和bytePos 方法實現如下:
func arrIndex(pos int) int {
return pos / 8
}
func bytePos(pos int) int {
return pos % 8
}
我們最終是要找到這地n個ip所在的bit位,然後查詢該bit位是否被置為1,置為1就代表這第n個ip是被分配了。用n/8得到的就是第n個ip所在bit位的位元組陣列的索引,用n%8得到的餘數就是在位元組裡的第幾個bit位,如何取出對應的bit位呢?
首先是b.Bitmap[aIndex]得到對應的位元組,然後將該位元組右移對應的bit位數,這樣第n個ip的bit位就變到了第一個bit位上。整個過程像下面這樣:
與運算是雙1結果才是1,所以如果最後一個bit位是1則最後與運算的結果就是數位1,如果最後一位bit位是0,則最後運算的結果就是0。
2,設定第n個ip被分配。
設定第n個ip被分配,即設定它對應的bit位為1,首先還是要找到這第n個ip在陣列中的位置,然後取出對應位元組byte,通過位運算設定其對應的bit位。
func (b *bitMap) BitSet(pos int) {
aIndex := arrIndex(pos)
bIndex := bytePos(pos)
b.Bitmap[aIndex] = b.Bitmap[aIndex] | (1 << bIndex)
}
零bit的或位運算不會改變原bit位值大小,而1的bit的或位運算會將原來bit位置為1,利用這個特性便可以很容易的寫出來上面的程式碼。
整個過程如圖所示:
3,釋放第n個ip的分配記錄。
釋放第n個ip原理和前面類似,設定第n個ip對應的bit位為0。
func (b *bitMap) BitClean(pos int) {
aIndex := arrIndex(pos)
bIndex := bytePos(pos)
b.Bitmap[aIndex] = b.Bitmap[aIndex] & (^(1 << bIndex))
}
零bit的與位運算會讓原bit位置為0,而1的與位運算不會改變原bit位的值,知道了這個特性再看上述程式碼應該就很容易了,其中^ 運運算元為取反的意思,這樣00000100 就會變為 11111011,這樣與原bit位進行與位運算就能將索引為2的bit位置為0了。
通過上述bitmap的實現可以解決ip分配的儲存問題,但還有一個問題要解決,那就是目前只知道了第幾個ip沒有分配,如何通過這個ip偏移量獲取到具體的ip地址?現在來解決這個問題。
一個網段裡第一個ip的主機號全為0,被稱為網路號,其ip偏移為0,拿192.168.0.0/16網段舉例,第一個ip就是192.168.0.0,第二個ip地址其ip偏移量為1,ip地址是192.168.0.1,以此類推,可以得到下面的公式:
ip地址=ip網路號+ip地址偏移
所以關鍵就是要得到一個ip的ip網路號,用ipv4舉例,在golang裡面ip型別本質上就是一個長度為4位元組陣列
type IP []byte
所以現在要把這個4位元組的陣列轉換為32位元整形,可以像下面這樣轉換
func ipToUint32(ip net.IP) uint32 {
if ip == nil {
return 0
}
ip = ip.To4()
if ip == nil {
return 0
}
return binary.BigEndian.Uint32(ip)
}
func (bigEndian) Uint32(b []byte) uint32 {
_ = b[3] // bounds check hint to compiler; see golang.org/issue/14808
return uint32(b[3]) | uint32(b[2])<<8 | uint32(b[1])<<16 | uint32(b[0])<<24
}
由於ip地址是大端排序,網段號排在位元組陣列前面,所以binary.BigEndian進行轉換。
這樣獲取ip的邏輯就是一個簡單的加法了
firstIP := ipToUint32(ip.Mask(cidr.Mask))
ip = uint32ToIP(firstIP + uint32(pos))
關於ip的分配還有最後一個比較關鍵的點,那就是釋放ip,前面已經提到我們已經可以辦到釋放第n個ip了,其中n就是ip的偏移量,那麼如何通過ip地址去計算ip的偏移量呢?
其實很容易,拿當前ip減去網路號就是ip偏移量了
ip偏移量=當前ip-網路號ip
這裡的具體程式碼我就不再展示了。
知道如何為容器分配ip地址了,還需要在網路名稱空間內 建立新的網路裝置,然後設定上這個ip。為了讓整個邏輯變的簡單,我們建立一個預設的網路,讓容器建立的時候自動在這個預設的網路下,併為其分配ip。
整個過程分為兩個階段,一個是程式啟動的時候,會去檢查主機上是否存在這個預設網路需要的設定,如果有則不再建立相關網路裝置,我將這個階段稱為網路初始化階段,第二個階段是容器建立時候,需要為容器建立相關網路設定的階段。我們挨個來看看。