Docker 築夢師系列(一):實現容器互聯

tuture發表於2020-04-29

我們研發開源了一款基於 Git 進行技術實戰教程寫作的工具,我們圖雀社群的所有教程都是用這款工具寫作而成,歡迎 Star

如果你想快速瞭解如何使用,歡迎閱讀我們的 教程文件哦

在實際應用中,不同的服務之間是需要通訊的,例如後端 API 和資料庫;幸運的是,Docker 為我們提供了網路(Network)機制,能夠輕鬆實現容器互聯。這篇文章將帶你輕鬆上手 Docker 網路,學會使用預設網路和自定義網路,成為一名能夠連線多個“夢境”的築夢師!

上一篇教程中,我們帶你瞭解了映象和容器這兩大關鍵的概念,熟悉了常用的 docker 命令,併成功地容器化了第一個應用。但是,那只是我們“築夢之旅”的序章。接下來,我們將實現後端 API 伺服器 + 資料庫的容器化。

我們為你準備好了應用程式程式碼,請執行以下命令:

# 如果你看了上一篇教程,倉庫已經克隆下來了
cd docker-dream
git fetch origin network-start
git checkout network-start

# 如果你打算直接從這篇教程開始
git clone -b network-start https://github.com/tuture-dev/docker-dream.git
cd docker-dream

和之前容器化前端靜態頁面伺服器相比,多了一個難點:伺服器和資料庫分別是兩個獨立的容器,但是伺服器需要連線和訪問資料庫,怎麼實現跨容器之間的通訊?

在《盜夢空間》中,不同的夢境之間是無法連線的,然而幸運的是在 Docker 中是可以的——藉助 Docker Network。

提示

在早期,Docker 容器可以通過 docker run 命令的 --link 選項來連線容器,但是 Docker 官方宣佈這種方式已經過時,並有可能被移除
參考文件)。而本文將講解 Docker 官方推薦的方式連線容器:自定義網路(User-defined Networks)。

Network 型別

Network,顧名思義就是“網路”,能夠讓不同的容器之間相互通訊。首先有必要要列舉一下 Docker Network 的五種驅動模式(driver):

  • bridge:預設的驅動模式,即“網橋”,通常用於單機(更準確地說,是單個 Docker 守護程式)
  • overlay:Overlay 網路能夠連線多個 Docker 守護程式,通常用於叢集,後續講 Docker Swarm 的文章會重點講解
  • host:直接使用主機(也就是執行 Docker 的機器)網路,僅適用於 Docker 17.06+ 的叢集服務
  • macvlan:Macvlan 網路通過為每個容器分配一個 MAC 地址,使其能夠被顯示為一臺物理裝置,適用於希望直連到物理網路的應用程式(例如嵌入式系統、物聯網等等)
  • none:禁用此容器的所有網路

這篇文章將圍繞預設的 Bridge 網路驅動展開。沒錯,就是連線不同夢境的那座“橋”。

小試牛刀

我們還是通過一些小實驗來理解和感受 Bridge Network。與上一節不同的是,我們將使用 Alpine Linux 映象作為實驗原材料,因為:

  • 非常輕量小巧(整個映象僅 5MB 左右)
  • 功能豐富,比“瑞士軍刀” Busybox 還要完善

網橋網路可分為兩類:

  1. 預設網路(Docker 執行時自帶,不推薦用於生產環境)
  2. 自定義網路(推薦使用)

讓我們分別實踐一下吧。

預設網路

這個小實驗的內容如下圖所示:

我們會在預設的 bridge 網路上連線兩個容器 alpine1alpine2。 執行以下命令,檢視當前已有的網路:

docker network ls

應該會看到以下輸出(注意你機器上的 ID 很有可能不一樣):

NETWORK ID          NAME                DRIVER              SCOPE
cb33efa4d163        bridge              bridge              local
010deedec029        host                host                local
772a7a450223        none                null                local

