# 第7章 構建鏡像
鏡像是所有容器的運行之本,因此,在構建Docker基礎設施時,掌控構建鏡像的藝術非常必要。
構建鏡像的方式將決定容器部署的速度、從容器獲取日志的難易程度、可配置的多少以及其安全程度。盡管構建鏡像時的首要關注點是容器可按預期運行,但在生產環境中,這里所列舉的因素將變得非常重要。
在著手構建鏡像之前,我們需要理解其實現方式的幾個方面。
雖然表面上Docker鏡像與虛擬機鏡像并無太大差異,但它們的實現方式卻完全不同。虛擬機鏡像提供了完整的文件系統虛擬化:鏡像中的文件系統可以與鏡像所在宿主機的文件系統完全不同。虛擬機鏡像通常以數據卷的形式實現,以大文件形式存儲在宿主機操作系統中。一旦為虛擬機分配了數據卷,虛擬機里的訪客操作系統就會在這個卷上創建并格式化出一個或多個分區。虛擬機管理程序會將這些文件作為原始磁盤展現給訪客操作系統。
這種虛擬化文件系統的方法提供了巨大的隔離性和靈活性,但其效率可能不佳。例如,在使用同一個鏡像運行多個虛擬機,或需要來自同一基礎鏡像的多個不同鏡像時,效率就會比較低。克隆虛擬機的標準方法是為新鏡像創建一份文件系統的新副本,使得兩個鏡像里的文件系統可以獨立演進。這種方法在創建副本時的磁盤空間占用和時間花費上代價昂貴,也正因為這樣,虛擬機廠商在這些普通復制無法正常工作的場景中,依靠**寫時復制**(copy-on-write,CoW)技術來提高鏡像的使用效率。
在創建和運行多個從同一基線數據啟動的進程時,寫時復制技術可以節省大量時間和空間。對于虛擬化而言,假設有20臺虛擬機需要使用相同的基礎鏡像,使用寫時復制的話,用戶就不需要創建20份該鏡像的副本(每個虛擬機一份)。相反,所有虛擬機可以從相同的鏡像文件啟動,帶來更快的啟動速度,并節省運行所有虛擬機需要的大量磁盤空間。寫時復制會讓每個虛擬機里的訪客操作系統以為它們是在基礎鏡像中對文件系統進行獨立修改,它的實現方式是為每個虛擬機提供一個在共享基礎鏡像之上的疊加層,這個層可以獨立于其他虛擬機進行修改。每當操作系統嘗試對文件系統進行修改時,實際上是發生在這個疊加層上的,基礎鏡像保持原封不動。
在操作系統想要對文件系統進行修改時,處于這些場景之后的虛擬機會把這些被編輯的磁盤扇區復制到疊加層上,并將這些副本提供給訪客操作系統,讓其當作原始版本。然后,虛擬機管理程序就允許訪客操作系統修改疊加層上的副本,保持基礎鏡像中的原始扇區不變。從這刻開始,這個虛擬機就再也看不到這些扇區的原始共享副本,只能看到疊加層中的副本(如圖7-1所示)。虛擬機管理程序為訪客操作系統提供了一個“幻象”:作為疊加層與基礎鏡像合并結果的文件系統會被當作一個單一的數據卷。

