Docker 鏡像優化與最佳實踐

摘要: 雲棲TechDay41期,阿里雲高級研發工程師御坂帶來Docker鏡像優化與最佳實踐。從Docker鏡像存儲的原理開始,針對鏡像的存儲、網路傳輸,介紹如何在構建中對這些關鍵點進行優化。並介紹Docker最新的多階段構建的功能,以解決構建依賴的中間產物問題。

以下是精彩內容整理:

鏡像概念

鏡像是什麼?從一個比較具體的角度去看,鏡像就是一個多層存儲的文件,相較於普通的ISO系統鏡像來說,分層存儲會帶來兩個優點,一個是分層存儲的鏡像比較容易擴展,比如我們可以基於一個Ubuntu鏡像去構建我們的Nginx鏡像,這樣我們只需要在Ubuntu鏡像的基礎上面做一些Nginx的安裝配置工作,一個Nginx鏡像工作就算製作完成了,我們不需要從頭開始去製作各種鏡像。另一點我們可以優化鏡像存儲空間,假如我們有兩個鏡像,Tag1.0鏡像和 Tag2.0鏡像,我們如果以傳統方式去傳這兩個鏡像,每個鏡像大概130多兆,但如果我們以分層的方式去存儲兩個鏡像,我們通過下面兩個紫色的才能共享,可以節約大量的空間,兩個鏡像加起來只需要140多兆的空間就可以存下來。這樣一是節省了存儲空間,二是可以減少網路上的開銷,比如我們已經把下面鏡像下載了,我們要去下載上面鏡像的時候,我們只需要去下10M的部分。

如果從抽象的角度去看,Docker鏡像其實是Docker提供的一種標準化的交付手段,傳統應用在交付的時候其實是交付一個可執行文件,這個可執行文件不包括它的運行環境,我們可能會因為32位系統或64位系統,或者開發測試使用1.0軟體,結果交付時候發現用戶的環境是2.0等各種各樣的問題,導致我們要去花時間去排查,如果我們以Docker鏡像的標準化形式去交付,我們就會避免掉這些問題。

鏡像基本操作與存儲方式

我們的一個鏡像會有一個坐標,一個鏡像坐標基本上會由四個部分組成,前面會有一個鏡像服務域名,每一個服務提供商都會有不同的域名,當我們確定服務提供商給我們的域名之後,我們一般會要到服務提供商那裡去申請自己的命名空間,倉庫名稱一般是標識鏡像的用途,比如說Ubuntu鏡像、CentOS鏡像,標籤一般是用於去區分鏡像版本,比如我們對Ubuntu鏡像可能會打一些16.04的包,在我們確定了一個鏡像服務域名以及在雲服務商申請命名空間之後,我們就可以對鏡像做一些操作了。

首先我們需要去登陸,我們會用第一條命令去登陸,然後,當我們在本地準備好一個鏡像想要上傳的時候,我們先要對這個鏡像進行打標,把它的坐標變成我們現在需要上傳鏡像的坐標,然後再去做一些推送拉取的動作,最後針對Docker還提供兩個額外命令去做鏡像交付,如果我們是特殊的環境,沒有辦法網路連通的時候,我們可以將這個鏡像打包成一個普通文件進行傳輸。比如我們和公安合作,他們沒有辦法通過我們的Registry下載鏡像,我們可能要把它打成一個普通文件,然後以U盤的方式去交付。

鏡像存儲細節

Docker鏡像是存在聯合文件系統的,每一個鏡像其實是分層存儲的,比如在第一層我們添加了三個新文件,然後在這一層基礎上我們又增加了一層,添加了一個文件,第三層可能會需要做一些修改,我們把File3做了一個修改移到上面來,然後刪掉了File4,這裡就會引到聯合文件系統裡面的寫時複製機制,當我們要去修改一個文件的時候,鏡像依賴底層都是只讀的,我們不能去直接修改,比如我們想去修改File3,我們不能直接去修改這個文件,我們需要在修改的時候把文件複製到當前這一層,比如說L3層,然後再去修改它。

一個鏡像做好之後,當我們想要知道鏡像裡面有哪一些內容的時候,我們其實會有一個視圖概念,我們從聯合文件系統的角度去看鏡像的時候,其實我們不會看到L1、L2、L3,我們會最後看到結果,File1、File2、File3,File4就看不到了,然後在我們了解原理之後,我們就可以去理解容器運行起來是一個什麼樣的情況。容器運行起來和上面形成是類似的,圖中下半部分,同樣也是L1、L2、L3的三層鏡像,當容器運行起來的時候,Docker daemon會動態生成一層可寫層作為容器的運行層,然後當容器裡面需要去修改一些文件,比如File2,也是copy on write機制把文件複製上來,然後做一些修改,新增文件的時候也是一樣,然後容器在運行的時候也會有一個視圖,當我們把容器停掉的時候,視圖一層就沒有了,它會被銷毀,但是容器層讀寫層還會保留,所以我們把容器停掉再啟動的時候,我們依舊會看到我們之前在容器裡面的一些操作。

