Dockerfile & Docker Compose 筆記

13 min read

# back-end

繼上一篇 Docker Container 架構的說明,這一篇要來討論 Docker 實作,並實際實作幾個自己會用到的 Container,同時也會從指令開始了解這些檔案是怎麼設定的。

Docker 第一步

一般我們使用 Docker 都是從 Docker Image 開始:

# 這邊可以參考 docker hub 上的各種 image,或是直接透過 docker search 進行搜尋
$ docker pull <image name>
# 根據該 Image 產生出 Container
$ docker run <image name> -it (直接進互動模式,簡言之,進入 container 使用 CLI 操作) --name <name>
# 確認有哪些 Container 正在執行
$ docker ps

Bang ! 三個步驟就可以完成一個環境的建置,如果今天是要清理掉一個 Container 呢?

# 今天如果只是要停止一個 container 運作,stop 就可以了
$ docker stop <container name or container id>
# 今天如果是要砍掉一個 container 服務,類似 archive 的概念,可以透過 kill
$ docker kill <container name or container id>
# 今天如果是要清理掉所有關於 container 的資料連 archive 都不,則是透過 rm
$ docker rm <container name or container id>
# 今天如果連 Image 都不留那就 ...
$ docker image rm <image name>

從上面指令我們可以了解到 Docker Container 的一個生命週期:

  • create:container 被建立出來,隔離空間被劃分出來。
  • start:container 執行指令,內部的 process 開始執行(run 即是 create 與 start 的結合)。
  • pause:container 內的 process 被暫停,暫時不消耗更多資源
  • stop & die:kill container,內部 process 被終止,記憶體釋出。
  • rm: container 的隔離空間資訊也刪除。

Docker 常見指令

# 了解 image 的詳細內容
$ docker inspect <image name>

# 暫停 container 的運作,如果今天只是要 process 暫停而不是停止並釋出記憶體可以使用
$ docker pause <container id or name>
$ docker unpause <container id or name>

# 在對應的 container 進入執行 command line 模式
$ docker exec <container id or name>

# 看看 container 的 output
$ docker logs <contvainer id or name>

# 在官方的 docker hub 上搜尋 image
$ docker search <name>

以一個 node server 與 mysql 為例

知道了上面的步驟,就讓我們透過 docker CLI 來迅速開啟一個 node.js + mysql DB 的 server 為例,想像我們現在有一包 node server 的專案檔如下列的檔案結構:

.
├── package.json
├── server
├──── index.js
└── yarn.lock

首先我們要起一個裝好 node 環境的 container 並把我們開發好的檔案放進去:

# alpine 在 docker hub 裡面常指的是基於輕量版 alpine linux 的意思
$ docker pull node:12-alpine

# -d 讓 container 在背景執行
# -p 將 docker port 對應到 host port
# -v 使用 volumes mount 特定資料夾到 docker 內的資料夾
# -w 設定 work directory 在 src
$ docker run -d --name docker-node-server -p 3000:3000 -v /Users/minw/Desktop/learning-casino-api:/src -w /src node:12-alpine

# 啟動 server
$ docker exec <container id> yarn install & yarn start

這時候輸入 docker ps 可以看到剛剛建立的 docker-node-server 正在執行。如果我們要將前面的指令與 mount 傳給別人,我們可以直接 commit 進 image 檔,並將 image 傳給別人,除了例如要保存一些錯誤現場的狀況,基於 Image 來保存狀態是很不切實際的:

# 了解在 container 儲存層裡面有什麼變動
$ docker diff <container id>
# 將 container 中的變動 commit 進 image 裡面,形成新的 image 檔案
$ docker commit <container id> node:<my-test-version>

因為 commit 的原理是將現在的 container 記錄下來,類似 snapshot 的觀念。例如:yarn installnode_modules、server 過程中產生的 Log 檔都會被放進 image 裡面,而且前一層是不能被改動的,這樣這些修改記錄都會持續保留在 image 裡面。所以更好的做法是利用 dockerfile 來附註 image。

