0%

Docker (三) - 撰寫 Dockerfile

Dockerfile 是一個 YAML 格式的純文字檔,由許多命令所組成。而這些命令就是在描述產生 Image 所需要的內容和要做的事情。

Docker 會自動讀取目錄中的 Dockerfile 來建立 Image,所以不建議修改 Dockerfile 的名稱,若是真的要修改或是要建立多個 Dockerfile 放在一起,那就必須在建立 Image 的時候指定要使用哪個 Dockerfile。

Dockerfile 的組成

我們直接先看一個簡單的 Dockerfile 範例,以下為 DotNet Core 3.1 的 Dockerfile。

1
2
3
4
FROM mcr.microsoft.com/dotnet/core/aspnet:3.0 AS runtime
WORKDIR /app
COPY published/aspnetapp.dll ./
ENTRYPOINT ["dotnet", "aspnetapp.dll"]

先簡略說明一下上面的 Dockerfile 要做哪些事情 :

  1. mcr.microsoft.com/dotnet/core/aspnet:3.0 這個 Image 做為基底並命名為 runtime
1
FROM mcr.microsoft.com/dotnet/core/aspnet:3.0 AS runtime
  1. 設定檔案目前位置到 /app
1
WORKDIR /app
  1. 複製所需的檔案到 /app 目錄下
1
COPY published/aspnetapp.dll ./
  1. 定義 Container 啟動時要執行的指令
1
ENTRYPOINT ["dotnet", "aspnetapp.dll"]

看完上述的範例後可以發現,Dockerfile 就是由一行行的指令所組成,而一個指令所做的事情對 Image 來說就是新增一層資料層,所以一個 Image 就是透過層層堆疊資料所組成的。

接著介紹一些在Dockerfile中常用的指令:

#開頭代表註解

Dockerfile 中可以使用 # 來代表註解

FROM

代表要以哪個 Image 做為基底進行修改

1
FROM <ImageName>:<tag>

一般我們會使用 DockerHub 上現有的環境 Image 來做基底直接修改,你不會希望每次都要自己重新設計環境的 Image,所以就使用現成的就好。

From 指令會先檢查本機上如果已經有下載需要的 Image 了那就直接使用,如果沒有那就會先下載再使用。

MAINTAINER

設定 Image 的維護者,通常就是作者

1
MAINTAINER <name>

LABEL

設定 Image 的 Metadata,例如:作者、Email、說明等等

1
LABEL <key>=<value> <key>=<value> ...

LABEL 指令可以透過空白間隔把所有的資訊一次寫完,相比 MAINTAINER 來說較為方便。例如:

1
LABEL description="This is sample image" version="1.0" owner="Tom Hanks"

如果要查看 LABEL 的資訊可以透過 docker inspect 來查詢。

AS

將 Image 賦予其他名稱,以下範例即為把 Image 命名為 base

1
FROM <ImageName>:<tag> AS base

WORKDIR

設定當前工作目錄

1
WORKDIR <path>

Docker 會將當前的工作目錄移到指定的目錄下,若是沒有這個目錄則會自動建立一個。

這裡的工作目錄是指 Image 的目錄不是本機專案下的目錄。

USER

指定運行 Container 時的用戶名稱或是 UID

1
2
USER <username>
USER <uid>

當定義了 USER 後,Dockerfile 中的 RUNCMDENTRYPOINT 等等的指令便會以 USER 指定的用戶來執行,但是該用戶必須要存在,否則就會指定失敗。

RUN

執行命令,可以是安裝軟體、建立檔案和目錄或是建立環境設定等等。

1
2
3
4
5
# shell form
RUN <Command>

# exec form
RUN ["executable", "param1", "param2"]

RUN 可以用來執行 shell 指令,例如 dotnet build 或 dotnet publish。

上面的範例介紹了兩種執行的格式,這裡說明一下使用的方法:

  • shell form:
    以 shell 的形式執行,Linux 預設是用 /bin/sh -c,而 Windows 預設是用 cmd /S /C
  • exec form:
    以 exec 的形式執行,例如 Linux 不想用預設的 shell 執行指令,可以透過
    RUN["/bin/bash", "-c", "echo hello"]來指定想要的 shell

Docker 官方也描述了 shellexec 的區別:

Unlike the shell form, the exec form does not invoke a command shell. This means that normal shell processing does not happen. For example, RUN [ “echo”, “$HOME” ] will not do variable substitution on $HOME.

