一個服務或是一個應用程式可能會需要依賴其它的一個或多個服務才能正常執行,所以為了省下Docker容器得一個一個按照順序用指令開起來的麻煩以及減少在這個過程中發生錯誤的機率,Docker Compose允許把這些相關聯的容器撰寫在一個設定檔案內,只要經過一個簡單的指令就可以一同啟動或是停止。



在本篇文章開始前,如果您還沒設定好您的Docker Compose執行環境,可以參考這篇文章來設定:

https://magiclen.org/linux-docker-ce

請務必先閱讀《Docker的基本使用方式》再來看本篇文章。

撰寫 docker-compose.yml,並用 docker-compose 指令來建立、啟動與停止容器

Docker Compose只有一個docker-compose.yml設定檔需要處理。我們可以新增一個目錄作為Docker專案目錄,並在這個目錄下新增docker-compose.yml純文字檔案,並輸入以下內容:

services:
  app:
    image: hello-world

docker-compose.yml使用YAML格式,縮排很重要,在撰寫時必須注意!

services欄位底下的每個欄位都是用來建立某一種容器的設定。如上面的docker-compose.yml檔案,app這個服務(service)可以用來建立出基於hello-world映像的容器,而app這個名稱是自訂的,用來識別這是什麼服務而已。通常一個服務會建立出一個容器,不過本篇文章中會說明到要如何讓一個服務建出多個容器,以符合一些特定需求。

使用以下指令,可以依照docker-compose.yml檔案中所定義的services,將所有容器建立出來並執行。如果有容器已經建立但停止了,就會重新啟動已存在的容器:

docker-compose up

docker-compose

如上圖,Docker Compose會使用hello-world映像(如果本地端不存在這個映像就會嘗試去Docker Hub上pull下來)來建立並啟動容器,在螢幕上顯示Hello from Docker!之類的訊息。

Docker Compose的映像名稱也可以用冒號:再接上標籤,如果不設定標籤,預設是使用latest標籤。

如果要指定建立出來的容器名稱,可以在服務加上container_name欄位。

services:
  app:
    image: hello-world
    container_name: hello-world

Docker Compose也可以搭配Dockerfile來同時建立出映像。假設我們撰寫了以下這樣的Dockerfile檔案:

FROM alpine
	 
RUN apk add --no-cache bash

CMD bash -c 'for((i=1;;i+=1)); do sleep 1 && echo "Counter: $i"; done'

若要使用Docker Compose來建立映像,要在服務加上build欄位,如下:

services:
  app:
    build:
      context: .

context欄位是用來設定Docker在建立映像時所參考的目錄(build context),在這個目錄底下的所有沒被忽略的檔案都會在建立映像的時期被傳給Docker的守護行程處理。

此時就可以使用docker-compose build指令來建立映像。

docker-compose

預設的映像名稱是<專案目錄名稱>_<服務名稱>。如果想要自訂映像名稱和標籤,要在服務加上image欄位。如下:

services:
  app:
    build:
      context: .
    image: counter

不過其實當服務有寫build欄位且本地端也不存在與image欄位值(或是預設映像名稱)同名的映像,docker-compose up指令還是會先去建立映像,不必先使用docker-compose build指令。

我們也可以讓docker-compose up指令在映像存在的時候去重新建立映像,只要加上--build參數即可。

使用docker-compose up指令執行這個Docker專案的結果如下:

docker-compose

此時要停止這個容器可以按下Ctrl + c

docker-compose up指令可以加上-d參數使容器於背景執行。

若要停止這個Docker專案下所有運行中的容器,可以使用docker-compose stop指令。

若要重新啟動這個Docker專案下所有運行中的容器,可以使用docker-compose restart指令。

若要刪除這個Docker專案下所有停止中的容器,可以使用docker-compose rm指令。

若要停止並刪除這個Docker專案下所有的容器,可以使用docker-compose down指令。

查看Docker專案中容器的日誌

可以用docker-compose logs指令來查看這個Docker專案的容器的日誌。如果想要持續監看容器的日誌,還可以替docker-compose logs指令加上-f參數。

docker-compose

之後會介紹要怎麼保留Docker專案的日誌。

改變映像預設執行的指令

繼續用上面加一計數的Docker專案當作例子,我們可以對docker-compose.yml中的app服務加上command欄位來修改映像的CMD命令所預設要執行的指令改成以下這樣:

