DOCKER STUDY NOTE

Docker Container 基礎入門篇 2

不當邊緣人的 container

Azole (小賴)

--

上一篇中,我們討論了 container 如何被啟動等基本的指令,以及簡單地介紹了一下 image 等,透過這些討論,我們知道了怎麼 pull image、怎麼啟動、停止一個 container 等。不過,到目前為止我們僅僅停留在單獨一個 container 的運作,今天就讓我們來討論看看 container 如何對外溝通。

既然要溝通,八九不離十就是要講網路了,而我想要分成以下角度來討論:

  • Container 跟 Host 之間(Host 是指用來跑 Docker 的環境,例如一台 Linux 或是 mac 之類的。)
  • Container 之間
  • 不同 Host 的 container 之間

現在先讓我們來討論比較單純的前兩個:

Container 與 Host 之間

按照慣例(哪來的?),我們先動手做做看,所以讓我們先用以下指令來啟動一個 nginx 的 web server:

$ docker container run --name websrv1 -d -p 9090:80 nginx
圖 1: 啟動 nginx container

剛好複習一下,在我們執行了這個指令後,因為本地沒有 nginx 這個 image (Unable to find image ‘nginx:latest’ locally),所以 Docker 會去 Docker Hub 上面 pull 這個 image 下來,也可以看到 nginx 這個 image 有 7 個 layer。

但跟第一篇不太一樣的是,我們加上了 --name -d-p 這三個參數,少了 -it/bin/bash,而且指令執行完後並沒有進入 container 中。

--name 顧名思義就是幫我們把這個 container 取個名字,它會顯示在我們執行 docker container ls時顯示的最後一個欄位 NAMES,如果像第一篇那樣沒有指定,Docker 會幫你隨機取個名字,做好命名,除了能更好的辨識之外,在操作 container 時,也可以用這個名字來取代 CONTAINER_ID,例如:

# 用 name 取代 CONTAINER_ID
$ docker container stop websrv1

再來是 -d (--detach),這個設置讓我們在啟動 container 後可以停留在本機中,因為這個參數的意思是讓這個 container 在 Host(執行 Docker 的這台電腦)的背景執行,所以執行之後,不會像第一篇那樣進入了一個執行 /bin/bash 的容器之中,如果不相信的話,你可以執行 docker container ls 來檢查看看,這個 container 是有被執行起來的。而當然,因為已經背景執行了,我們沒有要跟這個 container 互動,所以不需要 -it這兩個參數了。

圖 2: 列出 container

在背景執行下,如果你想要進入這個 container 也還是可以的,就是透過我們第一篇分享的 docker container exec 就可以了,注意圖 3 中,我們是用 websrv1 這個 container name 取代了 ID。

圖 3: 進入 nginx container 中

最後一個不一樣的參數是 -p 9090:80 (--publish ),這個參數意思是說我們將我們本機的 9090 port 映射到 container 開出來的 80 port,當然,必須要這個 container 有開放 80 port,這個映射才有意義。

圖 4: port 映射

在設置了這個映射之後,如果你是在自己的電腦練習,那可以用瀏覽器打開 http://localhost:9090,這樣就可以看到 nginx 的首頁。如果你跟我一樣是在 AWS EC2 上用 Linux 練習,那可以設定好 security group 後,用 EC2 的 public IP 來測試,或是在 Linux 上直接用 curl 指令測試。

圖 5: 在 Linux 上用 curl 指令測試

當我們對這台主機的 9090 port 發出 HTTP 請求時,實際上是被導到轉到了 container 中。

Container 之間

Docker 官方文件提供一個很不錯的實驗,讓我們也來做做看:

首先我們先用 alpine 這個輕量級的 image 來啟動兩個 container:

$ docker container run -dit --name alpine1 alpine ash

$ docker container run -dit --name alpine2 alpine ash

由於本機中沒有 alpine 這個 image,所以一樣 Docker 會先去 pull image 回來,在開始之前我們來看一下它有多輕量:

圖 6: 列出 image

有沒有很驚人,竟然只有 7.34 MB,相較於 alpinenginx, node 這些 image 真的是胖嘟嘟…

再往下之前,不知道大家有沒有發現到,這裡在用 alpine 啟動 container 時,用的參數是 -dit ,但之前討論過的 -d 是讓 container 在背景執行,那又為什麼需要加上 -it 呢?這不是有需要互動時才需要的嗎?這是因為 alpine 這樣的 image 不像 nginx 一樣,nginx image 在啟動的時候會執行一個能持續運行的程式,例如 nginx image 是 nginx -g daemon off;。而 alpine 則不然,它預設是執行 /bin/sh(其實是 link 到 /bin/busybox,也就是 ash),而我們上述的指令,是讓它啟動 ash ,如果沒有加上 -it ,那這個 shell 會被開啟,然後很快地就又結束了,container 也會隨之關閉,所以我們需要加上 -it 來 sh 或其他 shell 可以被分配一個虛擬終端機 (pseudo terminal),以保持 container 的運行。