這段描述說明了 exec 執行並不會使用到 command shell,所以執行 RUN [ "echo", "$HOME" ] 這種指令, $HOME 變數是不會被替換掉的,也就是會直接輸出 $HOME 這串字;如果想要有 shell 的處理功能那就必須指定 shell 來達成,例如: RUN [ "sh", "-c", "echo $HOME" ]

另外要特別注意的是,Dockerfile 中的每一個指令都是使用 Image 重新啟動一個 Container,並且在最上方新增一層可寫層再執行命令;執行完後以 Commit 的方式提交修改以產生新的 Image。所以每次執行都是獨立的 Container,不要當成 Shell Script 在寫。 下面舉一個簡單的例子:

1
2
RUN cd /app
RUN ./test.sh

上面這個範例是想要切換到 /app 目錄下執行 test.sh,但是因為兩條指令是在不同的 Container上,所以會造成第二步要執行 test.sh 時,當下的工作目錄並不在 /app 目錄下,就會造成找不到 test.sh

不過你可能會覺得每次都要重新建立 Container 會非常耗資源和時間,Docker 官方給出了下面這段描述:

Layering RUN instructions and generating commits conforms to the core concepts of Docker where commits are cheap and containers can be created from any point in an image’s history, much like source control.

提交修改產生新的 Image非常的簡單也不耗資源,而且還可以選擇在任何一層建立 Container。這種層層堆疊的方式不斷疊加新的修改在 Image上,正符合了 Docker的核心理念。

COPY

複製來源的目錄或是文件到 Image 中的目錄下

1
2
COPY [SourcePath, TargetPath]
COPY SourcePath TargetPath

路徑可以是絕對路徑或是相對路徑,但是一般都是使用相對路徑才會方便在不同電腦上執行。

若是使用相對路徑要特別注意,來源的路徑是相對於執行 Docker 命令時所在的位置,不是 Dockerfile所在的位置。 而目標路徑就是相對於 WORKDIR 所設定的當前路徑。

ADD

除了等同於 COPY 的複製功能,還可以複製 URL 規格的檔案及解壓縮檔案。

1
2
ADD [SourcePath, TargetPath]
ADD SourcePath TargetPath

如果複製下來的檔案是壓縮檔,ADD 指令會自動將檔案解壓縮。

所以也可以直接從本機複製壓縮檔,ADD 指令同樣會自動解壓縮檔案,如下面的範例:

1
ADD test.tar.gz .

CMD

設定 Image 啟動為 Containre 時預設要執行的指令

1
2
3
4
5
6
7
8
# shell form
CMD command parameter1 parameter2

# exec form,官方推薦
CMD["executable", "param1", "param2"]

# 適用有定義ENTRYPOINT
CMD["parameter1", "param2"]

CMD 有三種格式,前兩個分別為 shellexec 的形式,官方則較推薦 exec 的形式。

第三種格式適用於有定義 ENTRYPOINT 指令的時候使用,CMD 中的參數會作為 ENTRYPOINT 的預設參數。

這裡要特別注意的是,Dockerfile 中 CMD 只能有一行,若有多行則只有最後一行有效。此外,如果在 docker run 的時候有指定參數,那 CMD 就會被覆蓋掉。

另外,CMDRUN 是不一樣,CMD 在建置時期不會執行,是在 Image 啟動成 Container 時才執行;而 RUN 是在建置時期執行並且 Commit 結果。

ENTRYPOINT

CMD 一樣在啟動為 Container 時才會被執行,而且還可以和 CMD 合用。但是和 CMD 不同的是,ENTRYPOINT 一定會被執行,不會被覆蓋掉。 官方也推薦如果每次都要執行相同的指令建議用 ENTRYPOINT,而若是需要替換則使用 CMD

1
2
3
4
5
# shell form
ENTRYPOINT command param1 param2

# exec form,官方推薦
ENTRYPOINT ["executable", "param1", "param2"]

ENTRYPOINTCMD 合用僅限 exec form才能使用,下面示範一個 ENTRYPOINTCMD 合用的範例:

1
2
ENTRYPOINT ["curl"]
CMD ["http://sample.com"]

這個範例會在 Container 啟動時執行 curl http://sample.com

EXPOSE

宣告要對外開放的 Port Number

1
2
EXPOSE <port>
EXPOSE <port>/<protocol>

EXPOSE 指令預設使用 tcp 協定,若是需要使用 udp 也可以指定。例如:

1
EXPOSE 80/udp

EXPOSE 指令並不會在 Container 啟動時真的打開 Port,要在 docker run 指定 -p 才會開啟,例如:

1
2
docker run -p 80:80 ...
docker run -p 1234:80 ...

ENV

設定環境變數

1
2
ENV <key> <value>
ENV <key>=<value>