services:
  app:
    build:
      context: .
    image: counter
    command: >
      bash -c 'for((i=1;;i+=2)); do sleep 1 && echo "Counter: $$i"; done'

使用docker-compose up指令後,可以看到容器內輸出的訊息變成加二計數。第6行的>字元可以設定多行的字串,第7行的$$是Docker Compose設定檔跳脫錢字號$的用法。

docker-compose

掛載外部檔案系統的目錄

在服務加上volumes欄位可以掛載Host的檔案系統的目錄到容器中。撰寫方式如下:

services:
  app:
    build:
      context: .
    volumes:
      - /path/to/src1:/path/to/dest1
      - /path/to/src2:/path/to/dest2

volumes欄位的值是一個清單,要使用-來設定底下的項目。路徑開頭可以使用~字元來表示家目錄。

暴露連接埠

在服務加上ports欄位可以將Host的連接埠映射到容器的連接埠。撰寫方式如下:

services:
  app:
    build:
      context: .
    ports:
      - host_port1:container_port1
      - host_port2:container_port2
      - host_port_start-host_port_end:container_port_start-container_port_end

同一個服務產生多個容器

有些伺服器程式只支援單執行緒,好比Node.js的伺服器程式。為了增加吞吐量,我們需要同時開好幾個同樣的這類伺服器程式來執行才行。

以Node.js的伺服器程式來說,雖然可以使用其內建的Cluster功能,利用pm2這個Node.js程式的行程管理器就可以很容易做成Cluster架構,讓多個行程跑同一支應用程式,共同監聽一樣的連接埠。但是在Docker環境下,還是會建議一個容器只執行一個行程,如此便不能使用Node.js的Cluster架構。

Docker Compose可以水平擴充(scale)某個服務,用法很簡單,只要在使用docker-compose up指令加上--scale參數,後面再接上服務名稱=實體數量就可以了。例如以下指令,可以讓app服務建出數量等同於電腦邏輯核心數量的容器。

docker-compose up --scale app=$(nproc)

不過如果這個服務有暴露連接埠的話,也要讓Host端的連接埠數量成倍成長才行,不然會產生連接埠重複使用的錯誤。例如原本將連接埠3000暴露出來的服務,docker-compose.yml檔案可以做如下的修改:

services:
  app:
    build:
      context: .
    ports:
      - 3000-3063:3000

如此一來,若在執行docker-compose up指令時加上--scale app=8參數,就會建立出8個容器,並用Host的連接埠3000~3007分別映射到這8個容器的連接埠3000。此處由於docker-compose.yml檔案的Host連接埠範圍是設定為3000-3063,因此這個app服務最多只能用--scale參數設定產生64個容器。

有了多個容器以及多個連接埠,我們就可以利用Ngnix等負載平衡伺服器來部署我們的伺服器程式了!有關Nginx的用法可以參考這篇文章:

https://magiclen.org/ubuntu-server-nginx/

變數

在服務加上environment欄位可以設定環境變數。撰寫方式如下:

services:
  app:
    build:
      context: .
    environment:
      VARIABLE1: value1
      VARIABLE2: value2

docker-compose.yml檔案本身也可以使用變數。可以在Docker專案根目錄下新增.env檔案,然後將docker-compose.yml檔案本身會用到的變數寫進這個檔案裡。格式如下:

VARIABLE1=value1
VARIABLE2=value2

若要在docker-compose.yml檔案中套用.env檔案所設定的變數,可以在那個要使用變數值的地方置入$變數名稱或是${變數名稱}。例如:

services:
  app:
    build:
      context: .
    image: hello-world:${TAG}

如果Docker Compose的設定檔不命名為docker-compose.yml,或是.env不命名為.env也是可以的。docker-compose指令可以用-f參數來設定Docker Compose的設定檔的路徑;用--env-file參數來設定.env檔案的路徑。

不過筆者習慣不在Docker Compose的設定檔中使用.env檔案的變數,因為這樣Docker Compose要讀取兩個檔案,用起來顯得很混亂。筆者比較習慣在不同的階段使用不同的docker-compose.yml檔案,把用於Production環境的Docker Compose設定檔命名為docker-compose.prod.yml;用於Development環境的Docker Compose設定檔命名為docker-compose.yml

單一的YAML檔案其實有類似變數的寫法,可以多加利用,語法如下:

foo: &myanchor
  key1: val1
  key2: val2

bar: *myanchor

