使用 Docker Compose 構建一鍵啓動的運行環境

如今,不管你是否喜歡,不管你是否承認,微服務架構模式的流行就擺在那裏。作爲架構師的你,如果再將系統設計成個大單體結構,那麼即便不懂技術的領導,都會給你送上幾次白眼。好吧,妥協了!開拆!“沒喫過豬肉,還沒見過豬跑嗎!”。拆不出 40-50 個服務,我就不信還拆不出 4-5 個服務 ^_^。

終於拆出了幾個服務,但又犯難了:以前單體程序,搭建一個運行環境十分 easy,程序往一個主機上一扔,配置配置,啓動就 ok 了;但自從拆成服務後,開發人員的調試環境、集成環境、測試環境等搭建就變得異常困難。

有人會說,現在都雲原生了?你不知道雲原生操作系統 k8s[1] 的存在麼?讓運維幫你在 k8s 上整環境啊。一般小廠,運維人員不多且很忙,開發人員只能 “自力更生,豐衣足食”。開發人員自己整 k8s?別扯了!沒看到這兩年 k8s 變得越來越複雜了嗎!如果有一年不緊跟 k8s 的演進,新版本中的概念你就可能很陌生,不知源自何方。一般開發人員根本搞不定 (如果你想搞定,可以看看我的 k8s 實戰課程 [2] 哦,包教包會 ^_^)。

那怎麼辦呢?角落裏曾經的沒落雲原生貴族 docker 發話了:要不讓我兄弟試試!

1. docker compose

docker[3] 雖然成了 “過氣網紅”,但 docker 依然是容器界的主流。至少對於非 docker 界的開發人員來說,一提到容器,大家首先想到的還是 docker。

docker 公司的產品推出不少,開發人員對多數都不買賬也是現實,但我們也不能一棒子打死,畢竟 docker 是可用的,還有一個可用的,那就是 docker 的兄弟:docker compose[4]。

Compose 是一個用於定義和運行多容器 Docker 應用程序的工具。使用 Compose,我們可以使用一個 YAML 文件 [5] 來配置應用程序的所有服務組件。然後,只需一條命令,我們就可以創建並啓動配置中的所有服務。

這不正是我們想要的工具麼! Compose 與 k8s 很像,都算是容器編排工具,最大的不同:Compose 更適合在單節點上的調試或集成環境中(雖然也支持跨主機,基於被淘汰的 docker swarm)。Compose 可以大幅提升開發人員以及測試人員搭建應用運行環境的效率。

2. 選版本

使用 docker compose 搭建運行環境,我們僅需一個 yml 文件。但 docker compose 工具也經歷了多年演化,這個文件的語法規範也有多個版本,截至目前,docker compose 的配置文件的語法版本就有 2、2.x 和 3.x 三種。並且不同規範版本支持的 docker 引擎版本還不同,這個對應關係如下圖。圖來自 docker compose 文件規範頁面 [6]:

選版本是最鬧心的。選哪個呢?設定兩個條件:

這樣一來,版本 3.2 是最低要求的了。我們就選 3.2:

// docker-compose.yml 
version: "3.2"

3. 選網絡

docker compose 默認會爲 docker-compose.yml 中的各個 service 創建一個 bridge 網絡,所有 service 在這個網絡裏可以相互訪問。以下面 docker-compose.yml 爲例:

// demo1/docker-compose.yml
version: "3.2"
services:
  srv1:
    image: nginx:latest
    container_name: srv1
  srv2:
    image: nginx:latest
    container_name: srv2

啓動這個 yml 中的服務:

# docker-compose -f docker-compose.yml up -d
Creating network "demo1_default" with the default driver
... ...

docker compose 會爲這組容器創建一個名爲 demo1_default 的橋接網絡:

# docker network ls
NETWORK ID          NAME                     DRIVER              SCOPE
f9a6ac1af020        bridge                   bridge              local
7099c68b39ec        demo1_default            bridge              local
... ...

關於 demo1_default 網絡的細節,可以通過 docker network inspect 7099c68b39ec 獲得。

對於這樣的網絡中的服務,我們在外部是無法訪問的。如果要訪問其中服務,我們需要對其中的服務做端口映射,比如如果我們要將 srv1 暴露到外部,我們可以將 srv1 監聽的服務端口 80 映射到主機上的某個端口,這裏用 8080,修改後的 docker-compose.yml 如下:

version: "3.2"
services:
  srv1:
    image: nginx:latest
    container_name: srv1
    ports:
    - "8080:80"
  srv2:
    image: nginx:latest
    container_name: srv2