ENV 有兩種格式,第一種 key 後面接空白,預設空白後面即為 value;而第二種比較清楚用等號連接。兩個都可以以空白為間隔依序串聯宣告。例如:

1
2
3
4
5
6
ENV k1 v1
ENV k2 v2
ENV k3 v4 k4 v4

ENV k5=v5
ENV k6=v6 k7=v7

宣告完環境變數後其他指令就可以使用,也可以在之後建起來的 Container 使用,例如:

1
COPY test.txt k1

環境變數可以透過 docker inspect 來查看,也可以在啟動 Container 時使用 --env-e 修改,例如:

1
docker run --env <key>=<value> -e <key>=<value>

ARG

宣告建置 Image 時要使用的參數

1
2
ARG <name>
ARG <name>=<value>

ARGENV 功能非常相似,都可以設定變數,但是差別在於 ARG 只提供在建置 Image 時使用,而 ENV 還可以在後續建立起來的 Container 使用。

除了在 Dockerfile 中直接設定參數外,也可以在執行 docker build 時透過 --build-arg 傳入,例如:

1
docker build --build-arg <argName>=<value> .

傳入之後需要在 Dockerfile 中宣告同樣的參數名稱才可以使用。

另外,可以在參數宣告前先使用這個參數,但是要在真正取得參數的值之前宣告,若沒在取得值之前宣告就會造成取到空字串。例如:

1
2
3
4
FROM busybox
USER ${user:-some_user}
ARG user
USER $user

第二行先使用了 user 這個參數,到了第三行有宣告 user 那後續再使用就不會有問題;而第三行的參數如果在 docker build 時有傳進來那就會有值,若沒有則還需要再指定值給他。

還要注意一點,如果使用了多階段建置的話,在每個階段都要重新宣告一次變數才能使用。 例如:

1
2
3
4
5
6
7
FROM busybox
ARG SETTINGS
RUN ./run/setup $SETTINGS

FROM busybox
ARG SETTINGS
RUN ./run/other $SETTINGS

ONBUILD

如果這個 Image 是作為其他 Image 的基底的話,就需要定義 ONBUILD 指令

1
ONBUILD <INSTRUCTION>

ONBUILD 後面的指令會在其他 Image 將其作為基底時才觸發,這個 Image 本身在建立的時候不會執行這些指令。例如 Image BaseA 的 Dockerfile 定義如下:

1
2
3
4
...
ONBUILD ADD . /app/src
ONBUILD RUN /usr/local/bin/python-build --dir /app/src
...

Image B 使用 Image BaseA 當作基底,則 Image BaseA 的 ONBUILD指令就會被觸發,等同於以下指令:

1
2
3
4
5
6
# 以 BaseA 作為基底
FROM BaseA

# 觸發 BaseA 的 ONBUILD 指令,而這些指令不需要寫,Docker 會自己加
ADD . /app/src
RUN /usr/local/bin/python-build --dir /app/src

多階段建置

Docker 提供的多階段建置(Multi-Stage Builds),可以將多個 Image 整合進一個 Dockerfile,使前面建置的 Image 可以讓後面使用。

前面有提到 Image 是層層堆疊所建立出來的,所以在建置 Image 時檔案也會越來越大。不過我們都希望 Image 可以越小越好,多階段建置就可以幫我們解決這個問題。

以 Dotnet 的應用程式來說,要執行 dotnet build 或是 dotnet publish 就需要有 dotnet cli 命令的 Dotnet SDK Image 作為基底才能執行。然而要把 Dotnet 應用執行起來只需要有 Runtime 的 Image 就可以了,並不需要含有 SDK 的Image。

透過多階段建置,我們可以先使用具有 SDK 的 Image 作為基底來建置和產生發行的檔案,接著再把產生的發行檔案複製到只有 Runtime 的 Image,這樣就可以減少 Image 上多餘的資料層。

下面以一個 Dotnet Core 3.1 的應用作為範例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim AS base
WORKDIR /app

# Expose Port
EXPOSE 80
EXPOSE 443

# Copy csproj and restore as distinct layers
FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS build

ARG BuildConfiguration=Debug

WORKDIR /src
COPY ["Sample.WebService.WebUrl/Sample.WebService.WebUrl.WebApplication/Sample.WebService.WebUrl.WebApplication.csproj", "Sample.WebService.WebUrl.WebApplication/"]
RUN dotnet restore "Sample.WebService.WebUrl.WebApplication/Sample.WebService.WebUrl.WebApplication.csproj"