來個小問題:那可以不可只加上 -t 或是 -i 就好呢?

好,現在讓我們來用 docker container inspect 這個指令查看我們所啟動的 container:

# 因為啟動的時候有命名,所以這邊可以直接用 container 的名字,沒有命名的話,
# 可以用 Docker 隨機給的,或是用 container id。
$ docker container inspect alpine1

inspect 這個指令下下去,會看到一大堆資訊,有點太多了,我們來選擇一下我們想看的部分:

$ docker container inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' alpine1
172.17.0.2

$ docker container inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' alpine2
172.17.0.3

由上述指令可以發現這兩個 container 的 ip 分別為 172.17.0.2 與 172.17.0.3,看起來很像在同一個網路,而且會跟你 Host 的 ip 不同。

我們進入第一個 container alpine1 去測試看看:

圖 7: 進入 alpine1 測試網路

透過這個實驗,我們發現在 alpine1 這個 container 中是可以透過 IP 直接 ping 到 alpine2 的,此外,也能 ping 到 google,也就是具有存取外部網路的能力這樣說來,我們在啟動 container 時,根本無須額外設定什麼就能讓 container 之間可以互相溝通跟具有對外溝通的能力。

不過,事情有這麼簡單嗎?

Docker 的網路模型

讓我們來看看 Docker 的網路模型,因為目前還是給初學者學習 Docker 用的,所以我們不會講到太難的部分,但可以先有一個概念是 Docker 的網路系統是「可插拔」的,Docker 安裝完後通常會提供幾個預設的,但如果你想要用第三方開發的來替換掉,也是可以的。

Docker 預設提供的網路驅動:bridge, host, overlay, ipvlan, macvlan。以下就讓我們討論看看 bridge 與 host:

bridge

我們可以透過 docker network ls 這個指令來查看目前的網路有哪些:

圖 8: 列出 Docker 網路

這裡可以看到預設有三個網路 bridge, host 與 none,而這三個網路使用的驅動分別是 bridge, host 跟 null。(因為預設的網路 bridge 與 host 會跟它們的 driver 同名,所以討論起來真的是很容易混淆,後續的討論中,要注意在講的是網路還是驅動。)

bridge 網路(驅動是 bridge)是 Docker 預設採用的網路,也就是當我們在啟動 container 時,如果沒有指定使用哪一個網路,預設就是用這一個,所以其實我們到目前為止啟動的 container 都是用 bridge 這種網路驅動。

在我們剛剛啟動的兩個 alpine container 的情況下,我們來查看一下預設的這個 network:

# 不管查看 network 還是查看 container,都一致性地用了 inspect,相當地好記
$ docker network inspect bridge

在顯示結果中找到 Containers 這一個區塊:

圖 9: bridge 網路中的 Containers 區塊

可以看到我們剛剛建立的兩個 alpine containers 被列在這裡了。此外,也可以看到其 subnet 為 “172.17.0.0/16”,而我們用 bridge 這個網路建立出來的 container 的 ip 也是在這個 subnet 範圍中。

讓我們來檢視一下 container 與 Host 的網路:

由圖 10 可以看到 alpine1 有一個 eth0@if19 的網路介面:

圖 10: alpine1 中的網路裝置

由圖 11 可以看到 alpine2 有一個 eth0@if21 的網路介面:

圖 11: alpine2 中的網路裝置

由圖 12 觀察到 Host 中跟 Docker 有關的介面有 docker0、veth68f022d@if18、vethdd292f5@if20。

圖 12: Host 中的網路裝置

在 Host(本機)中的這個 docker0 是在安裝 Docker 之後會被建立的一個 bridge,那bridge 與這些虛擬網卡之間怎麼溝通呢?簡單的來說,大概就是下圖:

圖 13: Docker bridge 網路

所以其實就是藉由 docker0 這個 bridge,讓 container 之間能互相溝通,也讓 container 有了對外部網路進行存取的能力。

除了預設的這個 bridge 網路之外,我們還可以自定義網路,且官方建議我們在 production 環境用我們自定義的 bridge 網路,而不是預設的這一個。

現在讓我們來自定義一個 bridge network 試試看:

$ docker network create --driver bridge my-net
0b0d2ab783c5da3970c92ca3d9c6e538ad68f9efee398a8aa6998844f5fbb6e8

$ docker network ls
NETWORK ID NAME DRIVER SCOPE
f913defc7bff bridge bridge local
2475296a2439 host host local
0b0d2ab783c5 my-net bridge local
b75b01b8790f none null local