&用來對某個欄位設定錨點(anchor),*用來引用某個錨點指定的欄位內容。

再看看以下寫法:

foo: &myanchor
  key1: val1
  key2: val2

bar:
  <<: *myanchor
  key2: val2-new
  key3: val3

<<搭配*可以用來合併欄位。例如以上的bar欄位底下會有key1key2key3欄位,且因為key2寫在<<之後,所以fookey2會被覆寫掉。

在Docker Compose的設定檔要使用這樣的語法,要注意錨點所在的欄位名稱必須要對於Docker Compose來說是有效的。若想要使用我們自訂的欄位名稱,要以x-開頭才可以。這種寫法筆者經常會用在服務的environment欄位上,用來定義多個服務共有的環境變數,例如:

x-env-suite-1: &vars
  VAR: Hello!

services:
  app1:
    image: alpine
    environment: *vars
    command: sh -c "echo $$VAR"
  app2:
    image: alpine
    environment:
      <<: *vars
    command: sh -c "echo $$VAR"

docker-compose up的執行結果如下圖:

docker-compose

透過網路介面讓不同容器進行溝通

這個小節會以TypeScript程式語言製作的一個簡單的需要連接資料庫的HTTP伺服器程式來當作例子。如果您不太熟悉TypeScript程式語言,有興趣了解更多的話,可以參考這邊的文章:

https://magiclen.org/tag/typescript-學習之路/

首先新增一個目錄,並將終端機工作目錄移動到這個新目錄下,接著在這個目錄下新增一個app.ts檔案,檔案內容如下:

import * as http from 'http';

import { MongoClient } from 'mongodb';

const PORT = process.env.PORT ?? 3000;
const MONGODB_URI = process.env.MONGODB_URI ?? 'mongodb://localhost:27017/db_counter';

async function main() {
    const client = new MongoClient(MONGODB_URI);

    await client.connect();

    const db = client.db();
    const counter = db.collection('counter');
     
    const server = http.createServer(async (req, res) => {
        const result = (await counter.findOneAndUpdate({ _id: 'counter' }, { $inc: { sequenceNumber: 1 } }, { upsert: true, returnDocument: 'after' })).value;
        
        if (result) {
            res.writeHead(200, {'Content-Type': 'text/plain'});
            res.write(`Counter: ${result.sequenceNumber}`);
        } else {
            res.writeHead(500);
        }

        res.end();
    });
     
    server.listen(PORT, () => {
        console.log(`HTTP server started at http://localhost:${PORT}`);
    });
}

main().catch((err) => {
    console.error(err);
});

也可以寫個README.md

DB Counter Server
=================

## How to Build & Run?

```bash
docker-compose up -d
```

Then visit `http://localhost:3000`.

一般來說Node.js程式專案都要有package.json檔案,但在這篇文章中的重點不是Node.js或是TypeScript,所以把package.json放在Docker環境中建立。

再來要新增.dockerignore檔案,如下:

*

!/app.ts
# !/src
# !/tests
# !/*.json

然後撰寫Dockerfile檔案,內容如下:

FROM node:slim AS builder

WORKDIR /build

RUN chown node:node /build

COPY --chown=node:node . .

USER node

RUN npm init -y && npm i mongodb && npm i -D typescript @types/node && npx tsc --init # Create a typescript project

RUN npm i && npx tsc && npm ci # Build project and create node_modules in the release mode 


FROM node:alpine

EXPOSE 3000

WORKDIR /app

RUN chown node:node /app

COPY --chown=node:node --from=builder /build/app.js /app/
COPY --chown=node:node --from=builder /build/node_modules /app/node_modules/

USER node

CMD node app.js

然後撰寫docker-compose.yml檔案,內容如下:

services:
  mongo:
    image: mongo
    volumes:
      - ~/docker/hello-db/mongo/db:/data/db
      - ~/docker/hello-db/mongo/configdb:/data/configdb
    networks:
      - db-counter-net

  app:
    build:
      context: .
    image: db-counter
    environment:
      MONGODB_URI: mongodb://mongo.db-counter-net:27017/db_counter
    ports:
      - 3000:3000
    networks:
      - db-counter-net
    command: sh -c "sleep 3; node app.js"

networks:
  db-counter-net:
    name: db-counter-net

上面的docker-compose.yml檔案,先看到mongo服務,這個服務會啟動MongoDB,MongoDB是一種NoSQL資料庫。之所以將外部檔案系統目錄分別掛載到/data/db/data/configdb路徑的目的是要讓MongoDB的資料能夠儲存在外部的檔案系統中,這樣在容器刪除後依然可以保留MongoDB的資料。