這三個預設網路分別對應上面的 bridgehostnone 網路型別。接下來我們將建立兩個容器,分別名為 alpine1alpine2,命令如下:

docker run -dit --name alpine1 alpine
docker run -dit --name alpine2 alpine

-dit-d(後臺模式)、-i(互動模式)和 -t(虛擬終端)三個選項的合併。通過這個組合,我們可以讓容器保持在後臺執行而不會退出(沒錯,相當於是在“空轉”)。

docker ps 命令確定以上兩個容器均在後臺執行:

CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
501559d2fab7        alpine              "/bin/sh"           2 seconds ago       Up 1 second                             alpine2
18bed3178732        alpine              "/bin/sh"           3 seconds ago       Up 2 seconds                            alpine1

通過以下命令檢視預設的 bridge 網路的詳情:

docker network inspect bridge

應該會輸出 JSON 格式的網路詳細資料:

[
  {
    "Name": "bridge",
    "Id": "cb33efa4d163adaa61d6b80c9425979650d27a0974e6d6b5cd89fd743d64a44c",
    "Created": "2020-01-08T07:29:11.102566065Z",
    "Scope": "local",
    "Driver": "bridge",
    "EnableIPv6": false,
    "IPAM": {
      "Driver": "default",
      "Options": null,
      "Config": [
        {
          "Subnet": "172.17.0.0/16",
          "Gateway": "172.17.0.1"
        }
      ]
    },
    "Internal": false,
    "Attachable": false,
    "Ingress": false,
    "ConfigFrom": {
      "Network": ""
    },
    "ConfigOnly": false,
    "Containers": {
      "18bed3178732b5c7a37d7ad820c111fac72a6b0f47844401d60a18690bd37ee5": {
        "Name": "alpine1",
        "EndpointID": "9c7d8ee9cbd017c6bbdfc023397b23a4ce112e4957a0cfa445fd7f19105cc5a6",
        "MacAddress": "02:42:ac:11:00:02",
        "IPv4Address": "172.17.0.2/16",
        "IPv6Address": ""
      },
      "501559d2fab736812c0cf181ed6a0b2ee43ce8116df9efbb747c8443bc665b03": {
        "Name": "alpine2",
        "EndpointID": "da192d61e4b2df039023446830bf477cc5a9a026d32938cb4a350a82fea5b163",
        "MacAddress": "02:42:ac:11:00:03",
        "IPv4Address": "172.17.0.3/16",
        "IPv6Address": ""
      }
    },
    "Options": {
      "com.docker.network.bridge.default_bridge": "true",
      "com.docker.network.bridge.enable_icc": "true",
      "com.docker.network.bridge.enable_ip_masquerade": "true",
      "com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
      "com.docker.network.bridge.name": "docker0",
      "com.docker.network.driver.mtu": "1500"
    },
    "Labels": {}
  }
]

我們重點要關注的是兩個欄位:

  • IPAM:IP 地址管理資訊(IP Address Management),可以看到閘道器地址為 172.17.0.1(由於篇幅有限,想要了解閘道器的同學可自行查閱計算機網路以及 TCP/IP 協議方面的資料)
  • Containers:包括此網路上連線的所有容器,可以看到我們剛剛建立的 alpine1alpine2,它們的 IP 地址分別為 172.17.0.2172.17.0.3(後面的 /16 是子網掩碼,暫時不用考慮)

提示

如果你熟悉 Go 模板語法,可以通過 -fformat)引數過濾掉不需要的資訊。例如我們只想檢視 bridge 的閘道器地址:

$ docker network inspect --format '{{json .IPAM.Config }}' bridge
[{"Subnet":"172.17.0.0/16","Gateway":"172.17.0.1"}]

讓我們進入 alpine1 容器中:

docker attach alpine1

注意

attach 命令只能進入設定了互動式執行的容器(也就是在啟動時加了 -i 引數)。

