附錄2、Dockerfile 參考及最佳實踐

本文是《Docker必知必會系列》第十篇,原文發佈於個人博客:悟塵紀

上一篇:Docker必知必會系列(附錄1):Docker 常用命令及示例

一、Dockerfile 簡介

Dockfile 是一種被 Docker 程序解釋的腳本,由一條一條的指令組成,每條指令對應 Linux 下面的一條命令。

Docker 通過從Dockerfile文本文件中讀取指令來自動構建鏡像,該文本文件按順序包含構建鏡像所需的所有命令。遵循特定的格式和指令集,您可以在 Dockerfile 參考 中找到詳細信息。

Docker 鏡像由只讀層組成,每個只讀層代表一個 Dockerfile 指令。各個層堆疊在一起,每個層都是上一層變化的增量。運行鏡像並生成容器時,可以在基礎層之上添加一個新的可寫層(“容器層”)。對運行中容器所做的所有更改(例如寫入新文件,修改現有文件和刪除文件)都將寫入到此可寫容器層。

二、Dockerfile 編寫建議

Dockerfile 的指令是忽略大小寫的,建議使用大寫,使用#作爲註釋,每一行只支持一條指令,每條指令可以攜帶多個參數。Dockerfile 常用指令:

類型 命令
基礎鏡像信息 FROM
維護者信息 MAINTAINER
鏡像操作指令 RUN、COPY、ADD、EXPOSE、WORKDIR、ONBUILD、USER、VOLUME 等
容器啓動時執行指令 CMD、ENTRYPOINT

下面針對 Dockerfile 中各種指令的最佳編寫方式給出建議。

FROM(指定基礎鏡像)

該指令有兩種格式:使用 FROM <image> 指定基礎鏡像爲該 image 的最後修改版本。或者實使用 FROM <image>:<tag> 指定基礎 image 爲該 image 的一個 tag 版本。

儘可能使用官方倉庫當前版本作爲你的基礎鏡像。推薦使用 Alpine 鏡像,因爲它被嚴格控制並保持最小尺寸(目前小於 5 MB),但它仍然是一個完整的發行版。

LABEL(向鏡像添加元數據)

你可以給鏡像添加標籤來幫助組織鏡像、記錄許可信息、輔助自動化構建等。每個標籤一行,由 LABEL 開頭加上一個或多個鍵值對。下面的示例展示了各種不同的可能格式。# 開頭的行是註釋內容。

注意:如果你的字符串中包含空格,必須將字符串放入引號中或者對空格使用轉義。如果字符串內容本身就包含引號,必須對引號使用轉義。

# Set one or more individual labels
LABEL com.example.version="0.0.1-beta"
LABEL vendor="ACME Incorporated"
LABEL com.example.release-date="2015-02-12"
LABEL com.example.version.is-production=""

一個鏡像可以包含多個標籤,可以在一行中指定多個標籤,但建議將多個標籤放入到一個 LABEL 指令中。

# Set multiple labels at once, using line-continuation characters to break long lines
LABEL vendor=ACME\ Incorporated \
    com.example.is-beta= \
    com.example.is-production="" \
    com.example.version="0.0.1-beta" \
    com.example.release-date="2015-02-12"

關於標籤的更多信息,可以參考 Understanding object labels

RUN(一般用於安裝軟件)

RUN 指令是在新鏡像內部執行的命令,可以運行任何被基礎 image 支持的命令,如:執行某些動作、安裝系統軟件、配置系統信息之類。爲了保持 Dockerfile 文件的可讀性,可理解性,以及可維護性,建議將長的或複雜的 RUN 指令用反斜槓 \ 分割成多行。

apt-get

RUN 指令最常見的用法是安裝包用的 apt-get。因爲 RUN apt-get 指令會安裝包,所以有幾個問題需要注意。

不要使用 RUN apt-get upgradedist-upgrade,因爲許多基礎鏡像中的「必須」包不會在一個非特權容器中升級。如果基礎鏡像中的某個包過時了,你應該聯繫它的維護者。如果你確定某個特定的包,比如 foo,需要升級,使用 apt-get install -y foo 就行,該指令會自動升級 foo 包。

永遠將 RUN apt-get updateapt-get install 組合成一條 RUN 聲明,例如:

RUN apt-get update && apt-get install -y \
    package-bar \
    package-baz \
    package-foo