再來看第22行的networks欄位,這個欄位可以用來定義上面的服務啟動前要建立或是要連結的Docker網路介面。name欄位可以設定Docker網路介面的名稱。如果不要建立Docker網路介面,而是沿用已經建立好的Docker網路介面的話,可以在網路介面的欄位下(與name同層)新增external欄位,並設定欄位值為true。第7行和第18行是服務下的networks欄位,可以設定該服務的容器建立後要使用的網路介面。

在Docker環境中,可以利用<容器ID或名稱>.<網路介面名稱>作為主機名稱來對應容器。使用Docker Compose時,則可以用服務名稱來替代其中的容器名稱。所以設定MONGODB_URI環境變數為mongodb://mongo.db-counter-net:27017/db_counter,可以讓app服務的HTTP伺服器程式連接到mongo服務的MongoDB。

第20行覆寫了映像預設要執行的指令,在執行HTTP伺服器之前先執行sleep指令是為了要等待一段時間,讓MongoDB能夠在HTTP伺服器啟動之前被啟動,這樣HTTP伺服器才能夠順利連接上資料庫。

服務/容器的相依關係(先後執行順序)

承上面的需要連接資料庫的HTTP伺服器程式,我們其實還可以在服務加上depends_on欄位來設定服務的相依性。如下:

services:
  mongo:
    image: mongo
    volumes:
      - ~/docker/hello-db/mongo/db:/data/db
      - ~/docker/hello-db/mongo/configdb:/data/configdb
    networks:
      - db-counter-net

  app:
    build:
      context: .
    image: db-counter
    environment:
      MONGODB_URI: mongodb://mongo.db-counter-net:27017/db_counter
    ports:
      - 3000:3000
    networks:
      - db-counter-net
    depends_on:
      - mongo

networks:
  db-counter-net:
    name: db-counter-net

這樣雖然可以確保app服務的容器在mongo服務的容器啟動後才會啟動,但無法保證app服務的容器會在mongo服務的容器中的MongoDB資料庫啟動之後才啟動。

為了確保容器中的服務的執行順序,可以加入Wait Service工具。

Dockerfile檔案修改如下:

FROM rust:slim AS wait-service-builder

WORKDIR /build

RUN apt update && apt install -y make musl-tools musl-dev \
    && rustup target add x86_64-unknown-linux-musl \
    && cargo search --limit 0

RUN cargo install --target x86_64-unknown-linux-musl wait-service --features json \
    && cp ${CARGO_HOME}/bin/wait-service /build \
    && strip /build/wait-service


FROM node:slim AS builder

WORKDIR /build

RUN chown node:node /build

COPY --chown=node:node . .

USER node

RUN npm init -y && npm i mongodb && npm i -D typescript @types/node && npx tsc --init -t ES6 # Create a typescript project

RUN npm i && npx tsc && npm ci # Build project and create node_modules in the release mode 


FROM node:alpine

EXPOSE 3000

WORKDIR /app

RUN chown node:node /app

COPY --chown=node:node --from=wait-service-builder /build/wait-service /app/
COPY --chown=node:node --from=builder /build/app.js /app/
COPY --chown=node:node --from=builder /build/node_modules /app/node_modules/

USER node

CMD node app.js

以上的Dockerfile檔案,第1行到第11行用來編譯可在Alpine Linux環境執行的wait-service指令工具,為一個獨立的階段。第37行將wait-service執行檔複製進執行階段的映像中。

wait-service的編譯方式會與它之後會在哪個執行環境運行有關,需自行調整。

再來要修改docker-compose.yml設定檔中app服務的command欄位,要透過wait-service指令工具來執行我們的這個基於Node.js的HTTP伺服器。

services:
  mongo:
    image: mongo
    volumes:
      - ~/docker/hello-db/mongo/db:/data/db
      - ~/docker/hello-db/mongo/configdb:/data/configdb
    networks:
      - db-counter-net

  app:
    build:
      context: .
    image: db-counter
    environment:
      MONGODB_URI: mongodb://mongo.db-counter-net:27017/db_counter
    ports:
      - 3000:3000
    networks:
      - db-counter-net
    depends_on:
      - mongo
    command: ./wait-service --tcp mongo.db-counter-net:27017 -- node app.js