如果你看到前面的命令提示符變成 / #,說明我們已經身處容器之中了。我們通過 ping 命令測試一下網路連線情況,首先 ping 一波圖雀社群的主站 tuture.co(-c 引數代表傳送資料包的數量,這裡我們設為 5):

/ # ping -c 5 tuture.co
PING tuture.co (150.109.19.98): 56 data bytes
64 bytes from 150.109.19.98: seq=2 ttl=37 time=65.294 ms
64 bytes from 150.109.19.98: seq=3 ttl=37 time=65.425 ms
64 bytes from 150.109.19.98: seq=4 ttl=37 time=65.332 ms

--- tuture.co ping statistics ---
5 packets transmitted, 3 packets received, 40% packet loss
round-trip min/avg/max = 65.294/65.350/65.425 ms

OK,雖然丟了幾個包,但是可以連上(取決於你的網路環境,全丟包也是正常的)。由此可見,容器內可以訪問主機所連線的全部網路(包括 localhost)。

接下來測試能否連線到 alpine2,在剛才 docker network inspect 命令的輸出中找到 alpine2 的 IP 為 172.17.0.3,嘗試能否 ping 通:

/ # ping -c 5 172.17.0.3
PING 172.17.0.3 (172.17.0.3): 56 data bytes
64 bytes from 172.17.0.3: seq=0 ttl=64 time=0.147 ms
64 bytes from 172.17.0.3: seq=1 ttl=64 time=0.103 ms
64 bytes from 172.17.0.3: seq=2 ttl=64 time=0.102 ms
64 bytes from 172.17.0.3: seq=3 ttl=64 time=0.125 ms
64 bytes from 172.17.0.3: seq=4 ttl=64 time=0.125 ms

--- 172.17.0.3 ping statistics ---
5 packets transmitted, 5 packets received, 0% packet loss
round-trip min/avg/max = 0.102/0.120/0.147 ms

完美!我們能夠從 alpine1 中訪問 alpine2 容器。作為練習,你可以自己嘗試一下能否從 alpine2 容器中 ping 通 alpine1 哦。

注意

如果你不想讓 alpine1 停下來,記得通過 Ctrl + P + Ctrl + Q(按住 Ctrl,然後依次按 P 和 Q 鍵)“脫離”(detach,也就是剛才 attach 命令的反義詞)容器,而不是按 Ctrl + D 哦。

自定義網路

如果你跟著上面一路試下來,會發現預設的 bridge 網路存在一個很大的問題:只能通過 IP 地址相互訪問。這毫無疑問是非常麻煩的,當容器數量很多的時候難以管理,而且每次的 IP 都可能發生變化。

而自定義網路則很好地解決了這一問題。在同一個自定義網路中,每個容器能夠通過彼此的名稱相互通訊,因為 Docker 為我們搞定了 DNS 解析工作,這種機制被稱為服務發現(Service Discovery)。具體而言,我們將建立一個自定義網路 my-net,並建立 alpine3alpine4 兩個容器,連上 my-net,如下圖所示。

讓我們開始動手吧。首先建立自定義網路 my-net

docker network create my-net
# 由於預設網路驅動為 bridge,因此相當於以下命令
# docker network create --driver bridge my-net

檢視當前所有的網路:

docker network ls

可以看到剛剛建立的 my-net

NETWORK ID          NAME                DRIVER              SCOPE
cb33efa4d163        bridge              bridge              local
010deedec029        host                host                local
feb13b480be6        my-net              bridge              local
772a7a450223        none                null                local

建立兩個新的容器 alpine3alpine4

docker run -dit --name alpine3 --network my-net alpine
docker run -dit --name alpine4 --network my-net alpine

可以看到,我們通過 --network 引數指定容器想要連線的網路(也就是剛才建立的 my-net)。

提示

如果在一開始建立並執行容器時忘記指定網路,那麼下次再想指定網路時,可以通過 docker network connect 命令再次連上(第一個引數是網路名稱 my-net,第二個是需要連線的容器 alpine3):