透過 docker network ls 我們看到有兩個 network 都是 bridge 這種 driver,其中一個是我們剛剛建立的 my-net。讓我們透過 docker network inspect 來查看一下:

$ docker network inspect my-net
[
{
"Name": "my-net",
"Id": "0b0d2ab783c5da3970c92ca3d9c6e538ad68f9efee398a8aa6998844f5fbb6e8",
"Created": "2023-08-29T02:19:39.985407948Z",
"Scope": "local",
"Driver": "bridge",
"EnableIPv6": false,
"IPAM": {
"Driver": "default",
"Options": {},
"Config": [
{
"Subnet": "172.18.0.0/16",
"Gateway": "172.18.0.1"
}
]
},
"Internal": false,
"Attachable": false,
"Ingress": false,
"ConfigFrom": {
"Network": ""
},
"ConfigOnly": false,
"Containers": {},
"Options": {},
"Labels": {}
}
]

透過 inspect 可以看到這個我們自定義的 my-net 網路其 subnet 是 172.18.0.0/16(你跟我的可能會不同),所以等一下用我們自已的這個網路啟動的 container 的 ip 應該都會是在這個範圍中。

讓我們停止並移除剛剛建立的兩個 alpine container,重新建立,並且透過 --network 來指令使用我們剛剛建立出來的 my-net:

# 停止並移除 alpine1 與 alpine2 containers
$ docker container stop alpine1 alpine2
$ docker container rm alpine1 alpine2

# 啟動兩個 container,network 都指定為 my-net
$ docker container run -dit --network my-net --name alpine1 alpine
b4b9db3bc2a3946b9176d6694c65fffd0d23ac66e37605dba107aa32e531454f
$ docker container run -dit --network my-net --name alpine2 alpine
42449933ecbe4a8fd0f4cce3a1cd237436d79538bd04cde026e2296332a07e5a

# 查看這兩個 container 的 IP
$ docker container inspect -f '{{range.NetworkSettings.Networks}} {{.IPAddress}}{{end}}' alpine1
172.18.0.2
$ docker container inspect -f '{{range.NetworkSettings.Networks}} {{.IPAddress}}{{end}}' alpine2
172.18.0.3

其 IP 果然是落在新的 subnet 中。讓我們進入 alpine1 中去測試看看:

圖 14: 進入 alpine1 中測試網路

一切都跟預設的那個 bridge 網路一樣,不過,有一個地方不太一樣,那就是我們自定義的網路可以透過「名字」來進行溝通,這個在預設的 bridge 網路是做不到的喔。

圖 15: 在 alpine1 中 ping alpine2

現在讓我們來啟動第三個 container,但讓它用預設的 bridge 網路:

$ docker container run -dit --name alpine3 alpine
8bb081fcfb1346f9d8a95c6f81a0e2889d2bde663e0a3ea1d92950bbeffff0c9

$ docker container inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' alpine3
172.17.0.2

可以看到 alpine3 的 IP 是在預設的 bridge 網路的 subnet 中。回到 alpine1 中:

圖 16: 在 alpine1 中 ping 不到 alpine3

果然 ping 不到了!

透過 ip addr查看 Host 網卡,可以看到除了剛剛的 docker0 外,現在有一個新的 bridge br-0b0d2ab783c5 被建立出來了,這個 bridge 就是用來負責 my-net 這個網路的溝通工作的。

圖 17: my-net 的 bridge

最後,我們再來建立一個用 my-net 的 container,但這次我們在建立後,會把這個 container 連接到預設的那一個 bridge 網路去:

$ docker container run -dit --network my-net --name alpine4 alpine
85e7772028766549fe5873765b1b339e6c291012dbd2c86aa14334eb68a06b7d

# alpine4 建立時,網路是設定成 my-net,
# 但可以透過 docker network connect 指令將這個 container 連結至其他網路
$ docker network connect bridge alpine4

讓我們看看 bridge 與 my-net 這兩個網路的情況:

$ docker network inspect bridge

如圖 18,bridge 這個網路底下果然有兩個 containers:

圖 18: bridge 底下的 containers
$ docker network inspect my-net

如圖 19,my-net 底下有三個,而且 alpine4 同時出現在 bridge 與 my-net 裡。

圖 19: my-net 底下的 containers

讓我們看一下 alpine4 的網路:

圖 20: alpine4 裡的網路裝置

alpine4 有兩張網卡: eth0 其 IP 為 172.18.0.4,是屬於 my-net 的,另外一個 eth1 其 IP 為 172.17.0.3,是屬於預設的 bridge 網路的。

到目前為止,整個網路結構會如下圖:

圖 21: 目前的網路結構

進入 alpine4 去測試看看:

$ docker container exec -it alpine4 ash

