近期Q-eye發布了一篇“鏡像瘦身”的分享,引起大家的關注,很多人來咨詢“鏡像瘦身”的方案。這篇分享從實例出發,證明了鏡像優化的重要性和有效性,起到了很好的拋磚引玉作用,但是其內容較短,原理性說明較少,希望通過本文來對“鏡像瘦身”過程進行梳理,以實戰案例進一步闡明該方案。
容器化發布通過將應用以及應用所依賴的環境(比如JRE、動態庫,環境變量,系統目錄等)一起打包為鏡像,解決了發布的一致性問題,即能夠Build Once,Run Everywhere,但是這種方案也帶來一個副作用,就是鏡像太大,容器鏡像提供了分層機制,每次只需要傳輸變更的層,一定程度上降低了傳輸量。
為了解釋清楚容器鏡像的壓縮和傳輸方法,首先簡單介紹一下容器分層:
當我們在主機上創建一個新的容器時,會為這個容器單獨創建一個文件系統,這個分層的文件系統是將鏡像中的文件層作為只讀層,并新建一個可讀可寫的文件層疊加到鏡像的只讀層上面,容器內的所有操作都發生在讀寫層。這樣做可以實現:
鏡像文件都是只讀的,可以使用一個鏡像創建N個容器,每個容器之間都相互隔離。
多個容器共享一個只讀層,能夠利用文件系統的緩存機制,加速數據的讀取。
當我們制作一個新的鏡像時,過程也是類似的,會在基礎鏡像文件層的基礎上新疊加一個讀寫層,并在讀寫層上放入新內容,然后將新的讀寫層變成一個新的只讀層,形成一個新的鏡像。所以新手容易碰到的一個問題是:我在Dockerfile里面使用rm刪除了基礎鏡像中的一些文件,但是為什么最終鏡像沒有減???理解了這個讀寫層的機制就會知道,任何操作都發生在讀寫層,因此刪除文件時并不能真的去刪除以前的文件,只是在讀寫層上做一個特殊標記,讓文件系統看不到這個文件而已,因此鏡像不會縮小。
因此分層機制有如下的弊端:
一旦寫入文件并打成了鏡像,后續基于這個鏡像制作的鏡像,就無法刪除這個文件所對應的空間。
一個容器鏡像是由描述文件和一系列數據文件組成,每個數據文件對應一個文件層。當我們拉取或者推送鏡像時,首先會獲取描述文件,然后根據描述文件判斷哪些層本地已經有了(或者遠端已經有了),然后就只傳輸不存在的層即可,大幅減少傳輸量。但是,如果兩個系統之間無法直連,就無法判斷哪些鏡像層對端已經有了,因此這個機制就會失效。
了解了這些原理后,就可以進一步討論如何優化鏡像的大小了。
為了降低容器鏡像的大小,在編寫Dockerfile時注意參考如下經驗:
各團隊盡量使用統一的基礎鏡像。建立并維護公司統一的基礎鏡像列表。
減少Dockerfile的行數,使用“&”連接多個命令,因為每一行命令都會生成一個層。
將增加文件和清理文件的動作放到一行里面,比如yum install和yum clean all,如果分為兩行,第二條清理動作就無法真正刪除文件。
只復制需要的文件,如果整個目錄復制,一定要仔細檢查目錄下是否有隱藏文件、臨時文件等不需要的內容。
容器鏡像自身有壓縮機制,因此把文件壓縮成壓縮文件然后打入容器,容器啟動時解壓的方法并不會有什么效果。
避免向生產鏡像打入一些不必要的工具,比如有的團隊打入了sshd,不應該使用這種方案,增加安全風險。
盡量精簡安裝的內容,比如只安裝工具的運行時,無須帶上幫助文檔、源碼、樣例等等。
一個典型的java應用的鏡像的大小是在500M左右,其中300M左右是基礎鏡像(包含OS/JRE等),還有200M是應用相關的文件。雖然看上去并不多,但是在微服務架構下,應用的數量比較多,假設我們每個版本發布30個鏡像,則總傳輸量會有300M + 200M* 30,大概要6G左右,還是會比較大。
可以通過將應用進一步分層來減少每次發布量,有兩種劃分分層的方法:
相似的幾個產品共享一個基礎鏡像,將公共的包放到基礎鏡像層假設A/B/C三個產品都使用了相同的技術架構,比如都使用了20個相同的jar包,這些jar包一共100M。如果這三個產品創建一個共享的中間層基礎鏡像,然后基于這個基礎鏡像再打各自的鏡像,則每次應用層的發布數據量會由原來的 200M * 3 變成 100M + 100M * 3,這樣就可以減少發布量。
假設A應用一共200M,但是三方jar包就有180M,自己的應用只有20M,則可以創建一個中間層基礎鏡像,這樣每次發版本時,如果三方jar包沒有變動,則中間層不需要重新傳遞,只傳遞20M的自有應用,也可以降低發布的量。
我通常把這種為了減少發布量的分層叫做offload層。效果非常直接,但是offload層也會帶來很多管理問題:
對于第一類,多個產品共享一個offload層,需要這幾個產品保持密切的溝通,假設需要更新offload層某個組件的版本,則幾個產品需要同時協同一起變更和發布。如果存在不一致則可能會引入問題。
第二個也會存在offload層更新的問題。比如:目前Java應用流行使用maven來管理依賴,如何根據maven的輸出及時更新offload層?如果更新不及時,也會造成不一致。
Offload層的更新問題如果依賴管理流程或者人為檢查是不可靠的,我們建議將offload層當成cache一樣使用,實現方案如下:
按照業務特點抽取出公共文件和冷文件做成offload層
在制作鏡像時,利用multi-stage builds機制,先將最新的全量的內容復制到Stage中,然后在Stage進行一次比對,如果offload層是最新的就用,如果不是最新的,則使用最新的替代。
以Java為例,Dockerfile寫法如下:
Stage
復制所有的lib包到/app/lib/目錄下
RUN一個腳本,檢查下/app/lib/下的文件和offload層中/app/lib_shared/下的文件,如果兩個文件一致,則刪除文件并建立一個軟鏈接來替代實際文件
Build
從Stage中復制/app/lib/目錄做成最終的真正的層
這樣即使出現依賴包發生了更新,而offload層未能及時更新的情況,只會造成鏡像offload失敗,鏡像比較大一些而已,不會造成故障。
注意: 目前發現tomcat如果要支持軟鏈接,需要打開allowLinking開關,否則會失敗。
前文提到過,鏡像分層傳輸必須是源倉庫和目標倉庫的網絡能夠互通的情況,但是實際場景往往比較復雜,比如很多項目都有嚴格的網絡管控,不允許服務器直接訪問外網;很多國際項目到國內的網絡連接比較差,速度慢并且經常丟包。所以我們需要分情況來討論。
假設能夠找到一臺機器,這臺機器既能夠訪問源倉庫也能夠訪問目標倉庫,可以在這臺機器行安裝一個Docker,然后直接使用docker pull/push的方式,拉取和上傳的過程都是增量傳輸;但是這種方式需要安裝Docker,并且會占用文件系統空間(鏡像會暫存在本地)。推薦使用我們開源的 image-transmit (https://github.com/wct-devops/image-transmit)工具,這個工具一端連接源倉庫,一端連接目標倉庫,直接將增量數據層轉發過去,中間數據不落盤,效率是最高的。同時這個工具是一個綠色版的界面化工具,使用簡單(也支持命令行),壓縮后只有幾M大小,資源消耗很少,可以在一些安全跳板機上穩定運行。
很多場景下我們無法實現在線的傳輸,需要先將鏡像保存成一個文件,然后利用各種手段發給現場,比如通過百度云盤中轉、存到U盤然后快遞過去等。在離線傳輸模式下重點是考慮如何能夠把鏡像包壓縮到最小,以及壓縮和解壓縮的時間。下面介紹幾種模式:
docker save|gzip方式,這種是最基本的方法,找一臺安裝有Docker的機器,將鏡像拉取到本地,然后使用save命令保存并壓縮。這種方式非常耗時,同時壓縮包也是最大的。
使用上文提到的image-transmit工具進行離線打包,默認使用tar算法。這個工具可以直接從倉庫上下載鏡像的數據文件,合并成壓縮包,比上一種方式減少了保存到本地然后導出以及壓縮和解壓縮的過程,速度可以提升20倍,同時如果一次壓縮多個鏡像,相同的鏡像層只會保留一份,這能夠降低壓縮包的大小,以我們自己的鏡像版本為例,使用工具得到的壓縮包是docker save方法的1/3到1/5大小。
同時image-transmit還提供了squashfs算法,這種壓縮算法在上一種方式的基礎上更進一步,會把每一個鏡像層都解壓,對每一個文件進行固實壓縮,舉個例子,A產品和B產品都用到了demo.jar,但是這個jar并不在基礎鏡像層,上一種方式是無法識別這種重復的,但是squashfs壓縮方式可以識別并將其壓縮為一份,這樣能夠進一步降低壓縮包的大小,以我們自己的鏡像包為例,這種壓縮方式可以在上一種方式的基礎上再降低30%~50%。但是這種壓縮算法由于需要將所有的鏡像層都解壓然后進行壓縮,導致其壓縮時間非常長,上一種方式5分鐘可以壓縮完的包,這種方式要一個小時左右。
壓縮算法的選擇可以根據實際情況來選擇,甚至把兩種方式都試一下,綜合選擇一個合適的。離線方式因為無法直連倉庫,所以上文中的方法是把所有的鏡像層都保存到離線包中了,那么如何在離線模式下也能實現增量方式發布呢?
image-transmit實現了這樣一種增量發布方法:在制作壓縮包時,可以根據上次的壓縮包的信息自動跳過已經發送過的鏡像層,只發增量變更的部分,這樣就可以進一步降低壓縮包的大小。不過這種方式也有弊端,比如必須嚴格按照順序下載版本,如果遺漏某個版本可能會造成現場倉庫缺失一些數據層,造成失敗。如果版本發布比較多,可以采用類似如下的方案來規避這種風險:
這種準實時的增量同步加上定期的全量同步,即可以降低同步量又可以避免缺失一些層造成傳輸失敗。
鏡像傳輸還有一些優化方向亟待研究,比如:
業界有很多鏡像,其容器內只有應用的二進制程序,因此其鏡像大小與傳統應用發布沒有什么區別。但是這種鏡像在做問題分析時,需要依賴外部的工具,一定程序上提高了故障分析的門檻,可以通過sidecar方式來提供排障工具。
鏡像過大不僅僅影響發布,在版本升級、容器切換等容器重新創建的場景下,消耗大量的網絡帶寬和磁盤IO,鏡像倉庫容易成為瓶頸,需要考慮類似Dragonfly的P2P分發方案。
目前我們主要使用Centos作為基礎鏡像,我們正在考慮更換更為輕量的,專門為容器設計的基礎鏡像。
“鏡像瘦身”其實是個系統性的工程,需要多個團隊相互配合,從技術平臺到業務應用到交付相互配合一起落地。