如何編寫最佳的Dockerfile
譯者按: Dockerfile的語法非常簡單,然而如何加快鏡像構建速度,如何減少Docker鏡像的大小卻不是那麼直觀,需要積累實踐經驗。這篇博客可以幫助你快速掌握編寫Dockerfile的技巧。
原文: How to write excellent Dockerfiles
譯者: Fundebug
為了保證可讀性,本文採用意譯而非直譯。另外,本文版權歸原作者所有,翻譯僅用於學習。
我已經使用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 ubuntunnADD . /appnnRUN apt-get update nRUN apt-get upgrade -y nRUN apt-get install -y nodejs ssh mysql nRUN cd /app && npm installnn# this should start three processes, mysql and sshn# in the background and node app in foregroundn# isnt it beautifully terrible? <3nCMD mysql & sshd & npm startn
構建鏡像:
docker build -t wtf .n
1. 編寫.dockerignore文件
構建鏡像時,Docker需要先準備context ,將所有需要的文件收集到進程中。默認的context包含Dockerfile目錄中的所有文件,但是實際上,我們並不需要.git目錄,node_modules目錄等內容。 .dockerignore 的作用和語法類似於 .gitignore,可以忽略一些不需要的文件,這樣可以有效加快鏡像構建時間,同時減少Docker鏡像的大小。示例如下:
.git/nnode_modules/n
2. 容器只運行單個應用
從技術角度講,你可以在Docker容器中運行多個進程。你可以將資料庫,前端,後端,ssh,supervisor都運行在同一個Docker容器中。但是,這會讓你非常痛苦:
- 非常長的構建時間(修改前端之後,整個後端也需要重新構建)
- 非常大的鏡像大小
- 多個應用的日誌難以處理(不能直接使用stdout,否則多個應用的日誌會混合到一起)
- 橫向擴展時非常浪費資源(不同的應用需要運行的容器數並不相同)
- 殭屍進程問題 - 你需要選擇合適的init進程
因此,我建議大家為每個應用構建單獨的Docker鏡像,然後使用 Docker Compose 運行多個Docker容器。
現在,我從Dockerfile中刪除一些不需要的安裝包,另外,SSH可以用docker exec替代。示例如下:
FROM ubuntunnADD . /appnnRUN apt-get update nRUN apt-get upgrade -ynn# we should remove ssh and mysql, and usen# separate container for database nRUN apt-get install -y nodejs # ssh mysql nRUN cd /app && npm installnnCMD npm startn
3. 將多個RUN指令合併為一個
Docker鏡像是分層的,下面這些知識點非常重要:
- Dockerfile中的每個指令都會創建一個新的鏡像層。
- 鏡像層將被緩存和復用
- 當Dockerfile的指令修改了,複製的文件變化了,或者構建鏡像時指定的變數不同了,對應的鏡像層緩存就會失效
- 某一層的鏡像緩存失效之後,它之後的鏡像層緩存都會失效
- 鏡像層是不可變的,如果我們再某一層中添加一個文件,然後在下一層中刪除它,則鏡像中依然會包含該文件(只是這個文件在Docker容器中不可見了)。
Docker鏡像類似於洋蔥。它們都有很多層。為了修改內層,則需要將外面的層都刪掉。記住這一點的話,其他內容就很好理解了。
現在,我們將所有的RUN指令合併為一個。同時把apt-get upgrade刪除,因為它會使得鏡像構建非常不確定(我們只需要依賴基礎鏡像的更新就好了)
FROM ubuntunnADD . /appnnRUN apt-get update n && apt-get install -y nodejs n && cd /app n && npm installnnCMD npm startn
記住一點,我們只能將變化頻率一樣的指令合併在一起。將node.js安裝與npm模塊安裝放在一起的話,則每次修改源代碼,都需要重新安裝node.js,這顯然不合適。因此,正確的寫法是這樣的:
FROM ubuntunnRUN apt-get update && apt-get install -y nodejs nADD . /app nRUN cd /app && npm installnnCMD npm startn
4. 基礎鏡像的標籤不要用latest
當鏡像沒有指定標籤時,將默認使用latest 標籤。因此, FROM ubuntu 指令等同於FROM ubuntu:latest。當時,當鏡像更新時,latest標籤會指向不同的鏡像,這時構建鏡像有可能失敗。如果你的確需要使用最新版的基礎鏡像,可以使用latest標籤,否則的話,最好指定確定的鏡像標籤。
示例Dockerfile應該使用16.04作為標籤。
FROM ubuntu:16.04 # its that easy!nnRUN apt-get update && apt-get install -y nodejs nADD . /app nRUN cd /app && npm installnnCMD npm startn
5. 每個RUN指令後刪除多餘文件
假設我們更新了apt-get源,下載,解壓並安裝了一些軟體包,它們都保存在/var/lib/apt/lists/目錄中。但是,運行應用時Docker鏡像中並不需要這些文件。我們最好將它們刪除,因為它會使Docker鏡像變大。
示例Dockerfile中,我們可以刪除/var/lib/apt/lists/目錄中的文件(它們是由apt-get update生成的)。
FROM ubuntu:16.04nnRUN apt-get update n && apt-get install -y nodejs n # added linesn && rm -rf /var/lib/apt/lists/*nnADD . /app nRUN cd /app && npm installnnCMD npm startn
6. 選擇合適的基礎鏡像(alpine版本最好)
在示例中,我們選擇了ubuntu作為基礎鏡像。但是我們只需要運行node程序,有必要使用一個通用的基礎鏡像嗎?node鏡像應該是更好的選擇。
FROM nodennADD . /app n# we dont need to install node n# anymore and use apt-getnRUN cd /app && npm installnnCMD npm startn
更好的選擇是alpine版本的node鏡像。alpine是一個極小化的Linux發行版,只有4MB,這讓它非常適合作為基礎鏡像。
FROM node:7-alpinennADD . /app nRUN cd /app && npm installnnCMD npm startn
apk是Alpine的包管理工具。它與apt-get有些不同,但是非常容易上手。另外,它還有一些非常有用的特性,比如no-cache和 --virtual選項,它們都可以幫助我們減少鏡像的大小。
7. 設置WORKDIR和 CMD
WORKDIR指令可以設置默認目錄,也就是運行RUN / CMD / ENTRYPOINT指令的地方。
CMD指令可以設置容器創建是執行的默認命令。另外,你應該講命令寫在一個數組中,數組中每個元素為命令的每個單詞(參考官方文檔)。
FROM node:7-alpinennWORKDIR /app nADD . /app nRUN npm installnnCMD ["npm", "start"]n
8. 使用ENTRYPOINT (可選)
ENTRYPOINT指令並不是必須的,因為它會增加複雜度。ENTRYPOINT是一個腳本,它會默認執行,並且將指定的命令錯誤其參數。它通常用於構建可執行的Docker鏡像。entrypoint.sh如下:
#!/usr/bin/env shn# $0 is a script name, n# $1, $2, $3 etc are passed argumentsn# $1 is our commandnCMD=$1nncase "$CMD" in n "dev" )n npm installn export NODE_ENV=developmentn exec npm run devn ;;nn "start" )n # we can modify files here, using ENV variables passed in n # "docker create" command. It cant be done during build process.n echo "db: $DATABASE_ADDRESS" >> /app/config.ymln export NODE_ENV=productionn exec npm startn ;;nn * )n # Run custom command. Thanks to this line we can still use n # "docker run our_image /bin/bash" and it will workn exec $CMD ${@:2}n ;;nesacn
示例Dockerfile:
FROM node:7-alpinennWORKDIR /app nADD . /app nRUN npm installnnENTRYPOINT ["./entrypoint.sh"] nCMD ["start"]n
可以使用如下命令運行該鏡像:
# 運行開發版本ndocker run our-app dev nn# 運行生產版本ndocker run our-app start nn# 運行bashndocker run -it our-app /bin/bashn
9. 在entrypoint腳本中使用exec
在前文的entrypoint腳本中,我使用了exec命令運行node應用。不使用exec的話,我們則不能順利地關閉容器,因為SIGTERM信號會被bash腳本進程吞沒。exec命令啟動的進程可以取代腳本進程,因此所有的信號都會正常工作。
10. COPY與ADD優先使用前者
COPY指令非常簡單,僅用於將文件拷貝到鏡像中。ADD相對來講複雜一些,可以用於下載遠程文件以及解壓壓縮包(參考官方文檔)。
FROM node:7-alpinennWORKDIR /appnnCOPY . /app nRUN npm installnnENTRYPOINT ["./entrypoint.sh"] nCMD ["start"]n
11. 合理調整COPY與RUN的順序
我們應該把變化最少的部分放在Dockerfile的前面,這樣可以充分利用鏡像緩存。
示例中,源代碼會經常變化,則每次構建鏡像時都需要重新安裝NPM模塊,這顯然不是我們希望看到的。因此我們可以先拷貝package.json,然後安裝NPM模塊,最後才拷貝其餘的源代碼。這樣的話,即使源代碼變化,也不需要重新安裝NPM模塊。
FROM node:7-alpinennWORKDIR /appnnCOPY package.json /app nRUN npm install nCOPY . /appnnENTRYPOINT ["./entrypoint.sh"] nCMD ["start"]n
12. 設置默認的環境變數,映射埠和數據卷
運行Docker容器時很可能需要一些環境變數。在Dockerfile設置默認的環境變數是一種很好的方式。另外,我們應該在Dockerfile中設置映射埠和數據卷。示例如下:
FROM node:7-alpinennENV PROJECT_DIR=/appnnWORKDIR $PROJECT_DIRnnCOPY package.json $PROJECT_DIR nRUN npm install nCOPY . $PROJECT_DIRnnENV MEDIA_DIR=/media n NODE_ENV=production n APP_PORT=3000nnVOLUME $MEDIA_DIR nEXPOSE $APP_PORTnnENTRYPOINT ["./entrypoint.sh"] nCMD ["start"]n
ENV指令指定的環境變數在容器中可以使用。如果你只是需要指定構建鏡像時的變數,你可以使用ARG指令。
13. 使用LABEL設置鏡像元數據
使用LABEL指令,可以為鏡像設置元數據,例如鏡像創建者或者鏡像說明。舊版的Dockerfile語法使用MAINTAINER指令指定鏡像創建者,但是它已經被棄用了。有時,一些外部程序需要用到鏡像的元數據,例如nvidia-docker需要用到com.nvidia.volumes.needed。示例如下:
FROM node:7-alpine nLABEL maintainer "jakub.skalecki@example.com" n...n
14. 添加HEALTHCHECK
運行容器時,可以指定--restart always選項。這樣的話,容器崩潰時,Docker守護進程(docker daemon)會重啟容器。對於需要長時間運行的容器,這個選項非常有用。但是,如果容器的確在運行,但是不可(陷入死循環,配置錯誤)用怎麼辦?使用HEALTHCHECK指令可以讓Docker周期性的檢查容器的健康狀況。我們只需要指定一個命令,如果一切正常的話返回0,否則返回1。對HEALTHCHECK感興趣的話,可以參考這篇博客。示例如下:
FROM node:7-alpine nLABEL maintainer "jakub.skalecki@example.com"nnENV PROJECT_DIR=/app nWORKDIR $PROJECT_DIRnnCOPY package.json $PROJECT_DIR nRUN npm install nCOPY . $PROJECT_DIRnnENV MEDIA_DIR=/media n NODE_ENV=production n APP_PORT=3000nnVOLUME $MEDIA_DIR nEXPOSE $APP_PORT nHEALTHCHECK CMD curl --fail http://localhost:$APP_PORT || exit 1nnENTRYPOINT ["./entrypoint.sh"] nCMD ["start"]n
當請求失敗時,curl --fail 命令返回非0狀態。
歡迎加入我們Fundebug的Docker技術交流群: 305097057。
版權聲明:n轉載時請註明作者Fundebug以及本文地址:nhttps://blog.fundebug.com/2017/05/15/write-excellent-dockerfile/n
推薦閱讀:
※Docker集群日誌收集:Syslog+Rsyslog+ELK
※Docker學習資源匯總
※Docker進階:容器中的數據管理
※Docker的一個簡單示例
TAG:Docker |