當前位置: 妍妍網 > 碼農

映像體積從1000M到10M,幾招就做到

2024-06-08碼農

來源:https://www.bilibili.com/read/cv26481588/

最近在接手一個新計畫後,將原本 1.6GB 的映像精簡到了 600 多MB,直接進入了賢者時間,特地記錄下最佳化過程中總結的一些經驗。

理論依據

映像的本質是映像層和執行配置檔組成的壓縮包,構建映像是透過執行 Dockerfile 中的 RUN 、COPY 和 ADD 等指令生成映像層和配置檔的過程。

關於映像組成、聯合檔案系統及其工作方式等理論,本文不再贅述,只從中提取和映像體積有關的關鍵點:

  • RUN、COPY 和 ADD 指令會在已有映像層的基礎上建立一個新的映像層,執行指令產生的所有檔案系統變更會在指令結束後作為一個映像層整體送出。

  • 映像層具有 copy-on-write 的特性,如果去更新其他映像層中已存在的檔,會先將其復制到新的映像層中再修改,造成雙倍的檔空間占用。

  • 如果去刪除其他映像層的一個檔,只會在當前映像層生成一個該檔的刪除標記,並不會減少整個映像的實際體積。

  • 上述理論可以透過如下 Dockerfile 來驗證:

    FROMalpine:latestCOPYresource.tar /RUNtouch /resource.tarRUNrm -f /resource.tarENTRYPOINT["/bin/ash"]

    我們在 Dockerfile 中簡單地添加、修改和刪除某個資原始檔,然後構建映像檢視其映像層資訊

    $ docker build -t test-image -f Dockerfile .$ docker history test-image:latestIMAGE CREATED CREATED BY SIZE COMMENT95f1695b2904 About a minute ago /bin/sh -c #(nop) ENTRYPOINT ["/bin/ash"] 0B1780448c656f About a minute ago /bin/sh -c rm -f /resource.tar 0Ba85d29bf7738 About a minute ago /bin/sh -c touch /resource.tar 135MB6dac335fa653 4minutes ago /bin/sh -c #(nop) COPY file:66065d6e23e0bc52… 135MBe66264b98777 7 weeks ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B<missing> 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 是一個特殊的空映像):

    FROMgolangCOPYhell0.go .ENVCGO_ENABLED=0RUNgo build hello.goFROMscratchCOPY--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 <PACKAGES> \ && dnf clean all \ && rm -rf /var/cache/dnf

    以 apt 為例:

    RUNapt-get update \ && apt-get install -y <PACKAGES> \ && 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-imageIMAGE CREATED CREATED BY SIZE COMMENT55ded8881d63 9hours ago 0B merge sha256:95f1695b29044522250de1b0c1904aaf8670b991ec1064d086c0c15865051d5d to sha256:e66264b98777e12192600bf9b4d663655c98a090072e1bab49e233d7531d1294<missing> 11hours ago /bin/sh -c #(nop) ENTRYPOINT ["/bin/ash"] 0B<missing> 11hours ago /bin/sh -c rm -f /resource.tar 0B<missing> 11hours ago /bin/sh -c touch /resource.tar 0B<missing> 11hours ago /bin/sh -c #(nop) COPY file:66065d6e23e0bc52… 0B<missing> 7 weeks ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B<missing> 7 weeks ago /bin/sh -c #(nop) ADD file:8e81116368669ed3d… 5.53MB

    最終生成的映像只有一個映像層,包含最後實際存在的檔案系統,在合並所有映像層的過程中,相當於禁用了 copy-on-write 特性。這種做法的壞處在於,映像在保存和分發時是可以復用映像層的,推播映像時會跳過映像倉庫已存在的映像層,拉取映像時會跳過本地已拉取過的映像層,而合並成一層後則失去了這種優勢。對於可能和其他共用映像層的場景,可以采取下面一種方式。

    2.分階段構建,將部份中間映像層壓縮成一層作為基礎映像。 在開發團隊內部,我們往往會在官方映像的基礎上添加或更新部份依賴,然後作為團隊內部統一使用的基礎映像,這種復用方式可以大大減少實際占用的映像體積。更進一步,我們可以將這類基礎映像壓縮成一層。

    下面以 golang 官方映像為例:FROM golang:1.16 as base

    FROM scratchCOPY --from=base / /ENTRYPOINT ["/bin/bash"]

    壓縮成一層後,golang:1.16 的映像體積從 919MB 變成 913MB,官方映像已經做了很多最佳化所以節省空間十分有限,但對於開發團隊內部制作的基礎映像,這種最佳化往往會帶來意外驚喜。

    復制檔的同時修改元資訊

    先將檔添加到映像內,然後再修改檔的執行許可權和所屬使用者,這類 COPY-RUN 指令在 Dockerfile 中十分常見:

    COPY output/hello /usr/bin/helloRUN 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。