docker network connect my-net alpine3

進入到 alpine3 中,測試能否 ping 通 alpine4

$ docker attach alpine3
/ # ping -c 5 alpine4
PING alpine4 (172.19.0.3): 56 data bytes
64 bytes from 172.19.0.3: seq=0 ttl=64 time=0.247 ms
64 bytes from 172.19.0.3: seq=1 ttl=64 time=0.176 ms
64 bytes from 172.19.0.3: seq=2 ttl=64 time=0.180 ms
64 bytes from 172.19.0.3: seq=3 ttl=64 time=0.176 ms
64 bytes from 172.19.0.3: seq=4 ttl=64 time=0.161 ms

--- alpine4 ping statistics ---
5 packets transmitted, 5 packets received, 0% packet loss
round-trip min/avg/max = 0.161/0.188/0.247 ms

可以看到 alpine4 被自動解析成了 172.19.0.3。我們可以通過 docker network inspect 來驗證一下:

$ docker network inspect --format '{{range .Containers}}{{.Name}}: {{.IPv4Address}} {{end}}' my-net
alpine4: 172.19.0.3/16 alpine3: 172.19.0.2/16

可以看到 alpine4 的 IP 的確為 172.19.0.3,解析是正確的!

一些收尾工作

實驗做完了,讓我們把之前所有的容器全部銷燬:

docker rm -f alpine1 alpine2 alpine3 alpine4

把建立的 my-net 也刪除:

docker network rm my-net

動手實踐

容器化伺服器

我們首先對後端伺服器也進行容器化。建立 server/Dockerfile,程式碼如下:

FROM node:10

# 指定工作目錄為 /usr/src/app,接下來的命令全部在這個目錄下操作
WORKDIR /usr/src/app

# 將 package.json 拷貝到工作目錄
COPY package.json .

# 安裝 npm 依賴
RUN npm config set registry https://registry.npm.taobao.org && npm install

# 拷貝原始碼
COPY . .

# 設定環境變數(伺服器的主機 IP 和埠)
ENV MONGO_URI=mongodb://dream-db:27017/todos
ENV HOST=0.0.0.0
ENV PORT=4000

# 開放 4000EXPOSE 4000

# 設定映象執行命令
CMD [ "node", "index.js" ]

可以看到這個 Dockerfile 比上一篇教程中的要複雜不少。每一行的含義已經註釋在程式碼中了,我們來看一看多了哪些新東西:

  • RUN 指令用於在容器中執行任何命令,這裡我們通過 npm install 安裝所有專案依賴(當然之前配置了一下 npm 映象,可以安裝得快一點)
  • ENV 指令用於向容器中注入環境變數,這裡我們設定了 資料庫的連線字串 MONGO_URI注意這裡給資料庫取名為 dream-db,後面就會建立這個容器),還配置了伺服器的 HOSTPORT
  • EXPOSE 指令用於開放埠 4000。之前在用 Nginx 容器化前端專案時沒有指定,是因為 Nginx 基礎映象已經開放了 8080 埠,無需我們設定;而這裡用的 Node 基礎映象則沒有開放,需要我們自己去配置
  • CMD 指令用於指定此容器的啟動命令(也就是 docker ps 檢視時的 COMMAND 一列),對於伺服器來說當然就是保持執行狀態。在後面“回憶與昇華”部分會詳細展開。

注意

初次嘗試容器的朋友很容易犯的一個錯誤就是忘記將伺服器的 hostlocalhost127.0.0.1)改成 0.0.0.0,導致伺服器無法在容器之外被訪問到(我自己學習的時候也浪費了很多時間)。

與之前前端容器化類似,建立 server/.dockerignore 檔案,忽略伺服器日誌 access.lognode_modules,程式碼如下:

node_modules
access.log

在專案根目錄下執行以下命令,構建伺服器映象,指定名稱為 dream-server

docker build -t dream-server server

