土法搞docker系列之自制docker的graph driver vdisk

xinkun發表於2019-05-22

寫在最前

偶然整理,翻出來14年剛開始學docker的時候的好多資料。當時docker剛剛進入國內,還有很多的問題。當時我們的思考方式很簡單,docker確實是個好的工具,雖然還不成熟。但是不能因為短時間內造橋不行,就不過河了。我們的方式很簡單,先造個小船划過去。由於各種條件的侷限,所以很多方法真的是因陋就簡,土法上馬,一切就是為了抓緊落地。時代更迭、版本變遷,這其中的很多技術方案本身可能已經無法為現有的方案提供有力的幫助了。但是解決問題的思路和原理可能還能為大家提供一點參考吧。這於我自己,也是一個整理回顧。所以我計劃寫成一個小的系列文章,這個系列直接取名為土法搞docker。

當時遇到的第一個問題,就是docker的底層graph driver,在centos 6下的devicemapper不穩定,有很大的概率會造成核心崩潰。但是如果不解決這個問題,是絕對無法將docker上到生產環境中的。以我貧瘠的核心知識和儲存知識,完全無力解決。那怎麼辦,那就用土辦法,自己寫一個graph driver。之所以叫自制而不叫自研,因為真的沒有多少可以稱之為研究的東西,完全是拼湊而成。自制的這個driver本身沒有多少技術含量,但是需要深入瞭解docker的執行原理和底層的儲存方式,然後尋找一種恰當的方式來解決它。

graph driver原理

graph driver的原理和介面從1.3到現在的最新版本,基本沒有什麼變化。這也有賴於docker當時優秀的設計。首先說graph driver是幹什麼的。我們都知道docker的映象/容器是由多層組成。graph driver其實就是負責了層檔案的管理工作。

這裡是driver介面的一些方法:

// ProtoDriver defines the basic capabilities of a driver.
// This interface exists solely to be a minimum set of methods
// for client code which choose not to implement the entire Driver
// interface and use the NaiveDiffDriver wrapper constructor.
//
// Use of ProtoDriver directly by client code is not recommended.
type ProtoDriver interface {
    // String returns a string representation of this driver.
    String() string
    // Create creates a new, empty, filesystem layer with the
    // specified id and parent. Parent may be "".
    Create(id, parent string) error
    // Remove attempts to remove the filesystem layer with this id.
    Remove(id string) error
    // Get returns the mountpoint for the layered filesystem referred
    // to by this id. You can optionally specify a mountLabel or "".
    // Returns the absolute path to the mounted layered filesystem.
    Get(id, mountLabel string) (dir string, err error)
    // Put releases the system resources for the specified id,
    // e.g, unmounting layered filesystem.
    Put(id string)
    // Exists returns whether a filesystem layer with the specified
    // ID exists on this driver.
    Exists(id string) bool
    // Status returns a set of key-value pairs which give low
    // level diagnostic status about this driver.
    Status() [][2]string
    // Cleanup performs necessary tasks to release resources
    // held by the driver, e.g., unmounting all layered filesystems
    // known to this driver.
    Cleanup() error
}

為了方便,我們舉一個例子。假設某個映象由兩層組成,layer1(lower)和layer2(upper)。layer1中有一個檔案A,layer2中有一個檔案B。那麼對單一的layer2來說,其實只有一個檔案也就是B。但是通過聯合檔案系統將layer1和layer2聯合起來時,得到layer1+2,將layer1+2掛載起來,那麼獲得的掛載點資料夾下應該包含了A和B兩個檔案(本文中將這種掛載點稱為聯合掛載點)。

graph

這裡結合這個例子分別對這些中比較重要的方法進行一下介紹:

  • Create: 建立一層,比如建立layer2
  • Remove: 移除某一層,比如移除layer2
  • Get: 將id及其下層的所有層通過聯合檔案系統聯合起來的layer1+2,將layer1+2掛載起來,返回掛載點
  • Put: 將id及其下層的所有層通過聯合檔案系統聯合起來的layer1+2的掛載點umount掉
  • Exists: 判斷id層是否存在
  • Cleanup: 將所有的掛載起來的全部解除安裝掉

其實docker對於層檔案的操作,都是通過這些介面組合而成的。比如docker建立容器時,最終需要用Create為容器建立一個新的可讀寫的層。而docker執行容器時,需要通過Get介面獲取容器及其映象所有層聯合起來的檔案從而形成容器的rootfs。