常見的存儲驅動主要有AUFS、OverlayFS,還有Device Mapper,前兩種驅動都是基於文件,它的原理就是需要修改一個文件的時候把整個文件複製上去做修改, Device Mapper更偏底層一點,它是基於塊設備的,它的好處在於當我想要修改一個文件的時候,我不會將整個文件拷上去,我會將文件修改的一些存儲塊拷上去做一些修改,當我有一些大文件想要修改的時候,Device Mapper會比AUFS、OverlayFS好很多。所以AUFS和OverlayFS就比較適合傳統的WEB應用,它的文件操作不會很多,但是它可能對我們的應用啟動速度會有一些要求,比如我可能經常要發布,我希望能夠啟動比較快,但是對於文件修改的一些效率我不是很關心,那可以使用基於文件的驅動,當我們是一些計算密集型的應用時候,我們就可以選擇Device Mapper,雖然啟動比較慢,但是它的運行效率相對表現要好一些。

鏡像自動化構建

我們構建一個鏡像的時候,Docker其實提供了一個標準化的構建指令集,當我們去用這些構建指令去寫類似於腳本,這種腳本我們稱之為DockerFile,Docker可以自動解析DockerFile,並將其構建成一個鏡像,所以你就可以簡單的認為這是一個標準化的腳本。DockerFile在做一些什麼?首先第一行FROM指令表示要以哪一個鏡像作為基礎鏡像進行構建,我們用了openJDK的官方鏡像,以JAVA環境作為基礎,我們在鏡像上面準備跑一個JAVA應用,然後接下來兩條LABLE是對鏡像進行打標,標下鏡像版本和構建日期,然後接下來的六個RUN是做了一個maven安裝,maven是JAVA的一個生命周期管理工具,接下來將一些源代碼從外面的環境添加到鏡像裡面,然後兩條RUN命令做了打包工作,最後寫了一個啟動命令。

總的來說DockerFile寫的還可以,至少思路是很清晰的,一步一步從基礎鏡像選擇到編譯環境,再把源代碼加進去,然後再到最後的構建,啟動命令寫好,可讀性、可維護性都可以,但是還是可以進行優化的。

我們可以減少鏡像的層數, Docker對於Docker鏡像的層數是有一定要求的,除掉最上面在容器運行時候的讀寫層以外,我們一個鏡像最多只能有127層,如果超過可能會出現問題,所以第二行命令LABLE就可以把它合成一層,減少了層數,下面六個RUN命令做了maven的安裝工作,我們也可以把它做成一層,把這些命令串起來,後面的構建我們也可以把它合成一層,這樣我們一下就把鏡像層數從14層減少到7層,減掉了一半。

我們在做鏡像優化的時候,我們希望能夠盡量減少鏡像的層數,但是和它相對應的是我們DockerFile的可讀性,我們需要在這兩者之間做折中,我們在保證可讀性不受很大影響的情況下去盡量減少它,其實六條RUN命令在做一件事,就是做maven環境打結,做編譯環境的準備工作。

接下來我們繼續對鏡像進行優化,我們可以做一些什麼工作呢?在安裝maven構建工具的時候我們多加了一行,我們把安裝包和展開目錄刪掉了,我們清理了構建的中間產物,我們要去注意每一個構建指令執行的時候,盡量把垃圾清理掉,我們通過apt-get去裝一些軟體的時候,我們也可以去做這樣的清理工作,就是把這些軟體包裝完之後就可以把它刪掉了,這樣可以盡量減少空間,通過增加一行命令,我們可以把鏡像的大小從137M削減到119M。

通過apt-get去裝軟體或者命令基本上是所有編寫DockerFile的人都去寫的,所以官方已經在debian、Ubuntu的倉庫鏡像裡面默認加了Hack,它會去幫助你在install自動去把源代碼刪掉。

我們可以利用構建的緩存,Docker構建默認會開啟緩存,緩存生效有三個關鍵點,鏡像父層沒有發生變化,構建指令不變,添加文件校驗和一致。只要一個構建指令滿足這三個條件,這一層鏡像構建就不會再執行,它會直接利用之前構建的結果,根據構建緩存特性我們可以加一行RUN,這裡是以JAVA應用為例,一般一個JAVA應用的pom文件都是描述JAVA的一些依賴,而在我們平常的開發過程中這些依賴包發生變化的頻率比較低,那麼我們就可以把POM加進來,把POM文件依賴全部都準備好,然後再去下源代碼,再去做構建工作,只要我們沒有把緩存關掉,我們每次構建的時候就不需要重新下安裝包,這樣可以節省大量時間,也可以節省一些網路流量。