networks:
  db-counter-net:
    name: db-counter-net

以上的docker-compose.yml檔案,第22行讓容器在啟動後去執行wait-service,在確認mongo.db-counter-net:27017能夠連得上之後才會去執行node app.js指令。

有關於Wait Service的用法可以參考這篇文章:

https://magiclen.org/wait-service/

將容器的日誌輸出到 syslog

為了方便管理運行在Docker環境的服務所產生出來的日誌,筆者習慣將這些日誌丟給Linux發行版內建的syslog(準確來說是rsyslog)處理。

在改變Docker容器的日誌輸出方式之前,有一些前置工作要做。

首先建立/etc/rsyslog.d/10-docker.conf檔案,檔案內容如下:

$FileCreateMode 0644

template(name="DockerLogFileName" type="list") {
    constant(value="/var/log/docker/")
    property(name="syslogtag" securepath="replace" regex.expression="docker/\\([^/:]*\\)[^/]*/.*" regex.submatch="1")
    constant(value="/containers.log")
}

if ($syslogtag startswith "docker/") then {
    $EscapeControlCharactersOnReceive off
    /var/log/docker/combined.log

    if (re_match($syslogtag, "docker/[^/:]*[^/]*/.*")) then {
        ?DockerLogFileName
    }

    stop
}

$FileCreateMode 0600

以上設定檔可以讓進入syslog且標籤是以docker/為開頭的日誌,被儲存到/var/log/docker目錄下。這個目錄下的combined.log檔案會存放所有的日誌,也會有子目錄分別存放日誌,稍候再提。

執行以下指令檢查rsyslog的設定檔是否正確:

sudo rsyslogd -N1

執行以下指令重啟rsyslog以套用設定:

sudo systemctl restart rsyslog

接著建立/etc/logrotate.d/docker檔案,檔案內容如下:

/var/log/docker/*.log {
  daily
  dateext
  missingok
  rotate 180
  compress
  delaycompress
  notifempty
  copytruncate
} 
  
/var/log/docker/*/*.log {
  daily
  dateext
  missingok
  rotate 180
  compress
  delaycompress
  notifempty
  copytruncate
}

以上設定檔的建立是為了避免時間久了syslog的日誌會太大而佔用過多的硬碟空間,所以透過Linux發行版內建的logrotate來做日誌輪替和壓縮。以上設定檔將備份頻率設為每天(daily),會儲存最多180天(rotate 180)的日誌。

最後修改Docker Compose設定檔案中的服務,用如下的方式加入logging欄位就可以把其所建立出來的容器所產生的日誌交給syslog處理了!

services:
  mongo:
    image: mongo
    volumes:
      - ~/docker/hello-db/mongo/db:/data/db
      - ~/docker/hello-db/mongo/configdb:/data/configdb
    networks:
      - db-counter-net

  app:
    build:
      context: .
    image: db-counter
    environment:
      MONGODB_URI: mongodb://mongo.db-counter-net:27017/db_counter
    ports:
      - 3000:3000
    networks:
      - db-counter-net
    depends_on:
      - mongo
    command: ./wait-service --tcp mongo.db-counter-net:27017 -- node app.js
    logging:
      driver: "syslog"
      options:
        tag: "docker/{{.ImageName}}/{{.Name}}/{{.ID}}"

networks:
  db-counter-net:
    name: db-counter-net

/var/log/docker目錄下的子目錄是依照日誌標籤的第一個斜線/和第二個斜線中間的字串來命名,也就是會讓相同映像名稱的日誌放在同一個檔案。

透過journalctl指令可以查看存進syslog的日誌。用法如下:

journalctl -t <日誌標籤> --output cat

在終端機輸入指令的時候,<日誌標籤>的部份不需要自行記憶,先輸入docker/,然後按兩次TAB,終端機就會出現之後的提示。

如果要持續監看日誌,可以替journalctl指令加上-f參數。

docker-compose

開機後自動啟動Docker Compose服務

在服務加上restart欄位可以設定容器的重啟策略。可用的欄位值和docker run指令的--restart參數一樣,例如:

services:
  app:
    restart: always
    build:
      context: .

指定不同的Docker Compose專案名稱

Docker Compose預設會使用Docker專案的目錄名稱作為專案名稱,如果不小心讓不同的Docker專案的映像名稱重複或是容器名稱有衝突的話,可以在使用docker-compose指令時加上-P參數來指定專案名稱。