圖7-1
Docker鏡像生來就是基于寫時復制技術的,且與標準的虛擬機不同,Docker的鏡像并不是完全虛擬化的,它們是構建于宿主機的文件系統之上的。這種方法是否比完全虛擬化具有性能優勢還有待考證,而且很大程度上取決于具體用例。例如,虛擬機世界里的寫時復制通常是基于扇區的,也就是,只有基礎鏡像上有變動的文件磁盤扇區會被復制到疊加層上進行編輯,而對Docker而言,整個文件會被復制和編輯,因此即便只是大文件的一部分被修改,整個文件都需要被復制。另一方面,使用Docker的鏡像,訪客和宿主機操作系統之間不需要文件系統轉換。我們討論構建Docker鏡像時重要的一點是,Docker進一步發揮了寫時復制技術的作用,可以很容易地堆疊多個寫時復制疊加層以創建一個鏡像或一系列相關的鏡像。
Docker使用寫時復制的主要原因有二。其一是讓用戶可以交互地構建鏡像,一次添加一個層。其二則具有更深遠的意義,與鏡像的存儲和分發有關。在我們構建系統時,我們通常采取的方式是,所有服務都基于相同的操作系統的小集合,甚至是共享一些基礎配置。以這種方式設置的容器鏡像彼此的差異只在于配置的“最后一英里”,這最后一英里只包含了將該鏡像與其他鏡像區分開來的內容,有些時候,容器使用的是完全一樣的鏡像。在這些場景中,寫時復制可以非常有效地節省時間和空間。
Docker還使用寫時復制將容器運行于一個疊加層上,而非直接運行于鏡像上。原始鏡像是以只讀模式被使用的,容器可能對文件系統做的任何修改都只會在這個疊加層上執行。讀者可能閱讀過“Docker鏡像是不可變的”這樣的Docker文獻,其確切意思是:一旦鏡像被創建,它就無法再被修改,因此你能做的事是在它的基礎上構建新鏡像。
Docker疊加層的使用其真正強大之處在于這些疊加層可以跨宿主機進行共享。每個疊加層包含了對其基礎鏡像的引用,而后者又是另外一個疊加層。每個疊加層都擁有唯一的ID,以及一個可選的名稱和版本號。Docker鏡像的具名疊加層是在鏡像倉庫中存儲與共享的。部署一個容器時,Docker會檢查容器所需的鏡像是否在本地倉庫中已經存在。如果在本地不存在,Docker在鏡像倉庫檢索這個鏡像,并拉取該鏡像所有疊加層的引用,然后確定哪些層已經在本地存在,并下載那些缺失的疊加層。
這種方法可以減少保存宿主機中所有鏡像所需的空間,并能顯著地減少新鏡像的下載時間。例如,在一個運行10個容器的場景中,10個鏡像的主干都來自于相同的基礎CentOS 7鏡像,宿主機只需要下載一次基礎鏡像,以及10個不同的疊加層,無須下載10個都包含CentOS 7完整副本的鏡像。同樣,下載更新的鏡像只需要下載最新的幾個疊加層。
本章后面將詳細討論如何再利用這些特性,但我們先來看一下構建鏡像的主要方面:使其工作。
從最基本的層面講,構建一個容器鏡像(后面簡稱為鏡像)可以通過兩種方法完成。第一個方法是從一個基礎鏡像(`ubuntu-14.04`)啟動一個容器,在容器內運行一系列命令,如安裝軟件包、編輯配置文件等,一旦鏡像處于期望狀態,對其進行保存。
我們來看看它是如何工作的。在一個終端中,使用`ubuntu`的基礎鏡像啟動一個運行`/bin/bash`可交互的容器。一旦進入容器內的shell,我們就在根目錄中創建一個名為`docker-was-here`的文件。這項操作不應修改基礎鏡像。相反,新文件應被創建在容器的文件系統疊加層上:
```
$ docker run -ti ubuntu /bin/bash
root@4621ac608b25:/# pwd
/
root@4621ac608b25:/# ls
bin boot dev etc home lib lib64 media mnt opt proc
root run sbin srv sys tmp usr var
root@4621ac608b25:/# touch docker-was-here
root@4621ac608b25:/#
```
現在,我們在第二個終端中創建一個基于上述容器內容的新鏡像,上述示例中其ID是`4621ac608b25`。
```
$ docker commit 4621ac608b25 my-new-image
6aeffe57ec698e0e5d618bd7b8202adad5c6a826694b26cb95448dda788d4ed8
```
最后,我們在這個終端中啟動一個新容器,這一次使用的是我們新創建的`my-new-image`鏡像。我們可以驗證鏡像包含了我們自建的`docker-was-here`文件。
```
$ docker run -ti my-new-image /bin/bash
root@50d33db925e4:/# ls
bin boot dev docker-was-here etc home lib lib64 media
mnt opt proc root run sbin srv sys tmp usr var
root@50d33db925e4:/# ls -la
total 72
drwxr-xr-x 32 root root 4096 May 1 03:33 .
drwxr-xr-x 32 root root 4096 May 1 03:33 ..
-rwxr-xr-x 1 root root 0 May 1 03:33 .dockerenv
-rwxr-xr-x 1 root root 0 May 1 03:33 .dockerinit
drwxr-xr-x 2 root root 4096 Mar 20 05:22 bin
drwxr-xr-x 2 root root 4096 Apr 10 2014 boot
drwxr-xr-x 5 root root 380 May 1 03:33 dev
-rw-r--r-- 1 root root 0 May 1 03:31 docker-was-here
drwxr-xr-x 64 root root 4096 May 1 03:33 etc
....
drwxr-xr-x 12 root root 4096 Apr 21 22:18 var
root@50d33db925e4:/#
```
盡管這種構建鏡像的交互方法非常直觀,但無助于可重現和自動化。對生產環境設置而言,很有必要使用可輕松重現的方法進行鏡像自動化構建。Docker提供了一個方法來完成這件事,該方法基于一個名為`Dockerfile`的文件。
一個`Dockerfile`包含了一系列指令,Docker在一個容器中運行這些指令以產生一個鏡像。這些指令可以分為兩組:一組修改鏡像的文件系統,一組修改鏡像的元數據。修改文件系統的指令示例之一是`ADD`——將URL定義的遠程地址文件寫入到鏡像文件系統中,或`RUN`——在鏡像上運行一個命令。修改元數據的指令示例之一是`CMD`——設置了容器進程啟動時要運行的默認命令及其參數。
在使用`docker build`時,Docker會以Dockerfile的`FROM`指令指定的基礎鏡像來啟動一個臨時的容器,然后在這個容器的上下文中運行每條指令。Docker會為每條指令創建一個中間鏡像。這是為了方便用戶漸進地構建鏡像:在Dockerfile里修改或添加一條指令時,Docker知道在此之前的指令并未發生變化,所以它會使用運行完上一條指令后構建的鏡像。
例如,要使用`Dockerfile`構建出和此前以交互方式構建而來的相同的鏡像,首先創建一個新目錄,然后在該目錄中創建一個名為Dockerfile的文件,其內容為:
```
FROM ubuntu
MAINTAINER Me Myself and I
RUN touch /docker-was-here
```
接下來,我們告訴Docker使用這個Dockerfile來構建`my-new-image`鏡像:
`$ docker build -t my-new-image .`Docker默認會在當前目錄中查找`Dockerfile`。如果用戶使用的是其他文件名或Dockerfile位于其他位置,可以使用`-f`來告訴Docker所使用的Dockerfile路徑:
`$ docker build -t my-new-image -f my-other-dockerfile .`@#@#@#@#@nav_point_90
正如前面所說,Docker鏡像呈現出的是一個分層構架,鏡像由一堆的文件系統疊加層組成。每一層都是源自前一層的一組文件的增加、修改和刪除。在層里增加文件時,新文件將被創建。在層里刪除文件時,這個文件會被標記為已刪除,但是請注意,這個文件還包含在前面的層里。在層里修改文件時,取決于Docker所運行的存儲驅動程序(在后面的存儲章節中詳述),要么整個文件在新層里被重新創建,或者只有這個文件的部分磁盤扇區在新層里被新扇區所替換。不論哪種方式,舊文件在前面的層中保持不變,而新層則包含了其新的修改。在每個層都有一個鏡像,這個鏡像是在基礎鏡像之上依次疊加先前層的結果。在所得到的鏡像之上同樣也是前面所有層的結果。
在構建Docker鏡像時,通常會從一個現有基礎鏡像開始,這個鏡像可能已經包含了很多層。Docker會按順序運行`Dockerfile`里的每條指令,在每條指令結束時Docker會以運行該指令產生的文件系統的變化生成一個新層。這是一項非常好的功能,因為它允許漸進式開發鏡像,而不必每次都等待所有的指令運行。例如,假設因為第10條指令包含一個錯誤,導致Docker構建鏡像時失敗,然后當用戶在修復了這個失敗的指令后重新嘗試構建時,Docker將不需要再次運行前面的9條指令。相反,它將以上一次正確構建的層為起點,然后從之前的失敗指令開始繼續構建。這可大大節省時間,因為有些指令可能會運行一些比較耗時的命令。
這種分層架構在部署階段同樣具有優勢,因為在部署一個新鏡像時,某些較深的層可能已經存在于宿主機中,因此只有新層需要通過網絡發送過去。在運行基于同一個或類似鏡像的多個容器時,這項功能將極大地減少所需的時間和空間(如圖7-2所示)。

