追求极简:Docker镜像构建演化史
// Dockerfile.target.alpine
自從2013年dotCloud公司(現(xiàn)已改名為Docker Inc)發(fā)布Docker容器技術(shù)以來,到目前為止已經(jīng)有五年多的時間了。這期間Docker技術(shù)飛速發(fā)展,并催生出一個生機勃勃的、以輕量級容器技術(shù)為基礎(chǔ)的龐大的容器平臺生態(tài)圈。作為Docker三大核心技術(shù)之一的鏡像技術(shù)在Docker的快速發(fā)展之路上可謂功不可沒:鏡像讓容器真正插上了翅膀,實現(xiàn)了容器自身的重用和標(biāo)準(zhǔn)化傳播,使得開發(fā)、交付、運維流水線上的各個角色真正圍繞同一交付物,“test what you write, ship what you test”成為現(xiàn)實。
對于已經(jīng)接納和使用Docker技術(shù)在日常開發(fā)工作中的開發(fā)者而言,構(gòu)建Docker鏡像已經(jīng)是家常便飯。但如何更高效地構(gòu)建以及構(gòu)建出Size更小的鏡像卻是很多Docker技術(shù)初學(xué)者心中常見的疑問,甚至是一些老手都未曾細致考量過的問題。本文將從一個Docker用戶角度來闡述Docker鏡像構(gòu)建的演化史,希望能起到一定的解惑作用。
一、鏡像:繼承中的創(chuàng)新
談鏡像構(gòu)建之前,我們先來簡要說下鏡像。
Docker技術(shù)本質(zhì)上并不是新技術(shù),而是將已有技術(shù)進行了更好地整合和包裝。內(nèi)核容器技術(shù)以一種完整形態(tài)最早出現(xiàn)在Sun公司的Solaris操作系統(tǒng)上,Solaris是當(dāng)時最先進的服務(wù)器操作系統(tǒng)。2005年Sun發(fā)布了Solaris Container技術(shù),從此開啟了內(nèi)核容器之門。
2008年,以Google公司開發(fā)人員為主導(dǎo)實現(xiàn)的Linux Container(即LXC)功能在被merge到Linux內(nèi)核中。LXC是一種內(nèi)核級虛擬化技術(shù),主要基于Namespaces和Cgroups技術(shù),實現(xiàn)共享一個操作系統(tǒng)內(nèi)核前提下的進程資源隔離,為進程提供獨立的虛擬執(zhí)行環(huán)境,這樣的一個虛擬的執(zhí)行環(huán)境就是一個容器。本質(zhì)上說,LXC容器與現(xiàn)在的Docker所提供容器是一樣的。Docker也是基于Namespaces和Cgroups技術(shù)之上實現(xiàn)的,Docker的創(chuàng)新之處在于其基于Union File System技術(shù)定義了一套容器打包規(guī)范,真正將容器中的應(yīng)用及其運行的所有依賴都封裝到一種特定格式的文件中去,而這種文件就被稱為鏡像(即image),原理見下圖(引自Docker官網(wǎng)):
圖1:Docker鏡像原理?
鏡像是容器的“序列化”標(biāo)準(zhǔn),這一創(chuàng)新為容器的存儲、重用和傳輸?shù)於嘶A(chǔ)。并且“坐上了巨輪”的容器鏡像可以傳播到世界每一個角落,這無疑助力了容器技術(shù)的飛速發(fā)展。
與Solaris Container、LXC等早期內(nèi)核容器技術(shù)不同,Docker為開發(fā)者提供了開發(fā)者體驗良好的工具集,這其中就包括了用于鏡像構(gòu)建的Dockerfile以及一種用于編寫Dockerfile領(lǐng)域特定語言。采用Dockerfile方式構(gòu)建成為鏡像構(gòu)建的標(biāo)準(zhǔn)方法,其可重復(fù)、可自動化、可維護以及分層精確控制等特點是采用傳統(tǒng)docker commit命令提交的鏡像所不能比擬的。
?
二、“鏡像是個筐”:初學(xué)者的認(rèn)知
“鏡像是個筐,什么都往里面裝”?– 這句俏皮話可能是大部分Docker初學(xué)者對鏡像最初認(rèn)知的真實寫照。這里我們用一個例子來生動地展示一下。我們將httpserver.go這個源文件編譯為httpd程序并通過鏡像發(fā)布,考慮到被編譯的源碼并非本文重點,這里使用了一個極簡的demo代碼:
//httpserver.go package mainimport ("fmt""net/http" )func main() {fmt.Println("http daemon start")fmt.Println(" -> listen on port:8080")http.ListenAndServe(":8080", nil) }接下來,我們來編寫一個用于構(gòu)建目標(biāo)image的Dockerfile:
From ubuntu:14.04RUN apt-get update \&& apt-get install -y software-properties-common \&& add-apt-repository ppa:gophers/archive \&& apt-get update \&& apt-get install -y golang-1.9-go \git \&& rm -rf /var/lib/apt/lists/*ENV GOPATH /root/go ENV GOROOT /usr/lib/go-1.9 ENV PATH="/usr/lib/go-1.9/bin:${PATH}"COPY ./httpserver.go /root/httpserver.go RUN go build -o /root/httpd /root/httpserver.go \&& chmod +x /root/httpdWORKDIR /root ENTRYPOINT ["/root/httpd"]構(gòu)建這個Image:
# docker build -t repodemo/httpd:latest . //...構(gòu)建輸出這里省略...# docker images REPOSITORY TAG IMAGE ID CREATED SIZE repodemo/httpd latest 183dbef8eba6 2 minutes ago 550MB ubuntu 14.04 dea1945146b9 2 months ago 188MB整個鏡像的構(gòu)建過程因環(huán)境而定。如果您的網(wǎng)絡(luò)速度一般,這個構(gòu)建過程可能會花費你10多分鐘甚至更多。最終如我們所愿,基于repodemo/httpd:latest這個鏡像的容器可以正常運行:
# docker run repodemo/httpd http daemon start-> listen on port:8080一個Dockerfile最終生產(chǎn)出一個鏡像。Dockerfile由若干Command組成,每個Command執(zhí)行結(jié)果都會單獨形成一個layer。我們來探索一下構(gòu)建出來的鏡像:
# docker history 183dbef8eba6 IMAGE CREATED CREATED BY SIZE COMMENT 183dbef8eba6 21 minutes ago /bin/sh -c #(nop) ENTRYPOINT ["/root/httpd"] 0B 27aa721c6f6b 21 minutes ago /bin/sh -c #(nop) WORKDIR /root 0B a9d968c704f7 21 minutes ago /bin/sh -c go build -o /root/httpd /root/h... 6.14MB ... ... aef7700a9036 30 minutes ago /bin/sh -c apt-get update && apt-get... 356MB .... ... <missing> 2 months ago /bin/sh -c #(nop) ADD file:8f997234193c2f5... 188MB我們?nèi)コ裟切㏒ize為0或很小的layer,我們看到三個size占比較大的layer,見下圖:
圖2:Docker鏡像分層探索?
雖然Docker引擎利用緩存機制可以讓同主機下非首次的鏡像構(gòu)建執(zhí)行得很快,但是在Docker技術(shù)熱情催化下的這種構(gòu)建思路讓docker鏡像在存儲和傳輸方面的優(yōu)勢蕩然無存,要知道一個ubuntu-server 16.04的虛擬機ISO文件的大小也就不過600多MB而已。
?
三、”理性的回歸”:builder模式的崛起
Docker使用者在新技術(shù)接觸初期的熱情“冷卻”之后迎來了“理性的回歸”。根據(jù)上面分層鏡像的圖示,我們發(fā)現(xiàn)最終鏡像中包含構(gòu)建環(huán)境是多余的,我們只需要在最終鏡像中包含足夠支撐httpd運行的運行環(huán)境即可,而base image自身就可以滿足。于是我們應(yīng)該去除不必要的中間層:
圖3:去除不必要的分層?
現(xiàn)在問題來了!如果不在同一鏡像中完成應(yīng)用構(gòu)建,那么在哪里、由誰來構(gòu)建應(yīng)用呢?至少有兩種方法:
不過方法1本地構(gòu)建有很多局限性,比如:本地環(huán)境無法復(fù)用、無法很好融入持續(xù)集成/持續(xù)交付流水線等。借助builder image進行構(gòu)建已經(jīng)成為Docker社區(qū)的一個最佳實踐,Docker官方為此也推出了各種主流編程語言的官方base image,比如:go、java、node、python以及ruby等。借助builder image進行鏡像構(gòu)建的流程原理如下圖:
圖4:借助builder image進行鏡像構(gòu)建的流程圖?
通過原理圖,我們可以看到整個目標(biāo)鏡像的構(gòu)建被分為了兩個階段:
我們選擇golang:1.9.2作為builder base image,構(gòu)建者鏡像的Dockerfile.build如下:
// Dockerfile.build FROM golang:1.9.2WORKDIR /go/src COPY ./httpserver.go .RUN go build -o httpd ./httpserver.go執(zhí)行構(gòu)建:
# docker build -t repodemo/httpd-builder:latest -f Dockerfile.build .構(gòu)建好的應(yīng)用程序httpd放在了鏡像repodemo/httpd-builder中的/go/src目錄下,我們需要一些“膠水”命令來連接兩個構(gòu)建階段,這些命令將httpd從構(gòu)建者鏡像中取出并作為下一階段構(gòu)建的輸入:
# docker create --name extract-httpserver repodemo/httpd-builder # docker cp extract-httpserver:/go/src/httpd ./httpd # docker rm -f extract-httpserver # docker rmi repodemo/httpd-builder通過上面的命令,我們將編譯好的httpd程序拷貝到了本地。下面是目標(biāo)鏡像的Dockerfile:
//Dockerfile.target From ubuntu:14.04COPY ./httpd /root/httpd RUN chmod +x /root/httpdWORKDIR /root ENTRYPOINT ["/root/httpd"]接下來我們來構(gòu)建目標(biāo)鏡像:
# docker build -t repodemo/httpd:latest -f Dockerfile.target .我們來看看這個鏡像的“體格”:
# docker images REPOSITORY TAG IMAGE ID CREATED SIZE repodemo/httpd latest e3d009d6e919 12 seconds ago 200MB200MB!目標(biāo)鏡像的Size降為原來的 1/2 還多。
?
四、“像賽車那樣減去所有不必要的東西”:追求最小鏡像
前面我們構(gòu)建出的鏡像的Size已經(jīng)縮小到200MB,但這還不夠。200MB的“體格”在我們的網(wǎng)絡(luò)環(huán)境下緩存和傳輸仍然很難令人滿意。我們要為鏡像進一步減重,減到盡可能的小,就像賽車那樣,為了能減輕重量將所有不必要的東西都拆除掉:我們僅保留能支撐我們的應(yīng)用運行的必要庫、命令,其余的一律不納入目標(biāo)鏡像。當(dāng)然不僅僅是Size上的原因,小鏡像還有額外的好處,比如:內(nèi)存占用小,啟動速度快,更加高效;不會因其他不必要的工具、庫的漏洞而被攻擊,減少了“攻擊面”,更加安全。
圖5:目標(biāo)鏡像還能更小些嗎??
一般應(yīng)用開發(fā)者不會從scratch鏡像從頭構(gòu)建自己的base image以及目標(biāo)鏡像的,開發(fā)者會挑選適合的base image。一些“蠅量級”甚至是“草量級”的官方base image的出現(xiàn)為這種情況提供了條件。
圖6:一些base image的Size比較(來自imagelayers.io截圖)?
從圖中看,我們有兩個選擇:busybox和alpine。
單從image的size上來說,busybox更小。不過busybox默認(rèn)的libc實現(xiàn)是uClibc,而我們通常運行環(huán)境使用的libc實現(xiàn)都是glibc,因此我們要么選擇靜態(tài)編譯程序,要么使用busybox:glibc鏡像作為base image。
而 alpine image 是另外一種蠅量級 base image,它使用了比 glibc 更小更安全的?musl libc?庫。 不過和 busybox image 相比,alpine image 體積還是略大。除了因為 musl比uClibc 大一些之外,alpine還在鏡像中添加了自己的包管理系統(tǒng)apk,開發(fā)者可以使用apk在基于alpine的鏡像中添 加需要的包或工具。因此,對于普通開發(fā)者而言,alpine image顯然是更佳的選擇。不過alpine使用的libc實現(xiàn)為musl,與基于glibc上編譯出來的應(yīng)用程序不兼容。如果直接將前面構(gòu)建出的httpd應(yīng)用塞入alpine,在容器啟動時會遇到下面錯誤,因為加載器找不到glibc這個動態(tài)共享庫文件:
standard_init_linux.go:185: exec user process caused "no such file or directory"對于Go應(yīng)用來說,我們可以采用靜態(tài)編譯的程序,但一旦采用靜態(tài)編譯,也就意味著我們將失去一些libc提供的原生能力,比如:在linux上,你無法使用系統(tǒng)提供的DNS解析能力,只能使用Go自實現(xiàn)的DNS解析器。
我們還可以采用基于alpine的builder image,golang base image就提供了alpine 版本。 我們就用這種方式構(gòu)建出一個基于alpine base image的極小目標(biāo)鏡像。
圖7:借助 alpine builder image 進行鏡像構(gòu)建的流程圖?
我們新建兩個用于 alpine 版本目標(biāo)鏡像構(gòu)建的 Dockerfile:Dockerfile.build.alpine 和Dockerfile.target.alpine:
? //Dockerfile.build.alpine
FROM golang:alpineWORKDIR /go/src COPY ./httpserver.go .RUN go build -o httpd ./httpserver.go? // Dockerfile.target.alpine
From alpineCOPY ./httpd /root/httpd RUN chmod +x /root/httpdWORKDIR /root ENTRYPOINT ["/root/httpd"]構(gòu)建builder鏡像:
# docker build -t repodemo/httpd-alpine-builder:latest -f Dockerfile.build.alpine .# docker images REPOSITORY TAG IMAGE ID CREATED SIZE repodemo/httpd-alpine-builder latest d5b5f8813d77 About a minute ago 275MB執(zhí)行“膠水”命令:
# docker create --name extract-httpserver repodemo/httpd-alpine-builder # docker cp extract-httpserver:/go/src/httpd ./httpd # docker rm -f extract-httpserver # docker rmi repodemo/httpd-alpine-builder構(gòu)建目標(biāo)鏡像:
# docker build -t repodemo/httpd-alpine -f Dockerfile.target.alpine .# docker images REPOSITORY TAG IMAGE ID CREATED SIZE repodemo/httpd-alpine latest 895de7f785dd 13 seconds ago 16.2MB16.2MB!目標(biāo)鏡像的Size降為不到原來的十分之一。我們得到了預(yù)期的結(jié)果。
?
五、“要有光,于是便有了光”:對多階段構(gòu)建的支持
至此,雖然我們實現(xiàn)了目標(biāo)Image的最小化,但是整個構(gòu)建過程卻是十分繁瑣,我們需要準(zhǔn)備兩個Dockerfile、需要準(zhǔn)備“膠水”命令、需要清理中間產(chǎn)物等。作為Docker用戶,我們希望用一個Dockerfile就能解決所有問題,于是就有了Docker引擎對多階段構(gòu)建(multi-stage build)的支持。注意:這個特性非常新,只有Docker 17.05.0-ce及以后的版本才能支持。
現(xiàn)在我們就按照“多階段構(gòu)建”的語法將上面的Dockerfile.build.alpine和Dockerfile.target.alpine合并到一個Dockerfile中:
//Dockerfile
FROM golang:alpine as builderWORKDIR /go/src COPY httpserver.go .RUN go build -o httpd ./httpserver.goFrom alpine:latestWORKDIR /root/ COPY --from=builder /go/src/httpd . RUN chmod +x /root/httpdENTRYPOINT ["/root/httpd"]Dockerfile的語法還是很簡明和易理解的。即使是你第一次看到這個語法也能大致猜出六成含義。與之前Dockefile最大的不同在于在支持多階段構(gòu)建的Dockerfile中我們可以寫多個“From baseimage”的語句了,每個From語句開啟一個構(gòu)建階段,并且可以通過“as”語法為此階段構(gòu)建命名(比如這里的builder)。我們還可以通過COPY命令在兩個階段構(gòu)建產(chǎn)物之間傳遞數(shù)據(jù),比如這里傳遞的httpd應(yīng)用,這個工作之前我們是使用“膠水”代碼完成的。
構(gòu)建目標(biāo)鏡像:
# docker build -t repodemo/httpd-multi-stage .# docker images REPOSITORY TAG IMAGE ID CREATED SIZE repodemo/httpd-multi-stage latest 35e494aa5c6f 2 minutes ago 16.2MB我們看到通過多階段構(gòu)建特性構(gòu)建的Docker Image與我們之前通過builder模式構(gòu)建的鏡像在效果上是等價的。
?
六、來到現(xiàn)實
沿著時間的軌跡,Docker 鏡像構(gòu)建走到了今天。追求又快又小的鏡像已成為了 Docker 社區(qū) 的共識。社區(qū)在自創(chuàng) builder 鏡像構(gòu)建的最佳實踐后終于迎來了多階段構(gòu)建這柄利器,從此構(gòu)建 出極簡的鏡像將不再困難。
Related posts:
總結(jié)
以上是生活随笔為你收集整理的追求极简:Docker镜像构建演化史的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 设计模式之动态代理模式实战
- 下一篇: 阿里巴巴P9大佬雷卷与中间件小哥重新定义