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