這樣啓動該組容器後,我們通過 curl localhost:8080 就可以訪問到容器中的 srv1 服務。不過這種情況下,服務間的相互發現比較麻煩,要麼藉助於外部的發現服務,要麼通過容器間的 link 來做。

開發人員大多隻有一個環境,不同服務的服務端口亦不相同,讓容器使用 host 網絡要比單獨創建一個 bridge 網絡來的更加方便。通過 network_mode 我們可以指定服務使用 host 網絡,就像下面這樣:

version: "3.2"
services:
  srv1:
    image: bigwhite/srv1:1.0.0
    container_name: srv1 
    network_mode: "host"

在 host 網絡下,容器監聽的端口就是主機上的端口,各個服務間通過端口區別各個服務實例 (前提是端口各不相同),ip 使用 localhost 即可。

使用 host 網絡還有一個好處,那就是我們在該環境之外的主機上訪問環境中的服務也十分方便,比如查看 prometheus 的面板等。

4. 依賴的中間件先啓動,預置配置次之

如今的微服務架構系統,除了自身實現的服務外,外圍還有大量其依賴的中間件,比如:redis、kafka(mq)、nacos/etcd(服務發現與註冊)、prometheus(時序度量數據服務)、mysql(關係型數據庫)、jaeger server(trace 服務器)、elastic(日誌中心)、pyroscope-server(持續 profiling 服務) 等。

這些中間件若沒有啓動成功,我們自己的服務多半啓動都要失敗,因此我們要保證這些中間件服務都啓動成功後,再來啓動我們自己的服務。

如何做呢?compose 規範中有一個迷惑人的 “depends_on”[7],比如下面配置文件中 srv1 依賴 redis 和 nacos 兩個 service:

version: "3.2"
services:
  srv1:
    image: bigwhite/srv1:1.0.0
    container_name: srv1 
    network_mode: "host"
    depends_on: 
      - "redis"
      - "nacos"
    environment:
      - NACOS_SERVICE_ADDR=127.0.0.1:8848
      - REDIS_SERVICE_ADDR=127.0.0.1:6379
    restart: on-failure

不深入瞭解,很多人會認爲 depends_on 可以保證先啓動依賴項 redis 和 nacos,並等依賴項 ready 後再啓動我們自己的服務 srv1。但實際上,depends_on 僅能保證先啓動依賴項,後啓動我們的服務。但它不會探測依賴項 redis 或 nacos 是否 ready,也不會等依賴項 ready 後,才啓動我們的服務。於是你會看到 srv1 啓動後依舊出現各種的報錯,包括無法與 redis、nacos 建立連接等。

要想真正實現依賴項 ready 後才啓動我們自己的服務,我們需要藉助外部工具了,docker compose 文檔對此有說明 [8]。其中一個方法是使用 wait-for-it 腳本 [9]。

我們可以改變一下自由服務的容器鏡像,將其 entrypoint 從執行服務的可執行文件變爲執行一個 start.sh 的腳本:

// Dockerfile
... ...
ENTRYPOINT ["/bin/bash""./start.sh"]

這樣我們就可以在 start.sh 腳本中 “定製” 我們的啓動邏輯了。下面是一個 start.sh 腳本的示例:

#! /bin/sh
  
./wait_for_it.sh $NACOS_SERVICE_ADDR -t 60 --strict -- echo "nacos is up" && \
./wait_for_it.sh $REDIS_SERVICE_ADDR -- echo "redis is up" && \
exec ./srv1

我們看到,在 start.sh 腳本中,我們使用 wait_for_it.sh 腳本 [10] 等待 nacos 和 redis 啓動,如果在限定時間內等待失敗,根據 restart 策略,我們的服務還會被 docker compose 重新拉起,直到 nacos 與 redis 都 ready,我們的服務纔會真正開始執行啓動過程。

在 exec ./srv1 之前,很多時候我們還需要進行一些配置初始化操作,比如向 nacos 中寫入預置的 srv1 服務的配置文件內容以保證 srv1 啓動後能從 nacos 中讀取到自己的配置文件,下面是加了配置初始化的 start.sh:

#! /bin/sh
  
./wait_for_it.sh $NACOS_SERVICE_ADDR -t 60 --strict -- echo "nacos is up" && \
./wait_for_it.sh $REDIS_SERVICE_ADDR -- echo "redis is up" && \
curl -X POST --header 'Content-Type: application/x-www-form-urlencoded' -d dataId=srv1.yml --data-urlencode content@./conf/srv1.yml "http://127.0.0.1:8848/nacos/v1/cs/configs?group=MY_GROUP" && \
exec ./srv1

我們通過 curl 將打入鏡像的./conf/srv1.yml 配置寫入已經啓動了的 nacos 中供後續 srv1 啓動時讀取。

