最近在接手一個(gè)新項(xiàng)目后,將原本 1.6GB 的鏡像精簡(jiǎn)到了 600 多MB,直接進(jìn)入了賢者時(shí)間,特地記錄下優(yōu)化過程中總結(jié)的一些經(jīng)驗(yàn)。
理論依據(jù)
鏡像的本質(zhì)是鏡像層和運(yùn)行配置文件組成的壓縮包,構(gòu)建鏡像是通過運(yùn)行 Dockerfile 中的?RUN?、COPY?和?ADD?等指令生成鏡像層和配置文件的過程。
關(guān)于鏡像組成、聯(lián)合文件系統(tǒng)及其工作方式等理論,本文不再贅述,只從中提取和鏡像體積有關(guān)的關(guān)鍵點(diǎn):
??RUN、COPY?和?ADD?指令會(huì)在已有鏡像層的基礎(chǔ)上創(chuàng)建一個(gè)新的鏡像層,執(zhí)行指令產(chǎn)生的所有文件系統(tǒng)變更會(huì)在指令結(jié)束后作為一個(gè)鏡像層整體提交。
??鏡像層具有?copy-on-write?的特性,如果去更新其他鏡像層中已存在的文件,會(huì)先將其復(fù)制到新的鏡像層中再修改,造成雙倍的文件空間占用。
??如果去刪除其他鏡像層的一個(gè)文件,只會(huì)在當(dāng)前鏡像層生成一個(gè)該文件的刪除標(biāo)記,并不會(huì)減少整個(gè)鏡像的實(shí)際體積。
上述理論可以通過如下 Dockerfile 來驗(yàn)證:
FROM?alpine:latest COPY?resource.tar?/ RUN?touch?/resource.tar RUN?rm?-f?/resource.tar ENTRYPOINT?["/bin/ash"]
我們?cè)?Dockerfile 中簡(jiǎn)單地添加、修改和刪除某個(gè)資源文件,然后構(gòu)建鏡像查看其鏡像層信息
$?docker?build?-t?test-image?-f?Dockerfile?. $?docker?history?test-image:latest IMAGE??????????CREATED??????????????CREATED?BY??????????????????????????????????????SIZE??????COMMENT 95f1695b2904???About?a?minute?ago???/bin/sh?-c?#(nop)??ENTRYPOINT?["/bin/ash"]??????0B 1780448c656f???About?a?minute?ago???/bin/sh?-c?rm?-f?/resource.tar??????????????????0B a85d29bf7738???About?a?minute?ago???/bin/sh?-c?touch?/resource.tar??????????????????135MB 6dac335fa653???4?minutes?ago????????/bin/sh?-c?#(nop)?COPY?file:66065d6e23e0bc52…???135MB e66264b98777???7?weeks?ago??????????/bin/sh?-c?#(nop)??CMD?["/bin/sh"]??????????????0B??????7?weeks?ago??????????/bin/sh?-c?#(nop)?ADD?file:8e81116368669ed3d…???5.53MB
在?docker history?的輸出結(jié)果中可以看到:
??RUN touch /resource.tar?指令只是修改了文件的元信息,但依然將整個(gè)文件拷貝到了新的鏡像層中。
??RUN rm -f /resource.tar?指令雖然刪除了文件,并且該文件在運(yùn)行容器時(shí)不可見,但依然在前兩個(gè)鏡像層中以及最終的鏡像中存在。
分析工具
給代碼做性能調(diào)優(yōu)時(shí),首先要借助 Profiling 工具找到代碼的性能瓶頸,對(duì)于優(yōu)化鏡像體積也是如此。下面介紹兩個(gè)可以分析鏡像體積的工具:
docker history
docker 自帶的?docker history?命令,該命令可以展示所有鏡像層的創(chuàng)建時(shí)間、指令以及體積等較為基礎(chǔ)的信息,但對(duì)于復(fù)雜的鏡像則有些乏力。使用方式見上方的示例。
dive
第三方的?dive?工具,該工具可以分析鏡像層組成,并列出每個(gè)鏡像層所包含的文件列表,可以很方便地定位到影響鏡像體積的構(gòu)建指令以及具體文件。
以?golang:1.16?鏡像為例,首先安裝 dive,然后執(zhí)行?dive golang:1.16,輸出如下:
dive image
如上圖所示,在左側(cè)選中鏡像層后,在右側(cè)的文件樹視圖中可以清晰地看到該層的具體文件,并能夠篩選相比上一層新增、更新或刪除的文件。在選中的鏡像層中,由于執(zhí)行了?apt-get?安裝編譯依賴,因此在?/usr/lib?目錄下新增了 150MB 依賴庫(kù)文件。
優(yōu)化技巧
下面介紹一些優(yōu)化效果比較顯著的優(yōu)化技巧。
分階段構(gòu)建與從零構(gòu)建
分階段構(gòu)建(multi-stage builds)和從零構(gòu)建(build from scratch)是優(yōu)化鏡像體積的基本手段和必備技巧。該技巧將鏡像構(gòu)建過程區(qū)分為構(gòu)建和運(yùn)行環(huán)境,在構(gòu)建環(huán)境安裝編譯器等依賴并編譯所需的二進(jìn)制包,然后將其復(fù)制到僅包含必要運(yùn)行依賴的運(yùn)行環(huán)境中。
對(duì) golang 這類能夠編譯靜態(tài)二進(jìn)制文件的語(yǔ)言來說分階段構(gòu)建的效果尤為明顯,我們可以將編譯產(chǎn)生的二進(jìn)制文件放到?scratch?鏡像中運(yùn)行(scratch?是一個(gè)特殊的空鏡像):
FROM?golang COPY?hell0.go?. ENV?CGO_ENABLED=0 RUN?go?build?hello.go FROM?scratch COPY?--from=0?/go/hello?. CMD?["./hello"]
如果直接使用 golang 鏡像作為運(yùn)行環(huán)境,其鏡像體積通常接近 1 個(gè) G,其中大部分文件都不是在運(yùn)行容器時(shí)所必要的。將編譯結(jié)果拷貝到運(yùn)行環(huán)境后,體積只有幾十 kb~mb 不等,如果需要在運(yùn)行容器中保留基本的系統(tǒng)工具,可以考慮使用 alpine 鏡像作為運(yùn)行環(huán)境。
關(guān)于分階段構(gòu)建和從零構(gòu)建的更多細(xì)節(jié)可參考 Docker 官方文檔中的?Use multi-stage builds?和?Create a simple parent image using scratch。
避免產(chǎn)生無用的文檔或緩存
docker 鏡像不應(yīng)該包含文檔、緩存等對(duì)運(yùn)行容器沒有作用的內(nèi)容。
1.?避免在本地保留安裝緩存。大部分包管理器會(huì)在安裝時(shí)緩存下載的資源以備之后使用,以 pip 為例,會(huì)將下載的響應(yīng)和構(gòu)建的中間文件保存在?~/.cache/pip?目錄,應(yīng)使用?--no-cache-dir?選項(xiàng)禁用默認(rèn)的緩存行為。
2.?避免安裝文檔。部分包管理器提供了選項(xiàng)可以不安裝附帶的文檔,如 dnf 可使用?--nodocs?選項(xiàng)。
3.?避免緩存包索引。部分包管理器在執(zhí)行安裝之前,會(huì)嘗試查詢所有已啟用倉(cāng)庫(kù)的包列表、版本等元信息緩存在本地作為索引。個(gè)別倉(cāng)庫(kù)的索引緩存可達(dá)到 150 M 以上。我們應(yīng)該僅在安裝包時(shí)查詢索引,并在安裝完成后清理,不應(yīng)該在單獨(dú)的指令中執(zhí)行?yum makecache?這類緩存索引的命令。
及時(shí)清理不需要的文件
運(yùn)行容器時(shí)不需要的文件,一定要在創(chuàng)建的同一層清理,否則依然會(huì)保留在最終的鏡像中。
通過包管理安裝包,通常會(huì)產(chǎn)生大量的緩存文件,一定要在同一?RUN?指令的結(jié)尾處立刻清理。在安裝依賴數(shù)量較多時(shí),可以節(jié)省大量的緩存空間。
以?dnf?為例:
RUN?dnf?install?-y?--nodocs?? ??&&?dnf?clean?all? ??&&?rm?-rf?/var/cache/dnf
以?apt?為例:
RUN?apt-get?update? ??&&?apt-get?install?-y?? ??&&?rm?-rf?/var/lib/apt/lists/* #?官方的?ubuntu/debian?鏡像?apt-get?會(huì)在安裝后自動(dòng)執(zhí)行?clean?命令
合并多個(gè)鏡像層
上文解釋過,應(yīng)該避免在不同鏡像層中更新文件而造成額外的體積占用。當(dāng)構(gòu)建的層數(shù)很多且執(zhí)行指令較復(fù)雜時(shí),很難避免在不同的鏡像層中更新文件,可通過以下手段精簡(jiǎn)這部分額外體積:
1.?在最終生成鏡像時(shí)將所有鏡像層合并成一層,在?docker build?命令中使用?—squash?即可實(shí)現(xiàn)(需要開啟 docker daemon 的實(shí)驗(yàn)性功能)。以本文開頭的 Dockerfile 為例:$?docker?build?-t?squash-image?--squash?-f?Dockerfile?.?
$?docker?history?squash-image
IMAGE??????????CREATED????????CREATED?BY??????????????????????????????????????SIZE??????COMMENT
55ded8881d63???9?hours?ago????????????????????????????????????????????????????0B????????merge?sha256:95f1695b29044522250de1b0c1904aaf8670b991ec1064d086c0c15865051d5d?to?sha256:e66264b98777e12192600bf9b4d663655c98a090072e1bab49e233d7531d1294
最終生成的鏡像只有一個(gè)鏡像層,包含最后實(shí)際存在的文件系統(tǒng),在合并所有鏡像層的過程中,相當(dāng)于禁用了?copy-on-write?特性。這種做法的壞處在于,鏡像在保存和分發(fā)時(shí)是可以復(fù)用鏡像層的,推送鏡像時(shí)會(huì)跳過鏡像倉(cāng)庫(kù)已存在的鏡像層,拉取鏡像時(shí)會(huì)跳過本地已拉取過的鏡像層,而合并成一層后則失去了這種優(yōu)勢(shì)。對(duì)于可能和其他共用鏡像層的場(chǎng)景,可以采取下面一種方式。
2.?分階段構(gòu)建,將部分中間鏡像層壓縮成一層作為基礎(chǔ)鏡像。?在開發(fā)團(tuán)隊(duì)內(nèi)部,我們往往會(huì)在官方鏡像的基礎(chǔ)上添加或更新部分依賴,然后作為團(tuán)隊(duì)內(nèi)部統(tǒng)一使用的基礎(chǔ)鏡像,這種復(fù)用方式可以大大減少實(shí)際占用的鏡像體積。更進(jìn)一步,我們可以將這類基礎(chǔ)鏡像壓縮成一層。下面以 golang 官方鏡像為例:FROM?golang:1.16?as?base
FROM?scratch
COPY?--from=base?/?/
ENTRYPOINT?["/bin/bash"]
壓縮成一層后,golang:1.16?的鏡像體積從 919MB 變成 913MB,官方鏡像已經(jīng)做了很多優(yōu)化所以節(jié)省空間十分有限,但對(duì)于開發(fā)團(tuán)隊(duì)內(nèi)部制作的基礎(chǔ)鏡像,這種優(yōu)化往往會(huì)帶來意外驚喜。
復(fù)制文件的同時(shí)修改元信息
先將文件添加到鏡像內(nèi),然后再修改文件的執(zhí)行權(quán)限和所屬用戶,這類 COPY-RUN 指令在 Dockerfile 中十分常見:
COPY?output/hello?/usr/bin/hello RUN?chmod?+x?/usr/bin/hello?&&?chown?normal:normal?/usr/bin/hello
但修改文件元信息也會(huì)將文件復(fù)制到新的鏡像層,以上指令會(huì)產(chǎn)生兩份相同的文件。在文件體積較大時(shí),會(huì)顯著增加整個(gè)鏡像的體積。事實(shí)上,我們可以在復(fù)制文件的同時(shí)完成對(duì)文件元信息的修改,COPY?和?ADD?指令都提供了修改元信息的?--chmod?和?--chown?選項(xiàng):
COPY?--chmod=755?--chown=normal:normal?output/hello?/usr/bin/hello
--chmod?特性目前還未添加到官方文檔,使用前需要開啟 docker 的 buildkit 特性(在?docker build?命令前添加?DOCKER_BUILDKIT=1?即可),目前只支持?--chmod=755?和?--chmod=0755?這種設(shè)置方法,不支持?--chmod=+x。
注:經(jīng)測(cè)試,當(dāng)使用?ADD?指令且源文件為下載鏈接時(shí)?--chmod?選項(xiàng)不起作用,不清楚這是 docker 的 bug 還是 feature。解決方案是直接使用?RUN?指令?wget + chmod?來替代?ADD。
編輯:黃飛
?
評(píng)論
查看更多