因爲2016年年中調換工做的緣由,對容器網絡的研究中斷過一段時間。隨着當前項目對Kubernetes應用的深刻,我感受以前對於容器網絡的粗淺理解已經不夠了,容器網絡成了擺在前面的「一道坎」。繼續深刻理解K8s網絡、容器網絡已經勢在必行。而這篇文章就算是一個從新開始,也是對以前淺表理解的一個補充。php
我仍是先從Docker容器網絡入手,雖然Docker與Kubernetes採用了不一樣的網絡模型:K8s是Container Network Interface, CNI模型,而Docker則採用的是Container Network Model, CNM模型。而要了解Docker容器網絡,理解Linux Network Namespace是不可或缺的。在本文中咱們將嘗試理解Linux Network Namespace及相關Linux內核網絡設備的概念,並手工模擬Docker容器網絡模型的部分實現,包括單機容器網絡中的容器與主機連通、容器間連通以及端口映射等。html
Docker經過libnetwork實現了CNM網絡模型。libnetwork設計doc中對CNM模型的簡單詮釋以下:linux
CNM模型有三個組件:nginx
Sandbox(沙盒):每一個沙盒包含一個容器網絡棧(network stack)的配置,配置包括:容器的網口、路由表和DNS設置等。web
Endpoint(端點):經過Endpoint,沙盒能夠被加入到一個Network裏。docker
Network(網絡):一組能相互直接通訊的Endpoints。網絡
光看這些,咱們還很難將之與現實中的Docker容器聯繫起來,畢竟是抽象的模型不對應到實體,總有種漂浮的趕腳。文檔中又給出了CNM模型在Linux上的參考實現技術,好比:沙盒的實現能夠是一個Linux Network Namespace;Endpoint能夠是一對VETH;Network則能夠用Linux Bridge或Vxlan實現。curl
這些實現技術反卻是比較接地氣。以前咱們在使用Docker容器時,瞭解過Docker是用linux network namespace實現的容器網絡隔離的。使用docker時,在物理主機或虛擬機上會有一個docker0的linux bridge,brctl show時能看到 docker0上「插上了」好多veth網絡設備:tcp
# ip link show ... ... 3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default link/ether 02:42:30:11:98:ef brd ff:ff:ff:ff:ff:ff 19: veth4559467@if18: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP mode DEFAULT group default link/ether a6:14:99:52:78:35 brd ff:ff:ff:ff:ff:ff link-netnsid 3 ... ... $ brctl show bridge name bridge id STP enabled interfaces ... ... docker0 8000.0242301198ef no veth4559467
模型與現實終於有點接駁了!下面咱們將進一步深刻對這些術語概念的理解。ide
Linux Bridge,即Linux網橋設備,是Linux提供的一種虛擬網絡設備之一。其工做方式很是相似於物理的網絡交換機設備。Linux Bridge能夠工做在二層,也能夠工做在三層,默認工做在二層。工做在二層時,能夠在同一網絡的不一樣主機間轉發以太網報文;一旦你給一個Linux Bridge分配了IP地址,也就開啓了該Bridge的三層工做模式。在Linux下,你能夠用iproute2工具包或brctl命令對Linux bridge進行管理。
VETH(Virtual Ethernet )是Linux提供的另一種特殊的網絡設備,中文稱爲虛擬網卡接口。它老是成對出現,要建立就建立一個pair。一個Pair中的veth就像一個網絡線纜的兩個端點,數據從一個端點進入,必然從另一個端點流出。每一個veth均可以被賦予IP地址,並參與三層網絡路由過程。
關於Linux Bridge和VETH的具體工做原理,能夠參考IBM developerWorks上的這篇文章《Linux 上的基礎網絡設備詳解》。
Network namespace,網絡名字空間,容許你在Linux建立相互隔離的網絡視圖,每一個網絡名字空間都有獨立的網絡配置,好比:網絡設備、路由表等。新建的網絡名字空間與主機默認網絡名字空間之間是隔離的。咱們平時默認操做的是主機的默認網絡名字空間。
概念老是抽象的,接下來咱們將在一個模擬Docker容器網絡的例子中看到這些Linux網絡概念和網絡設備究竟是起到什麼做用的以及是如何操做的。
爲了進一步瞭解network namespace、bridge和veth在docker容器網絡中的角色和做用,咱們來作一個demo:用network namespace模擬Docker容器網絡,實際上Docker容器網絡在linux上也是基於network namespace實現的,咱們只是將其「自動化」的建立過程作成了「分解動做」,便於你們理解。
咱們在一臺物理機上進行這個Demo實驗。物理機安裝了Ubuntu 16.04.1,內核版本:4.4.0-57-generic。Docker容器版本:
Client: Version: 1.12.1 API version: 1.24 Go version: go1.6.3 Git commit: 23cf638 Built: Thu Aug 18 05:33:38 2016 OS/Arch: linux/amd64 Server: Version: 1.12.1 API version: 1.24 Go version: go1.6.3 Git commit: 23cf638 Built: Thu Aug 18 05:33:38 2016 OS/Arch: linux/amd64
另外,環境中需安裝了iproute2和brctl工具。
咱們來模擬一個擁有兩個容器的容器橋接網絡:
對應的用手工搭建的模擬版本拓撲以下(因爲在同一臺主機,模擬版本採用172.16.0.0/16網段):
默認狀況下,咱們在Host上看到的都是default network namespace的視圖。爲了模擬容器網絡,咱們新建兩個network namespace:
sudo ip netns add Container_ns1 sudo ip netns add Container_ns2 $ sudo ip netns list Container_ns2 Container_ns1
建立的ns也能夠在/var/run/netns路徑下看到:
$ sudo ls /var/run/netns Container_ns1 Container_ns2
咱們探索一下新建立的ns的網絡空間(經過ip netns exec命令能夠在特定ns的內部執行相關程序,這個exec命令是相當重要的,後續還會發揮更大做用):
$ sudo ip netns exec Container_ns1 ip a 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 $ sudo ip netns exec Container_ns2 ip a 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 $ sudo ip netns exec Container_ns2 ip route
能夠看到,新建的ns的網絡設備只有一個loopback口,而且路由表爲空。
咱們在default network namespace下建立MyDocker0 linux bridge:
$ sudo brctl addbr MyDocker0 $ brctl show bridge name bridge id STP enabled interfaces MyDocker0 8000.000000000000 no
給MyDocker0分配ip地址並生效該設備,開啓三層,爲後續充當Gateway作準備:
$ sudo ip addr add 172.16.1.254/16 dev MyDocker0 $ sudo ip link set dev MyDocker0 up
啓用後,咱們發現default network namespace的路由配置中增長了一條路由:
$ route -n 內核 IP 路由表 目標 網關 子網掩碼 標誌 躍點 引用 使用 接口 0.0.0.0 10.11.36.1 0.0.0.0 UG 100 0 0 eno1 ... ... 172.16.0.0 0.0.0.0 255.255.0.0 U 0 0 0 MyDocker0 ... ...
到目前爲止,default ns與Container_ns一、Container_ns2之間尚未任何瓜葛。接下來就是見證奇蹟的時刻了。咱們經過veth pair創建起多個ns之間的聯繫:
建立鏈接default ns與Container_ns1之間的veth pair – veth1和veth1p:
$sudo ip link add veth1 type veth peer name veth1p $sudo ip -d link show ... ... 21: veth1p@veth1: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000 link/ether 66:6d:e7:75:3f:43 brd ff:ff:ff:ff:ff:ff promiscuity 0 veth addrgenmode eui64 22: veth1@veth1p: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000 link/ether 56:cd:bb:f2:10:3f brd ff:ff:ff:ff:ff:ff promiscuity 0 veth addrgenmode eui64 ... ...
將veth1「插到」MyDocker0這個bridge上:
$ sudo brctl addif MyDocker0 veth1 $ sudo ip link set veth1 up $ brctl show bridge name bridge id STP enabled interfaces MyDocker0 8000.56cdbbf2103f no veth1
將veth1p「放入」Container_ns1中:
$ sudo ip link set veth1p netns Container_ns1 $ sudo ip netns exec Container_ns1 ip a 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 21: veth1p@if22: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000 link/ether 66:6d:e7:75:3f:43 brd ff:ff:ff:ff:ff:ff link-netnsid 0
這時,你在default ns中將看不到veth1p這個虛擬網絡設備了。按照上面拓撲,位於Container_ns1中的veth應該改名爲eth0:
$ sudo ip netns exec Container_ns1 ip link set veth1p name eth0 $ sudo ip netns exec Container_ns1 ip a 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 21: eth0@if22: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000 link/ether 66:6d:e7:75:3f:43 brd ff:ff:ff:ff:ff:ff link-netnsid 0
將Container_ns1中的eth0生效並配置IP地址:
$ sudo ip netns exec Container_ns1 ip link set eth0 up $ sudo ip netns exec Container_ns1 ip addr add 172.16.1.1/16 dev eth0
賦予IP地址後,自動生成一條直連路由:
sudo ip netns exec Container_ns1 ip route 172.16.0.0/16 dev eth0 proto kernel scope link src 172.16.1.1
如今在Container_ns1下能夠ping通MyDocker0了,但因爲沒有其餘路由,包括默認路由,ping其餘地址仍是不通的(好比:docker0的地址:172.17.0.1):
$ sudo ip netns exec Container_ns1 ping -c 3 172.16.1.254 PING 172.16.1.254 (172.16.1.254) 56(84) bytes of data. 64 bytes from 172.16.1.254: icmp_seq=1 ttl=64 time=0.074 ms 64 bytes from 172.16.1.254: icmp_seq=2 ttl=64 time=0.064 ms 64 bytes from 172.16.1.254: icmp_seq=3 ttl=64 time=0.068 ms --- 172.16.1.254 ping statistics --- 3 packets transmitted, 3 received, 0% packet loss, time 1998ms rtt min/avg/max/mdev = 0.064/0.068/0.074/0.010 ms $ sudo ip netns exec Container_ns1 ping -c 3 172.17.0.1 connect: Network is unreachable
咱們再給Container_ns1添加一條默認路由,讓其能ping通物理主機上的其餘網絡設備或其餘ns空間中的網絡設備地址:
$ sudo ip netns exec Container_ns1 ip route add default via 172.16.1.1 $ sudo ip netns exec Container_ns1 ip route default via 172.16.1.1 dev eth0 172.16.0.0/16 dev eth0 proto kernel scope link src 172.16.1.1 $ sudo ip netns exec Container_ns1 ping -c 3 172.17.0.1 PING 172.17.0.1 (172.17.0.1) 56(84) bytes of data. 64 bytes from 172.17.0.1: icmp_seq=1 ttl=64 time=0.068 ms 64 bytes from 172.17.0.1: icmp_seq=2 ttl=64 time=0.076 ms 64 bytes from 172.17.0.1: icmp_seq=3 ttl=64 time=0.069 ms --- 172.17.0.1 ping statistics --- 3 packets transmitted, 3 received, 0% packet loss, time 1999ms rtt min/avg/max/mdev = 0.068/0.071/0.076/0.003 ms
不過這時候,若是想在Container_ns1中ping通物理主機以外的地址,好比:google.com,那仍是不通的。爲何呢?由於ping的icmp的包的源地址沒有作snat(docker是經過設置iptables規則實現的),致使出去的以172.16.1.1爲源地址的包「有去無回」了^0^。
接下來,咱們按照上述步驟,再建立鏈接default ns與Container_ns2之間的veth pair – veth2和veth2p,因爲步驟相同,這裏就不列出那麼多信息了,只列出關鍵操做:
$ sudo ip link add veth2 type veth peer name veth2p $ sudo brctl addif MyDocker0 veth2 $ sudo ip link set veth2 up $ sudo ip link set veth2p netns Container_ns2 $ sudo ip netns exec Container_ns2 ip link set veth2p name eth0 $ sudo ip netns exec Container_ns2 ip link set eth0 up $ sudo ip netns exec Container_ns2 ip addr add 172.16.1.2/16 dev eth0 $ sudo ip netns exec Container_ns2 ip route add default via 172.16.1.2
至此,模擬建立告一段落!兩個ns之間以及它們與default ns之間連通了!
$ sudo ip netns exec Container_ns2 ping -c 3 172.16.1.1 PING 172.16.1.1 (172.16.1.1) 56(84) bytes of data. 64 bytes from 172.16.1.1: icmp_seq=1 ttl=64 time=0.101 ms 64 bytes from 172.16.1.1: icmp_seq=2 ttl=64 time=0.083 ms 64 bytes from 172.16.1.1: icmp_seq=3 ttl=64 time=0.087 ms --- 172.16.1.1 ping statistics --- 3 packets transmitted, 3 received, 0% packet loss, time 1998ms rtt min/avg/max/mdev = 0.083/0.090/0.101/0.010 ms $ sudo ip netns exec Container_ns1 ping -c 3 172.16.1.2 PING 172.16.1.2 (172.16.1.2) 56(84) bytes of data. 64 bytes from 172.16.1.2: icmp_seq=1 ttl=64 time=0.053 ms 64 bytes from 172.16.1.2: icmp_seq=2 ttl=64 time=0.092 ms 64 bytes from 172.16.1.2: icmp_seq=3 ttl=64 time=0.089 ms --- 172.16.1.2 ping statistics --- 3 packets transmitted, 3 received, 0% packet loss, time 1999ms rtt min/avg/max/mdev = 0.053/0.078/0.092/0.017 ms
固然此時兩個ns之間連通,主要仍是經過直連網絡,實質上是MyDocker0在二層起到的做用。以在Container_ns1中ping Container_ns2的eth0地址爲例:
Container_ns1此時的路由表:
$ sudo ip netns exec Container_ns1 ip route default via 172.16.1.1 dev eth0 172.16.0.0/16 dev eth0 proto kernel scope link src 172.16.1.1
ping 172.16.1.2執行後,根據路由表,將首先匹配到直連網絡(第二條),即無需gateway轉發即可以直接將數據包送達。arp查詢後(要麼從arp cache中找到,要麼在MyDocker0這個二層交換機中泛洪查詢)得到172.16.1.2的mac地址。ip包的目的ip填寫172.16.1.2,二層數據幀封包將目的mac填寫爲剛剛查到的mac地址,經過數據鏈路層發送出去。這一過程就是一個標準的二層交換機的數據報文交換過程。
而若是是在Container_ns1中ping 172.17.0.1(同主機上的docker0 bridge的地址),那麼MyDocker0將在三層起到做用。即ping 172.17.0.1執行後,根據路由表,沒有匹配到直連網絡,只能經過default路由將數據包經過發到eth0發給172.16.1.1。雖然都是發給172.16.1.1,但此次更相似於「數據被直接發到 Bridge 上,而不是Bridge從一個端口接收(這塊兒與我以前的文章中的理解稍有差別)」。二層的目的mac地址填寫的是gateway 172.16.1.1本身的mac地址(Bridge的mac地址),此時的MyDocker0更像是一塊普通網卡的角色。MyDocker0收到數據包後,發現並不是是發給本身的ip包,經過主機路由表找到直連鏈路路由,MyDocker0將數據包Forward到docker0上(封裝的二層數據包的目的MAC地址爲docker0的mac地址)。此時的docker0也是一種「網卡」的角色。
如今,你應該大體瞭解docker engine在建立單機容器網絡時都在背後作了哪些手腳了吧(固然,這裏只是簡單模擬,docker實際作的要比這複雜許多)。
端口映射讓位於容器中的service能夠將服務範圍擴展到主機以外,好比:一個運行於container中的nginx能夠經過宿主機的9091端口對外提供http server服務:
$ sudo docker run -d -p 9091:80 nginx:latest 8eef60e3d7b48140c20b11424ee8931be25bc47b5233aa42550efabd5730ac2f $ curl 10.11.36.15:9091 <!DOCTYPE html> <html> <head> <title>Welcome to nginx!</title> <style> body { width: 35em; margin: 0 auto; font-family: Tahoma, Verdana, Arial, sans-serif; } </style> </head> <body> <h1>Welcome to nginx!</h1> <p>If you see this page, the nginx web server is successfully installed and working. Further configuration is required.</p> <p>For online documentation and support please refer to <a href="http://nginx.org/">nginx.org</a>.<br/> Commercial support is available at <a href="http://nginx.com/">nginx.com</a>.</p> <p><em>Thank you for using nginx.</em></p> </body> </html>
容器的端口映射實際是經過docker engine的docker proxy功能實現的。默認狀況下,docker engine(截至docker 1.12.1版本)採用userland proxy(–userland-proxy=true)爲每一個expose端口的容器啓動一個proxy實例來作端口流量轉發:
$ ps -ef|grep docker-proxy root 26246 6228 0 16:18 ? 00:00:00 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 9091 -container-ip 172.17.0.2 -container-port 80
docker-proxy實際上就是在default ns和container ns之間轉發流量而已。咱們徹底能夠模擬這一過程。
咱們建立一個fileserver demo:
//testfileserver.go package main import "net/http" func main() { http.ListenAndServe(":8080", http.FileServer(http.Dir("."))) }
咱們在Container_ns1下啓動這個Fileserver service:
$ sudo ip netns exec Container_ns1 ./testfileserver $ sudo ip netns exec Container_ns1 lsof -i tcp:8080 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME testfiles 3605 root 3u IPv4 297022 0t0 TCP *:http-alt (LISTEN)
能夠看到在Container_ns1下面,8080已經被testfileserver監聽,不過在default ns下,8080端口依舊是avaiable的。
接下來,咱們在default ns下建立一個簡易的proxy:
//proxy.go ... ... var ( host string port string container string containerport string ) func main() { flag.StringVar(&host, "host", "0.0.0.0", "host addr") flag.StringVar(&port, "port", "", "host port") flag.StringVar(&container, "container", "", "container addr") flag.StringVar(&containerport, "containerport", "8080", "container port") flag.Parse() fmt.Printf("%s\n%s\n%s\n%s", host, port, container, containerport) ln, err := net.Listen("tcp", host+":"+port) if err != nil { // handle error log.Println("listen error:", err) return } log.Println("listen ok") for { conn, err := ln.Accept() if err != nil { // handle error log.Println("accept error:", err) continue } log.Println("accept conn", conn) go handleConnection(conn) } } func handleConnection(conn net.Conn) { cli, err := net.Dial("tcp", container+":"+containerport) if err != nil { log.Println("dial error:", err) return } log.Println("dial ", container+":"+containerport, " ok") go io.Copy(conn, cli) _, err = io.Copy(cli, conn) fmt.Println("communication over: error:", err) }
在default ns下執行:
./proxy -host 0.0.0.0 -port 9090 -container 172.16.1.1 -containerport 8080 0.0.0.0 9090 172.16.1.1 80802017/01/11 17:26:10 listen ok
咱們http get一下宿主機的9090端口:
$curl 10.11.36.15:9090 <pre> <a href="proxy">proxy</a> <a href="proxy.go">proxy.go</a> <a href="testfileserver">testfileserver</a> <a href="testfileserver.go">testfileserver.go</a> </pre>
成功得到file list!
proxy的輸出日誌:
2017/01/11 17:26:16 accept conn &{{0xc4200560e0}} 2017/01/11 17:26:16 dial 172.16.1.1:8080 ok communication over: error:<nil>
因爲每一個作端口映射的Container都要啓動至少一個docker proxy與之配合,一旦運行的container增多,那麼docker proxy對資源的消耗將是大大的。所以docker engine在docker 1.6以後(好像是這個版本)提供了基於iptables的端口映射機制,無需再啓動docker proxy process了。咱們只需修改一下docker engine的啓動配置便可:
在使用systemd init system的系統中若是爲docker engine配置–userland-proxy=false,能夠參考《當Docker遇到systemd》這篇文章。
因爲這個與network namespace關係不大,後續單獨理解^0^。