# Copy everything else and build
COPY Sample.WebService.WebUrl/. .
WORKDIR "/src/Sample.WebService.WebUrl.WebApplication"
RUN dotnet build "Sample.WebService.WebUrl.WebApplication.csproj" -c $BuildConfiguration -o /app/build

FROM build AS publish
RUN dotnet publish "Sample.WebService.WebUrl.WebApplication.csproj" -c $BuildConfiguration -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Sample.WebService.WebUrl.WebApplication.dll"]

簡單說明上面的 Dockerfile 要做哪些事情 :

  1. mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim 做為基底並命名為 base,這個Image就是只有 Runtime 而沒有 SDK 的Image
1
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim AS base
  1. 設定檔案目前位置到 /app
1
WORKDIR /app
  1. 宣告要開放的 Port Number
1
2
3
# Expose Port
EXPOSE 80
EXPOSE 443
  1. mcr.microsoft.com/dotnet/core/sdk:3.1-buster 做為建置的基底並命名為 build。這裡就是使用 SDK 的 Image,因為接下來需要對專案進行建置和發行
1
FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS build
  1. 設定一個參數 BuildConfiguration 供下方指令使用
1
ARG BuildConfiguration=Debug
  1. 設定檔案目前位置到 /src 目錄下
1
WORKDIR /src
  1. 複製編譯所需的檔案到 /src 目錄下
1
COPY ["Sample.WebService.WebUrl/Sample.WebService.WebUrl.WebApplication/Sample.WebService.WebUrl.WebApplication.csproj", "Sample.WebService.WebUrl.WebApplication/"]
  1. 復原所需的套件
1
RUN dotnet restore "Sample.WebService.WebUrl.WebApplication/Sample.WebService.WebUrl.WebApplication.csproj"
  1. 複製專案裡剩下的所有檔案
1
COPY Sample.WebService.WebUrl/. .
  1. 設定檔案目前位置到 /src/Sample.WebService.WebUrl.WebApplication
1
WORKDIR "/src/Sample.WebService.WebUrl.WebApplication"
  1. 執行 dotnet build 建置專案
1
RUN dotnet build "Sample.WebService.WebUrl.WebApplication.csproj" -c $BuildConfiguration -o /app/build
  1. 以 build 的成品做為發行基底並命名為 publish
1
FROM build AS publish
  1. 執行 dotnet publish 發行專案
1
RUN dotnet publish "Sample.WebService.WebUrl.WebApplication.csproj" -c $BuildConfiguration -o /app/publish
  1. 以 base 的成品做為基底並命名為 final,base 即為第一步使用的只有 Runtime 的 Image
1
FROM base AS final
  1. 設定檔案目前位置到 /app
1
WORKDIR /app
  1. 從 publish 複製成品到 final 的 publish 資料夾,這一步正是上述介紹所提到的把用 SDK 建置發行好的成品複製到 Runtime 的 Image
1
COPY --from=publish /app/publish .
  1. 定義 Container 啟動時要執行的指令
1
ENTRYPOINT ["dotnet", "Sample.WebService.WebUrl.WebApplication.dll"]

使用 Dockerfile 建立 Image

介紹完了這麼多 Dockerfile 的指令和撰寫方式,最終就是要使用 Dockerfile 來建立出 Image。

建立的方式是使用 docker build 來指定 Image 的名稱和 Dockerfile

1
docker build [OPTIONS] PATH/URL

Options 可以自己指定相關的設置,我們先看一個基本的範例:

1
docker build -t sampleImage .

這個範例指定了一個參數 -t-t 就是指定這個 Image 的名稱是甚麼,也可以加上 tag,例如: -t sampleImage:2020.1。而最後的 . 是指定 Dockerfile 的位置,這是假設 Dockerfile就在當前目錄下。

呈上,較常見執行 Docker 指令的時候並不會剛好就跟 Dockerfile 在同一個目錄,所以可以透過 -f 來指定 Dockerfile 的路徑,例如:

1
docker build -t sampleImage -f /Sample/Sample.Web/Dockerfile .

最後還是要指定 . 讓 Docker 知道 Dockerfile 是在指定的目錄下。

Options 較常使用的就是 -t-f,其他的設定是比較建議在啟動 Container 時再指定,這樣才能依照需求去設定啟動的 Container,設定上會比較有彈性。

想要知道更多 docker build 的設定請參考 Docker官方介紹。

參考

[1] Docker 學習筆記 (四) — 如何撰寫Dockerfile
[2] 透過 Multi-Stage Builds 改善持續交付流程
[3] Dockerfile reference
[4] docker build
[5] [Docker] 多階段建置映像檔
[6] Docker – Dockerfile 指令教學,含範例解說