譯者按: Dockerfile 的文法非常簡單,然而如何加快鏡像建構速度,如何減少 Docker 鏡像的大小卻不是那麼直覺,需要積累實踐經驗。這篇部落格可以幫助你快速掌握編寫 Dockerfile 的技巧。
我已經使用 Docker 有一段時間了,其中編寫 Dockerfile 是非常重要的一部分工作。在這篇部落格中,我打算分享一些建議,幫助大家編寫更好的 Dockerfile。
目标:
- 更快的建構速度
- 更小的 Docker 鏡像大小
- 更少的 Docker 鏡像層
- 充分利用鏡像緩存
- 增加 Dockerfile 可讀性
- 讓 Docker 容器使用起來更簡單
總結
- 編寫.dockerignore 檔案
- 容器隻運作單個應用
- 将多個 RUN 指令合并為一個
- 基礎鏡像的标簽不要用 latest
- 每個 RUN 指令後删除多餘檔案
- 選擇合适的基礎鏡像(alpine 版本最好)
- 設定 WORKDIR 和 CMD
- 使用 ENTRYPOINT (可選)
- 在 entrypoint 腳本中使用 exec
- COPY 與 ADD 優先使用前者
- 合理調整 COPY 與 RUN 的順序
- 設定預設的環境變量,映射端口和資料卷
- 使用 LABEL 設定鏡像中繼資料
- 添加 HEALTHCHECK
示例
示例 Dockerfile 犯了幾乎所有的錯(當然我是故意的)。接下來,我會一步步優化它。假設我們需要使用 Docker 運作一個 Node.js 應用,下面就是它的 Dockerfile(CMD 指令太複雜了,是以我簡化了,它是錯誤的,僅供參考)。
FROM ubuntu
ADD . /app
RUN apt-get update
RUN apt-get upgrade -y
RUN apt-get install -y nodejs ssh mysql
RUN cd /app && npm install
# this should start three processes, mysql and ssh
# in the background and node app in foreground
# isn't it beautifully terrible? <3
CMD mysql & sshd & npm start
建構鏡像:
docker build -t wtf .
1. 編寫.dockerignore 檔案
建構鏡像時,Docker 需要先準備
context
,将所有需要的檔案收集到程序中。預設的
context
包含 Dockerfile 目錄中的所有檔案,但是實際上,我們并不需要.git 目錄,node_modules 目錄等内容。
.dockerignore
的作用和文法類似于
.gitignore
,可以忽略一些不需要的檔案,這樣可以有效加快鏡像建構時間,同時減少 Docker 鏡像的大小。示例如下:
.git/
node_modules/
2. 容器隻運作單個應用
從技術角度講,你可以在 Docker 容器中運作多個程序。你可以将資料庫,前端,後端,ssh,supervisor 都運作在同一個 Docker 容器中。但是,這會讓你非常痛苦:
- 非常長的建構時間(修改前端之後,整個後端也需要重新建構)
- 非常大的鏡像大小
- 多個應用的日志難以處理(不能直接使用 stdout,否則多個應用的日志會混合到一起)
- 橫向擴充時非常浪費資源(不同的應用需要運作的容器數并不相同)
- 僵屍程序問題 - 你需要選擇合适的 init 程序
是以,我建議大家為每個應用建構單獨的 Docker 鏡像,然後使用
Docker Compose運作多個 Docker 容器。
現在,我從 Dockerfile 中删除一些不需要的安裝包,另外,SSH 可以用
docker exec替代。示例如下:
FROM ubuntu
ADD . /app
RUN apt-get update
RUN apt-get upgrade -y
# we should remove ssh and mysql, and use
# separate container for database
RUN apt-get install -y nodejs # ssh mysql
RUN cd /app && npm install
CMD npm start
3. 将多個 RUN 指令合并為一個
Docker 鏡像是分層的,下面這些知識點非常重要:
- Dockerfile 中的每個指令都會建立一個新的鏡像層。
- 鏡像層将被緩存和複用
- 當 Dockerfile 的指令修改了,複制的檔案變化了,或者建構鏡像時指定的變量不同了,對應的鏡像層緩存就會失效
- 某一層的鏡像緩存失效之後,它之後的鏡像層緩存都會失效
- 鏡像層是不可變的,如果我們再某一層中添加一個檔案,然後在下一層中删除它,則鏡像中依然會包含該檔案(隻是這個檔案在 Docker 容器中不可見了)。
Docker 鏡像類似于洋蔥。它們都有很多層。為了修改内層,則需要将外面的層都删掉。記住這一點的話,其他内容就很好了解了。
現在,我們将所有的
RUN指令合并為一個。同時把
apt-get upgrade
删除,因為它會使得鏡像建構非常不确定(我們隻需要依賴基礎鏡像的更新就好了)
FROM ubuntu
ADD . /app
RUN apt-get update \
&& apt-get install -y nodejs \
&& cd /app \
&& npm install
CMD npm start
記住一點,我們隻能将變化頻率一樣的指令合并在一起。将 node.js 安裝與 npm 子產品安裝放在一起的話,則每次修改源代碼,都需要重新安裝 node.js,這顯然不合适。是以,正确的寫法是這樣的:
FROM ubuntu
RUN apt-get update && apt-get install -y nodejs
ADD . /app
RUN cd /app && npm install
CMD npm start
4. 基礎鏡像的标簽不要用 latest
當鏡像沒有指定标簽時,将預設使用
latest
标簽。是以,
FROM ubuntu
指令等同于
FROM ubuntu:latest
。當時,當鏡像更新時,latest 标簽會指向不同的鏡像,這時建構鏡像有可能失敗。如果你的确需要使用最新版的基礎鏡像,可以使用 latest 标簽,否則的話,最好指定确定的鏡像标簽。
示例 Dockerfile 應該使用
16.04
作為标簽。
FROM ubuntu:16.04 # it's that easy!
RUN apt-get update && apt-get install -y nodejs
ADD . /app
RUN cd /app && npm install
CMD npm start
5. 每個 RUN 指令後删除多餘檔案
假設我們更新了 apt-get 源,下載下傳,解壓并安裝了一些軟體包,它們都儲存在
/var/lib/apt/lists/
目錄中。但是,運作應用時 Docker 鏡像中并不需要這些檔案。我們最好将它們删除,因為它會使 Docker 鏡像變大。
示例 Dockerfile 中,我們可以删除
/var/lib/apt/lists/
目錄中的檔案(它們是由 apt-get update 生成的)。
FROM ubuntu:16.04
RUN apt-get update \
&& apt-get install -y nodejs \
# added lines
&& rm -rf /var/lib/apt/lists/*
ADD . /app
RUN cd /app && npm install
CMD npm start
6. 選擇合适的基礎鏡像(alpine 版本最好)
在示例中,我們選擇了
ubuntu
作為基礎鏡像。但是我們隻需要運作 node 程式,有必要使用一個通用的基礎鏡像嗎?
node
鏡像應該是更好的選擇。
FROM node
ADD . /app
# we don't need to install node
# anymore and use apt-get
RUN cd /app && npm install
CMD npm start
更好的選擇是 alpine 版本的
node
鏡像。alpine 是一個極小化的 Linux 發行版,隻有 4MB,這讓它非常适合作為基礎鏡像。
FROM node:7-alpine
ADD . /app
RUN cd /app && npm install
CMD npm start
apk 是 Alpine 的包管理工具。它與
apt-get
有些不同,但是非常容易上手。另外,它還有一些非常有用的特性,比如
no-cache
和
--virtual
選項,它們都可以幫助我們減少鏡像的大小。
7. 設定 WORKDIR 和 CMD
WORKDIR指令可以設定預設目錄,也就是運作
RUN
/
CMD
ENTRYPOINT
指令的地方。
CMD指令可以設定容器建立是執行的預設指令。另外,你應該講指令寫在一個數組中,數組中每個元素為指令的每個單詞(參考
官方文檔)。
FROM node:7-alpine
WORKDIR /app
ADD . /app
RUN npm install
CMD ["npm", "start"]
8. 使用 ENTRYPOINT (可選)
ENTRYPOINT指令并不是必須的,因為它會增加複雜度。
ENTRYPOINT
是一個腳本,它會預設執行,并且将指定的指令錯誤其參數。它通常用于建構可執行的 Docker 鏡像。entrypoint.sh 如下:
#!/usr/bin/env sh
# $0 is a script name,
# $1, $2, $3 etc are passed arguments
# $1 is our command
CMD=$1
case "$CMD" in
"dev" )
npm install
export NODE_ENV=development
exec npm run dev
;;
"start" )
# we can modify files here, using ENV variables passed in
# "docker create" command. It can't be done during build process.
echo "db: $DATABASE_ADDRESS" >> /app/config.yml
export NODE_ENV=production
exec npm start
;;
* )
# Run custom command. Thanks to this line we can still use
# "docker run our_image /bin/bash" and it will work
exec $CMD ${@:2}
;;
esac
示例 Dockerfile:
FROM node:7-alpine
WORKDIR /app
ADD . /app
RUN npm install
ENTRYPOINT ["./entrypoint.sh"]
CMD ["start"]
可以使用如下指令運作該鏡像:
# 運作開發版本
docker run our-app dev
# 運作生産版本
docker run our-app start
# 運作bash
docker run -it our-app /bin/bash
9. 在 entrypoint 腳本中使用 exec
在前文的 entrypoint 腳本中,我使用了
exec
指令運作 node 應用。不使用
exec
的話,我們則不能順利地關閉容器,因為 SIGTERM 信号會被 bash 腳本程序吞沒。
exec
指令啟動的程序可以取代腳本程序,是以所有的信号都會正常工作。
10. COPY 與 ADD 優先使用前者
COPY指令非常簡單,僅用于将檔案拷貝到鏡像中。
ADD相對來講複雜一些,可以用于下載下傳遠端檔案以及解壓壓縮包(參考
FROM node:7-alpine
WORKDIR /app
COPY . /app
RUN npm install
ENTRYPOINT ["./entrypoint.sh"]
CMD ["start"]
11. 合理調整 COPY 與 RUN 的順序
我們應該把變化最少的部分放在 Dockerfile 的前面,這樣可以充分利用鏡像緩存。
示例中,源代碼會經常變化,則每次建構鏡像時都需要重新安裝 NPM 子產品,這顯然不是我們希望看到的。是以我們可以先拷貝
package.json
,然後安裝 NPM 子產品,最後才拷貝其餘的源代碼。這樣的話,即使源代碼變化,也不需要重新安裝 NPM 子產品。
FROM node:7-alpine
WORKDIR /app
COPY package.json /app
RUN npm install
COPY . /app
ENTRYPOINT ["./entrypoint.sh"]
CMD ["start"]
12. 設定預設的環境變量,映射端口和資料卷
運作 Docker 容器時很可能需要一些環境變量。在 Dockerfile 設定預設的環境變量是一種很好的方式。另外,我們應該在 Dockerfile 中設定映射端口和資料卷。示例如下:
FROM node:7-alpine
ENV PROJECT_DIR=/app
WORKDIR $PROJECT_DIR
COPY package.json $PROJECT_DIR
RUN npm install
COPY . $PROJECT_DIR
ENV MEDIA_DIR=/media \
NODE_ENV=production \
APP_PORT=3000
VOLUME $MEDIA_DIR
EXPOSE $APP_PORT
ENTRYPOINT ["./entrypoint.sh"]
CMD ["start"]
ENV 指令指定的環境變量在容器中可以使用。如果你隻是需要指定建構鏡像時的變量,你可以使用
ARG指令。
13. 使用 LABEL 設定鏡像中繼資料
使用
LABEL指令,可以為鏡像設定中繼資料,例如鏡像建立者或者鏡像說明。舊版的 Dockerfile 文法使用
MAINTAINER指令指定鏡像建立者,但是它已經被棄用了。有時,一些外部程式需要用到鏡像的中繼資料,例如
nvidia-docker需要用到
com.nvidia.volumes.needed
。示例如下:
FROM node:7-alpine
LABEL maintainer "[email protected]"
...
14. 添加 HEALTHCHECK
運作容器時,可以指定
--restart always
選項。這樣的話,容器崩潰時,Docker 守護程序(docker daemon)會重新開機容器。對于需要長時間運作的容器,這個選項非常有用。但是,如果容器的确在運作,但是不可(陷入死循環,配置錯誤)用怎麼辦?使用
HEALTHCHECK指令可以讓 Docker 周期性的檢查容器的健康狀況。我們隻需要指定一個指令,如果一切正常的話傳回 0,否則傳回 1。對 HEALTHCHECK 感興趣的話,可以參考
這篇部落格FROM node:7-alpine
LABEL maintainer "[email protected]"
ENV PROJECT_DIR=/app
WORKDIR $PROJECT_DIR
COPY package.json $PROJECT_DIR
RUN npm install
COPY . $PROJECT_DIR
ENV MEDIA_DIR=/media \
NODE_ENV=production \
APP_PORT=3000
VOLUME $MEDIA_DIR
EXPOSE $APP_PORT
HEALTHCHECK CMD curl --fail http://localhost:$APP_PORT || exit 1
ENTRYPOINT ["./entrypoint.sh"]
CMD ["start"]
當請求失敗時,
curl --fail
指令傳回非 0 狀态。
本文采用意譯,版權歸原作者所有
轉載時請注明作者
以及本文位址:
https://blog.fundebug.com/2017/05/15/write-excellent-dockerfile/