## 可以用名字來 ping 到 alpine1
/ # ping -c 2 alpine1
PING alpine1 (172.18.0.2): 56 data bytes
64 bytes from 172.18.0.2: seq=0 ttl=64 time=0.202 ms
64 bytes from 172.18.0.2: seq=1 ttl=64 time=0.090 ms

--- alpine1 ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max = 0.090/0.146/0.202 ms

## 不可以用名字來 ping 到 alpine3,預設的 bridge 網路不能用名字溝通
/ # ping -c 2 alpine3
ping: bad address 'alpine3'

## 可以用 ip ping 到 alpine3
/ # ping -c 2 172.17.0.2
PING 172.17.0.2 (172.17.0.2): 56 data bytes
64 bytes from 172.17.0.2: seq=0 ttl=64 time=0.338 ms
64 bytes from 172.17.0.2: seq=1 ttl=64 time=0.085 ms

--- 172.17.0.2 ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max = 0.085/0.211/0.338 ms

host

這種網路驅動是讓 container 直接採用本機(host)網路,也就是不隔離網路了。而在 Docker 安裝完成後,也會自動建立一個 host driver 的 host network,這個剛剛我們已經透過 docker network ls 看過了。

那就讓我們來啟動一個 nginx,但指定採用 host 這個 network,來看看會發生什麼事:

# 先確認 host 上 port 80 的使用情況
$ ss -tnpl | grep :80
# 會沒有任何結果

# 透過參數 --network 來指定使用何種網路驅動
$ docker container run -d --network host nginx
6d1696969c526c7abdee05e8905823d23964e2c4cb78fe8b78d203ea57230a28

# 查看 container
$ docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
6d1696969c52 nginx "/docker-entrypoint.…" 11 seconds ago Up 10 seconds sleepy_golick

# 測試連線
$ curl localhost
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
...略
</style>
</head>
<body>
...略
</body>
</html>

# 確認 host 上 port 80 的使用情況
$ ss -tnpl | grep :80
LISTEN 0 511 0.0.0.0:80 0.0.0.0:*
LISTEN 0 511 [::]:80 [::]:*
圖 22: 實驗過程

由上圖可以看到,我們在啟動 container 時,透過 --network 參數來指定用 host 網路,這是讓這個 container 直接地使用了 host 的網路,在這個情況下,我們也不需要用 -p 來設定 port 映射,因為當 container 中是要用 80 port 時,它就會直接佔用了我們本機的 80 port。我們也透過 curlss 指令驗證了這件事。

備註1:host 網路驅動僅支援 Linux 作業系統,所以如果你的 host 跟我一樣是 mac 或是 windows,那這個實驗會做不出來喔。

備註2: 如果你用來實驗的 linux 作業系統上 80 port 已經被佔用了的話,這個 container 就會執行失敗,所以實驗前可以先檢查一下。

結語

本篇簡單介紹了兩個網路驅動 bridge 與 host,也展示了如何將 container 中的 port 映射出來,以及 container 間是透過 bridge 來互相溝通與存取外部網路的。Docker 網路的部分還可以有更多、更深入的討論,先讓我們把 Docker 基礎的部分都順過一輪,我們再來回頭討論,挫折感比較不會那麼重(?)。

最後要推薦一下 Docker 的官方文件,至少在 bridge 與 host 這邊寫得還蠻不錯的,本篇其實也只是記錄了一下我自己做官方文件中提供的實驗的紀錄而已,真心推薦大家親自去看看。

系列文

指令整理

這邊整理本文討論過的指令,方便大家練習與查找:

# 從 image nginx 啟動名稱為 websrv1 的 container,設置為背景執行,並且將 container 的 80 port 映射到本機的 9090
$ docker container run --name websrv1 -d -p 9090:80 nginx

# 查看名為 alpine1 的 container 的資訊
$ docker container inspect alpine1

# 查看名為 alpine1 的 container 的資訊,但只看 NetworkSettings 中的 Networks 的 IPAddress
$ docker container inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' alpine1

# 查看 docker 目前的網路
$ docker network ls

# 查看名為 bridge 這個網路的資訊
$ docker network inspect bridge

# 啟動名為 alpine1 的 container,並且其網路設置為 my-net
$ docker container run -dit --network my-net --name alpine1 alpine ash

# 將 alpine4 這個 container 連結至 bridge 這個網路
$ docker network connect bridge alpine4

# 移除 my-net 這個 docker 網路
$ docker network rm my-net

參考資料

本書透過 56 個動手做的實驗,帶領讀者探討 Docker container 底層技術,進而學習到 Linux 容器技術的實現。如果你喜歡動手做,或是想要動手做但不知道怎麼開始,歡迎參考本書!

--

--

Azole (小賴)

As a passionate software engineer and dedicated technical instructor, I have a particular fondness for container technologies and AWS.