Docker 的核心思想就是如何將應用整合到容器中,并且能在容器中實際運行。
將應用整合到容器中并且運行起來的這個過程,稱為“容器化”(Containerizing),有時也叫作“Docker化”(Dockerizing)。
容器是為應用而生的,具體來說,容器能夠簡化應用的構建、部署和運行過程。
完整的應用容器化過程主要分為以下幾個步驟。
? 編寫應用代碼。
? 創建一個 Dockerfile,其中包括當前應用的描述、依賴以及該如何運行這個應用。
? 對該 Dockerfile 執行 docker image build 命令。
? 等待 Docker 將應用程序構建到 Docker 鏡像中。
一旦應用容器化完成(即應用被打包為一個 Docker 鏡像),就能以鏡像的形式交付并以容器的方式運行了。
下圖展示了上述步驟。
接下來我們會逐步展示如何將一個簡單的單節點 Node.js Web 應用容器化。
如果是 Windows 操作系統的話,處理過程也是大同小異。
應用容器化的過程大致分為如下幾個步驟:
? 獲取應用代碼。
? 分析 Dockerfile。
? 構建應用鏡像。
? 運行該應用。
? 測試應用。
? 容器應用化細節。
? 生產環境中的多階段構建。
? 最佳實踐。
應用代碼可以從網盤獲取(https://pan.baidu.com/s/150UgIJPvuQUf0yO3KBLegg 提取碼:pkx4)。
$ cd psweb
$ ls -l
total 28
-rw-r--r-- 1 root root 341 Sep 29 16:26 app.js
-rw-r--r-- 1 root root 216 Sep 29 16:26 circle.yml
-rw-r--r-- 1 root root 338 Sep 29 16:26 Dockerfile
-rw-r--r-- 1 root root 421 Sep 29 16:26 package.json
-rw-r--r-- 1 root root 370 Sep 29 16:26 README.md
drwxr-xr-x 2 root root 4096 Sep 29 16:26 test
drwxr-xr-x 2 root root 4096 Sep 29 16:26 views
該目錄下包含了全部的應用源碼,以及包含界面和單元測試的子目錄。這個應用結構非常簡單。
應用代碼準備就緒后,接下來分析一下 Dockerfile 的具體內容。
在代碼目錄當中,有個名稱為 Dockerfile 的文件。這個文件包含了對當前應用的描述,并且能指導 Docker 完成鏡像的構建。
在 Docker 當中,包含應用文件的目錄通常被稱為構建上下文(Build Context)。通常將 Dockerfile 放到構建上下文的根目錄下。
另外很重要的一點是,文件開頭字母是大寫 D,這里是一個單詞。像“dockerfile”或者“Docker file”這種寫法都是不允許的。
接下來了解一下 Dockerfile 文件當中都包含哪些具體內容。
$ cat Dockerfile
FROM alpine
LABEL maintainer="nigelpoulton@hotmail.com"
RUN apk add --update nodejs nodejs-npm
COPY . /src
WORKDIR /src
RUN npm install
EXPOSE 8080
ENTRYPOINT ["node", "./app.js"]
Dockerfile 主要包括兩個用途:
? 對當前應用的描述。
? 指導 Docker 完成應用的容器化(創建一個包含當前應用的鏡像)。
不要因 Dockerfile 就是一個描述文件而對其有所輕視!Dockerfile 能實現開發和部署兩個過程的無縫切換。
同時 Dockerfile 還能幫助新手快速熟悉這個項目。Dockerfile 對當前的應用及其依賴有一個清晰準確的描述,并且非常容易閱讀和理解。
因此,要像重視你的代碼一樣重視這個文件,并且將它納入到源控制系統當中。
下面是這個文件中的一些關鍵步驟概述:以 alpine 鏡像作為當前鏡像基礎,指定維護者(maintainer)為“nigelpoultion@hotmail.com”,安裝 Node.js 和 NPM,將應用的代碼復制到鏡像當中,設置新的工作目錄,安裝依賴包,記錄應用的網絡端口,最后將 app.js 設置為默認運行的應用。
具體分析一下每一步的作用。
每個 Dockerfile 文件第一行都是 FROM 指令。
FROM 指令指定的鏡像,會作為當前鏡像的一個基礎鏡像層,當前應用的剩余內容會作為新增鏡像層添加到基礎鏡像層之上。
本例中的應用基于 Linux 操作系統,所以在 FROM 指令當中所引用的也是一個 Linux 基礎鏡像;如果要容器化的應用是一個基于 Windows 操作系統的應用,就需要指定一個像 microsoft/aspnetcore-build 這樣的 Windows 基礎鏡像了。
截至目前,基礎鏡像的結構如下圖所示。
接下來,Dockerfile 中通過標簽(LABLE)方式指定了當前鏡像的維護者為“nigelpoulton@hotmail. com”。
每個標簽其實是一個鍵值對(Key-Value),在一個鏡像當中可以通過增加標簽的方式來為鏡像添加自定義元數據。
備注維護者信息有助于為該鏡像的潛在使用者提供溝通途徑,這是一種值得提倡的做法。
RUN apk add --update nodejs nodejs-npm 指令使用 alpine 的 apk 包管理器將 nodejs 和 nodejs-npm 安裝到當前鏡像之中。
RUN 指令會在 FROM 指定的 alpine 基礎鏡像之上,新建一個鏡像層來存儲這些安裝內容。當前鏡像的結構如下圖所示。
COPY. / src 指令將應用相關文件從構建上下文復制到了當前鏡像中,并且新建一個鏡像層來存儲。COPY 執行結束之后,當前鏡像共包含 3 層,如下圖所示。
下一步,Dockerfile 通過 WORKDIR 指令,為 Dockerfile 中尚未執行的指令設置工作目錄。
該目錄與鏡像相關,并且會作為元數據記錄到鏡像配置中,但不會創建新的鏡像層。
然后,RUN npm install 指令會根據 package.json 中的配置信息,使用 npm 來安裝當前應用的相關依賴包。
npm 命令會在前文設置的工作目錄中執行,并且在鏡像中新建鏡像層來保存相應的依賴文件。
目前鏡像一共包含 4 層,如下圖所示。
因為當前應用需要通過 TCP 端口 8080 對外提供一個 Web 服務,所以在 Dockerfile 中通過 EXPOSE 8080 指令來完成相應端口的設置。
這個配置信息會作為鏡像的元數據被保存下來,并不會產生新的鏡像層。
最終,通過 ENTRYPOINT 指令來指定當前鏡像的入口程序。ENTRYPOINT 指定的配置信息也是通過鏡像元數據的形式保存下來,而不是新增鏡像層。
到目前為止,應該已經了解基本的原理和流程,接下來是時候嘗試構建自己的鏡像了。
下面的命令會構建并生成一個名為 web:latest 的鏡像。命令最后的點(.)表示 Docker 在進行構建的時候,使用當前目錄作為構建上下文。
一定要在命令最后包含這個點,并且在執行命令前,要確認當前目錄是 psweb(包含 Dockerfile 和應用代碼的目錄)。
命令執行結束后,檢查本地 Docker 鏡像庫是否包含了剛才構建的鏡像。
$ docker image ls
REPO TAG IMAGE ID CREATED SIZE
web latest fc69fdc4c18e 10 seconds ago 64.4MB
恭喜,應用容器化已經成功了!
讀者可以通過 docker image inspect web:latest 來確認剛剛構建的鏡像配置是否正確。這個命令會列出 Dockerfile 中設置的所有配置項。
在創建一個鏡像之后,將其保存在一個鏡像倉庫服務是一個不錯的方式。這樣存儲鏡像會比較安全,并且可以被其他人訪問使用。
Docker Hub 就是這樣的一個開放的公共鏡像倉庫服務,并且這也是docker image push 命令默認的推送地址。
在推送鏡像之前,需要先使用 Docker ID 登錄 Docker Hub。除此之外,還需要為待推送的鏡像打上合適的標簽。
接下來介紹一下如何登錄 Docker Hub,并將鏡像推送到其中。
在后續的例子中,需要用自己的 Docker ID 替換示例中所使用的 ID。所以每當看到“nigelpoulton”時,記得替換為自己的 Docker ID。
$ docker login
Login with **your** Docker ID to push and pull images from Docker Hub...
Username: nigelpoulton
Password:
Login Succeeded
推送 Docker 鏡像之前,還需要為鏡像打標簽。這是因為 Docker 在鏡像推送的過程中需要如下信息。
? Registry(鏡像倉庫服務)。
? Repository(鏡像倉庫)。
? Tag(鏡像標簽)。
無須為 Registry 和 Tag 指定值。當沒有為上述信息指定具體值的時候,Docker 會默認 Registry=docker.io、Tag=latest。
但是 Docker 并沒有給 Repository 提供默認值,而是從被推送鏡像中的 REPOSITORY 屬性值獲取。
這一點可能不好理解,下面會通過一個完整的例子來介紹如何向 Docker Hub 中推送一個鏡像。
在前面的例子中執行了 docker image ls 命令。在該命令對應的輸出內容中可以看到,鏡像倉庫的名稱是 web。
這意味著執行 docker image push 命令,會嘗試將鏡像推送到 docker.io/web:latest 中。
但是其實 nigelpoulton 這個用戶并沒有 web 這個鏡像倉庫的訪問權限,所以只能嘗試推送到 nigelpoulton 這個二級命名空間(Namespace)之下。
因此需要使用 nigelpoulton 這個 ID,為當前鏡像重新打一個標簽。
$ docker image tag web:latest nigelpoulton/web:latest
為鏡像打標簽命令的格式是docker image tag<current-tag> <new-tag> ,其作用是為指定的鏡像添加一個額外的標簽,并且不需要覆蓋已經存在的標簽。
再次執行 docker image ls 命令,可以看到這個鏡像現在有了兩個標簽,其中一個包含 Docker ID nigelpoulton。
$ docker image ls
REPO TAG IMAGE ID CREATED SIZE
web latest fc69fdc4c18e 10 secs ago 64.4MB
nigelpoulton/web latest fc69fdc4c18e 10 secs ago 64.4MB
現在將該鏡像推送到 Docker Hub。
$ docker image push nigelpoulton/web:latest
The push refers to repository [docker.io/nigelpoulton/web]
2444b4ec39ad: Pushed
ed8142d2affb: Pushed
d77e2754766d: Pushed
cd7100a72410: Mounted from library/alpine
latest: digest: sha256:68c2dea730...f8cf7478 size: 1160
下圖展示了 Docker 如何確定鏡像所要推送的目的倉庫。
因為權限問題,所以需要把上面例子中出現的 ID(nigelpoulton)替換為自己的 Docker ID,才能進行推送操作。
在接下來的例子當中,將使用 web:latest 這個標簽。
前文中容器化的這個應用程序其實很簡單,從 app.js 這個文件內容中可以看出,這其實就是一個在 8080 端口提供 Web 服務的應用程序。
下面的命令會基于 web:latest 這個鏡像,啟動一個名為 c1 的容器。該容器將內部的 8080 端口與 Docker 主機的 80 端口進行映射。
這意味讀者可以打開一個瀏覽器,在地址欄輸入 Docker 主機的 DNS 名稱或者 IP 地址,然后就能直接訪問這個 Web 應用了。
如果 Docker 主機已經運行了某個使用 80 端口的應用程序,讀者可以在執行 docker container run 命令時指定一個不同的映射端口。例如,可以使用 -p 5000:8080 參數,將 Docker 內部應用程序的 8080 端口映射到主機的 5000 端口。
$ docker container run -d --name c1 \
-p 80:8080 \
web:latest
-d 參數的作用是讓應用程序以守護線程的方式在后臺運行。
-p 80:8080 參數的作用是將主機的80端口與容器內的8080端口進行映射。
接下來驗證一下程序是否真的成功運行,并且對外提供服務的端口是否正常工作。
$ docker container ls
ID IMAGE COMMAND STATUS PORTS
49.. web:latest "node ./app.js" UP 6 secs 0.0.0.0:80->8080/tcp
為了方便閱讀,只截取了命令輸出內容的一部分。從上面的輸出內容中可以看到,容器已經正常運行。需要注意的是,80端口已經成功映射到了 8080 之上,并且任意外部主機(0.0.0.0:80)均可以通過 80 端口訪問該容器。
打開瀏覽器,在地址欄輸入 DNS 名稱或者 IP 地址,就能訪問到正在運行的應用程序了。可以看到下圖所示的界面。
如果沒有出現這樣的界面,嘗試執行下面的檢查來確認原因所在。
使用 docker container ls指令來確認容器已經啟動并且正常運行。容器名稱是c1,并且從輸出內容中能看到 0.0.0.0:80->8080/tcp。
確認防火墻或者其他網絡安全設置沒有阻止訪問 Docker 主機的 80 端口。
如此,應用程序已經容器化并成功運行了。
到現在為止,應當成功完成一個示例應用程序的容器化。下面是其中一些細節部分的回顧和總結。
Dockerfile 中的注釋行,都是以#開頭的。
除注釋之外,每一行都是一條指令(Instruction)。指令的格式是指令參數如下。
INSTRUCTION argument
指令是不區分大小寫的,但是通常都采用大寫的方式。這樣 Dockerfile 的可讀性會高一些。
Docker image build命令會按行來解析 Dockerfile 中的指令并順序執行。
部分指令會在鏡像中創建新的鏡像層,其他指令只會增加或修改鏡像的元數據信息。
在上面的例子當中,新增鏡像層的指令包括 FROM、RUN 以及 COPY,而新增元數據的指令包括 EXPOSE、WORKDIR、ENV以 及 ENTERPOINT。
關于如何區分命令是否會新建鏡像層,一個基本的原則是,如果指令的作用是向鏡像中增添新的文件或者程序,那么這條指令就會新建鏡像層;如果只是告訴 Docker 如何完成構建或者如何運行應用程序,那么就只會增加鏡像的元數據。
可以通過docker image history 來查看在構建鏡像的過程中都執行了哪些指令。
在上面的輸出內容當中,有兩點是需要注意的。
首先,每行內容都對應了 Dockerfile 中的一條指令(順序是自下而上)。CREATE BY 這一列中還展示了當前行具體對應 Dockerfile 中的哪條指令。
其次,從這個輸出內容中,可以觀察到只有 4 條指令會新建鏡像層(就是那些 SIZE 列對應的數值不為零的指令),分別對應 Dockerfile 中的 FROM、RUN 以及 COPY 指令。
雖然其他指令看上去跟這些新建鏡像層的指令并無區別,但實際上它們只在鏡像中新增了元數據信息。這些指令之所以看起來沒有區別,是因為 Docker 對之前構建鏡像層方式的兼容。
可以通過執行 docker image inspect 指令來確認確實只有 4 個層被創建了。
$ docker image inspect web:latest
<Snip>
},
"RootFS": {
"Type": "layers",
"Layers": [
"sha256:cd7100...1882bd56d263e02b6215",
"sha256:b3f88e...cae0e290980576e24885",
"sha256:3cfa21...cc819ef5e3246ec4fe16",
"sha256:4408b4...d52c731ba0b205392567"
]
},
使用 FROM 指令引用官方基礎鏡像是一個很好的習慣,這是因為官方的鏡像通常會遵循一些最佳實踐,并且能幫助使用者規避一些已知的問題。
除此之外,使用 FROM 的時候選擇一個相對較小的鏡像文件通常也能避免一些潛在的問題。
通過 docker image build 命令具體的輸出內容,可以了解鏡像構建的過程。
在下面的片段中,可以看到基本的構建過程是,運行臨時容器 -> 在該容器中運行 Dockerfile 中的指令 -> 將指令運行結果保存為一個新的鏡像層 -> 刪除臨時容器。
Step 3/8 : RUN apk add --update nodejs nodejs-npm
---> Running in e690ddca785f << Run inside of temp container
fetch http://dl-cdn...APKINDEX.tar.gz
fetch http://dl-cdn...APKINDEX.tar.gz
(1/10) Installing ca-certificates (20171114-r0)
<Snip>
OK: 61 MiB in 21 packages
---> c1d31d36b81f << Create new layer
Removing intermediate container << Remove temp container
Step 4/8 : COPY . /src
生產環境中的多階段構建
對于 Docker 鏡像來說,過大的體積并不好!
越大則越慢,這就意味著更難使用,而且可能更加脆弱,更容易遭受攻擊。
鑒于此,Docker 鏡像應該盡量小。對于生產環境鏡像來說,目標是將其縮小到僅包含運行應用所必需的內容即可。問題在于,生成較小的鏡像并非易事。
不同的 Dockerfile 寫法就會對鏡像的大小產生顯著影響。
常見的例子是,每一個 RUN 指令會新增一個鏡像層。因此,通過使用 && 連接多個命令以及使用反斜杠(\)換行的方法,將多個命令包含在一個 RUN 指令中,通常來說是一種值得提倡的方式。
另一個問題是開發者通常不會在構建完成后進行清理。當使用 RUN 執行一個命令時,可能會拉取一些構建工具,這些工具會留在鏡像中移交至生產環境。
有多種方式來改善這一問題——比如常見的是采用建造者模式(Builder Pattern)。但無論采用哪種方式,通常都需要額外的培訓,并且會增加構建的復雜度。
建造者模式需要至少兩個 Dockerfile,一個用于開發環境,一個用于生產環境。
首先需要編寫 Dockerfile.dev,它基于一個大型基礎鏡像(Base Image),拉取所需的構建工具,并構建應用。
接下來,需要基于 Dockerfile.dev 構建一個鏡像,并用這個鏡像創建一個容器。
這時再編寫 Dockerfile.prod,它基于一個較小的基礎鏡像開始構建,并從剛才創建的容器中將應用程序相關的部分復制過來。
整個過程需要編寫額外的腳本才能串聯起來。
這種方式是可行的,但是比較復雜。
多階段構建(Multi-Stage Build)是一種更好的方式!
多階段構建能夠在不增加復雜性的情況下優化構建過程。
下面介紹一下多階段構建方式。
多階段構建方式使用一個 Dockerfile,其中包含多個 FROM 指令。每一個 FROM 指令都是一個新的構建階段(Build Stage),并且可以方便地復制之前階段的構件。
示例源碼可從百度網盤獲取(https://pan.baidu.com/s/1M2paPY0f0lE5wm48HBk-Zw 提取碼: 2e7s ),Dockerfile 位于app目錄。
這是一個基于 Linux 系統的應用,因此只能運行在 Linux 容器環境上。
Dockerfile 如下所示。
FROM node:latest AS storefront
WORKDIR /usr/src/atsea/app/react-app
COPY react-app .
RUN npm install
RUN npm run build
FROM maven:latest AS appserver
WORKDIR /usr/src/atsea
COPY pom.xml .
RUN mvn -B -f pom.xml -s /usr/share/maven/ref/settings-docker.xml dependency
\:resolve
COPY . .
RUN mvn -B -s /usr/share/maven/ref/settings-docker.xml package -DskipTests
FROM java:8-jdk-alpine AS production
RUN adduser -Dh /home/gordon gordon
WORKDIR /static
COPY --from=storefront /usr/src/atsea/app/react-app/build/ .
WORKDIR /app
COPY --from=appserver /usr/src/atsea/target/AtSea-0.0.1-SNAPSHOT.jar .
ENTRYPOINT ["java", "-jar", "/app/AtSea-0.0.1-SNAPSHOT.jar"]
CMD ["--spring.profiles.active=postgres"]
首先注意到,Dockerfile 中有 3 個 FROM 指令。每一個 FROM 指令構成一個單獨的構建階段。
各個階段在內部從 0 開始編號。不過,示例中針對每個階段都定義了便于理解的名字。
? 階段 0 叫作 storefront。
? 階段 1 叫作 appserver。
? 階段 2 叫作 production。
storefront 階段拉取了大小超過 600MB 的 node:latest 鏡像,然后設置了工作目錄,復制一些應用代碼進去,然后使用 2 個 RUN 指令來執行 npm 操作。
這會生成 3 個鏡像層并顯著增加鏡像大小。指令執行結束后會得到一個比原鏡像大得多的鏡像,其中包含許多構建工具和少量應用程序代碼。
appserver 階段拉取了大小超過 700MB 的 maven:latest 鏡像。然后通過 2 個 COPY 指令和 2 個 RUN 指令生成了 4 個鏡像層。
這個階段同樣會構建出一個非常大的包含許多構建工具和非常少量應用程序代碼的鏡像。
production 階段拉取 java:8-jdk-alpine 鏡像,這個鏡像大約 150MB,明顯小于前兩個構建階段用到的 node 和 maven 鏡像。
這個階段會創建一個用戶,設置工作目錄,從 storefront 階段生成的鏡像中復制一些應用代碼過來。
之后,設置一個不同的工作目錄,然后從 appserver 階段生成的鏡像中復制應用相關的代碼。最后,production 設置當前應用程序為容器啟動時的主程序。
重點在于 COPY --from 指令,它從之前的階段構建的鏡像中僅復制生產環境相關的應用代碼,而不會復制生產環境不需要的構件。
還有一點也很重要,多階段構建這種方式僅用到了一個 Dockerfile,并且 docker image build 命令不需要增加額外參數。
下面演示一下構建操作。克隆代碼庫并切換到 app 目錄,并確保其中有 Dockerfile。
$ cd atsea-sample-shop-app/app
$ ls -l
total 24
-rw-r--r-- 1 root root 682 Oct 1 22:03 Dockerfile
-rw-r--r-- 1 root root 4365 Oct 1 22:03 pom.xml
drwxr-xr-x 4 root root 4096 Oct 1 22:03 react-app
drwxr-xr-x 4 root root 4096 Oct 1 22:03 src
執行構建(這可能會花費幾分鐘)。
$ docker image build -t multi:stage .
Sending build context to Docker daemon 3.658MB
Step 1/19 : FROM node:latest AS storefront
latest: Pulling from library/node
aa18ad1a0d33: Pull complete
15a33158a136: Pull complete
<Snip>
Step 19/19 : CMD --spring.profiles.active=postgres
---> Running in b4df9850f7ed
---> 3dc0d5e6223e
Removing intermediate container b4df9850f7ed
Successfully built 3dc0d5e6223e
Successfully tagged multi:stage
示例中 multi:stage 標簽是自行定義的,可以根據自己的需要和規范來指定標簽名稱。不過并不要求一定必須為多階段構建指定標簽。
執行 docker image ls 命令查看由構建命令拉取和生成的鏡像。
$ docker image ls
REPO TAG IMAGE ID CREATED SIZE
node latest 9ea1c3e33a0b 4 days ago 673MB
<none> <none> 6598db3cefaf 3 mins ago 816MB
maven latest cbf114925530 2 weeks ago 750MB
<none> <none> d5b619b83d9e 1 min ago 891MB
java 8-jdk-alpine 3fd9dd82815c 7 months ago 145MB
multi stage 3dc0d5e6223e 1 min ago 210MB
輸出內容的第一行顯示了在 storefront 階段拉取的 node:latest 鏡像,下一行內容為該階段生成的鏡像(通過添加代碼,執行 npm 安裝和構建操作生成該鏡像)。
這兩個都包含許多的構建工具,因此鏡像體積非常大。
第 3~4 行是在 appserver 階段拉取和生成的鏡像,它們也都因為包含許多構建工具而導致體積較大。
最后一行是 Dockerfile 中的最后一個構建階段(stage2/production)生成的 multi:stage 鏡像。
可見它明顯比之前階段拉取和生成的鏡像要小。這是因為該鏡像是基于相對精簡的 java:8-jdk-alpine 鏡像構建的,并且僅添加了用于生產環境的應用程序文件。
最終,無須額外的腳本,僅對一個單獨的 Dockerfile 執行 docker image build 命令,就創建了一個精簡的生產環境鏡像。
多階段構建是隨 Docker 17.05 版本新增的一個特性,用于構建精簡的生產環境鏡像。
下面介紹一些最佳實踐。
Docker 的構建過程利用了緩存機制。觀察緩存效果的一個方法,就是在一個干凈的 Docker 主機上構建一個新的鏡像,然后再重復同樣的構建。
第一次構建會拉取基礎鏡像,并構建鏡像層,構建過程需要花費一定時間;第二次構建幾乎能夠立即完成。
這就是因為第一次構建的內容(如鏡像層)能夠被緩存下來,并被后續的構建過程復用。
docker image build 命令會從頂層開始解析 Dockerfile 中的指令并逐行執行。而對每一條指令,Docker 都會檢查緩存中是否已經有與該指令對應的鏡像層。
如果有,即為緩存命中(Cache Hit),并且會使用這個鏡像層;如果沒有,則是緩存未命中(Cache Miss),Docker 會基于該指令構建新的鏡像層。
緩存命中能夠顯著加快構建過程。
下面通過實例演示其效果。
示例用的 Dockerfile 如下。
FROM alpine
RUN apk add --update nodejs nodejs-npm
COPY . /src
WORKDIR /src
RUN npm install
EXPOSE 8080
ENTRYPOINT ["node", "./app.js"]
第一條指令告訴 Docker 使用 alpine:latest 作為基礎鏡像。
如果主機中已經存在這個鏡像,那么構建時會直接跳到下一條指令;如果鏡像不存在,則會從 Docker Hub(docker.io)拉取。
下一條指令(RUN apk...)對鏡像執行一條命令。
此時,Docker 會檢查構建緩存中是否存在基于同一基礎鏡像,并且執行了相同指令的鏡像層。
在此例中,Docker 會檢查緩存中是否存在一個基于 alpine:latest 鏡像且執行了 RUN apk add --update nodejs nodejs-npm 指令構建得到的鏡像層。
如果找到該鏡像層,Docker 會跳過這條指令,并鏈接到這個已經存在的鏡像層,然后繼續構建;如果無法找到符合要求的鏡像層,則設置緩存無效并構建該鏡像層。
此處“設置緩存無效”作用于本次構建的后續部分。也就是說 Dockerfile 中接下來的指令將全部執行而不會再嘗試查找構建緩存。
假設 Docker 已經在緩存中找到了該指令對應的鏡像層(緩存命中),并且假設這個鏡像層的 ID 是 AAA。
下一條指令會復制一些代碼到鏡像中(COPY . /src)。因為上一條指令命中了緩存,Docker 會繼續查找是否有一個緩存的鏡像層也是基于 AAA 層并執行了 COPY . /src 命令。
如果有,Docker 會鏈接到這個緩存的鏡像層并繼續執行后續指令;如果沒有,則構建鏡像層,并對后續的構建操作設置緩存無效。
假設 Docker 已經有一個對應該指令的緩存鏡像層(緩存命中),并且假設這個鏡像層的 ID 是 BBB。
那么 Docker 將繼續執行 Dockerfile 中剩余的指令。
理解以下幾點很重要。
首先,一旦有指令在緩存中未命中(沒有該指令對應的鏡像層),則后續的整個構建過程將不再使用緩存。
在編寫 Dockerfile 時須特別注意這一點,盡量將易于發生變化的指令置于 Dockerfile 文件的后方執行。
這意味著緩存未命中的情況將直到構建的后期才會出現,從而構建過程能夠盡量從緩存中獲益。
通過對 docker image build 命令加入 --nocache=true 參數可以強制忽略對緩存的使用。
還有一點也很重要,那就是 COPY 和 ADD 指令會檢查復制到鏡像中的內容自上一次構建之后是否發生了變化。
例如,有可能 Dockerfile 中的 COPY . /src 指令沒有發生變化,但是被復制的目錄中的內容已經發生變化了。
為了應對這一問題,Docker 會計算每一個被復制文件的 Checksum 值,并與緩存鏡像層中同一文件的 checksum 進行對比。如果不匹配,那么就認為緩存無效并構建新的鏡像層。
合并鏡像并非一個最佳實踐,因為這種方式利弊參半。
總體來說,Docker 會遵循正常的方式構建鏡像,但之后會增加一個額外的步驟,將所有的內容合并到一個鏡像層中。
當鏡像中層數太多時,合并是一個不錯的優化方式。例如,當創建一個新的基礎鏡像,以便基于它來構建其他鏡像的時候,這個基礎鏡像就最好被合并為一層。
缺點是,合并的鏡像將無法共享鏡像層。這會導致存儲空間的低效利用,而且 push 和 pull 操作的鏡像體積更大。
執行 docker image build命令時,可以通過增加 --squash 參數來創建一個合并的鏡像。
下圖闡釋了合并鏡像層帶來的存儲空間低效利用的問題。
兩個鏡像的內容是完全一樣的,區別在于是否進行了合并。在使用 docker image push 命令發送鏡像到 Docker Hub 時,合并的鏡像需要發送全部字節,而不合并的鏡像只需要發送不同的鏡像層即可。
在構建 Linux 鏡像時,若使用的是 APT 包管理器,則應該在執行 apt-get install 命令時增加 no-install-recommends 參數。
這能夠確保 APT 僅安裝核心依賴(Depends 中定義)包,而不是推薦和建議的包。這樣能夠顯著減少不必要包的下載數量。
在構建 Windows 鏡像時,盡量避免使用 MSI 包管理器。因其對空間的利用率不高,會大幅增加鏡像的體積。