連線伺服器與資料庫

根據之前的知識,我們為現在的“夢想清單”應用建立一個自定義網路 dream-net

docker network create dream-net

我們使用官方的 mongo 映象建立並執行 MongoDB 容器,命令如下:

docker run --name dream-db --network dream-net -d mongo

我們指定容器名稱為 dream-db(還記得這個名字嗎),所連線的網路為 dream-net,並且在後臺模式下執行(-d)。

提示

你也許會問,為什麼這裡開啟容器的時候沒有指定埠對映呢?因為在同一自定義網路中的所有容器會互相暴露所有埠,不同的應用之間可以更輕鬆地相互通訊;同時,除非通過 -p--publish)手動開放埠,網路之外無法訪問網路中容器的其他埠,實現了良好的隔離性。網路之內的互操作性網路內外的隔離性也是 Docker Network 的一大優勢所在。

危險!

這裡我們在開啟 MongoDB 資料庫容器時沒有設定任何鑑權措施(例如設定使用者名稱和密碼),所有連線資料庫的請求都可以任意修改資料,在生產環境是極其危險的。後續文章中我們會講解如何在容器中管理機密資訊(例如密碼)。

然後執行伺服器容器:

docker run -p 4000:4000 --name dream-api --network dream-net -d dream-server

檢視伺服器容器的日誌輸出,確定 MongoDB 連線成功:

$ docker logs dream-api                                                       
Server is running on http://0.0.0.0:4000
Mongoose connected.

接著你可以通過 Postman 或者 curl 來測試一波伺服器 API (localhost:4000 ),這裡為了節約篇幅就省略了。當然你也可以直接跳過,因為馬上我們就可以通過前端來運算元據了!

容器化前端頁面

正如上一篇文章所實現的那樣,在專案根目錄下,通過以下命令進行容器化:

docker build -t dream-client client

然後執行容器:

docker run -p 8080:80 --name client -d dream-client

可以通過 docker ps 命令檢驗三個容器是否全部正確開啟:

最後,訪問 localhost:8080

可以看到,我們在最後重新整理了幾次頁面,資料記錄也都還在,說明我們帶有資料庫的全棧應用跑起來了!讓我們通過互動式執行的方式進入到資料庫容器 dream-db 中,通過 Mongo Shell 簡單地查詢一波剛才的資料:

$ docker exec -it dream-db mongo
MongoDB shell version v3.4.10
connecting to: mongodb://127.0.0.1:27017
MongoDB server version: 3.4.10
Welcome to the MongoDB shell.
For interactive help, type "help".
> use todos
switched to db todos
> db.getCollection('todos').find()
{ "_id" : ObjectId("5e171fda820251a751aae6f5"), "completed" : true, "text" : "瞭解 Docker Network", "timestamp" : ISODate("2020-01-09T12:43:06.865Z"), "__v" : 0 }
{ "_id" : ObjectId("5e171fe08202517c11aae6f6"), "completed" : true, "text" : "搭建預設網路", "timestamp" : ISODate("2020-01-09T12:43:12.205Z"), "__v" : 0 }
{ "_id" : ObjectId("5e171fe3820251d1a4aae6f7"), "completed" : false, "text" : "搭建自定義網路", "timestamp" : ISODate("2020-01-09T12:43:15.962Z"), "__v" : 0 }

完美!然後按 Ctrl + D 就可以退出來了。

回憶與昇華

理解命令:夢境的主旋律

每個容器自從被建立之時,就註定要執行一道命令(Command),就好像在築夢時要安排一個主旋律、一個基調那樣。之前在執行 docker ps 的時候,你應該也注意到了 COMMAND 一欄,正是每個容器所執行的命令。那麼我們怎麼指定容器的命令呢?又能不能執行新的命令呢?

首先,我們主要通過兩種方式指定容器的命令:

通過 Dockerfile 提供預設命令