apt-get update 放在一條單獨的 RUN 聲明中會導致緩存問題以及後續的 apt-get install 失敗。比如,假設你有一個 Dockerfile 文件:

FROM ubuntu:18.04
RUN apt-get update
RUN apt-get install -y curl

構建鏡像後,所有的層都在 Docker 的緩存中。假設你後來又修改了其中的 apt-get install 添加了一個包:

FROM ubuntu:18.04
RUN apt-get update
RUN apt-get install -y curl nginx

Docker 發現修改後的 RUN apt-get update 指令和之前的完全一樣。所以,apt-get update 不會執行,而是使用之前的緩存鏡像。因爲 apt-get update 沒有運行,後面的 apt-get install 可能安裝的是過時的 curlnginx 版本。

使用 RUN apt-get update && apt-get install -y 可以確保你的 Dockerfiles 每次安裝的都是包的最新的版本,而且這個過程不需要進一步的編碼或額外干預。這項技術叫作 cache busting。你也可以顯示指定一個包的版本號來達到 cache-busting,這就是所謂的固定版本,例如:

RUN apt-get update && apt-get install -y \
    package-bar \
    package-baz \
    package-foo=1.3.*

固定版本會迫使構建過程檢索特定的版本,而不管緩存中有什麼。這項技術也可以減少因所需包中未預料到的變化而導致的失敗。

下面是一個 RUN 指令的示例模板,展示了所有關於 apt-get 的建議。