圖7-2
這種分層架構也帶來了一些在現實場景中需要考慮的注意事項。其中之一是,鏡像無法縮小。如果一個鏡像所有層加起來在文件系統中是500 MB,擴展它的任何自定義鏡像在文件系統中至少需要500 MB,即使上層刪除了下層的文件。
鏡像大小相當重要,特別是在宿主機上安裝這些鏡像時,不僅因為下載鏡像到宿主機所耗費的時間取決于它的大小,還因為鏡像文件越大宿主機上所需的空間也越多。在開發過程中,其大小也很重要,因為對于一個新的開發人員,或對于啟用一組新的Docker宿主機的人,甚至對持續集成/持續部署的服務器來說,都有可能需要花費大量時間下載開發過程所需容器的所有鏡像。考慮到Docker是用來提升開發過程,這多少會讓人有些沮喪。
注意,如果正在部署一個微服務架構,減小鏡像大小尤為重要。但是,如果正在部署大型的虛擬機類容器,由于你可能使用了一個功能完整的操作系統,本節的大部分內容并不會給你帶來多大作用。
#### 從“小”做起
控制鏡像空間要從最小化的基礎鏡像開始。在極端情況下,可以從一個空的文件系統開始,并在其中部署操作系統,不過這應該不是應用最廣的方法。
下一個選項是類似[busybox](http://www.busybox.net/)或[alpine](http://www.alpinelinux.org/)的微型發行版。Busybox大約2.5 MB,一開始是為嵌入式應用程序創建的。它包含了最基本的Unix工具供用戶使用,不過用戶也可以創建添加(或刪除)了額外命令的自己的busybox發行版。Busybox對支持運行靜態編譯二進制文件的鏡像支持良好,如用Go寫的進程。
Alpine在Busybox基礎上,擴展添加了一個以安全為重點的內核構建版本及一個名為`apk`的包管理器,并是基于[Musl libc](http://www.musl-libc.org/)——一個更輕也可能更快的libc版本。Alpine可以作為容器通用的Linux發行版,不過用戶將不得不花費更多的力氣完成所需功能的配置:盡管Alpine提供了一個包管理器,可用的包列表比Debian或CentOS這類完整的發行版要小得多。相比全功能發行版,Alpine的優勢在于它活動部件更少,因此更小巧,且更易于理解,而且作為一個必然結果,也更易于加固。
下一個選項是使用主流Linux發行版版本(如Ubuntu或CentOS)的容器優化版本。這些容器通常運行完整發行版的精簡版本,它們刪除了所有的桌面程序,且具有針對生產環境服務器優化的配置。這些鏡像一般只有幾百MB,如Ubuntu 14.04大約是190 MB,而CentOS 7大約是215 MB,不過它們提供了一個全功能的操作系統。這是目前在構建自定義鏡像時最容易入手的地方,因為它們對打包服務的支持相當好,而且網絡上存在大量的文檔和手冊可以作參考。Docker registry充滿了這類鏡像,大多數鏡像由Docker公司直接支持。
一個明智的選擇是,標準化出一個特定的鏡像版本,并盡可能地將其用于所有容器。如果宿主機里所有的容器都具有相同的基礎鏡像,一旦第一個容器下載完成,下載其余容器的速度將更快,因為它們無須重新下載基礎鏡像層。不過需要注意的是,只有基礎鏡像已經下載好**之后**,才能在下載其他鏡像時發揮作用。截至本文撰寫時,如果在基礎鏡像尚未下載完成時,同時下載多個鏡像,那么每個鏡像都會下載一次基礎鏡像,因為在開始下載鏡像時本地倉庫還不存在這個基礎鏡像。
在選定一個小的基礎鏡像之后,下一步是在運行Dockerfile之后保持鏡像的小巧。
每運行Dockerfile里的一個命令,就會生成一個新的鏡像層。層生成時,一個新的最小鏡像大小就被設定了:即使用戶在Dockerfile的下一個命令中刪除文件,也不會釋放任何空間,位于宿主機文件系統上的鏡像大小也不會縮小。
出于這個原因,如何在Dockerfile中組織命令將影響最終的鏡像大小。例如,通過包管理器安裝一個軟件包:當調用包管理器時,它的索引會被更新,它會下載一些包到緩存目錄,并在將包中文件放置到文件系統的最終位置前,將包展開到預演區域。如果用戶像往常一樣運行包安裝命令,這些永遠也用不上的緩存包文件將會永遠地成為鏡像的一部分。不過,如果用戶在同一條安裝命令中刪除它們,這些文件就會像從未存在過一樣。
例如,可以像這樣在一個單一步驟里安裝Scala并執行清理操作:
```
RUN curl -o /tmp/scala-2.10.2.tgz http://www.scala-lang.org/
files/archive/scala-2.10.2.tgz \
&& tar xzf /tmp/scala-2.10.2.tgz -C /usr/share/ \
&& ln -s /usr/share/scala-2.10.2 /usr/share/scala \
&& for i in scala scalc fsc scaladoc scalap; do ln -s /usr/
share/scala/bin/${i} /usr/bin/${i}; done \
&& rm -f /tmp/scala-2.10.2.tgz
```
在上面的示例中,如果像這樣獨立地運行上述命令:
```
RUN curl -o /tmp/scala-2.10.2.tgz http://www.scala-lang.org/
files/archive/scala-2.10.2.tgz
RUN tar xzf /tmp/scala-2.10.2.tgz -C /usr/share/
RUN ln -s /usr/share/scala-2.10.2 /usr/share/scala
RUN for i in scala scalc fsc scaladoc scalap; do ln -s /usr/
share/scala/bin/${i} /usr/bin/${i}; done
RUN rm -f /tmp/scala-2.10.2.tgz
```
其后的鏡像將包含這個`.tgz`文件,盡管在最后一條命令之后無法在文件系統中看到這個文件。
有兩種可以配置容器中運行進程的方法:一種方法是通過環境變量將配置傳遞給容器內部,另一種是將配置文件和/或目錄掛載到容器中。兩種方法都發生在容器啟動時期。兩種方法都是非常有用,有各自的應用場所,但它們本質上有很大的不同。
#### 通過環境變量配置
在Docker啟動一個容器時,它可以將環境變量轉發給容器進程,進而轉發給運行于容器內的進程。我們來看一下它是如何工作的。啟動一個容器,在shell中運行一個命令來打印環境變量`MY_VAR`的值:
```
$ docker run --rm busybox /bin/sh -c 'echo "my variable is $MY_VAR"'
my variable is
```
這個環境變量未在容器中預定義,因此并沒有值。現在在容器內運行相同的命令,不過這次我們通過Docker傳遞一個環境變量給容器:
```
$ docker run -e "MY_VAR=docker-was-here" --rm busybox /bin/sh -c 'echo "my variable is $MY_VAR"'
my variable is docker-was-here
```
理想情況下,通過環境變量,容器內的進程是完全可配置的。有時,我們會容器化那些通過配置文件獲取配置的服務。我們將在下一節中討論如何處理這些場景,不過現在我們先專注于環境變量的直接使用。
使用環境變量可以在進程及其配置間提供大量的隔離,這在“十二要素”([12 factor](http://12factor.net/config),一份用于構建基于服務的應用程序的宣言)中被認為是更好的方法。Docker為此設計了一個參數選項,在啟動時將這些環境變量傳遞給容器。
這種分離的好處在于用戶可以使用相同的鏡像,而不管如何計算用于運行容器的配置。當容器化的進程通過環境獲取它的配置時,所有的配置責任都屬于調用Docker來啟動容器的那個進程。這個模式帶來了極大的靈活性,因為配置可以來自容器啟動腳本的硬編碼中,或來自文件,或來自一些分布式配置服務,甚至是來自于調度器。
有時,用戶需要包裝一個無法通過環境變量配置的服務。最常見的場景是從一個或多個配置文件(如`nginx`)讀取配置的進程。
#### 1. 使用模板文件
有一個應用廣泛的模式用于處理這種場景:使用一個入口點腳本,獲取環境變量并在文件系統上生成配置文件,然后調用實際進程,該進程將在啟動時讀取那些新生成的配置文件。
我們來看一個示例。構建一個容器使用[node-pushserver](https://www.npmjs.com/package/node-pushserver)來給iOS和Android手機發送推送通知。對于這個例子,我們會創建一個名為`entrypoint.sh`的shell腳本,并在`Dockerfile`中將其添加到容器中:
```
from node:0.10
RUN npm install node-pushserver -g \
&& npm install debug –g
ADD entrypoint.sh /entrypoint.sh
ADD config.json.template /config.json.template
ADD cert-dev.pem /cert-dev.pem
ADD key-dev.pem /key-dev.pem
ENV APP_PORT 8000
ENV CERT_PATH /cert-dev.pem
ENV KEY_PATH /key-dev.pem
ENV GATEWAY_ADDRESS gateway.push.apple.com
ENV FEEDBACK_ADDRESS feedback.push.apple.com
CMD ["/entrypoint.sh"]
```
這個`Dockerfile`具有多個環境變量默認值。如上所見,這些環境變量決定了從服務自身端口到MongoDB的主機/端口,以及所需證書的位置,甚至是所使用的蘋果服務器(在開發環境和生產環境中,它們可能會有所不同)。最后,容器將運行的進程是我們自己的`entrypoint.sh`,它看起來像是這樣的:
```
#!/bin/sh
# 渲染一個模板配置文件
# 展開變量 + 保留格式
render_template() {
eval "echo \"$(cat $1)\""
}
## 如果沒有MongoDB前綴,則拒絕啟動
[ -z "MONGODB_CONNECT_URL" ] && echo "ERROR: you need to specify MONGODB_CONNECT_URL" && exit -1
## 對引號進行轉義,以免在渲染時被刪除
cat /config.json.template | sed s/\"/\\\\\"/g > /config.json.escaped
## 渲染模板
render_template /config.json.escaped > /config.json
cat /config.json
/usr/local/bin/pushserver -c /config.json
```
這個腳本文件有些需要注意的地方。首先,我們定義了一個函數`render_template`,參數是一個文件名,它將展開其中的環境變量,并返回其內容。
接著,我們對因為某些關鍵配置不存在就很快失敗的情況做了嚴格把關。在這里,我們要求調用者提供一個名為`MONGODB_CONNECT_URL`的環境變量,它沒有默認值。
最后是從模板生成配置文件的部分。模板看起來是這樣的:
```
{
"webPort": ${APP_PORT},
"mongodbUrl": "${MONGODB_CONNECT_URL}",
"apn": {
"connection": {
"gateway": "${GATEWAY_ADDRESS}",
"cert": "${CERT_PATH}",
"key": "${KEY_PATH}"
},
"feedback": {
"address": "${FEEDBACK_ADDRESS}",
"cert": "${CERT_PATH}",
"key": "${KEY_PATH}",
"interval": 43200,
"batchFeedback": true
}
}
}
```
我們對雙引號進行了轉義,否則超級簡單的渲染引擎`render_template`會將它們刪除。然后,我們調用`render_template`,它將獲取轉義過雙引號的文件,并生成最終的配置文件。看起來就像這樣:
```
{
"webPort": 8300,
"mongodbUrl": "mongodb://10.54.199.197/stagingpushserver,mongodb://10.54.199.209?replicaSet=rs0&readPreference=primaryPreferred",
"apn": {
"connection": {
"gateway": "gateway.push.apple.com",
"cert": "/certs/apn-cert.pem",
"key": "/certs/apn-key.pem"
},
"feedback": {
"address": "feedback.push.apple.com",
"cert": "/certs/apn-cert.pem",
"key": "/certs/apn-key.pem",
"interval": 43200,
"batchFeedback": true
}
}
}
```
最后,這個腳本通過`/usr/local/bin/pushserver -c /config.json`調用真正的服務,它將加載我們新生成的`config.json`文件。
#### 2. 掛載配置文件
值得注意的是,我們前面生成的配置文件也加載了兩個證書,雖然我們也可以把它們當作環境變量傳遞,如`echo ${CERT} > /certs/apn-cert.pem`,在這個實例中,我們還是以掛載文件的方式來提供。這是一個處理這些以文件進行配置的容器的替代方法。
在啟動一個容器時,用戶可以掛載本地目錄或文件到容器文件系統中,并且這發生在容器進程啟動之前。有鑒于此,配置上述容器的另一種方法是在容器啟動**之前**運行生成配置文件的腳本,然后把文件掛載到容器里。這種方法的缺點是,用戶需要在宿主機上找到一個合適的地方來寫入這些配置文件,每個容器可能都要有個不同的版本,然后在銷毀容器時正確地清理這些文件。在容器外定制配置文件與在容器內定制相比無更多益處,但是后者會更受歡迎,因為配置文件被包含在容器內。
有時,我們需要容器能感知同一宿主機上的其他容器,并向它們提供服務。例如,提供日志收集服務的容器,它會把所有其他容器的日志發送給類似Kibana的日志聚合器。其他需求可能與這類容器的監控有關。
這類容器需要訪問宿主機的Docker進程,以便與它進行通信,并查詢現有容器及其配置。
在討論如何實現這類容器之前,讓我們使用一個日志示例來討論這些容器可以解決的問題類型:我們希望將來自所有容器的日志發送給某些日志聚合服務,而且我們希望可以在一個容器里完成這件事。這要求我們運行一個日志收集進程,如logstash或fluentd,并且配置它為可以從每個容器獲取日志。Docker通常將每個容器的日志存儲在它自己的目錄中,遵循這個模式:`/var/log/docker/containers/$CONTAINER_ID/$CONTAINER_ID-json.log`——這里是json日志。
一個解決方案是構建我們自己的日志收集器,理解這個布局,并可以與Docker通信來查詢現有容器的情況。
不過,多數時候用戶不想編寫自己的日志收集服務,而是選擇配置一個現有的。這意味著當宿主機上添加或刪除容器時,這個日志收集器的配置會發生改變。幸運的是,已經有一些工具可以根據來自宿主機的Docker服務器的信息來重新構建配置文件。
這類工具之一是[docker-gen](https://github.com/jwilder/docker-gen)。這個工具在Docker提供的容器信息基礎上,使用提供的模板來生成配置文件。它所提供的模板語言對多數任務來說都足夠強大,它運作的方式是它會**監視**或**輪詢**Docker進程以獲取容器內的變化(添加、刪除等),并在發生變化時從模板重新生成配置文件。
在我們示例中,我們希望重新生成日志收集器配置以便所有容器的日志可以被正確地解析、標記并發送給我們所使用的日志聚合器。
讓我們以一個現實世界的例子來看看這是如何工作的,該示例使用fluentd作為日志收集器,使用ElasticSearch/Kibana作為日志聚合器。
首先,我們需要創建我們的日志收集器容器:
```
FROM phusion/baseimage
# 設置正確的環境變量
ENV HOME /root
# 使用baseimage-docker的init系統
CMD ["/sbin/my_init"]
RUN apt-get update && apt-get -y upgrade \
&& apt-get install -y curl build-essential ruby ruby-dev wget libcurl4-openssl-dev \
&& gem install fluentd --no-ri --no-rdoc \
&& gem install fluent-plugin-elasticsearch --no-ri --no-rdoc \
&& gem install fluent-plugin-record-reformer --no-ri --no-rdoc
ADD . /app
WORKDIR /app
RUN wget https://github.com/jwilder/docker-gen/releases/download/0.3.6/
docker-gen-linux-amd64-0.3.6.tar.gz \
&& tar xvzf docker-gen-linux-amd64-0.3.6.tar.gz \
&& mkdir /etc/service/dockergen
ADD fluentd.sh /etc/service/fluentd/run
ADD dockergen.sh /etc/service/dockergen/run
```
這個Dockerfile的相關部分是我們安裝了fluentd,然后為fluentd安裝了一些插件。第一個用于將日志發送給ElasticSearch,另一個是`record-reformer`,用于在發送日志給ElasticSearch之前對其進行轉換和標記。最后,我們安裝了docker-gen。
由于需要在同一個容器內同時運行docker-gen和fluentd,所以我們需要某種服務管理程序。在這個例子中,我們的鏡像是基于`phusion/baseimage`的,這是Ubuntu一個Docker`化`的精簡版本。相比常規Ubuntu,這個鏡像提供的自定義項之一是使用`runit`作為進程管理程序。Dockerfile的最后兩行中,有兩個`ADD`指令用于添加docker-gen和fluentd的運行腳本。一旦容器啟動,這兩個腳本將運行,從而運行并監管docker-gen和fluentd。
docker-gen的啟動腳本使用以下設置來啟動docker-gen:它將監視Docker宿主機所運行容器的變化,如果出現任何變化,它將從模板`/app/templates/fluentd.conf.tmpl`重新生成`/etc/fluent.conf`文件。一旦完成,它將運行`sv force-restart fluentd`(通過`runit`)強制重啟fluentd,這將導致fluentd重載新配置。這是docker-gen的啟動文件:
```
#!/bin/sh
exec /app/docker-gen \
-watch \
-notify "sv force-restart fluentd" \
/app/templates/fluentd.conf.tmpl \
/etc/fluent.conf
```
fluentd的啟動腳本更為直接一些,它只是使用docker-gen生成的配置文件`/etc/fluent.conf`來啟動fluentd:
```
#!/bin/sh
exec /usr/local/bin/fluentd -c /etc/fluent.conf -v
```
接下來我們所需的是docker-gen用于生成FluentD配置的模板文件。這是現實世界中的一個詳細示例:
```
## 文件輸入
## 讀取標簽為docker.container的日志
{{range $key, $value := .}}
<source>
type tail
format json
time_key time
time_format %Y-%m-%dT%T.%LZ
path /var/lib/docker/containers/{{ $value.ID }}/{{ $value.ID }}-json.log
pos_file /var/lib/docker/containers/{{ $value.ID }}/{{ $value.ID }}-json.log.pos
tag docker.container.{{ $value.Name }}
rotate_wait 5
read_from_head true
</source>
{{end}}
{{range $key, $value := .}}
<match docker.container.{{ $value.Name }}>
type record_reformer
renew_record false
enable_ruby false
tag ps.{{ $value.Name }}
<record>
hostname {{ $.Env.HOSTNAME }}
cluster_id {{ $.Env.CLUSTER_ID }}
container_name {{ $value.Name }}
image_name {{ $value.Image.Repository }}
image_tag {{ $value.Image.Tag }}
</record>
</match>
{{end}}
{{range $key, $value := .}}
<match ps.{{ $value.Name }}>
type elasticsearch
host {{ $.Env.ELASTIC_SEARCH_HOST }}
port {{ $.Env.ELASTIC_SEARCH_PORT }}
index_name fluentd type_name {{ $value.Name }}
logstash_format true
buffer_type memory
flush_interval 3
retry_limit 17
retry_wait 1.0
num_threads 1
</match>
{{end}}
```
這里內容較多,不過主要看一下模板的關鍵部分。我們為每個容器生成了3個條目:一個`source`類型,兩個`match`類型。在這個例子中,我們生成這些條目的方法是:對每個容器進行迭代(`{{range ...}}`)并為其構建相應條目(如`<source ...> ... </source>`)。在一個`range`塊內,我們可以使用`$value`變量來讀取當前容器的數據,這個變量是一個包含Docker有關該容器所有信息的字典。
例如,在`source`條目中,我們告訴fluentd上哪兒去查找每個容器的日志文件。所有容器的日志都位于`/var/lib/docker/containers`下以該容器ID命名的目錄中,其文件名也是以容器ID命名。通過這句來完成:
`path /var/lib/docker/containers/{{ $value.ID }}/{{ $value.ID }}-json.log`我們還為來自這個源的日志打上容器名標簽,這在之后過濾時將非常有用:
`tag docker.container.{{ $value.Name }}`模板里其他條目遵循相同的結構。第一個`match`條目用來重寫日志條目并為其添加額外信息,如集群名稱和宿主機名稱。這兩個值都來自于環境變量:
```
hostname {{ $.Env.HOSTNAME }}
cluster_id {{ $.Env.CLUSTER_ID }}
```
我們還添加了來自Docker的其他有用的信息:容器所運行的鏡像名稱以及鏡像標簽。這樣,我們可以在之后使用鏡像名稱甚至是鏡像版本來過濾日志。在相同鏡像的不同版本顯示出不同的錯誤率時,這將非常有幫助,我相信讀者也同意這個說法。這是添加了這些標簽的代碼:
```
image_name {{ $value.Image.Repository }}
image_tag {{ $value.Image.Tag }}
```
最后一個`match`條目會將日志條目發送給ElasticSearch,而它的地址和端口也是來自于環境變量。
現在,使用這個設定,每次一個新容器被創建或被銷毀,會使用上述3個條目為每個容器重新生成`/etc/fluent.conf`文件。這樣我們從所有容器獲取并發送給ElasticSearch的日志就被正確打上時間戳和標記。
在使用Docker鏡像時,一個共同的、備受關注的問題是它們到底有多可信。Docker和其他容器提供商正致力于為所下載、運行的鏡像提供一個高層次的信任。這種信任來自兩個層面。一個是鏡像本身是否值得信任,鏡像是否由受信任的開發者編寫,如Docker公司或Red Hat。另一個是保證所下載的鏡像確實是自己想要下載的鏡像。截至本書編寫時,Docker尚未提供端到端的信任鏈,不過已經有一部分功能存在。
要保護自己,避免運行包含惡意軟件或其他風險的鏡像,目前我們最好的選擇是自己構建鏡像。多數現存鏡像都是開放源碼的,并通過Dockerfile來生成。與下載鏡像相反,復制這個Dockerfile,對其進行檢查,然后從該Dockerfile構建鏡像。要完全確保鏡像不具有危害,用戶需要檢查所有鏡像層,包含基礎操作系統層。也就是說,如果一個鏡像是基于另一個鏡像,而后者又是基于一個知名的操作系統鏡像,那么用戶需要驗證并構建前兩層。
盡管容器文件系統是可寫的,但最好還是將它們視為只讀,并且只在啟動時段進行寫入,如需要在這段時間生成配置文件。將容器文件系統視為只讀的主要原因是這些文件系統比宿主機的文件系統要慢,也因為在容器被銷毀時數據極易丟失。很顯然,如果一個容器運行著一個數據庫,用戶需要在某些地方寫入數據。這種情況下,用戶可以使用容器自身的文件系統,或寫入宿主機掛載的數據卷中。
不過,多數容器可能完全不需要寫入文件系統,因為它們不保存數據。多數情況下,進程還是會寫入文件系統以生成日志。
在容器從業者中一個通用的模式是將日志寫入進程的標準輸出中,而非寫入文件系統。這樣,用戶就依賴Docker自己的日志收集器來提取那些日志,不再需要對容器的文件系統進行寫入。然后,用戶在每臺宿主機上運行一個日志收集器進程來提取這些Docker生成的日志,并將其發送給一個中央日志服務器進行存檔、分析與查詢。在運行微服務架構時,不同容器的數量非常巨大并且是動態的,這個模式十分普遍。
在為第三方服務構建鏡像時,有時我們無法讓服務將日志輸出到標準輸出中。例如,多數Web服務器就不這么做。不過,也有一個相對簡單的方法可以實現相同的行為:將日志文件鏈接到標準輸出中。
例如,`nginx`只會把日志寫到文件系統的日志文件中。在這種情況下,我們會指示Nginx繼續這么做:
`access_log /var/log/nginx/access.log main;`不過在Dockerfile中,我們將`/dev/stdout`鏈接到該文件中,因此當Nginx寫入`access.log`時,它反而是寫入到容器的標準輸出:
`RUN ln -sf /dev/stdout /var/log/nginx/access.log`@#@#@#@#@nav_point_97
理解Docker對疊加文件系統的使用,以及如何構建輕便、可配置、可重用且迎合生態系統的鏡像,能夠為構建高效的Docker基礎設施提供良好的基礎。如我們所展示的,有時我們需要考慮不同配置的范式和運行時來進行軟件設計,并以此打造對Docker友好的鏡像,不過這些額外的付出從長遠看是值得的,相比它為我們節省的時間和免去的麻煩要小得多。
Docker基礎設施的重要一環是鏡像倉庫。下一章將詳細講述這個話題。
- 版權信息
- 版權聲明
- 內容提要
- 對本書的贊譽
- 譯者介紹
- 前言
- 本書面向的讀者
- 誰真的在生產環境中使用Docker
- 為什么使用Docker
- 開發環境與生產環境
- 我們所說的“生產環境”
- 功能內置與組合工具
- 哪些東西不要Docker化
- 技術審稿人
- 第1章 入門
- 1.1 術語
- 1.1.1 鏡像與容器
- 1.1.2 容器與虛擬機
- 1.1.3 持續集成/持續交付
- 1.1.4 宿主機管理
- 1.1.5 編排
- 1.1.6 調度
- 1.1.7 發現
- 1.1.8 配置管理
- 1.2 從開發環境到生產環境
- 1.3 使用Docker的多種方式
- 1.4 可預期的情況
- 為什么Docker在生產環境如此困難
- 第2章 技術棧
- 2.1 構建系統
- 2.2 鏡像倉庫
- 2.3 宿主機管理
- 2.4 配置管理
- 2.5 部署
- 2.6 編排
- 第3章 示例:極簡環境
- 3.1 保持各部分的簡單
- 3.2 保持流程的簡單
- 3.3 系統細節
- 利用systemd
- 3.4 集群范圍的配置、通用配置及本地配置
- 3.5 部署服務
- 3.6 支撐服務
- 3.7 討論
- 3.8 未來
- 3.9 小結
- 第4章 示例:Web環境
- 4.1 編排
- 4.1.1 讓服務器上的Docker進入準備運行容器的狀態
- 4.1.2 讓容器運行
- 4.2 連網
- 4.3 數據存儲
- 4.4 日志
- 4.5 監控
- 4.6 無須擔心新依賴
- 4.7 零停機時間
- 4.8 服務回滾
- 4.9 小結
- 第5章 示例:Beanstalk環境
- 5.1 構建容器的過程
- 部署/更新容器的過程
- 5.2 日志
- 5.3 監控
- 5.4 安全
- 5.5 小結
- 第6章 安全
- 6.1 威脅模型
- 6.2 容器與安全性
- 6.3 內核更新
- 6.4 容器更新
- 6.5 suid及guid二進制文件
- 6.6 容器內的root
- 6.7 權能
- 6.8 seccomp
- 6.9 內核安全框架
- 6.10 資源限制及cgroup
- 6.11 ulimit
- 6.12 用戶命名空間
- 6.13 鏡像驗證
- 6.14 安全地運行Docker守護進程
- 6.15 監控
- 6.16 設備
- 6.17 掛載點
- 6.18 ssh
- 6.19 私鑰分發
- 6.20 位置
- 第7章 構建鏡像
- 7.1 此鏡像非彼鏡像
- 7.1.1 寫時復制與高效的鏡像存儲與分發
- 7.1.2 Docker對寫時復制的使用
- 7.2 鏡像構建基本原理
- 7.2.1 分層的文件系統和空間控管
- 7.2.2 保持鏡像小巧
- 7.2.3 讓鏡像可重用
- 7.2.4 在進程無法被配置時,通過環境變量讓鏡像可配置
- 7.2.5 讓鏡像在Docker變化時對自身進行重新配置
- 7.2.6 信任與鏡像
- 7.2.7 讓鏡像不可變
- 7.3 小結
- 第8章 存儲Docker鏡像
- 8.1 啟動并運行存儲的Docker鏡像
- 8.2 自動化構建
- 8.3 私有倉庫
- 8.4 私有registry的擴展
- 8.4.1 S3
- 8.4.2 本地存儲
- 8.4.3 對registry進行負載均衡
- 8.5 維護
- 8.6 對私有倉庫進行加固
- 8.6.1 SSL
- 8.6.2 認證
- 8.7 保存/載入
- 8.8 最大限度地減小鏡像體積
- 8.9 其他鏡像倉庫方案
- 第9章 CI/CD
- 9.1 讓所有人都進行鏡像構建與推送
- 9.2 在一個構建系統中構建所有鏡像
- 9.3 不要使用或禁止使用非標準做法
- 9.4 使用標準基礎鏡像
- 9.5 使用Docker進行集成測試
- 9.6 小結
- 第10章 配置管理
- 10.1 配置管理與容器
- 10.2 面向容器的配置管理
- 10.2.1 Chef
- 10.2.2 Ansible
- 10.2.3 Salt Stack
- 10.2.4 Puppet
- 10.3 小結
- 第11章 Docker存儲引擎
- 11.1 AUFS
- 11.2 DeviceMapper
- 11.3 BTRFS
- 11.4 OverlayFS
- 11.5 VFS
- 11.6 小結
- 第12章 Docker 網絡實現
- 12.1 網絡基礎知識
- 12.2 IP地址的分配
- 端口的分配
- 12.3 域名解析
- 12.4 服務發現
- 12.5 Docker高級網絡
- 12.6 IPv6
- 12.7 小結
- 第13章 調度
- 13.1 什么是調度
- 13.2 調度策略
- 13.3 Mesos
- 13.4 Kubernetes
- 13.5 OpenShift
- Red Hat公司首席工程師Clayton Coleman的想法
- 第14章 服務發現
- 14.1 DNS服務發現
- DNS服務器的重新發明
- 14.2 Zookeeper
- 14.3 基于Zookeeper的服務發現
- 14.4 etcd
- 基于etcd的服務發現
- 14.5 consul
- 14.5.1 基于consul的服務發現
- 14.5.2 registrator
- 14.6 Eureka
- 基于Eureka的服務發現
- 14.7 Smartstack
- 14.7.1 基于Smartstack的服務發現
- 14.7.2 Nerve
- 14.7.3 Synapse
- 14.8 nsqlookupd
- 14.9 小結
- 第15章 日志和監控
- 15.1 日志
- 15.1.1 Docker原生的日志支持
- 15.1.2 連接到Docker容器
- 15.1.3 將日志導出到宿主機
- 15.1.4 發送日志到集中式的日志平臺
- 15.1.5 在其他容器一側收集日志
- 15.2 監控
- 15.2.1 基于宿主機的監控
- 15.2.2 基于Docker守護進程的監控
- 15.2.3 基于容器的監控
- 15.3 小結
- DockOne社區簡介
- 看完了