現在阿里雲的容器鏡像服務其實已經提供了構建功能,我們在統計用戶失敗案例的時候就會發現,網路原因導致的失敗佔90%,比如如果用戶通過node開發NPM在安裝一些軟體包的時候經常卡在中間。所以我們建議加一個軟體源,我們把阿里雲maven地址加到裡面去,我們把配置項加到阿里雲的軟體地址,加阿里雲的maven源作為軟體包的下載目標,時間直接少了40%,這樣對一個鏡像構建的成功率也是有幫助的。

多階段構建

DockerFile最終需要做到的產物其實是JAVA應用,我們對於構建、編譯、打包或者安裝這些事情都不關心,我們要的其實是最後的產物。所以,我們可以採取分步的方式去做鏡像構建,首先我們將之前遇到的所有問題全部都做成基礎鏡像,上面FROM鏡像其實已經改了新的,鏡像裡面已經把軟體源的地址改成了Maven,緩存都已經做好了。我們會去利用緩存,然後添加源代碼,我們把前面構建的事情做成了鏡像,讓鏡像去完成構建,然後我們才會去完成把JAVA包拷進去,啟動工作,但是兩個DockerFile其實是兩個鏡像,所以我們需要一段腳本去輔助它,第一行的shell腳本是做第一個構建指令,我們指定以Bulid的DockerFile去啟動構建,然後生成一個APP Bulid鏡像,接下來兩行腳本是把鏡像生成出來,把裡面的構建產物拷出來,然後我們再去做構建,最後把我們需要的JAVA應用給構建出來,這樣我們的DockerFile相比之前就更加清晰了,而且分步很簡單。

Docker在17.05之後官方支持了多階段構建,我們把下面的腳本去掉了,我們不需要一段輔助腳本,我們只需要在後面申明基礎鏡像的地方標記,我們第一階段的構建產物名字叫什麼,我們就可以在第二個構建階段裡面用第一個構建階段的產物。比如我們第一階段把JAVA應用構建好,把Maven包裡面的target下面的JAVA架包拷到新的鏡像裡面,然後在所有優化做完之後效果如圖,我們在第一次構建的時候,優化前102秒,在Docker構建優化後只花55秒就完成了,主要優化在網路上面。當我們修改了JAVA文件重新進行構建,第二次構建花了86秒,因為Maven安裝那一塊被緩存了,我們利用了構建緩存,所以少掉20多秒,優化後只花了8秒,因為所有的源代碼前面的一些軟體包下載全部被緩存了,我們直接拉新的鏡像,然後依賴沒有變,直接進行構建,所以8秒基本上是完整構建時間。

我們再來看一下存儲空間上面的優化,第一次構建我們在優化前把鏡像打出來有137M,但是在我們整個優化之後,只有81M了,這裡的基礎鏡像由JDK改成JRE,為什麼?因為之前我們把所有流程都放在一個鏡像裡面時,我們是需要去做構建的,構建時需要去RUN Maven,這種情況下沒有JDK環境是RUN不起來的,但是如果我們分階段,把構建交給Maven鏡像來做,把真正運行交給新的鏡像來做,就沒必要用JDK了,我們直接用JRE,優化之後鏡像少了將近50%。當我們修改源代碼重新進行構建的時候,由於鏡像成共享的原因,第二次構建在優化前其實多加了兩層到三層,一共有9M,但是優化後的第二次構建只增加1.93KB,這樣我們針對DockerFile的優化就已經做完了。

鏡像優化有哪些重要的點呢?具體如下:

  • 減少鏡像的層數,盡量把一些功能上面統一的命令合到一起來做;
  • 注意清理鏡像構建的中間產物,比如一些安裝包在裝完之後就把它刪掉;
  • 注意優化網路請求,我們去用一些鏡像源,去用一些網路比較好的開源站點,這樣可以節約時間、減少失敗率;
  • 盡量去用構建緩存,我們盡量把一些不變的東西或者變的比較少的東西放在前面,因為不變的東西都是可以被緩存的;
  • 多階段進行鏡像構建,將我們鏡像製作的目的做一個明確,把我們的構建和真正的一些產物做分離,構建就用構建的鏡像去做,最終產物就打最終產物的鏡像。

容器鏡像服務

最後介紹一下阿里雲容器鏡像服務。這個服務已經公測一年了,現在我們的服務公測是全部免費的,現在在全球的12個Region都已經部署了我們的服務,每個Region其實都有內網服務和VPC網路服務,如果ECS也在同樣的Region,那麼它的服務是非常快的。然後團隊管理和組織帳號功能也已經上線了,鏡像購建和鏡像消息通知其實都是一些DevOps能力,針對一些鏡像優化我們提供了一些鏡像層信息瀏覽功能,我們後續也會提供分析,推出鏡像安全掃描、鏡像同步。

更多技術乾貨敬請關注云棲社區知乎機構號:阿里云云棲社區 - 知乎

推薦閱讀:

[原] 數據科學的容器革命

TAG:Docker | 镜像 | 容器 |