在分析這些介面時,我們其實可以發現一個問題,其實介面中沒有獲取單層,比如只獲取layer2的介面。比如docker save映象時,因為要匯出每一層的單獨的檔案,這又是如何實現的呢?其基本原理其實算是Get(layer1)以及Get(layer2),然後將兩層的掛載的資料夾進行diff,從而得到只歸屬於layer2層的檔案。

func (gdw *NaiveDiffDriver) Diff(id, parent string) (arch archive.Archive, err error) {
    driver := gdw.ProtoDriver
    //獲取id的聯合掛載點
    layerFs, err := driver.Get(id, "")
    ...
    //獲取parent的聯合掛載點
    parentFs, err := driver.Get(parent, "")
    ...
    //遍歷兩個掛載點內的所有檔案並進行比較,得到二者的差異檔案
    //則差異檔案就是隻屬於id層的檔案列表
    changes, err := archive.ChangesDirs(layerFs, parentFs)
    ...

這裡我們可以特別想下,介面沒有獲取單層,也就是獲取layer2這種層的介面,那麼其實就意味著docker其實並不真的需要一個聯合檔案系統。這也就是我們能夠自制vdisk的基礎。

那麼大家可能會有個小疑問,既然不一定真的需要聯合檔案系統,那麼使用或者不使用聯合檔案系統有什麼差別呢?差別並不在Get介面上,而是在Create介面上。使用聯合檔案系統時,建立一個新的單層可以非常快速,因為新的層的內容為空。而不使用聯合檔案系統呢,則需要將所有父層的檔案全部拷貝到新的層中,以便在Get介面呼叫時可以快速掛載。這樣二者的建立效率就一目瞭然了。

docker自身也支援一個預設的非聯合檔案系統的graph driver,也就是vfs。
vfs這個驅動簡單明瞭。我當年就是從這裡開始graph driver的理解和學習的。

vdisk原理

我們的實際需求其實是要在centos下用一個非聯合檔案系統的方式來取代devicemapper,實現一個穩定可靠的底層儲存。那麼如何實現,其實有幾種路線選擇。vfs足夠簡單穩定,但是無法限制使用者對於磁碟的使用量。使用不同的lvm盤來儲存每層,由於需要預分配足夠的磁碟空間,又會導致磁碟空間的浪費。最終,我們選擇了一個折中的方案。就是使用稀疏檔案來儲存每一層,然後通過loop裝置掛載,來表達聯合檔案系統的掛載效果。

那麼同上一個例子,對於layer1的所在層,我們其實可以建立稀疏檔案file1,並在其中儲存檔案A。而對於layer2的所在層如何處理呢?因為介面中沒有獲取單層檔案的介面,我們因此可以建立file2,並在其中儲存檔案A和B,也就是layer1+2,來實現layer1和layer2的聯合。而對於只匯出layer2時,只需要將file1和file2的檔案進行diff就可以處理了(同上文所說)。

明白了這個原理後,其實程式碼就好寫了。這也是我當時剛學golang後寫的第一個docker功能。程式碼原理上我參考了vfs的實現,也參考了dm驅動的deviceset進行loop裝置的管理。其實完全是東拼西湊來的,這裡就不獻醜了,回頭我傳到github (https://github.com/xuxinkun) 上去,有興趣的再來圍觀吧。

vdisk的弊端

這個驅動因為使用的是稀疏檔案和loop裝置,因此我命名為loopfile,後來被改名為vdisk。這個驅動原是想應急使用。但是因為足夠簡單,所以足夠穩定。線上上幾乎是零故障。雖然後來修復了devicemapper的bug,但是在JDOS 1.0的叢集上仍然大規模使用的是這個。當然這其中的一個重要原因其實是因為1.0(基於openstack,採用nova+docker方式管理)還是將容器當做虛擬機器來使用,實際建立完容器,仍然需要使用者通過部署平臺來部署指令碼。因此對於容器建立時間不是那麼敏感。同時由於映象預分發,所以建立時間並不是太大的問題。

但是如果映象層數過多,因為每層的檔案中要包含全部父層的檔案,存在很大的冗餘空間佔用。為了解決Dockerfile或者多次commit導致的映象多層問題,我還為docker增加了compress功能,用以將多層壓縮為一層。這個的實現方式我將在後續文章中講述。

後來,進入到JDOS 2.0時代,這種方式就完全無法應付快速啟動容器的需求了。dm的問題也由團隊後來的核心專家進行了解決。從此我們就跨入了dm的時代。當然這些就是後話了。

相關文章