dockerfile製作docker鏡像

dockerfile轉載自https://blog.csdn.net/wo18237095579/article/details/80540571

使用 Dockerfile 定製鏡像

        Dockerfile 指令詳解

                    FROM 指定基礎鏡像

                    RUN 執行命令

                                構建鏡像

                                鏡像構建上下文(Context)

                                其它 docker build 的用法

                                直接用 Git repo 進行構建

                                用給定的 tar 壓縮包構建

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

                    COPY 複製文件

                    ADD 更高級的複製文件

                    CMD 容器啓動命令

                    ENTRYPOINT 入口點

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

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

                   ENV 設置環境變量

                   ARG 構建參數

                   VOLUME 定義匿名卷

                   EXPOSE 聲明端口

                   WORKDIR 指定工作目錄

                   USER 指定當前用戶

                   HEALTHCHECK 健康檢查

                  ONBUILD 爲他人做嫁衣裳

================================================================================================

使用 Dockerfile 定製鏡像

  鏡像的定製實際上就是定製每一層所添加的配置、文件。如果我們可以把每一層修改、安裝、構建、操作的命令都寫入一個腳本,用這個腳本來構建、定製鏡像,那麼無法重複的問題、鏡像構建透明性的問題、體積的問題就都會解決。這個腳本就是 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 。



Dockerfile 指令詳解



第一、FROM 指定基礎鏡像

  所謂定製鏡像,那一定是以一個鏡像爲基礎,在其上進行定製。而 FROM 就是指定基礎鏡像,因此一個 Dockerfile 中 FROM 是必備的指令,並且必須是第一條指令。


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


  如果沒有找到對應服務的鏡像,官方鏡像中還提供了一些更爲基礎的操作系統鏡像,如ubuntu 、 debian 、 centos 等,這些操作系統的軟件庫爲我們提供了更廣闊的擴展空間。


  除了選擇現有鏡像爲基礎鏡像外,Docker 還存在一個特殊的鏡像,名爲 scratch 。這個鏡像是虛擬的概念,並不實際存在,它表示一個空白的鏡像。

        FROM scratch



        如果你以 scratch 爲基礎鏡像的話,意味着你不以任何鏡像爲基礎,接下來所寫的指令將作爲鏡像第一層開始存在。


  不以任何系統爲基礎,直接將可執行文件複製進鏡像的做法並不罕見,比如 swarm 、 coreos/etcd 。對於 Linux 下靜態編譯的程序來說,並不需要有操作系統提供運行時支持,所需的一切庫都已經在可執行文件裏了,因此直接 FROM scratch 會讓鏡像體積更加小巧。使用 Go 語言 開發的應用很多會使用這種方式來製作鏡像,這也是爲什麼有人認爲 Go是特別適合容器微服務架構的語言的原因之一。



