在容器領域,docker 公司提出的容器鏡像已經成為目前容器打包交付的事實標準。構建鏡像需要編寫 Dockerfile,如何編寫一個優雅的 Dockerfile 呢?在 Docker 公司的官方文檔中給出了一篇
Best practices for writing Dockerfiles。
(https://g.126.fm/03ncYHS)
本文在此基礎上做了一些刪減,力圖讓大家在短時間內寫出一份不錯的 Dockerfile。
本文分為三個部分,首先會直接給出一份 Dockerfile 的參考模板,然後說明如和構建高效的鏡像並解釋這個模板這樣組織的原因,最後會補充說明一些編寫過程中的常見問題。
docker 官方給出的參考文檔中給出的 Dockerfile 指令接近 20 個,而我們平時在編寫的時候,經常用到的不超過 10 個。因此,這裡給出了一份 Dockerfile 的參考模板,幾乎可以覆蓋大部分的使用場景。
FROM base_image:tag # 引用基礎鏡像 *必要*
ARG arg_key[=default_value1] # 聲明變量
ENV env_key=value2 # 聲明環境變量
# 構建幾乎不變的部分,例如整體的目錄結構,build時依賴的文件和工具包等
COPY src dst
RUN command1 && command2 ...
WORKDIR /path/to/work/dir # 設置工作目錄
# 構建較少變動的部分,例如應用的依賴的文件、依賴的包等
COPY src dst
RUN command3 && command4 ...
# 構建經常變動的部分,例如應用的編譯生成
COPY src dst
RUN command5 && command6 ...
# 容器入口 *必要*
ENTRYPOINT ["/entry.app"] # 指定容器啟動時默認執行的命令
CMD ["--options"] # 指定容器啟動時默認命令的默認參數
容器的一個重要的特點就是能夠快速迭代,因此在容器鏡像迭代的各個環節也應該儘量做到簡潔高效。
1. 鏡像build精簡 context:每次 build,context 都會複製給 docker daemon,因此要去掉 context 中無關的部分
多層鏡像:如果鏡像很複雜,通常將其分成基礎鏡像(適用於多種應用,內容基本不變的部分)和應用鏡像,應用鏡像通過 FROM 基礎鏡像來減少 build 的步驟
利用構建緩存(build cache):每次在 build 時,docker daemon 會默認從已在緩存中的父鏡像開始,將下一條指令與從該基本鏡像派生的所有子鏡像進行比較,以查看是否其中一個是使用完全相同的指令構建的。如果不是,則緩存無效。因此,為了能夠提高緩存的命中率,在編寫 Dockerfile 時,應該儘量按照變動的頻率來組織(如上文中的模板)
減少 layers:RUN, COPY, ADD 等指令會在 build 時產生對應的 layer,在較舊的 Docker 版本中,需要最小化鏡像中的層數以確保其性能。因此,使用&&來連接多個 RUN 命令是一個常用的方法(如上文中的模板)
使用 multi-stage builds:新特性,後文會詳細介紹
2. 鏡像pulldocker 官方詳細描述了 docker 鏡像和容器在宿主機上的存儲方式:https://docs.docker.com/storage/storagedriver/,簡單來說就是:
根據鏡像的存儲方式,我們也可以加快鏡像的 pull 過程:
常見問題 1. 注意Dockerfile中的指令是逐條執行,且相互獨立# 下面這種寫法會報錯,第二個RUN執行時的WORKDIR依舊是原來的目錄,不是/some/dir
RUN cd /some/dir
RUN bash script.sh
# 改成下面兩種之一
RUN cd /some/dir && bash script.sh
RUN bash /some/dir/script.sh
前文也提到過,Dockerfile 中每條指令逐條執行,且相互獨立。大部分的指令在 build 時會生成對應的一層(layer),並被緩存。這種機制在絕大部分的情況下都工作的很好,但是有時也會產生問題:
# Dockerfile1
FROM ubuntu:18.04
RUN apt-get update
RUN apt-get install -y nginx
# Dockerfile2
FROM ubuntu:18.04
RUN apt-get update
RUN apt-get install -y nginx curl
如上,原 Dockerfile1 使用一段時間之後修改成 Dockerfile2(只修改了install這一行)。由於緩存機制(假設之前 build 的緩存還存在),Dockerfile2 在 build 時,update這一行不會真的執行,而是直接拿之前的緩存。此時安裝的 nginx 和 curl 可能就不是當前的最新版本。
# 官方推薦的apt-get使用方式:
RUN apt-get update && apt-get install -y \
curl \
nginx=1.16.* \
&& rm -rf /var/lib/apt/lists/*
兩種指令都可以用來定義變量,但是使用上有很多要注意的點:
ARG key=value
FROM xxx${key}xxxx
ARG key # 這裡需要再次聲明才能使用
ARG 變量的作用範圍是 build 階段 ARG 之後的指令,不會帶入鏡像
ENV 環境變量作用範圍是 build 階段 ENV 聲明的指令,並且會編入鏡像,容器運行時也會這些環境變量也生效
CMD 和 ENTRYPOINT 中不能使用 ARG 和 ENV 定義的變量
當 ARG 和 ENV 變量同名時(無論是誰先定義),ENV 環境變量的值會覆蓋 ARG 變量
ENV 會產生中間層(layer),被編入鏡像,即使使用 unset 也無法去掉,例如:
FROM alpine
ENV ADMIN_USER="mark" # 此時產生了layer
RUN echo $ADMIN_USER > ./mark
RUN unset ADMIN_USER # 使用unset只是去掉了build時的環境變量,但是最終生成的鏡像中還是會有這個變量
# 運行鏡像還是會列印環境變量
docker run --rm test sh -c 'echo $ADMIN_USER'
mark
# 如果想要消除這種影響,可以改成:
FROM alpine
RUN export ADMIN_USER="mark" \
&& echo $ADMIN_USER > ./mark \
&& unset ADMIN_USER
CMD sh
兩個指令幾乎相同,當你只想複製本地 context 中的文件到鏡像中時,請無腦用 COPY。
COPY 與 ADD 使用時,注意以下規則:
注意文件的屬性,複製時可以同時修改屬主和屬組 COPY/ADD [--chown=<user>:<group>] <src> <dest>
如果不清楚目錄與反斜線對這兩個指令的影響,對所有目錄都加上反斜線就比較好理解了,如COPY <src_dir>/ <dest_dir>/,因為:
<src>必須在 context 下,不能使用../跳出 context
ADD 指令除了 COPY 的所有功能外,還有以下特性,如非必要,儘量少用:
5. CMD與ENTRYPOINT又是一對很類似的指令,使用時需要注意:
CMD 單獨使用時,用來指定容器啟動時默認執行的命令
ENTRYPOINT 單獨使用時,可以完全取代 CMD
ENTRYPOINT 和 CMD 一起使用時,CMD 變成 ENTRYPOINT 的默認參數
推薦使用 ENTRYPOINT/CMD 的 exec 書寫形式:即ENTRYPOINT ["entry.app", "arg"],因為 shell 書寫形式(ENTRYPOINT entry.app arg)會額外啟動 shell 進程
下表列出了 CMD 與 ENTRYPOINT 的各種組合時的效果:
另外,通過在 docker run 最後的添加欄位,可以指定 ENTRYPOINT 的實際參數
# 鏡像 test_entrypoint
ENTRYPOINT ["./entry.app"]
CMD ["--help"]
# 運行 test_entrypoint
docker run test_entrypoint # 即./entry.app --help
# 帶參數運行
docker run test_entrypoint -a -t # 即 ./entry.app -a -t
Docker 17.05 之後的版本支持一種新的 build 方式:多階段構建(multi-stage builds)。與傳統方式的區別在與,多階段構建能夠使用多個 FROM 將整個 build 階段分成多個階段:
例如,上文提到的模板就可以通過多階段構建的方式來優化。假設我們最終只想得到 entry.app 及其運行環境,而不需要它的編譯環境,那麼可以通過如下方式優化最終生成的鏡像的大小:
# 使用多階段構建,這裡命名一個builder階段,生成編譯後的app
FROM base_image:tag AS builder
ARG arg_key[=default_value1] # 聲明變量
ENV env_key=value2 # 聲明環境變量
# 構建整體的目錄結構,build時依賴的文件和工具包等
COPY src dst
RUN command1 && command2 ...
WORKDIR /path/to/work/dir # 設置工作目錄
# 構建編譯環境
COPY src dst
RUN command3 && command4 ...
# 編譯生成entry.app
COPY src dst
RUN compile_entry_app
# 構建最終鏡像的階段,只保留應用和其運行環境,編譯的依賴都不需要
FROM base_image:tag
COPY src dest # 複製運行環境
WORKDIR /path/to/work/dir # 設置工作目錄
COPY --from=builder entry.app . # 從builder階段複製app
# 容器入口
ENTRYPOINT ["/entry.app"] # 指定容器啟動時默認執行的命令
CMD ["--options"] # 指定容器啟動時默認命令的默認參數