在構建映象時,我們可以在 Dockerfile 的最後通過 CMD 指令指定命令,例如在構建後端伺服器時的 [ "node", "server.js" ] 命令。在指定命令時,我們有三種寫法:

  • CMD ["executable","param1","param2"](exec 格式,推薦
  • CMD ["param1","param2"](需要結合 Entrypoint 使用)
  • CMD command param1 param2(shell 格式)

其中 executable 代表可執行檔案的路徑,例如 node/bin/shparam1param2 代表引數。我們在後續討論 Dockerfile 的高階使用時會討論 Entrypoint 的使用,這篇文章不會涉及

注意

在使用第一種 exec 格式時,必須使用雙引號,因為整個命令將以 JSON 格式被解析。

提示

如果要執行變數替換等 Shell 操作,例如 echo $HOME,直接寫成 ["echo", "$HOME"] 是無效的,需要改寫成 ["sh", "-c", "echo $HOME"]

建立或執行容器時指定命令

在建立或執行容器時,通過新增命令引數可以覆蓋構建映象時指定的命令,例如:

docker run nginx echo hello

通過指定 echo hello 命令引數,就會讓這個容器輸出一個 hello 然後退出,而不會執行預設的 nginx -g 'daemon off;'

當然,正如第一篇文章所實踐的,我們還可以指定命令為 bash(或 shmongonode 等其他互動式程式),然後結合 -it 選項,就可以進入容器中互動式執行了。

通過 exec 執行新的命令

通過 docker exec,我們可以讓已經執行中的容器執行新的命令。例如,對於我們之前的 dream-db 容器,我們通過 mongodump 命令來建立資料庫備份:

docker exec dream-db mongodump

然後可以進一步通過 docker exec -it 來進入 dream-db 中進行互動式執行,檢查剛才匯出的 dump 目錄:

$ docker exec -it dream-db bash
root@c51d9355d8da:/# ls dump/
admin  todos

同樣地,按 Ctrl + D 退出就可以了。

提示

你也許會好奇,為什麼在 docker run 互動式執行的時候按 Ctrl + D 就容器就直接停止了,而在 docker exec 的情況下退出卻不會導致容器停止呢?因為 docker exec -it 相當於在現有的容器上執行了一個新的終端程式,而不會影響之前的主命令程式。只要主程式不結束,容器就不會停止。

小訣竅:如何輕鬆記住幾十個 Docker 命令?

在剛才的實戰中,我們也接觸了很多新的 Docker 命令,怎麼記住那麼多命令呢?其實 docker 大部分命令都符合以下格式:

docker <物件型別> <操作名稱> [其他選項和引數]
  • 物件型別:到目前,我們接觸的 Docker 物件型別包括容器
    container映象 image網路 network
  • 操作名稱:操作可以分為兩大類:1)適用於所有物件的操作,例如 lsrminspectprune 等等;2)物件專屬操作,例如容器專有的 run 操作,映象專有的 build 操作,以及網路專有的 connect 操作等等
  • 其他選項和引數:可通過 help 命令或 --help 查閱每個命令具體的選項和引數

由於部分命令很常用,Docker 還提供了方便的簡寫命令,例如顯示當前所有容器 docker container ls,可以簡寫成 docker ps

我們首先複習一下容器(Container)物件上的命令吧(紅色代表適用於所有物件的操作,藍色代表此物件的專有操作):

再複習一下映象(Image)物件上的命令:

最後複習一下網路(Network)物件上的命令:

至此,這篇教程也結束了。但是我們的築夢之旅才剛剛開始——還有很多問題沒有解決:1)現在前端應用還無法在除了本地以外的環境使用(因為訪問的後端 API 是硬編碼的 localhost);2)還沒有真正部署到遠端機器;3)MongoDB 還處於“裸奔”的狀態(沒設定密碼)。不要方,我們在接下里的教程中就會去解決哦。

想要學習更多精彩的實戰技術教程?來圖雀社群逛逛吧。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

圖雀社群

相關文章