docker教程——Dockerfile 定製鏡像(六)

一、鏡像制定

        從剛纔的 docker commit 的學習中,我們可以瞭解到,鏡像的定製實際上就是定製每一層所添加的配置、文件。如果我們可以把每一層修改、安裝、構建、操作的命令都寫入一個腳本,用這個腳本來構建、定製鏡像,那麼之前提及的無法重複的問題、鏡像構建透明性的問題、體積的問題就都會解決。這個腳本就是 Dockerfile。

        Dockerfile 是一個文本文件,其內包含了一條條的指令(Instruction),每一條指令構建一層,因此每一條指令的內容,就是描述該層應當如何構建。還以之前定製 nginx 鏡像爲例,這次我們使用 Dockerfile 來定製。在一個空白目錄中,建立一個文本文件,並命名爲 Dockerfile:

$ mkdir mynginx
$ cd mynginx
$ touch Dockerfile

其內容爲:

FROM nginx
RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html

這個 Dockerfile 很簡單,一共就兩行。涉及到了兩條指令,FROM 和 RUN。

FROM 指定基礎鏡像

        所謂定製鏡像,那一定是以一個鏡像爲基礎,在其上進行定製。就像我們之前運行了一個 nginx 鏡像的容器,再進行修改一樣,基礎鏡像是必須指定的。而 FROM 就是指定基礎鏡像,因此一個 Dockerfile 中 FROM 是必備的指令,並且必須是第一條指令。

        在 Docker Hub 上有非常多的高質量的官方鏡像,有可以直接拿來使用的服務類的鏡像,如 nginx、redis、mongo、mysql、httpd、php、tomcat 等;也有一些方便開發、構建、運行各種語言應用的鏡像,如 node、openjdk、python、ruby、golang 等。可以在其中尋找一個最符合我們最終目標的鏡像爲基礎鏡像進行定製。

RUN 執行命令

        RUN 指令是用來執行命令行命令的。由於命令行的強大能力,RUN 指令在定製鏡像時是最常用的指令之一。其格式有兩種:

shell 格式:RUN <命令>,就像直接在命令行中輸入的命令一樣。剛纔寫的 Dockerfile 中的 RUN 指令就是這種格式。

RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html

exec 格式:RUN ["可執行文件", "參數1", "參數2"],這更像是函數調用中的格式。

二、構建鏡像

        好了,讓我們再回到之前定製的 nginx 鏡像的 Dockerfile 來。現在我們明白了這個 Dockerfile 的內容,那麼讓我們來構建這個鏡像吧。在 Dockerfile 文件所在目錄執行:

$ docker build -t nginx:v3 .
Sending build context to Docker daemon 2.048 kB
Step 1 : FROM nginx
 ---> e43d811ce2f4
Step 2 : RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
 ---> Running in 9cdc27646c7b
 ---> 44aa4490ce2c
Removing intermediate container 9cdc27646c7b
Successfully built 44aa4490ce2c

從命令的輸出結果中,我們可以清晰的看到鏡像的構建過程。在 Step 2 中,如同我們之前所說的那樣,RUN 指令啓動了一個容器 9cdc27646c7b,執行了所要求的命令,並最後提交了這一層 44aa4490ce2c,隨後刪除了所用到的這個容器 9cdc27646c7b。這裏我們使用了 docker build 命令進行鏡像構建。其格式爲:

docker build [選項] <上下文路徑/URL/->

        在這裏我們指定了最終鏡像的名稱 -t nginx:v3,構建成功後,我們可以像之前運行 nginx:v2 那樣來運行這個鏡像,其結果會和 nginx:v2 一樣。

三、鏡像構建上下文(Context)

        如果注意,會看到 docker build 命令最後有一個 .。. 表示當前目錄,而 Dockerfile 就在當前目錄,因此不少初學者以爲這個路徑是在指定 Dockerfile 所在路徑,這麼理解其實是不準確的。如果對應上面的命令格式,你可能會發現,這是在指定上下文路徑。那麼什麼是上下文呢?

        首先我們要理解 docker build 的工作原理。Docker 在運行時分爲 Docker 引擎(也就是服務端守護進程)和客戶端工具。Docker 的引擎提供了一組 REST API,被稱爲 Docker Remote API,而如 docker 命令這樣的客戶端工具,則是通過這組 API 與 Docker 引擎交互,從而完成各種功能。因此,雖然表面上我們好像是在本機執行各種 docker 功能,但實際上,一切都是使用的遠程調用形式在服務端(Docker 引擎)完成。也因爲這種 C/S 設計,讓我們操作遠程服務器的 Docker 引擎變得輕而易舉。

        當我們進行鏡像構建的時候,並非所有定製都會通過 RUN 指令完成,經常會需要將一些本地文件複製進鏡像,比如通過 COPY 指令、ADD 指令等。而 docker build 命令構建鏡像,其實並非在本地構建,而是在服務端,也就是 Docker 引擎中構建的。那麼在這種客戶端/服務端的架構中,如何才能讓服務端獲得本地文件呢?

        這就引入了上下文的概念。當構建的時候,用戶會指定構建鏡像上下文的路徑,docker build 命令得知這個路徑後,會將路徑下的所有內容打包,然後上傳給 Docker 引擎。這樣 Docker 引擎收到這個上下文包後,展開就會獲得構建鏡像所需的一切文件。如果在 Dockerfile 中這麼寫:

COPY ./package.json /app/

這並不是要複製執行 docker build 命令所在的目錄下的 package.json,也不是複製 Dockerfile 所在目錄下的 package.json,而是複製 上下文  (context) 目錄下的 package.json。

        因此,COPY 這類指令中的源文件的路徑都是相對路徑。這也是初學者經常會問的爲什麼 COPY ../package.json /app 或者 COPY /opt/xxxx /app 無法工作的原因,因爲這些路徑已經超出了上下文的範圍,Docker 引擎無法獲得這些位置的文件。如果真的需要那些文件,應該將它們複製到上下文目錄中去。

        如果觀察 docker build 輸出,我們其實已經看到了這個發送上下文的過程:

$ docker build -t nginx:v3 .
Sending build context to Docker daemon 2.048 kB
...

        一般來說,應該會將 Dockerfile 置於一個空目錄下,或者項目根目錄下。如果該目錄下沒有所需文件,那麼應該把所需文件複製一份過來。如果目錄下有些東西確實不希望構建時傳給 Docker 引擎,那麼可以用 .gitignore 一樣的語法寫一個 .dockerignore,該文件是用於剔除不需要作爲上下文傳遞給 Docker 引擎的。

四、其它 docker build 的用法

1、直接用 Git repo 進行構建

        或許你已經注意到了,docker build 還支持從 URL 構建,比如可以直接從 Git repo 中構建:

$ docker build https://github.com/twang2218/gitlab-ce-zh.git#:11.1
​
Sending build context to Docker daemon 2.048 kB
Step 1 : FROM gitlab/gitlab-ce:11.1.0-ce.0
11.1.0-ce.0: Pulling from gitlab/gitlab-ce
aed15891ba52: Already exists
773ae8583d14: Already exists
...

這行命令指定了構建所需的 Git repo,並且指定默認的 master 分支,構建目錄爲 /11.1/,然後 Docker 就會自己去 git clone 這個項目、切換到指定分支、並進入到指定目錄後開始構建。

2、用給定的 tar 壓縮包構建

$ docker build http://server/context.tar.gz

        如果所給出的 URL 不是個 Git repo,而是個 tar 壓縮包,那麼 Docker 引擎會下載這個包,並自動解壓縮,以其作爲上下文,開始構建。

3、從標準輸入中讀取 Dockerfile 進行構建

docker build - < Dockerfile

cat Dockerfile | docker build -

        如果標準輸入傳入的是文本文件,則將其視爲 Dockerfile,並開始構建。這種形式由於直接從標準輸入中讀取 Dockerfile 的內容,它沒有上下文,因此不可以像其他方法那樣可以將本地文件 COPY 進鏡像之類的事情。

4、從標準輸入中讀取上下文壓縮包進行構建

$ docker build - < context.tar.gz

        如果發現標準輸入的文件格式是 gzip、bzip2 以及 xz 的話,將會使其爲上下文壓縮包,直接將其展開,將裏面視爲上下文,並開始構建。

五、Dockerfile指令詳解

1、COPY 複製文件

格式:

COPY [--chown=<user>:<group>] <源路徑>... <目標路徑>
COPY [--chown=<user>:<group>] ["<源路徑1>",... "<目標路徑>"]

和 RUN 指令一樣,也有兩種格式,一種類似於命令行,一種類似於函數調用。COPY 指令將從構建上下文目錄中 <源路徑> 的文件/目錄複製到新的一層的鏡像內的 <目標路徑> 位置。比如:

COPY package.json /usr/src/app/

<源路徑> 可以是多個,甚至可以是通配符,其通配符規則要滿足 Go 的 filepath.Match 規則,如:

COPY hom* /mydir/
COPY hom?.txt /mydir/

<目標路徑> 可以是容器內的絕對路徑,也可以是相對於工作目錄的相對路徑(工作目錄可以用 WORKDIR 指令來指定)。目標路徑不需要事先創建,如果目錄不存在會在複製文件前先行創建缺失目錄。

        此外,還需要注意一點,使用 COPY 指令,源文件的各種元數據都會保留。比如讀、寫、執行權限、文件變更時間等。這個特性對於鏡像定製很有用。特別是構建相關文件都在使用 Git 進行管理的時候。

        在使用該指令的時候還可以加上 --chown=<user>:<group> 選項來改變文件的所屬用戶及所屬組。

COPY --chown=55:mygroup files* /mydir/
COPY --chown=bin files* /mydir/
COPY --chown=1 files* /mydir/
COPY --chown=10:11 files* /mydir/

2、ADD 更高級的複製文件

        ADD 指令和 COPY 的格式和性質基本一致。但是在 COPY 基礎上增加了一些功能。比如 <源路徑> 可以是一個 URL,這種情況下,Docker 引擎會試圖去下載這個鏈接的文件放到 <目標路徑> 去。下載後的文件權限自動設置爲 600,如果這並不是想要的權限,那麼還需要增加額外的一層 RUN 進行權限調整,另外,如果下載的是個壓縮包,需要解壓縮,也一樣還需要額外的一層 RUN 指令進行解壓縮。所以不如直接使用 RUN 指令,然後使用 wget 或者 curl 工具下載,處理權限、解壓縮、然後清理無用文件更合理。因此,這個功能其實並不實用,而且不推薦使用。

        如果 <源路徑> 爲一個 tar 壓縮文件的話,壓縮格式爲 gzip, bzip2 以及 xz 的情況下,ADD 指令將會自動解壓縮這個壓縮文件到 <目標路徑> 去。在某些情況下,這個自動解壓縮的功能非常有用,比如官方鏡像 ubuntu 中:

FROM scratch
ADD ubuntu-xenial-core-cloudimg-amd64-root.tar.gz /
...

但在某些情況下,如果我們真的是希望複製個壓縮文件進去,而不解壓縮,這時就不可以使用 ADD 命令了。在 Docker 官方的 Dockerfile 最佳實踐文檔 中要求,儘可能的使用 COPY,因爲 COPY 的語義很明確,就是複製文件而已,而 ADD 則包含了更復雜的功能,其行爲也不一定很清晰。最適合使用 ADD 的場合,就是所提及的需要自動解壓縮的場合。另外需要注意的是,ADD 指令會令鏡像構建緩存失效,從而可能會令鏡像構建變得比較緩慢。