Dockerfile

dockerfile 是用來設定 image 如何建立 container 的檔案,一般來說,我們會盡量維持環境基礎,在這之上透過 dockerfile 來執行,讓環境 image 檔案保持單純,而不是全部包進 image 之中。

所以一樣以上方的 node server 為例,我們可以在相同的專案資料夾內設定一個 Dockerfile,並放入以下內容:

FROM node:12-alpine
WORKDIR /src
COPY . /src
RUN yarn install
CMD [ "yarn", "start" ]
EXPOSE 3000

這邊每一行對於 Image 都是一個 Layer 的概念,在撰寫 Dockerfile 的時候要思考的是,這一層 Image 要做什麼事情,類似於 Git 在 Commit 的概念,讓每一層都是有分隔而且不會重複的意義,而 Docker 每一次基於 Dockerfile 去執行的時候,會比對內容是否有改變來 rebuilt,若我們希望加速這個過程,可以將比較常變動的內容放在後方,來避免前面的指令需要被重複執行。

所以我們針對 dockerfie 進行,只要在有 dockerfile 的環境裡行 build

$ docker build -t node-simple .

這邊要瞭解到,當我們補上 . 亦即提供了 Context,對於一些需要路徑的操作 e.g. COPY, WORKDIR 等來說,會依據這個傳入的 Context 來處理。

常見指令 QA

這邊在看 Dockerfile 指令時大部分都相當直接,但有幾個時常讓我混淆-

  • COPY vs ADD: 最主要的差別在 ADD 可以從一個 URL 裡面抓資料並進行解壓縮,並不會做 Cache 處理,所以一般來說大部分時候還是用 COPY 居多。
  • RUN vs CMD: RUN 會被 commit 進 image 裡面,CMD 則是單純的執行指令,也是如果今天我們 docker inspect <image name> 的時候會在裡面看到最後執行的指令。
  • ENTRYPOINT vs CMD : ENTRYPOINTCMD 的概念相同,但他比較像是接受 CMD 作為參數的指令,為什麼會有這個指令?因為當我們如果試圖為 CMD 帶入參數時,會發現在 CLI 輸入的指令會覆蓋掉 CMD 的指令,所以才會需要 ENTRYPOINT 放腳本檔,讓我們可以在 CMD 帶入參數。
  • ENV vs ARG : 兩者都是設定環境變數,但 ARG 的環境變數不會在 container 之中,通常用於 docker build 階段,而 ENV 就是一般我們使用的環境變數。

改善 Dockerfile

  • alpine version: 選擇使用 alpine tag 的環境可以得到比較簡潔的 image。
  • Multi-stage build: 透過雙層的 FROM 將一些只是需要環境來建置的資源保留,環境移除。
  • dockerignore / 移除過程中的檔案:避免一些 node_modules, log 檔案一併被複製進 image。

Docker Compose

如果今天是一個多 Container 的服務,舉例來說:除了上述的 node server 之外,我們還需要一個 mysql container,過去我們可以怎麼做?

先開一個共享網路,接著把我們的服務都加進這個網路裡:

$ docker network create -d bridge <network name>
$ docker run -d -p 3306:3306 -v /my/own/datadir:/var/lib/mysql --network <network name> mysql:5.4
$ docker run -it --network <network name> . /bin/bash

只有一個兩個還好,今天如果有很多 container 這邊操作的複雜度就會提升,於是有了 docker compose 協助處理多 docker container 的配置。

version: '2.1'