RUN apt-get update && apt-get install -y \
    aufs-tools \
    automake \
    build-essential \
    curl \
    dpkg-sig \
    libcap-dev \
    libsqlite3-dev \
    mercurial \
    reprepro \
    ruby1.9.1 \
    ruby1.9.1-dev \
    s3cmd=1.1.* \
 && rm -rf /var/lib/apt/lists/*

其中 s3cmd 指令指定了一個版本號 1.1.*。如果之前的鏡像使用的是更舊的版本,指定新的版本會導致 apt-get udpate 緩存失效並確保安裝的是新版本。

另外,清理掉 apt 緩存 var/lib/apt/lists 可以減小鏡像大小。因爲 RUN 指令的開頭爲 apt-get udpate,包緩存總是會在 apt-get install 之前刷新。

注意:官方的 Debian 和 Ubuntu 鏡像會自動運行 apt-get clean,所以不需要顯式的調用 apt-get clean。

CMD(設置容器啓動時默認操作)

CMD 指令的主要目的是爲正在執行的容器提供缺省值。指定容器啓動時執行的操作。該操作可以是執行自定義腳本,也可以是執行系統命令。該指令只能在文件中存在一次,如果有多個,則只執行最後一條。

CMD 大多數情況下都應該以 CMD ["executable", "param1", "param2"...] 的形式使用。因此,如果創建鏡像的目的是爲了部署某個服務(比如 Apache),你可能會執行類似於 CMD ["apache2", "-DFOREGROUND"] 形式的命令。我們建議任何服務鏡像都使用這種形式的命令。

多數情況下,CMD 都需要一個交互式的 shell (bash, Python, perl 等),例如 CMD ["perl", "-de0"],或者 CMD ["PHP", "-a"]。使用這種形式意味着,當你執行類似 docker run -it python 時,你會進入一個準備好的 shell 中。

CMD 應該在極少的情況下以 CMD ["param", "param"] 的形式與 ENTRYPOINT 協同使用,除非你和你的鏡像使用者都對 ENTRYPOINT 的工作方式十分熟悉。

注意: 不要將 RUN 與 CMD 混淆。 Run 實際上是運行一個命令並提交結果; CMD 在構建時不執行任何操作,但會指定鏡像的預期命令。

ENTRYPOINT(設置鏡像主命令)

ENTRYPOINT 的最佳用處是設置鏡像的主命令,與 CMD 非常相似。當 CMDENTRYPOINT 都存在時,CMD 的指令變成了 ENTRYPOINT 指令的參數。並且此 CMD 提供的參數會被 docker run 後面的命令覆蓋。

FROM ubuntu
CMD ["-l"]
ENTRYPOINT ["/usr/bin/ls"]

如果你使用 CMD 命令且 CMD 是一個完整的可執行的命令,那麼 CMD 指令和 ENTRYPOINT 會互相覆蓋只有最後一個 CMD 或者 ENTRYPOINT 有效。

# CMD指令將不會被執行,只有ENTRYPOINT指令被執行
CMD echo “Hello, World!”
ENTRYPOINT ls -l

ENTRYPOINT 指令也可以結合一個輔助腳本使用,和前面命令行風格類似,即使啓動工具需要不止一個步驟。

例如,Postgres 官方鏡像使用下面的腳本作爲 ENTRYPOINT

#!/bin/bash
set -e
if [ "$1" = 'postgres' ]; then
    chown -R postgres "$PGDATA"
    if [ -z "$(ls -A "$PGDATA")" ]; then
        gosu postgres initdb
    fi
    exec gosu postgres "$@"
fi
exec "$@"

注意:該腳本使用了 Bash 的內置命令 exec,所以最後運行的進程就是容器的 PID 爲 1 的進程。這樣,進程就可以接收到任何發送給容器的 Unix 信號了。

該輔助腳本被拷貝到容器,並在容器啓動時通過 ENTRYPOINT 執行:

COPY ./docker-entrypoint.sh /

ENTRYPOINT ["/docker-entrypoint.sh"]

該腳本可以讓用戶用幾種不同的方式和 Postgres 交互。

你可以很簡單地啓動 Postgres

docker run postgres

也可以執行 Postgres 並傳遞參數:

docker run postgres postgres --help

最後,你還可以啓動另外一個完全不同的工具,比如 Bash

docker run --rm -it postgres bash

EXPOSE(暴露容器端口)

EXPOSE 指令用於指將容器中的端口映射成宿主機的某個端口。當你需要訪問容器的時候,可以不使用容器的 IP 地址而是使用宿主機器的 IP 地址和映射後的端口。因此,你應該爲你的應用程序使用常見的端口。例如,提供 Apache web 服務的鏡像應該使用 EXPOSE 80,而提供 MongoDB 服務的鏡像使用 EXPOSE 27017

對於外部訪問,用戶可以在執行 docker run 時使用 -p 標誌來將容器的指定端口映射到宿主機所選擇的端口。

ENV(設置環境變量)

爲了方便新程序運行,你可以使用 ENV 來爲容器中安裝的程序更新 PATH 環境變量。

例如使用 ENV PATH /usr/local/nginx/bin:$PATH 來確保 CMD ["nginx"] 能正確運行。

ENV 指令也可用於爲你想要容器化的服務提供必要的環境變量,比如 Postgres 需要的 PGDATA

最後,ENV 也能用於設置常見的版本號,比如下面的示例:

ENV PG_MAJOR 9.3
ENV PG_VERSION 9.3.4
RUN curl -SL http://example.com/postgres-$PG_VERSION.tar.xz | tar -xJC /usr/src/postgress && …
ENV PATH /usr/local/postgres-$PG_MAJOR/bin:$PATH

類似於程序中的常量,這種方法可以讓你只需改變 ENV 指令來自動的改變容器中的軟件版本。

在使用 ENV 設置環境變量時,有幾點需要注意:

  • 1)具有傳遞性,也就是當前鏡像被用作其它鏡像的基礎鏡像時,新鏡像會擁有當前這個基礎鏡像所有的環境變量
  • 2)ENV 定義的環境變量,可以在 dockerfile 被後面的所有指令(CMD 除外)中使用,但不能被 docker run 的命令參數引用

ADD 和 COPY(複製文件到容器)

雖然 ADDCOPY 功能類似,但一般優先使用 COPY。因爲它比 ADD 更透明。COPY 只支持簡單將本地文件拷貝到容器中,而 ADD 有一些並不明顯的功能(比如本地 tar 提取和遠程 URL 支持)。因此,ADD 的最佳用例是將本地 tar 文件自動提取到鏡像中,例如 ADD rootfs.tar.xz

如果你的 Dockerfile 有多個步驟需要使用上下文中不同的文件。單獨 COPY 每個文件,而不是一次性的 COPY 所有文件,這將保證每個步驟的構建緩存只在特定的文件變化時失效。例如:

COPY requirements.txt /tmp/
RUN pip install --requirement /tmp/requirements.txt
COPY . /tmp/

如果將 COPY . /tmp/ 放置在 RUN 指令之前,只要 . 目錄中任何一個文件變化,都會導致後續指令的緩存失效。

爲了讓鏡像儘量小,最好不要使用 ADD 指令從遠程 URL 獲取包,而是使用 curlwget。這樣你可以在文件提取完之後刪掉不再需要的文件來避免在鏡像中額外添加一層。比如儘量避免下面的用法:

ADD http://example.com/big.tar.xz /usr/src/things/

RUN tar -xJf /usr/src/things/big.tar.xz -C /usr/src/things

RUN make -C /usr/src/things all

而是應該使用下面這種方法:

RUN mkdir -p /usr/src/things \
    && curl -SL http://example.com/big.tar.xz \
    | tar -xJC /usr/src/things \
    && make -C /usr/src/things all

上面使用的管道操作,所以沒有中間文件需要刪除。

對於其他不需要 ADD 的自動提取功能的文件或目錄,你應該使用 COPY

VOLUME(指定掛載點)

VOLUME 指令用於暴露任何數據庫存儲文件,配置文件,或容器創建的文件和目錄。強烈建議使用 VOLUME 來管理鏡像中的可變部分和用戶可以改變的部分。

USER(指定運行鏡像時使用的用戶)

如果某個服務不需要特權執行,建議使用 USER 指令切換到非 root 用戶。先在 Dockerfile 中使用類似 RUN groupadd -r postgres && useradd -r -g postgres postgres 的指令創建用戶和用戶組。

注意:在鏡像中,用戶和用戶組每次被分配的 UID/GID 都是不確定的,下次重新構建鏡像時被分配到的 UID/GID 可能會不一樣。如果要依賴確定的 UID/GID,你應該顯示的指定一個 UID/GID。

你應該避免使用 sudo,因爲它不可預期的 TTY 和信號轉發行爲可能造成的問題比它能解決的問題還多。如果你真的需要和 sudo 類似的功能(例如,以 root 權限初始化某個守護進程,以非 root 權限執行它),你可以使用 gosu

最後,爲了減少層數和複雜度,避免頻繁地使用 USER 來回切換用戶。

WORKDIR(切換目錄)

爲跟在它後面的 RUNCMDENTRYPOINTCOPYADD 指令設置工作目錄。其效果類似於 Linux 命名中的cd 命令,用於目錄的切換,但是和 cd 不一樣的是:如果切換到的目錄不存在,WORKDIR 會爲此創建目錄。

爲了清晰性和可靠性,你應該總是在 WORKDIR 中使用絕對路徑。另外,你應該使用 WORKDIR 來替代類似於 RUN cd ... && do-something 的指令,後者難以閱讀、排錯和維護。

ONBUILD(在子鏡像中執行)

Onbuild 指令向鏡像添加一個觸發器指令,以便在以後將該鏡像用作另一個構建的基礎鏡像時執行。 意思就是:這個鏡像創建時不會執行,以後,如果其它鏡像以這個鏡像爲基礎,會先執行這個鏡像的 ONBUILD 命令。

任何構建指令都可以註冊爲觸發器。如果 Onbuild 指令執行失敗,子鏡像的 FROM 指令就會中止。執行完觸發器後,將從最終圖像中清除觸發器。換句話說,它們不會傳遞到“孫子代”版本鏡像中。

ARG(設置構建鏡像時變量)

ARG 定義的變量只在建立 image 時有效,建立完成後變量就失效消失。用戶可以在 docker build 時使用帶有--build-arg = 標誌的命令將變量傳遞給構建器。

同時使用ARGENV指令爲 RUN 指令設置變量時,ENV 指令定義的環境變量 會始終覆蓋ARG同名指令。例如:

FROM ubuntu
ARG CONT_IMG_VER
ENV CONT_IMG_VER v1.0.0
RUN echo $CONT_IMG_VER

然後,使用以下命令構建鏡像:

docker build --build-arg CONT_IMG_VER=v2.0.1 .

在情況下,RUN指令將使用v1.0.0,而不是ARG用戶傳遞的值:v2.0.1

Docker 有一組預定義的 ARG 變量,您可以在 Dockerfile 中不使用相應的 ARG 指令而使用它們:

  • HTTP_PROXY
  • http_proxy
  • HTTPS_PROXY
  • https_proxy
  • FTP_PROXY
  • ftp_proxy
  • NO_PROXY
  • no_proxy

要使用它們,只需在命令行上使用 --build-arg <varname>=<value> 標誌賦值。默認情況下,這些預定義的變量被排除在 docker 歷史記錄的輸出之外。 這可以降低意外泄漏 httpproxy 變量中敏感的身份驗證信息的風險。

例如,使用 --build-arg HTTP_PROXY=http://user:[email protected] 構建鏡像時,httpproxy 變量的值在 docker 歷史記錄中不可用,也不會被緩存。

三、一般準則和建議

容器應該是短暫的

通過 Dockerfile 構建的鏡像所啓動的容器應該儘可能短暫(生命週期短)。「短暫」意味着可以停止和銷燬容器,並且創建一個新容器並部署好所需的設置和配置的工作量應該是極小的。

使用 .dockerignore 文件

使用 Dockerfile 構建鏡像時最好將 Dockerfile 放置在一個新建的空目錄下。然後將構建鏡像所需要的文件添加到該目錄中。爲了提高構建鏡像的效率,你可以在目錄下新建一個 .dockerignore 文件來指定要忽略的文件和目錄。.dockerignore 文件的排除模式語法和 Git 的 .gitignore 文件相似。

使用多階段構建

Docker 17.05 以上版本中,你可以使用多階段構建來減少所構建鏡像的大小。

避免安裝不必要的包

爲了降低複雜性、減少依賴、減小文件大小、節約構建時間,你應該避免安裝任何不必要的包。例如,不要在數據庫鏡像中包含一個文本編輯器。

一個容器只運行一個進程

應該保證在一個容器中只運行一個進程。將多個應用解耦到不同容器中,保證了容器的橫向擴展和複用。例如 web 應用應該包含三個容器:web 應用、數據庫、緩存。

如果容器互相依賴,你可以使用 Docker 自定義網絡 來把這些容器連接起來。

鏡像層數儘可能少

每執行一個指令,都會有一次鏡像的提交,鏡像是分層的結構,需要在 Dockerfile 可讀性(也包括長期的可維護性)和減少層數之間做一個平衡。

將多行參數排序

將多行參數按字母順序排序(比如要安裝多個包時)。這可以幫助你避免重複包含同一個包,更新包列表時也更容易。也便於 PRs 閱讀和審查。建議在反斜槓符號 \ 之前添加一個空格,以增加可讀性。

下面是來自 buildpack-deps 鏡像的例子:

RUN apt-get update && apt-get install -y \
  bzr \
  cvs \
  git \
  mercurial \
  subversion

使用構建緩存

在鏡像的構建過程中,Docker 會遍歷 Dockerfile 文件中的指令,然後按順序執行。在執行每條指令之前,Docker 都會在緩存中查找是否已經存在可重用的鏡像,如果有就使用現存的鏡像,不再重複創建。如果你不想在構建過程中使用緩存,你可以在 docker build 命令中使用 --no-cache=true 選項。

但是,如果你想在構建的過程中使用緩存,你得明白什麼時候會或不會找到匹配的鏡像,遵循的基本規則如下:

  • 從一個基礎鏡像開始(FROM 指令指定),下一條指令將和該基礎鏡像的所有子鏡像進行匹配,檢查這些子鏡像被創建時使用的指令是否和被檢查的指令完全一樣。如果不是,則緩存失效。
  • 在大多數情況下,只需要簡單地對比 Dockerfile 中的指令和子鏡像。然而,有些指令需要更多的檢查和解釋。
  • 對於 ADDCOPY 指令,鏡像中對應文件的內容也會被檢查,每個文件都會計算出一個校驗和。文件的最後修改時間和最後訪問時間不會納入校驗。在緩存的查找過程中,會將這些校驗和和已存在鏡像中的文件校驗和進行對比。如果文件有任何改變,比如內容和元數據,則緩存失效。
  • 除了 ADDCOPY 指令,緩存匹配過程不會查看臨時容器中的文件來決定緩存是否匹配。例如,當執行完 RUN apt-get -y update 指令後,容器中一些文件被更新,但 Docker 不會檢查這些文件。這種情況下,只有指令字符串本身被用來匹配緩存。

一旦緩存失效,所有後續的 Dockerfile 指令都將產生新的鏡像,緩存不會被使用。

四、官方鏡像示例

這些官方鏡像的 Dockerfile 都是參考典範:https://github.com/docker-library/docs

所有 Markdown 文件都通過 markdownfmt 進行運行(僅添加了一些較小的差異首選項和較小的DockerHub兼容性更改),並通過Travis CI 驗證其格式正確。

參考

相關文章

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章