3、CMD 容器啓動命令

        CMD 指令的格式和 RUN 相似,也是兩種格式:

shell 格式:CMD <命令>

exec 格式:CMD ["可執行文件", "參數1", "參數2"...]

參數列表格式:CMD ["參數1", "參數2"...]。在指定了 ENTRYPOINT 指令後,用 CMD 指定具體的參數。

        之前介紹容器的時候曾經說過,Docker 不是虛擬機,容器就是進程。既然是進程,那麼在啓動容器的時候,需要指定所運行的程序及參數。CMD 指令就是用於指定默認的容器主進程的啓動命令的。

        在運行時可以指定新的命令來替代鏡像設置中的這個默認命令,比如,ubuntu 鏡像默認的 CMD 是 /bin/bash,如果我們直接 docker run -it ubuntu 的話,會直接進入 bash。我們也可以在運行時指定運行別的命令,如 docker run -it ubuntu cat /etc/os-release。這就是用 cat /etc/os-release 命令替換了默認的 /bin/bash 命令了,輸出了系統版本信息。

        在指令格式上,一般推薦使用 exec 格式,這類格式在解析時會被解析爲 JSON 數組,因此一定要使用雙引號 ",而不要使用單引號。如果使用 shell 格式的話,實際的命令會被包裝爲 sh -c 的參數的形式進行執行。比如:

CMD echo $HOME

在實際執行中,會將其變更爲:

CMD [ "sh", "-c", "echo $HOME" ]

這就是爲什麼我們可以使用環境變量的原因,因爲這些環境變量會被 shell 進行解析處理。

        提到 CMD 就不得不提容器中應用在前臺執行和後臺執行的問題。這是初學者常出現的一個混淆。Docker 不是虛擬機,容器中的應用都應該以前臺執行,而不是像虛擬機、物理機裏面那樣,用 upstart/systemd 去啓動後臺服務,容器內沒有後臺服務的概念。

        而使用 service nginx start 命令,則是希望 upstart 來以後臺守護進程形式啓動 nginx 服務。而 CMD service nginx start 會被理解爲 CMD [ "sh", "-c", "service nginx start"],因此主進程實際上是 sh。那麼當 service nginx start 命令結束後,sh 也就結束了,sh 作爲主進程退出了,自然就會令容器退出。正確的做法是直接執行 nginx 可執行文件,並且要求以前臺形式運行。比如:

CMD ["nginx", "-g", "daemon off;"]

4、ENTRYPOINT 入口點

        ENTRYPOINT 的格式和 RUN 指令格式一樣,分爲 exec 格式和 shell 格式。ENTRYPOINT 的目的和 CMD 一樣,都是在指定容器啓動程序及參數。ENTRYPOINT 在運行時也可以替代,不過比 CMD 要略顯繁瑣,需要通過 docker run 的參數 --entrypoint 來指定。當指定了 ENTRYPOINT 後,CMD 的含義就發生了改變,不再是直接的運行其命令,而是將 CMD 的內容作爲參數傳給 ENTRYPOINT 指令,換句話說實際執行時,將變爲:

<ENTRYPOINT> "<CMD>"

那麼有了 CMD 後,爲什麼還要有 ENTRYPOINT 呢?這種 <ENTRYPOINT> "<CMD>" 有什麼好處麼?讓我們來看幾個場景。

場景一:讓鏡像變成像命令一樣使用

        假設我們需要一個得知自己當前公網 IP 的鏡像,那麼可以先用 CMD 來實現:

FROM ubuntu:18.04
RUN apt-get update \
    && apt-get install -y curl \
    && rm -rf /var/lib/apt/lists/*
CMD [ "curl", "-s", "https://ip.cn" ]

假如我們使用 docker build -t myip . 來構建鏡像的話,如果我們需要查詢當前公網 IP,只需要執行:

$ docker run myip
當前 IP:61.148.226.66 來自:北京市 聯通

嗯,這麼看起來好像可以直接把鏡像當做命令使用了,不過命令總有參數,如果我們希望加參數呢?比如從上面的 CMD 中可以看到實質的命令是 curl,那麼如果我們希望顯示 HTTP 頭信息,就需要加上 -i 參數。那麼我們可以直接加 -i 參數給 docker run myip 麼?

$ docker run myip -i
docker: Error response from daemon: invalid header field value "oci runtime error: container_linux.go:247: starting container process caused \"exec: \\\"-i\\\": executable file not found in $PATH\"\n".

我們可以看到可執行文件找不到的報錯,executable file not found。之前我們說過,跟在鏡像名後面的是 command,運行時會替換 CMD 的默認值。因此這裏的 -i 替換了原來的 CMD,而不是添加在原來的 curl -s https://ip.cn 後面。而 -i 根本不是命令,所以自然找不到。那麼如果我們希望加入 -i 這參數,我們就必須重新完整的輸入這個命令:

$ docker run myip curl -s https://ip.cn -i

這顯然不是很好的解決方案,而使用 ENTRYPOINT 就可以解決這個問題。現在我們重新用 ENTRYPOINT 來實現這個鏡像:

FROM ubuntu:18.04
RUN apt-get update \
    && apt-get install -y curl \
    && rm -rf /var/lib/apt/lists/*
ENTRYPOINT [ "curl", "-s", "https://ip.cn" ]

這次我們再來嘗試直接使用 docker run myip -i:

$ docker run myip
當前 IP:61.148.226.66 來自:北京市 聯通
​
$ docker run myip -i
HTTP/1.1 200 OK
Server: nginx/1.8.0
Date: Tue, 22 Nov 2016 05:12:40 GMT
Content-Type: text/html; charset=UTF-8
Vary: Accept-Encoding
X-Powered-By: PHP/5.6.24-1~dotdeb+7.1
X-Cache: MISS from cache-2
X-Cache-Lookup: MISS from cache-2:80
X-Cache: MISS from proxy-2_6
Transfer-Encoding: chunked
Via: 1.1 cache-2:80, 1.1 proxy-2_6:8006
Connection: keep-alive
​
當前 IP:61.148.226.66 來自:北京市 聯通

可以看到,這次成功了。這是因爲當存在 ENTRYPOINT 後,CMD 的內容將會作爲參數傳給 ENTRYPOINT,而這裏 -i 就是新的 CMD,因此會作爲參數傳給 curl,從而達到了我們預期的效果。

場景二:應用運行前的準備工作

        啓動容器就是啓動主進程,但有些時候,啓動主進程前,需要一些準備工作。比如 mysql 類的數據庫,可能需要一些數據庫配置、初始化的工作,這些工作要在最終的 mysql 服務器運行之前解決。

        此外,可能希望避免使用 root 用戶去啓動服務,從而提高安全性,而在啓動服務前還需要以 root 身份執行一些必要的準備工作,最後切換到服務用戶身份啓動服務。或者除了服務外,其它命令依舊可以使用 root 身份執行,方便調試等。

        這些準備工作是和容器 CMD 無關的,無論 CMD 爲什麼,都需要事先進行一個預處理的工作。這種情況下,可以寫一個腳本,然後放入 ENTRYPOINT 中去執行,而這個腳本會將接到的參數(也就是 <CMD>)作爲命令,在腳本最後執行。比如官方鏡像 redis 中就是這麼做的:

FROM alpine:3.4
...
RUN addgroup -S redis && adduser -S -G redis redis
...
ENTRYPOINT ["docker-entrypoint.sh"]
​
EXPOSE 6379
CMD [ "redis-server" ]

可以看到其中爲了 redis 服務創建了 redis 用戶,並在最後指定了 ENTRYPOINT 爲 docker-entrypoint.sh 腳本。

#!/bin/sh
...
# allow the container to be started with `--user`
if [ "$1" = 'redis-server' -a "$(id -u)" = '0' ]; then
    chown -R redis .
    exec su-exec redis "$0" "$@"
fi
​
exec "$@"

該腳本的內容就是根據 CMD 的內容來判斷,如果是 redis-server 的話,則切換到 redis 用戶身份啓動服務器,否則依舊使用 root 身份執行。比如:

$ docker run -it redis id
uid=0(root) gid=0(root) groups=0(root)

5、ENV 設置環境變量

格式有兩種:

ENV <key> <value>
ENV <key1>=<value1> <key2>=<value2>...

這個指令很簡單,就是設置環境變量而已,無論是後面的其它指令,如 RUN,還是運行時的應用,都可以直接使用這裏定義的環境變量。

ENV VERSION=1.0 DEBUG=on \
    NAME="Happy Feet"

這個例子中演示瞭如何換行,以及對含有空格的值用雙引號括起來的辦法,這和 Shell 下的行爲是一致的。定義了環境變量,那麼在後續的指令中,就可以使用這個環境變量。比如在官方 node 鏡像 Dockerfile 中,就有類似這樣的代碼:

ENV NODE_VERSION 7.2.0
​
RUN curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" \
  && curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" \
  && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \
  && grep " node-v$NODE_VERSION-linux-x64.tar.xz\$" SHASUMS256.txt | sha256sum -c - \
  && tar -xJf "node-v$NODE_VERSION-linux-x64.tar.xz" -C /usr/local --strip-components=1 \
  && rm "node-v$NODE_VERSION-linux-x64.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt \
  && ln -s /usr/local/bin/node /usr/local/bin/nodejs

在這裏先定義了環境變量 NODE_VERSION,其後的 RUN 這層裏,多次使用 $NODE_VERSION 來進行操作定製。可以看到,將來升級鏡像構建版本的時候,只需要更新 7.2.0 即可,Dockerfile 構建維護變得更輕鬆了。

6、ARG 構建參數

格式:ARG <參數名>[=<默認值>]

        構建參數和 ENV 的效果一樣,都是設置環境變量。所不同的是,ARG 所設置的構建環境的環境變量,在將來容器運行時是不會存在這些環境變量的。但是不要因此就使用 ARG 保存密碼之類的信息,因爲 docker history 還是可以看到所有值的。Dockerfile 中的 ARG 指令是定義參數名稱,以及定義其默認值。該默認值可以在構建命令 docker build 中用 --build-arg <參數名>=<值> 來覆蓋。

7、VOLUME 定義匿名卷

格式爲:

VOLUME ["<路徑1>", "<路徑2>"...]
VOLUME <路徑>

之前我們說過,容器運行時應該儘量保持容器存儲層不發生寫操作,對於數據庫類需要保存動態數據的應用,其數據庫文件應該保存於卷(volume)中,後面的章節我們會進一步介紹 Docker 卷的概念。爲了防止運行時用戶忘記將動態文件所保存目錄掛載爲卷,在 Dockerfile 中,我們可以事先指定某些目錄掛載爲匿名卷,這樣在運行時如果用戶不指定掛載,其應用也可以正常運行,不會向容器存儲層寫入大量數據。

VOLUME /data

這裏的 /data 目錄就會在運行時自動掛載爲匿名卷,任何向 /data 中寫入的信息都不會記錄進容器存儲層,從而保證了容器存儲層的無狀態化。當然,運行時可以覆蓋這個掛載設置。比如:

docker run -d -v mydata:/data xxxx

在這行命令中,就使用了 mydata 這個命名卷掛載到了 /data 這個位置,替代了 Dockerfile 中定義的匿名卷的掛載配置。

8、EXPOSE 聲明端口

格式: EXPOSE <端口1> [<端口2>...]

        EXPOSE 指令是聲明運行時容器提供服務端口,這只是一個聲明,在運行時並不會因爲這個聲明應用就會開啓這個端口的服務。在 Dockerfile 中寫入這樣的聲明有兩個好處,一個是幫助鏡像使用者理解這個鏡像服務的守護端口,以方便配置映射;另一個用處則是在運行時使用隨機端口映射時,也就是 docker run -P 時,會自動隨機映射 EXPOSE 的端口。

        要將 EXPOSE 和在運行時使用 -p <宿主端口>:<容器端口> 區分開來。-p,是映射宿主端口和容器端口,換句話說,就是將容器的對應端口服務公開給外界訪問,而 EXPOSE 僅僅是聲明容器打算使用什麼端口而已,並不會自動在宿主進行端口映射。

9、WORKDIR 指定工作目錄

格式:WORKDIR <工作目錄路徑>

        使用 WORKDIR 指令可以來指定工作目錄(或者稱爲當前目錄),以後各層的當前目錄就被改爲指定的目錄,如該目錄不存在,WORKDIR 會幫你建立目錄。之前提到一些初學者常犯的錯誤是把 Dockerfile 等同於 Shell 腳本來書寫,這種錯誤的理解還可能會導致出現下面這樣的錯誤:

RUN cd /app
RUN echo "hello" > world.txt

如果將這個 Dockerfile 進行構建鏡像運行後,會發現找不到 /app/world.txt 文件,或者其內容不是 hello。原因其實很簡單,在 Shell 中,連續兩行是同一個進程執行環境,因此前一個命令修改的內存狀態,會直接影響後一個命令;而在 Dockerfile 中,這兩行 RUN 命令的執行環境根本不同,是兩個完全不同的容器。這就是對 Dockerfile 構建分層存儲的概念不瞭解所導致的錯誤。

        之前說過每一個 RUN 都是啓動一個容器、執行命令、然後提交存儲層文件變更。第一層 RUN cd /app 的執行僅僅是當前進程的工作目錄變更,一個內存上的變化而已,其結果不會造成任何文件變更。而到第二層的時候,啓動的是一個全新的容器,跟第一層的容器更完全沒關係,自然不可能繼承前一層構建過程中的內存變化。

        因此如果需要改變以後各層的工作目錄的位置,那麼應該使用 WORKDIR 指令。

10、USER 指定當前用戶

格式:USER <用戶名>[:<用戶組>]

        USER 指令和 WORKDIR 相似,都是改變環境狀態並影響以後的層。WORKDIR 是改變工作目錄,USER 則是改變之後層的執行 RUN, CMD 以及 ENTRYPOINT 這類命令的身份。當然,和 WORKDIR 一樣,USER 只是幫助你切換到指定用戶而已,這個用戶必須是事先建立好的,否則無法切換。

RUN groupadd -r redis && useradd -r -g redis redis
USER redis
RUN [ "redis-server" ]

如果以 root 執行的腳本,在執行期間希望改變身份,比如希望以某個已經建立好的用戶來運行某個服務進程,不要使用 su 或者 sudo,這些都需要比較麻煩的配置,而且在 TTY 缺失的環境下經常出錯。建議使用 gosu。

# 建立 redis 用戶,並使用 gosu 換另一個用戶執行命令
RUN groupadd -r redis && useradd -r -g redis redis
# 下載 gosu
RUN wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.7/gosu-amd64" \
    && chmod +x /usr/local/bin/gosu \
    && gosu nobody true
# 設置 CMD,並以另外的用戶執行
CMD [ "exec", "gosu", "redis", "redis-server" ]

11、HEALTHCHECK 健康檢查

格式:

HEALTHCHECK [選項] CMD <命令>:設置檢查容器健康狀況的命令
HEALTHCHECK NONE:如果基礎鏡像有健康檢查指令,使用這行可以屏蔽掉其健康檢查指令

HEALTHCHECK 指令是告訴 Docker 應該如何進行判斷容器的狀態是否正常,這是 Docker 1.12 引入的新指令。在沒有 HEALTHCHECK 指令前,Docker 引擎只可以通過容器內主進程是否退出來判斷容器是否狀態異常。很多情況下這沒問題,但是如果程序進入死鎖狀態,或者死循環狀態,應用進程並不退出,但是該容器已經無法提供服務了。在 1.12 以前,Docker 不會檢測到容器的這種狀態,從而不會重新調度,導致可能會有部分容器已經無法提供服務了卻還在接受用戶請求。

        當在一個鏡像指定了 HEALTHCHECK 指令後,用其啓動容器,初始狀態會爲 starting,在 HEALTHCHECK 指令檢查成功後變爲 healthy,如果連續一定次數失敗,則會變爲 unhealthy。HEALTHCHECK 支持下列選項:

  • --interval=<間隔>:兩次健康檢查的間隔,默認爲 30 秒;
  • --timeout=<時長>:健康檢查命令運行超時時間,如果超過這個時間,本次健康檢查就被視爲失敗,默認 30 秒;
  • --retries=<次數>:當連續失敗指定次數後,則將容器狀態視爲 unhealthy,默認 3 次。

和 CMD, ENTRYPOINT 一樣,HEALTHCHECK 只可以出現一次,如果寫了多個,只有最後一個生效。在 HEALTHCHECK [選項] CMD 後面的命令,格式和 ENTRYPOINT 一樣,分爲 shell 格式,和 exec 格式。命令的返回值決定了該次健康檢查的成功與否:0:成功;1:失敗;2:保留,不要使用這個值。

        假設我們有個鏡像是個最簡單的 Web 服務,我們希望增加健康檢查來判斷其 Web 服務是否在正常工作,我們可以用 curl 來幫助判斷,其 Dockerfile 的 HEALTHCHECK 可以這麼寫:

FROM nginx
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
HEALTHCHECK --interval=5s --timeout=3s \
  CMD curl -fs http://localhost/ || exit 1

這裏我們設置了每 5 秒檢查一次(這裏爲了試驗所以間隔非常短,實際應該相對較長),如果健康檢查命令超過 3 秒沒響應就視爲失敗,並且使用 curl -fs http://localhost/ || exit 1 作爲健康檢查命令。使用 docker build 來構建這個鏡像:

$ docker build -t myweb:v1 .

構建好了後,我們啓動一個容器:

$ docker run -d --name web -p 80:80 myweb:v1

當運行該鏡像後,可以通過 docker container ls 看到最初的狀態爲 (health: starting):

$ docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
03e28eb00bd0 myweb:v1 "nginx -g 'daemon off" 3 seconds ago Up 2 seconds (health: starting) 80/tcp, 443/tcp web

在等待幾秒鐘後,再次 docker container ls,就會看到健康狀態變化爲了 (healthy):

$ docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
03e28eb00bd0 myweb:v1 "nginx -g 'daemon off" 18 seconds ago Up 16 seconds (healthy) 80/tcp, 443/tcp web

如果健康檢查連續失敗超過了重試次數,狀態就會變爲 (unhealthy)。爲了幫助排障,健康檢查命令的輸出(包括 stdout 以及 stderr)都會被存儲於健康狀態裏,可以用 docker inspect 來查看。

$ docker inspect --format '{{json .State.Health}}' web | python -m json.tool
{
    "FailingStreak": 0,
    "Log": [
        {
            "End": "2016-11-25T14:35:37.940957051Z",
            "ExitCode": 0,
            "Output": "<!DOCTYPE html>\n<html>\n<head>\n<title>Welcome to nginx!</title>\n<style>\n body {\n width: 35em;\n margin: 0 auto;\n font-family: Tahoma, Verdana, Arial, sans-serif;\n }\n</style>\n</head>\n<body>\n<h1>Welcome to nginx!</h1>\n<p>If you see this page, the nginx web server is successfully installed and\nworking. Further configuration is required.</p>\n\n<p>For online documentation and support please refer to\n<a href=\"http://nginx.org/\">nginx.org</a>.<br/>\nCommercial support is available at\n<a href=\"http://nginx.com/\">nginx.com</a>.</p>\n\n<p><em>Thank you for using nginx.</em></p>\n</body>\n</html>\n",
            "Start": "2016-11-25T14:35:37.780192565Z"
        }
    ],
    "Status": "healthy"
}

12、ONBUILD 爲他人做嫁衣裳

格式:ONBUILD <其它指令>

        ONBUILD 是一個特殊的指令,它後面跟的是其它指令,比如 RUN, COPY 等,而這些指令,在當前鏡像構建時並不會被執行。只有當以當前鏡像爲基礎鏡像,去構建下一級鏡像的時候纔會被執行。Dockerfile 中的其它指令都是爲了定製當前鏡像而準備的,唯有 ONBUILD 是爲了幫助別人定製自己而準備的。

        假設我們要製作 Node.js 所寫的應用的鏡像。我們都知道 Node.js 使用 npm 進行包管理,所有依賴、配置、啓動信息等會放到 package.json 文件裏。在拿到程序代碼後,需要先進行 npm install 纔可以獲得所有需要的依賴。然後就可以通過 npm start 來啓動應用。因此,一般來說會這樣寫 Dockerfile:

FROM node:slim
RUN mkdir /app
WORKDIR /app
COPY ./package.json /app
RUN [ "npm", "install" ]
COPY . /app/
CMD [ "npm", "start" ]

把這個 Dockerfile 放到 Node.js 項目的根目錄,構建好鏡像後,就可以直接拿來啓動容器運行。但是如果我們還有第二個 Node.js 項目也差不多呢?好吧,那就再把這個 Dockerfile 複製到第二個項目裏。那如果有第三個項目呢?再複製麼?文件的副本越多,版本控制就越困難,讓我們繼續看這樣的場景維護的問題。

        如果第一個 Node.js 項目在開發過程中,發現這個 Dockerfile 裏存在問題,比如敲錯字了、或者需要安裝額外的包,然後開發人員修復了這個 Dockerfile,再次構建,問題解決。第一個項目沒問題了,但是第二個項目呢?雖然最初 Dockerfile 是複製、粘貼自第一個項目的,但是並不會因爲第一個項目修復了他們的 Dockerfile,而第二個項目的 Dockerfile 就會被自動修復。

        那麼我們可不可以做一個基礎鏡像,然後各個項目使用這個基礎鏡像呢?這樣基礎鏡像更新,各個項目不用同步 Dockerfile 的變化,重新構建後就繼承了基礎鏡像的更新?好吧,可以,讓我們看看這樣的結果。那麼上面的這個 Dockerfile 就會變爲:

FROM node:slim
RUN mkdir /app
WORKDIR /app
CMD [ "npm", "start" ]

這裏我們把項目相關的構建指令拿出來,放到子項目裏去。假設這個基礎鏡像的名字爲 my-node 的話,各個項目內的自己的 Dockerfile 就變爲:

FROM my-node
COPY ./package.json /app
RUN [ "npm", "install" ]
COPY . /app/

基礎鏡像變化後,各個項目都用這個 Dockerfile 重新構建鏡像,會繼承基礎鏡像的更新。

        那麼,問題解決了麼?沒有。準確說,只解決了一半。如果這個 Dockerfile 裏面有些東西需要調整呢?比如 npm install 都需要加一些參數,那怎麼辦?這一行 RUN 是不可能放入基礎鏡像的,因爲涉及到了當前項目的 ./package.json,難道又要一個個修改麼?所以說,這樣製作基礎鏡像,只解決了原來的 Dockerfile 的前4條指令的變化問題,而後面三條指令的變化則完全沒辦法處理。ONBUILD 可以解決這個問題。讓我們用 ONBUILD 重新寫一下基礎鏡像的 Dockerfile:

FROM node:slim
RUN mkdir /app
WORKDIR /app
ONBUILD COPY ./package.json /app
ONBUILD RUN [ "npm", "install" ]
ONBUILD COPY . /app/
CMD [ "npm", "start" ]

這次我們回到原始的 Dockerfile,但是這次將項目相關的指令加上 ONBUILD,這樣在構建基礎鏡像的時候,這三行並不會被執行。然後各個項目的 Dockerfile 就變成了簡單地:

FROM my-node

是的,只有這麼一行。當在各個項目目錄中,用這個只有一行的 Dockerfile 構建鏡像時,之前基礎鏡像的那三行 ONBUILD 就會開始執行,成功的將當前項目的代碼複製進鏡像、並且針對本項目執行 npm install,生成應用鏡像。

六、多階段構建

1、原始方式

        在 Docker 17.05 版本之前,我們構建 Docker 鏡像時,通常會採用兩種方式:

1.全部放入一個 Dockerfile

        一種方式是將所有的構建過程編包含在一個 Dockerfile 中,包括項目及其依賴庫的編譯、測試、打包等流程,這裏可能會帶來的一些問題:

  • 鏡像層次多,鏡像體積較大,部署時間變長
  • 源代碼存在泄露的風險

例如,編寫 app.go 文件,該程序輸出 Hello World!

package main  
​
import "fmt"  
​
func main(){  
    fmt.Printf("Hello World!");
}

編寫 Dockerfile.one 文件

FROM golang:1.9-alpine
​
RUN apk --no-cache add git ca-certificates
​
WORKDIR /go/src/github.com/go/helloworld/
​
COPY app.go .
​
RUN go get -d -v github.com/go-sql-driver/mysql \
  && CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app . \
  && cp /go/src/github.com/go/helloworld/app /root
​
WORKDIR /root/
​
CMD ["./app"]

構建鏡像

$ docker build -t go/helloworld:1 -f Dockerfile.one .

2.分散到多個 Dockerfile

        另一種方式,就是我們事先在一個 Dockerfile 將項目及其依賴庫編譯測試打包好後,再將其拷貝到運行環境中,這種方式需要我們編寫兩個 Dockerfile 和一些編譯腳本才能將其兩個階段自動整合起來,這種方式雖然可以很好地規避第一種方式存在的風險,但明顯部署過程較複雜。

例如,編寫 Dockerfile.build 文件

FROM golang:1.9-alpine
​
RUN apk --no-cache add git
​
WORKDIR /go/src/github.com/go/helloworld
​
COPY app.go .
​
RUN go get -d -v github.com/go-sql-driver/mysql \
  && CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

編寫 Dockerfile.copy 文件

FROM alpine:latest
​
RUN apk --no-cache add ca-certificates
​
WORKDIR /root/
​
COPY app .
​
CMD ["./app"]

新建 build.sh

#!/bin/sh
echo Building go/helloworld:build
​
docker build -t go/helloworld:build . -f Dockerfile.build
​
docker create --name extract go/helloworld:build
docker cp extract:/go/src/github.com/go/helloworld/app ./app
docker rm -f extract
​
echo Building go/helloworld:2
​
docker build --no-cache -t go/helloworld:2 . -f Dockerfile.copy
rm ./app

現在運行腳本即可構建鏡像

$ chmod +x build.sh
​
$ ./build.sh

對比兩種方式生成的鏡像大小

$ docker image ls
​
REPOSITORY TAG IMAGE ID CREATED SIZE
go/helloworld 2 f7cf3465432c 22 seconds ago 6.47MB
go/helloworld 1 f55d3e16affc 2 minutes ago 295MB

2、使用多階段構建

        爲解決以上問題,Docker v17.05 開始支持多階段構建 (multistage builds)。使用多階段構建我們就可以很容易解決前面提到的問題,並且只需要編寫一個 Dockerfile,例如,編寫 Dockerfile 文件:

FROM golang:1.9-alpine as builder
​
RUN apk --no-cache add git
​
WORKDIR /go/src/github.com/go/helloworld/
​
RUN go get -d -v github.com/go-sql-driver/mysql
​
COPY app.go .
​
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
​
FROM alpine:latest as prod
​
RUN apk --no-cache add ca-certificates
​
WORKDIR /root/
​
COPY --from=0 /go/src/github.com/go/helloworld/app .
​
CMD ["./app"]

構建鏡像

$ docker build -t go/helloworld:3 .

對比三個鏡像大小

$ docker image ls
​
REPOSITORY TAG IMAGE ID CREATED SIZE
go/helloworld 3 d6911ed9c846 7 seconds ago 6.47MB
go/helloworld 2 f7cf3465432c 22 seconds ago 6.47MB
go/helloworld 1 f55d3e16affc 2 minutes ago 295MB

很明顯使用多階段構建的鏡像體積小,同時也完美解決了上邊提到的問題。

1.只構建某一階段的鏡像

        我們可以使用 as 來爲某一階段命名,例如

FROM golang:1.9-alpine as builder

例如當我們只想構建 builder 階段的鏡像時,增加 --target=builder 參數即可

$ docker build --target builder -t username/imagename:tag .

2.構建時從其他鏡像複製文件

        上面例子中我們使用 COPY --from=0 /go/src/github.com/go/helloworld/app . 從上一階段的鏡像中複製文件,我們也可以複製任意鏡像中的文件。

$ COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf

3、實戰多階段構建 Laravel 鏡像

1.準備

        新建一個 Laravel 項目或在已有的 Laravel 項目根目錄下新建 Dockerfile .dockerignore laravel.conf 文件。在 .dockerignore 文件中寫入以下內容。

.idea/
.git/
vendor/
node_modules/
public/js/
public/css/
yarn-error.log
​
bootstrap/cache/*
storage/
​
# 自行添加其他需要排除的文件,例如 .env.* 文件

在 laravel.conf 文件中寫入 nginx 配置。

server {
  listen 80 default_server;
  root /app/laravel/public;
  index index.php index.html;
​
  location / {
      try_files $uri $uri/ /index.php?$query_string;
  }
​
  location ~ .*\.php(\/.*)*$ {
    fastcgi_pass laravel:9000;
    include fastcgi.conf;
​
    # fastcgi_connect_timeout 300;
    # fastcgi_send_timeout 300;
    # fastcgi_read_timeout 300;
  }
}

2.前端構建

第一階段進行前端構建。

FROM node:alpine as frontend
​
COPY package.json /app/
​
RUN cd /app \
      && npm install --registry=https://registry.npm.taobao.org
​
COPY webpack.mix.js /app/
COPY resources/assets/ /app/resources/assets/
​
RUN cd /app \
      && npm run production

3.安裝 Composer 依賴

第二階段安裝 Composer 依賴。

FROM composer as composer
​
COPY database/ /app/database/
COPY composer.json composer.lock /app/
​
RUN cd /app \
      && composer config -g repo.packagist composer https://packagist.laravel-china.org \
      && composer install \
           --ignore-platform-reqs \
           --no-interaction \
           --no-plugins \
           --no-scripts \
           --prefer-dist

4.整合以上階段所生成的文件

第三階段對以上階段生成的文件進行整合。

FROM php:7.2-fpm-alpine as laravel
​
ARG LARAVEL_PATH=/app/laravel
​
COPY --from=composer /app/vendor/ ${LARAVEL_PATH}/vendor/
COPY . ${LARAVEL_PATH}
COPY --from=frontend /app/public/js/ ${LARAVEL_PATH}/public/js/
COPY --from=frontend /app/public/css/ ${LARAVEL_PATH}/public/css/
COPY --from=frontend /app/mix-manifest.json ${LARAVEL_PATH}/mix-manifest.json
​
RUN cd ${LARAVEL_PATH} \
      && php artisan package:discover \
      && mkdir -p storage \
      && mkdir -p storage/framework/cache \
      && mkdir -p storage/framework/sessions \
      && mkdir -p storage/framework/testing \
      && mkdir -p storage/framework/views \
      && mkdir -p storage/logs \
      && chmod -R 777 storage

5.最後一個階段構建 NGINX 鏡像

FROM nginx:alpine as nginx
​
ARG LARAVEL_PATH=/app/laravel
​
COPY laravel.conf /etc/nginx/conf.d/
COPY --from=laravel ${LARAVEL_PATH}/public ${LARAVEL_PATH}/public

6.構建 Laravel 及 Nginx 鏡像

使用 docker build 命令構建鏡像。

$ docker build -t my/laravel --target=laravel .
​
$ docker build -t my/nginx --target=nginx .

7.啓動容器並測試

新建 Docker 網絡

$ docker network create laravel

啓動 laravel 容器, --name=laravel 參數設定的名字必須與 nginx 配置文件中的 fastcgi_pass laravel:9000; 一致

$ docker run -it --rm --name=laravel --network=laravel my/laravel

啓動 nginx 容器

$ docker run -it --rm --network=laravel -p 8080:80 my/nginx

瀏覽器訪問 127.0.0.1:8080 可以看到 Laravel 項目首頁。

8.生產環境優化

        本小節內容爲了方便測試,將配置文件直接放到了鏡像中,實際在使用時 建議 將配置文件作爲 config 或 secret 掛載到容器中,請讀者自行學習 Swarm mode 或 Kubernetes 的相關內容。

附錄

完整的 Dockerfile 文件如下。

FROM node:alpine as frontend
​
COPY package.json /app/
​
RUN cd /app \
      && npm install --registry=https://registry.npm.taobao.org
​
COPY webpack.mix.js /app/
COPY resources/assets/ /app/resources/assets/
​
RUN cd /app \
      && npm run production
​
FROM composer as composer
​
COPY database/ /app/database/
COPY composer.json /app/
​
RUN cd /app \
      && composer config -g repo.packagist composer https://packagist.laravel-china.org \
      && composer install \
           --ignore-platform-reqs \
           --no-interaction \
           --no-plugins \
           --no-scripts \
           --prefer-dist
​
FROM php:7.2-fpm-alpine as laravel
​
ARG LARAVEL_PATH=/app/laravel
​
COPY --from=composer /app/vendor/ ${LARAVEL_PATH}/vendor/
COPY . ${LARAVEL_PATH}
COPY --from=frontend /app/public/js/ ${LARAVEL_PATH}/public/js/
COPY --from=frontend /app/public/css/ ${LARAVEL_PATH}/public/css/
COPY --from=frontend /app/mix-manifest.json ${LARAVEL_PATH}/mix-manifest.json
​
RUN cd ${LARAVEL_PATH} \
      && php artisan package:discover \
      && mkdir -p storage \
      && mkdir -p storage/framework/cache \
      && mkdir -p storage/framework/sessions \
      && mkdir -p storage/framework/testing \
      && mkdir -p storage/framework/views \
      && mkdir -p storage/logs \
      && chmod -R 777 storage
​
FROM nginx:alpine as nginx
​
ARG LARAVEL_PATH=/app/laravel
​
COPY laravel.conf /etc/nginx/conf.d/
COPY --from=laravel ${LARAVEL_PATH}/public ${LARAVEL_PATH}/public

 

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