5. 全家桶,一應俱全

就像前面提到的,如今的系統對外部的中間件 “依存度” 很高,好在主流中間件都提供了基於 docker 啓動的官方支持。這樣我們的開發環境也可以是一個一應俱全的“全家桶”。不過要有一個很容易滿足的前提:你的機器配置足夠高,才能把這些中間件全部運行起來。

有了這些全家桶,我們無論是診斷問題 (看 log、看 trace、看度量數據),還是作性能優化(看持續 profiling 的數據),都方便的不要不要的。

6. 結合 Makefile,簡化命令行輸入

docker-compose 這個工具有一個 “嚴重缺陷”,那就是名字太長 ^_^。這導致我們每次操作都要敲入很多命令字符,當你使用的 compose 配置文件名字不爲 docker-compose.yml 時,更是如此,我們還需要通過 - f 選項指定配置文件路徑。

爲了簡化命令行輸入,減少鍵盤敲擊次數,我們可以將複雜的 docker-compose 命令與 Makefile 相結合,通過定製命令行命令並將其賦予簡單的 make target 名字來實現這一簡化目標,比如:

// Makefile

pull: 
    docker-compose -f my-docker-compose.yml pull

pull-my-system:
    docker-compose -f my-docker-compose.yml pull srv1 srv2 srv3

up: pull-my-system
    docker-compose -f my-docker-compose.yml up

upd: pull-my-system
    docker-compose -f my-docker-compose.yml up -d

    up2log: pull-my-system
    docker-compose -f my-docker-compose.yml up > up.log 2>&1

down: 
    docker-compose -f my-docker-compose.yml down

ps: 
    docker-compose -f my-docker-compose.yml ps -a

log: 
    docker-compose -f my-docker-compose.yml logs -f

# usage example: make upsrv service=srv1
service=
upsrv:
    docker-compose -f my-docker-compose.yml up -d ${service}

config:
    docker-compose -f my-docker-compose.yml config

另外服務依賴的中間件一般都時啓動與運行開銷較大的系統,每次和我們的服務一起啓停十分浪費時間,我們可以將這些依賴與我們的服務分別放在不同的 compose 配置文件中管理,這樣我們每次重啓自己的服務時,沒有必要重新啓動這些依賴,這樣可以節省大量 “等待” 時間。

7. .env 文件

有些時候,我們需要在 compose 的配置文件中放置一些 “變量”,我們通常使用環境變量來實現“變量” 的功能,比如:我們將 srv1 的鏡像版本改爲一個環境變量:

version: "3.2"
services:
  srv1:
    image: bigwhite/srv1:${SRV1_VER}
    container_name: srv1 
    network_mode: "host"
  ... ...

docker compose 支持通過同路徑下的. env 文件的方式 docker-compose.yml 中環境變量的值,比如:

// .env
SRV1_VER=dev

這樣 docker compose 在啓動 srv1 時會將. env 中 SRV1_VER 的值讀取出來並替換掉 compose 配置文件中的相應環境變量。通過這種方式,我們可以靈活的修改我們使用的鏡像版本。

8. 優點與不足

使用 docker compose 工具,我們可以輕鬆擁有並快速啓動一個 all-in-one 的運行環境,大幅度加速了部署、調試與測試的效率,在特定的工程環節,它可以給予開發與測試人員很大幫助。

不過這樣的運行環境也有一些不足,比如:

Gopher Daily(Gopher 每日新聞) 歸檔倉庫 - https://github.com/bigwhite/gopherdaily

我的聯繫方式:

參考資料

[1] 

雲原生操作系統 k8s: https://kubernetes.io

[2] 

我的 k8s 實戰課程: https://coding.imooc.com/class/284.html

[3] 

docker: https://tonybai.com/2017/12/21/the-concise-history-of-docker-image-building

[4] 

docker compose: https://docs.docker.com/compose/

[5] 

YAML 文件: https://tonybai.com/2019/02/25/introduction-to-yaml-creating-a-kubernetes-deployment/

[6] 

docker compose 文件規範頁面: https://docs.docker.com/compose/compose-file/

[7] 

迷惑人的 “depends_on”: https://docs.docker.com/compose/compose-file/compose-file-v3/#depends_on

[8] 

docker compose 文檔對此有說明: https://docs.docker.com/compose/startup-order/

[9] 

wait-for-it 腳本: https://github.com/vishnubob/wait-for-it

[10] 

wait_for_it.sh 腳本: https://github.com/vishnubob/wait-for-it/blob/master/wait-for-it.sh

[11] 

鏈接地址: https://m.do.co/c/bff6eed92687

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/KVaAQW3LbWeY_ElpRhwDIg