第二、RUN 執行命令

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


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

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

            1

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

  既然 RUN 就像 Shell 腳本一樣可以執行命令,那麼我們是否就可以像 Shell 腳本一樣把每個命令對應一個 RUN 呢?比如這樣:


                FROM debian:jessie

                RUN apt-get update

                RUN apt-get install -y gcc libc6-dev make

                RUN wget -O redis.tar.gz "http://download.redis.io/releases/redis-3.2.5.tar.gz"

                RUN mkdir -p /usr/src/redis

                RUN tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1

                RUN make -C /usr/src/redis

                RUN make -C /usr/src/redis install

  之前說過,Dockerfile 中每一個指令都會建立一層, RUN 也不例外。每一個 RUN 的行爲,就和剛纔我們手工建立鏡像的過程一樣:新建立一層,在其上執行這些命令,執行結束後, commit 這一層的修改,構成新的鏡像。


  而上面的這種寫法,創建了 7 層鏡像。這是完全沒有意義的,而且很多運行時不需要的東西,都被裝進了鏡像裏,比如編譯環境、更新的軟件包等等。結果就是產生非常臃腫、非常多層的鏡像,不僅僅增加了構建部署的時間,也很容易出錯。 


  Union FS 是有最大層數限制的,比如 AUFS,曾經是最大不得超過 42 層,現在是不得超過127 層。


  上面的 Dockerfile 正確的寫法應該是這樣:


        FROM debian:jessie

        RUN buildDeps='gcc libc6-dev make' \

            && apt-get update \

            && apt-get install -y $buildDeps \

            && wget -O redis.tar.gz "http://download.redis.io/releases/redis-3.2.5.tar.gz" \

            && mkdir -p /usr/src/redis \

            && tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \

            && make -C /usr/src/redis \

            && make -C /usr/src/redis install \

            && rm -rf /var/lib/apt/lists/* \

            && rm redis.tar.gz \

            && rm -r /usr/src/redis \

            && apt-get purge -y --auto-remove $buildDeps


  首先,之前所有的命令只有一個目的,就是編譯、安裝 redis 可執行文件。因此沒有必要建立很多層,這只是一層的事情。因此,這裏沒有使用很多個 RUN 對一一對應不同的命令,而是僅僅使用一個 RUN 指令,並使用 && 將各個所需命令串聯起來。將之前的 7 層,簡化爲了1 層。在撰寫 Dockerfile 的時候,要經常提醒自己,這並不是在寫 Shell 腳本,而是在定義每一層該如何構建。


  並且,這裏爲了格式化還進行了換行。Dockerfile 支持 Shell 類的行尾添加 \ 的命令換行方式,以及行首 # 進行註釋的格式。良好的格式,比如換行、縮進、註釋等,會讓維護、排障更爲容易,這是一個比較好的習慣。


  此外,還可以看到這一組命令的最後添加了清理工作的命令,刪除了爲了編譯構建所需要的軟件,清理了所有下載、展開的文件,並且還清理了 apt 緩存文件。這是很重要的一步,之前有說過,鏡像是多層存儲,每一層的東西並不會在下一層被刪除,會一直跟隨着鏡像。因此鏡像構建時,一定要確保每一層只添加真正需要添加的東西,任何無關的東西都應該清理掉。


  很多人初學 Docker 製作出了很臃腫的鏡像的原因之一,就是忘記了每一層構建的最後一定要清理掉無關文件。



第三、構建鏡像

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


 1) 在 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 ,構建成功後,我們可以直接運行這個鏡像,其結果就是我們的主頁被改變成了Hello, Docker!。


2)鏡像構建上下文(Context)

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


  首先我們要理解 docker build 的工作原理。Docker 在運行時分爲 Docker 引擎(也就是服務端守護進程)和客戶端工具。Docker 的引擎提供了一組 REST API,被稱爲 DockerRemote 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 -t nginx:v3 . 中的這個 . ,實際上是在指定上下文的目錄, docker build 命令會將該目錄下的內容打包交給 Docker 引擎以幫助構建鏡像。


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

                                    

                                    $ docker build -t nginx:v3 .

                                    Sending build context to Docker daemon 2.048 kB

                                    ...


  理解構建上下文對於鏡像構建是很重要的,避免犯一些不應該的錯誤。比如有些初學者在發現 COPY /opt/xxxx /app 不工作後,於是乾脆將 Dockerfile 放到了硬盤根目錄去構建,結果發現 docker build 執行後,在發送一個幾十 GB 的東西,極爲緩慢而且很容易構建失敗。那是因爲這種做法是在讓 docker build 打包整個硬盤,這顯然是使用錯誤。


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


  那麼爲什麼會有人誤以爲 . 是指定 Dockerfile 所在目錄呢?這是因爲在默認情況下,如果不額外指定 Dockerfile 的話,會將上下文目錄下的名爲 Dockerfile 的文件作爲 Dockerfile。


  這只是默認行爲,實際上 Dockerfile 的文件名並不要求必須爲 Dockerfile ,而且並不要求必須位於上下文目錄中,比如可以用 -f ../Dockerfile.php 參數指定某個文件作爲 Dockerfile 。


  當然,一般大家習慣性的會使用默認的文件名 Dockerfile ,以及會將其置於鏡像構建上下文目錄中。


其它 docker build 的用法


3)直接用 Git repo 進行構建

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


                    $ docker build https://github.com/twang2218/gitlab-ce-zh.git#:8.14

                    docker build https://github.com/twang2218/gitlab-ce-zh.git\#:8.14

                    Sending build context to Docker daemon 2.048 kB

                    Step 1 : FROM gitlab/gitlab-ce:8.14.0-ce.0

                    8.14.0-ce.0: Pulling from gitlab/gitlab-ce

                    aed15891ba52: Already exists

                    773ae8583d14: Already exists

                    ...

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


4)用給定的 tar 壓縮包構建

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

                        

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


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

                    docker build - < Dockerfile

                      或

                    cat Dockerfile | docker build -


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


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

                            $ docker build - < context.tar.gz

                            

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


COPY 複製文件

  格式:

                        

                        COPY <源路徑>... <目標路徑>

                        COPY ["<源路徑1>",... "<目標路徑>"]


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


                            COPY package.json /usr/src/app/


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

                        

                        COPY hom* /mydir/

                        COPY hom?.txt /mydir/


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


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


第五、 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 指令會令鏡像構建緩存失效,從而可能會令鏡像構建變得比較緩慢。


  因此在 COPY 和 ADD 指令中選擇的時候,可以遵循這樣的原則,所有的文件複製均使用 COPY 指令,僅在需要自動解壓縮的場合使用 ADD 。


第六、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 去啓動後臺服務,容器內沒有後臺服務的概念。


  初學者一般將 CMD 寫爲:


            CMD service nginx start


  然後發現容器執行後就立即退出了。甚至在容器內去使用 systemctl 命令結果卻發現根本執行不了。這就是因爲沒有搞明白前臺、後臺的概念,沒有區分容器和虛擬機的差異,依舊在以傳統虛擬機的角度去理解容器。


  對於容器而言,其啓動程序就是容器應用進程,容器就是爲了主進程而存在的,主進程退出,容器就失去了存在的意義,從而退出,其它輔助進程不是它需要關心的東西。


  而使用 service nginx start 命令,則是希望 systemd 來以後臺守護進程形式啓動 nginx 服務。而剛纔說了 CMD service nginx start 會被理解爲 CMD [ “sh”, “-c”, “service nginxstart”] ,因此主進程實際上是 sh 。那麼當 service nginx start 命令結束後, sh 也就結束了, sh 作爲主進程退出了,自然就會令容器退出。


  正確的做法是直接執行 nginx 可執行文件,並且要求以前臺形式運行。比如:

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

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:16.04

RUN apt-get update \

&& apt-get install -y curl \

&& rm -rf /var/lib/apt/lists/*

CMD [ "curl", "-s", "http://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: con

tainer_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 http://ip.cn 後面。而 -i 根本不是命令,所以自然找不到。


  那麼如果我們希望加入 -i 這參數,我們就必須重新完整的輸入這個命令:


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


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


FROM ubuntu:16.04

RUN apt-get update \

    && apt-get install -y curl \

    && rm -rf /var/lib/apt/lists/*

ENTRYPOINT [ "curl", "-s", "http://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 中去執行,而這個腳本會將接到的參數(也就是 )作爲命令,在腳本最後執行。比如官方鏡像 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 爲 dockerentrypoint.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)


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.ta

r.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 構建維護變得更輕鬆了。


  下列指令可以支持環境變量展開: 

  ADD 、 COPY 、 ENV 、 EXPOSE 、 LABEL 、 USER 、 WORKDIR 、 VOLUME 、 STOPSIGNAL 、 ONBUILD 。


  可以從這個指令列表裏感覺到,環境變量可以使用的地方很多,很強大。通過環境變量,我們可以讓一份 Dockerfile 製作更多的鏡像,只需使用不同的環境變量即可。


ARG 構建參數

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


  構建參數和 ENV 的效果一樣,都是設置環境變量。所不同的是, ARG 所設置的構建環境的環境變量,在將來容器運行時是不會存在這些環境變量的。但是不要因此就使用 ARG 保存密碼之類的信息,因爲 docker history 還是可以看到所有值的。


  Dockerfile 中的 ARG 指令是定義參數名稱,以及定義其默認值。該默認值可以在構建命令 docker build 中用 --build-arg <參數名>=<值> 來覆蓋。


  在 1.13 之前的版本,要求 –build-arg 中的參數名,必須在 Dockerfile 中用 ARG 定義過了,換句話說,就是 –build-arg 指定的參數,必須在 Dockerfile 中使用了。如果對應參數沒有被使用,則會報錯退出構建。從 1.13 開始,這種嚴格的限制被放開,不再報錯退出,而是顯示警告信息,並繼續構建。這對於使用 CI 系統,用同樣的構建流程構建不同的 Dockerfile 的時候比較有幫助,避免構建命令必須根據每個 Dockerfile 的內容修改。


VOLUME 定義匿名卷

  格式爲:


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

VOLUME <路徑>

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


VOLUME /data


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


docker run -d -v mydata:/data xxxx


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


EXPOSE 聲明端口

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


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


  此外,在早期 Docker 版本中還有一個特殊的用處。以前所有容器都運行於默認橋接網絡中,因此所有容器互相之間都可以直接訪問,這樣存在一定的安全性問題。於是有了一個 Docker 引擎參數 --icc=false ,當指定該參數後,容器間將默認無法互訪,除非互相間使用了 --links 參數的容器纔可以互通,並且只有鏡像中 EXPOSE 所聲明的端口纔可以被訪問。這個 --icc=false 的用法,在引入了 docker network 後已經基本不用了,通過自定義網絡可以很輕鬆的實現容器間的互聯與隔離。


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


WORKDIR 指定工作目錄

  格式爲 WORKDIR <工作目錄路徑> 。


  使用 WORKDIR 指令可以來指定工作目錄(或者稱爲當前目錄),以後各層的當前目錄就被改爲指定的目錄,如該目錄不存在, WORKDIR 會幫你建立目錄。


  之前提到一些初學者常犯的錯誤是把 Dockerfile 等同於 Shell 腳本來書寫,這種錯誤的理解還可能會導致出現下面這樣的錯誤:


RUN cd /app

RUN echo "hello" > world.txt


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


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


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


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" ]


HEALTHCHECK 健康檢查

  格式:


HEALTHCHECK [選項] CMD <命令> :設置檢查容器健康狀況的命令

HEALTHCHECK NONE :如果基礎鏡像有健康檢查指令,使用這行可以屏蔽掉其健康檢查指令

  HEALTHCHECK 指令是告訴 Docker 應該如何進行判斷容器的狀態是否正常,這是 Docker 1.12 引入的新指令。


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


  而自 1.12 之後,Docker 提供了 HEALTHCHECK 指令,通過該指令指定一行命令,用這行命令來判斷容器主進程的服務狀態是否還正常,從而比較真實的反應容器實際狀態。


  當在一個鏡像指定了 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 (health: healthy)         80/tcp, 443/tcp         web

        

  如果健康檢查連續失敗超過了重試次數,狀態就會變爲 (unhealthy) 。


  爲了幫助排障,健康檢查命令的輸出(包括 stdout 以及 stderr )都會被存儲於健康狀態裏,可以用 docker inspect 來查看。


                                    $ docker inspect --format '{{json .State.Health}}' upbeat_allen | python -m json.tool

                                    {

                                        "FailingStreak": 0,

                                        "Log": [

                                            {

                                                "End": "2018-06-14T04:55:37.477730277-04:00",

                                                "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": "2018-06-14T04:55:37.408045977-04:00"

                                            },

                                            {

                                                "End": "2018-06-14T04:55:42.553816257-04:00",

                                                "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": "2018-06-14T04:55:42.480940888-04:00"

                                            },

                                            {

                                                "End": "2018-06-14T04:55:47.631694051-04:00",

                                                "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": "2018-06-14T04:55:47.557214953-04:00"

                                            },

                                            {

                                                "End": "2018-06-14T04:55:52.708195002-04:00",

                                                "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": "2018-06-14T04:55:52.63499573-04:00"

                                            },

                                            {

                                                "End": "2018-06-14T04:55:57.795117794-04:00",

                                                "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": "2018-06-14T04:55:57.714289056-04:00"

                                            }

                                        ],

                                        "Status": "healthy"

                                    }


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" ]


  這裏我們把項目相關的構建指令拿出來,放到子項目裏去。假設這個基礎鏡像的名字爲 mynode 的話,各個項目內的自己的 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 ,生成應用鏡像。


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