本文将以 Node 程式展示如何優化 Docker 鏡像(優化思想是通用的,不分程式),主要解決鏡像大小過大、CI/CD 建構鏡像速度,本文示範如何一步步優化 Dockerfile 檔案,絕對的幹貨,建議先贊再看,看完不是幹貨再取消贊也不是不行????。優化的結果如下:
文章轉載:樂位元組
大小從 1.06G 到 73.4M
建構速度從 29.6 秒到 1.3 秒(對比的是第二次建構的速度)
簡單寫了一個自己用的 wechat-bot ,接下來就以這個項目示範怎麼去優化 Docker 鏡像
以下是我沒有仔細研究 Docker 剛開始寫的 Dockerfile 檔案
build 之後,如下圖,我這個簡單的 Node 程式鏡像竟然有 1G 多,接下來我們将逐漸去優化減少這個大小

在優化之前,有些東西我們必須了解,解決問題的第一步就是先找出導緻問題的原因
Dockerfile 檔案,其内包含了一條條的指令,每一條指令建構一層,是以每一條指令的内容,就是描述該層如何建構
Docker 鏡像并非隻是一個檔案,而是由一堆檔案組成,最主要的檔案是 層(Layers)
鏡像建構時,會一層層建構,前一層是後一層的基礎
每一層建構完就不會再發生改變,後一層上的任何改變隻發生在自己這一層。比如,删除前一層檔案的操作,實際不是真的删除前一層的檔案,而是僅在目前層标記為該檔案已删除。在最終容器運作的時候,雖然不會看到這個檔案,但是實際上該檔案會一直跟随鏡像
鏡像層将會被緩存和複用(這也是從第二次開始建構鏡像時,速度會快的原因,優化鏡像建構速度的原理也是利用緩存原理來做)
當 Dockerfile 的指令修改了,操作的檔案變化了,或者建構鏡像時指定的變量不同了,對應的鏡像層緩存就會失效
docker build 的緩存機制,docker 是怎麼知道檔案變化的呢?
Docker 采取的政策是:擷取 Dockerfile 下内容(包括檔案的部分 inode 資訊),計算出一個唯一的 hash 值,若 hash 值未發生變化,則可以認為檔案内容沒有發生變化,可以使用緩存機制,反之亦然。
某一層的鏡像緩存失效之後,它之後的鏡像層緩存都會失效
鏡像的每一層隻記錄檔案變更,在容器啟動時,Docker 會将鏡像的各個層進行計算,最後生成一個檔案系統
當我知道這點時,我恍然大悟,我們使用的作業系統,比如安卓、ios、win、mac 等,其實就是一個檔案系統,我們的軟體界面互動等,其實就是在讀寫檔案,我們網頁寫個彈框,操作 dom,就是在讀寫本地檔案或者是讀寫記憶體裡的資料,個人的一些見解不知道對不對,本人非科班出身的前端 coder ????
參考資料:docker 鏡像分層原理
ok,我們已經知道鏡像是由多層檔案系統組成,想要優化它的大小,就需要去減少層數、每一層盡量隻包含該層需要的東西,任何額外的東西應該在該層建構結束前清理掉,下面開始正文
這也是絕多數人知道的優化鏡像手段,Alpine 是一個很小的 Linux 發行版,隻要選擇 Node 的 Alpine 版本,就會有很大改進,我們把這一句改成指令改成 <code>FROM node:14.17.4-alpine</code> (可以去 dockerhub 檢視 node 有哪些版本标簽),build 後鏡像大小如下圖,瞬間從 1.06G 降到 238M,可以說是效果顯著
還可以使用其它的基礎小鏡像,比如 mhart/alpine-node,這個還能再小,改成 <code>FROM mhart/alpine-node:14.17.3</code> 再試試,可以看到又小了 5M ????,雖然不多,但是秉着能壓榨一點是一點的“老闆原則”,積少成多,極緻壓榨
既然 Alpine 是最小的 Linux,那我們試下用純淨的 Alpine 鏡像,自己再裝 Node 試試
build 後看下圖,隻有 174M 了,又小了不少
結論就是不嫌麻煩追求極緻就用方案二,從 1.06G 減少到 174M
<code>ENV</code> 指令是可以一次性設定多個環境變量,能一次指令執行完,就不用兩次,多一個指令就多一層
<code>EXPOSE</code> 指令是暴露端口,其實也可以不用寫這個指令,在啟動容器的時候自己映射端口,如果寫了這個指令的話,因為端口不經常變,是以把這個指令提前,寫上這個指令有兩個好處:
幫助鏡像使用者了解這個鏡像服務的守護端口,以友善配置映射
在運作時使用随機端口映射時,也就是 <code>docker run -P</code> 時,會自動随機映射 <code>EXPOSE</code> 的端口
至于寫還是不寫,看個人吧,我個人一般不寫,因為我在項目啟動指令會指定項目端口,啟動容器的時候映射出來就好,這樣我就要維護一個地方,Dockerfile 也寫了的話,項目端口變了,這裡也要修改,多了點維護成本,當然也有辦法讓兩邊端口變量取自配置檔案,隻要改配置檔案即可
下面是改寫後的 Dockerfile
這一步的優化,無論從鏡像大小還是建構鏡像速度都看不到明顯的差别,因為改動的層内容少(展現不出來),但是可以檢視到鏡像的層是變少了的,可以自行試試檢視鏡像的層試試
減少鏡像層數是“好老闆”的傳統優良習慣,不讓“員工”浪費資源
從下圖可以看到每次我們 build 的時候最耗時的就是在執行 <code>yarn</code> 指令裝依賴的時候,大部分時候我們隻是改代碼,依賴不變,這時候如果可以讓這一步緩存起來,依賴沒有變化的時候,就不需要重新裝依賴,就可以大大改進編譯速度
前面我們說了鏡像建構時,是一層層建構,前一層是後一層的基礎,既然是這樣的話,我們就把 package.json 檔案單獨提前拷貝到鏡像,然後下一步裝依賴,執行指令裝依賴這層的前一層是拷貝 package.json 檔案,因為安裝依賴指令不會變化,是以隻要 package.json 檔案沒變化,就不會重新執行 <code>yarn</code> 安裝依賴,它會複用之前安裝好的依賴,原理講清楚了,下面我們看效果
改變後的 Dockerfile 檔案
build 看下圖,編譯時間從 29.6s 到 1.3s,使用了緩存的層前面會有個 CACHED 字眼,仔細看下圖可以看到
充分利用 docker 緩存特性是優化建構速度的利器
多階段建構這裡不多說了,不了解的可以先搜相關資料了解
因為我們運作 node 程式是隻需要生産的依賴和最終 node 可以運作的檔案,就是說我們運作項目隻需要 package.js 檔案裡 dependencies 裡的依賴,devDependencies 依賴隻是編譯階段用的,比如 eslint 等這些工具在項目運作時是用不到的,再比如我們項目是用 typescript 寫的,node 是不能直接運作 ts 檔案,ts 檔案需要編譯成 js 檔案,運作項目我們隻需要編譯後的檔案和 dependencies 裡的依賴就可以運作,也就是說最終鏡像隻需要我們需要的東西,任何其他東西都可以删掉,下面我們使用多階段改寫 Dockerfile
細心的朋友會發現我這裡有指定 alpine 版本,而上面都是用的 latest 版本,因為就在剛剛發現有個坑需要注意下,就是我們選擇 alpine 版本的時候,最好不要選擇 latest 版本,因為後面要裝的軟體版本可能會在 alpine 的 latest 版本沒有對應軟體的版本号,就會安裝錯誤,我剛剛就翻車了,點選檢視 alpine 版本下的包資訊
build 後,我們看看鏡像大小,上次的是 174M 再次降到 73.4M,極緻壓榨。鏡像:”放過我把,我真的沒有了“????
講解:
我把這個建構分成了三個階段:
第一階段:建構基礎鏡像
安裝依賴、編譯、運作等等階段,就是所有階段共用的東西都在第一階段封到一個基礎鏡像裡供其它階段使用,比如設定環境變量、設定工作目錄、安裝 nodejs、yarn 等等
第二階段:裝依賴階段
在這個階段,裝依賴,如果項目需要編譯,可以在這個階段裝依賴編譯好
這裡在說下裝依賴的小細節,就是執行 <code>yarn --production</code> 加個 production 參數或者環境變量 <code>NODE_ENV</code> 為 <code>production</code>,yarn 将不會安裝 devDependencies 中列出的任何軟體包,點我檢視官方文檔說明,因為我設定了環境變量是以就沒加這個參數
第三階段:最終使用鏡像
拷貝第二階段安裝的好的依賴檔案夾,然後在拷貝代碼檔案到工作目錄,執行啟動指令,第二階段裝依賴多出的一些垃圾我們不需要,我們就隻拷貝我們要用的東西,大大減少鏡像的大小
如果項目需要編譯,在拷貝編譯後的檔案夾,不需要拷貝編譯前的代碼,有編譯後的代碼和依賴就可以跑起項目
多階段建構,最後生成的鏡像隻能是最後一個階段的結果,但是,能夠将前置階段中的檔案拷貝到後邊的階段中,這就是多階段建構的最大意義。
最終優化成果:
至此,壓榨鏡像手段就完了,如果各位老闆還有壓榨手段可以分享分享
鏡像内心獨白:”你禮貌嗎?還來“
github 提供的 actions,每次都是一個幹淨的執行個體,什麼意思,就是每次執行,都是幹淨的機器,這會導緻一個問題,會導緻 docker 沒法使用緩存,那有沒有解決辦法呢,我想到了兩種解決辦法:
docker 官方提供的 action 緩存方案
我用的是 Github cache 方案
自托管 actions 運作機器
相當于 gitlab 的 runner 一樣,自己提供運作器,自己提供的就不會每次都是幹淨的機器,詳情看 actions 官方文檔
先建構一個已經安裝好依賴包的鏡像,然後基于此鏡像再次建構,相當于多階段建構,把前兩個階段建構的鏡像産物推送到鏡像倉庫,再以這個鏡像為基礎去建構後續部分。借助鏡像倉庫存儲基礎鏡像進而達到緩存的效果(此方案來源于評論裡的大佬)
參考資料:
在 GitHub Actions 上使用 Docker 層緩存建構鏡像
項目倉庫位址 wechat-bot
文章有錯誤的地方歡迎指正,避免誤人子弟