services:
  mysql:
    container_name: db-server
    image: mysql:5.7
    command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    ports:
      - 3306:3306
    environment:
      - MYSQL_RANDOM_ROOT_PASSWORD=yes
      - MYSQL_DATABASE=test
      - MYSQL_USER=root
      - MYSQL_PASSWORD=root
    healthcheck:
      test: "exit 0"
      interval: 30s
      timeout: 10s
      retries: 5

  api:
    container_name: api-server
    build:
      context: .
      dockerfile: local.Dockerfile
    image: api-server
    depends_on:
      mysql:
        condition: service_healthy
    ports:
      - 3000:3000
    environment:
      - MYSQL_HOST=host.docker.internal
      - MYSQL_PORT=6017
      - MYSQL_DATABASE=test
      - MYSQL_USERNAME=root
      - MYSQL_PASSWORD=root
    volumes:
      - ./server:/app/server

這邊一個 docker-compose.yml 的範例讓我們可以看到,大致 docker-compose 其實就是把我們設定的過程變成一個 yml 檔,要注意的是,這邊 yml 是有執行順序的差別,以上段為例:若 mysql 還未啟動,那 api 可能無法順利運行。若要在 yml 上定義相依關係,可以透過 link 參數。

Docker 部署實作

而實際上我們在部署 Dockerize 的 App 服務時,我們可以使用高度集成的服務像是:AWS ECS 來管理 Container 的生命週期也不需要再安裝 docker 的環境,但其實也可以在 EC2 上執行,順帶一提 Docker Compose 並不適合 Production 的環境,顯而易見的 Docker Compose 並沒有辦法做到多機器多容器的部署,如果需要可以考慮 k8s 或 docker swarm 等服務。

這邊就以兩個過去學習中常需要的案例來示範 AWS 跟 GCP 版本:

LAMP

首先我們先在 Local 端嘗試建置起一個 LAMP 的服務,我們需要一個基於 apache 的 php server,與一個 mysql DB。

所以我們今天可以直接上 Dockerhub 找一個 LAMP image、又或者基於 AWS 部署上的安排,將 apache server 跟 mysql DB 分離在不同主機上。

假設選擇後者,這邊決定起兩個 container 來執行,mysql 資料使用 volume 保留在外埠資料夾中,並且將 container port 對應到 local 的 9016 port,而 php server 程式碼為了保持開發時更新,將程式碼同樣保留外部的資料夾。

version: '3'

services:
  mysql:
    container_name: lamp-mysql-db
    image: mysql:5.7
    command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    restart: "always"
    ports:
      - "9016:3306"
    volumes:
      - ./data:/var/lib/mysql
    environment:
      - MYSQL_RANDOM_ROOT_PASSWORD=yes
      - MYSQL_DATABASE=test
      - MYSQL_USER=admin
      - MYSQL_PASSWORD=admin
  server:
    container_name: lamp-server
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:80"
    environment:
      - MYSQL_HOST=host.docker.internal
      - MYSQL_PORT=9016
      - MYSQL_DATABASE=test
      - MYSQL_USER=admin
      - MYSQL_PASSWORD=admin
    depends_on:
      - mysql
    volumes:
      - ./src:/var/www/html

另外 php 這邊需要再疊上一層 layer 放 mysqli driver,所以針對 php-apache 我們加上 dockerfile,值得注意的是,當更新 dockerfile 的時候,若已經存在原本的 image 記得在 docker compose up 加上 --build flag 產生包新 layer 的 image:

FROM php:7-apache
RUN docker-php-ext-install mysqli

綜合以上我們將資料夾結構保持下列形式:

.
├── data
├── src
├── docker-compose.yml
└── dockerfile

接著我們將這份檔案推上安裝好 Docker 的 EC2 即可直接部署我們 server,記得將 RDS 的參數保存在 EC2 環境變數中,而不是使用原本的 mysql container。

小結

Docer Compose 加上 Dockerfile 真的讓平時自己開發省力許多,在 debug 上逐漸可以掌握一些流程:

  • 確認 Container 是否成功啟動
  • docker log 確認最後 Container 回傳的訊息
  • 確認 docker image 的參數內容確認最後執行的 CMD 是否合理

以上可以初步排除很多入門時會踩到的坑。