Docker 原理剖析(三)rootfs

Geffin發表於2020-12-17

前言

我們之前介紹過了 Linux 最基礎的兩種技術,Namespace 和 Cgroups。Namespace 的作用是隔離,它可以讓程式只能看到 Namespace 裡面的世界;Cgroups 的作用是限制,給這個世界圍上了一堵牆。這樣,這個程式便真的與世隔絕了。

現在世界已經有了牆,那麼如果容器低頭看向了地面,它能看到什麼呢?或者說,容器裡的程式看到的檔案系統又是什麼樣子的呢?

Change Root

你可能會說這是一個 Mount Namespace 的問題,容器裡的應用程式,理應看到一份完全獨立的檔案系統。這樣,它就可以在自己的容器目錄下進行操作,而完全不會受宿主機以及其他容器的影響。

真的嘛?當然是假的,即使開啟了 Mount Namespace,容器程式看到的檔案系統也跟宿主機完全一樣。

為什麼呢?其實,Mount Namespace 修改的,是容器程式對檔案系統掛載點的認知。但是,這也就意味著,只有在掛載這個操作發生之後,程式的檢視才會被改變。而在此之前,新建立的容器會直接繼承宿主機的各個掛載點。

也許你會說,在建立新程式時,除了宣告要啟用 Mount Namespace 之外,我們還可以告訴容器程式,有哪些目錄需要重新掛載。

更重要的是,因為我們建立的新程式啟用了 Mount Namespace,所以這次重新掛載的操作,只在容器程式的 Mount Namespace 中有效。如果在宿主機檢查這個掛載,你會發現它其實是不存在的。

這就是 Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它對容器程式檢視的改變,一定是伴隨著掛載操作才能生效。

如果我們希望它更加友好,每當建立一個新容器時,我希望容器程式看到的檔案系統就是一個獨立的隔離環境,而不是繼承自宿主機的檔案系統。這應該怎麼做?

很簡單,我們可以在容器程式啟動之前重新掛載它的整個根目錄“/”。而由於 Mount Namespace 的存在,這個掛載對宿主機不可見。

在 Linux 作業系統裡,有一個名為 chroot 的命令可以幫助你在 shell 中方便地完成這個工作。顧名思義,它的作用就是幫你改變程式的根目錄到你指定的位置。對於被 chroot 的程式來說,它並不會感受到自己的根目錄已經被修改。

一般來說,為了能夠讓容器的這個根目錄看起來更真實,我們一般會在這個容器的根目錄下掛載一個完整作業系統的檔案系統,所以我們在容器中通過 ls 命令檢視根目錄下的內容時可以看到作業系統的所有目錄和檔案。

而這個掛載在容器根目錄上、用來為容器程式提供隔離後執行環境的檔案系統,就是所謂的容器映象。它還有一個更為專業的名字,叫作:rootfs(根檔案系統)。

所以,一個最常見的 rootfs,或者說容器映象,會包括一些目錄和檔案,比如 /bin,/etc,/proc 等等。而你進入容器之後執行的 /bin/bash,就是 /bin 目錄下的可執行檔案,與宿主機的 /bin/bash 完全不同。

總結一下,Docker 最核心的原理實際上就是為待建立的使用者程式:

  1. 啟用 Linux Namespace 配置
  2. 設定指定的 Cgroups 引數
  3. 切換程式的根目錄(Change Root)

rootfs

rootfs 是一個作業系統所包含的檔案、配置和目錄,並不包括作業系統核心。在 Linux 作業系統中,這兩部分是分開存放的,作業系統只有在開機啟動時才會載入指定版本的核心映象。

所以說,rootfs 只包括了作業系統的軀殼,並沒有包括作業系統的靈魂。那麼,靈魂在哪裡呢?實際上,同一臺機器上的所有容器,都共享宿主機作業系統的核心。這意味著,當應用程式需要配置核心引數、載入額外的核心模組,以及跟核心進行直接的互動時,這些操作和依賴的物件,都是宿主機作業系統的核心,它對於該機器上的所有容器來說是一個全域性變數,牽一髮而動全身。

由於有了 rootfs 的存在,容器便有了一個重要特性 – 一致性。什麼是一致性呢?之前沒有容器的時候,由於雲端與本地伺服器環境不同,應用的打包過程異常困難。但有了 rootfs 之後,這個問題便被優雅地解決了。由於 rootfs 裡打包的不只是應用,而是整個作業系統的檔案和目錄,也就意味著,應用以及它執行所需要的所有依賴,都被封裝在了一起。

現在,你可能會有一個新的問題,每開發一個應用,都要重複製作一次 rootfs 嗎?

其實並不是這樣的,我們可以用增量的方式去做修改。Docker 在映象的設計中,引入了層(layer)的概念。也就是說,使用者製作映象的每一步操作,都會生成一個層,也就是一個增量 rootfs。

它的原來源於聯合檔案系統,主要的功能是將多個不同位置的目錄聯合掛載到同一個目錄下。例如,我有一個 A 目錄(有 a 檔案與 b 檔案),一個 B 目錄(有 a 檔案與 c 檔案)。然後,我們用聯合掛載的方式把這兩個目錄掛載到 C 目錄下(有 a 檔案,b 檔案,c 檔案),可見此時 A 目錄與 B 目錄的檔案被合併到了一起。

我們可以看一個 Ubuntu 映象,實際上它是 Ubuntu 作業系統的 rootfs,包含了 Ubuntu 作業系統的所有檔案和目錄。不過這個 rootfs,由多個層組成,每一個層都是一個增量 rootfs,每一層都是 Ubuntu 作業系統檔案與目錄的一部分。在使用映象時,Docker 會把這些增量聯合掛載在一個統一的掛載點上,這個掛載點就是 /var/lib/docker/aufs/mnt/。(映象的層都放置在 /var/lib/docker/aufs/diff 目錄下)

rootfs 的組成

rootfs 由三部分組成,由上往下分別是:可讀寫層,init 層,只讀層。

我們以之前使用的 Ubuntu 映象為例。
在這裡插入圖片描述
只讀層是容器的 rootfs 的下五層,它們的掛載方式都是隻讀的,可見這些層都以增量的方式分別包含了 Ubuntu 作業系統的一部分。

可讀寫層是容器的 rootfs 的最上面一層,在沒有寫入檔案之前,這個目錄是空的。而一旦在容器裡做了寫操作,你修改產生的內容就會以增量的方式出現在這個層中。但是,如果我現在要做的,是刪除只讀層裡的一個檔案呢?為了實現這樣的刪除操作,會在可讀寫層建立一個 whiteout 檔案,把只讀層裡的檔案遮擋起來。比如,你要刪除只讀層裡一個名叫 foo 的檔案,那麼這個刪除操作實際上是在可讀寫層建立了一個名叫.wh.foo 的檔案。這樣,當這兩個層被聯合掛載之後,foo 檔案就會被.wh.foo 檔案遮擋起來,消失了。綜上所述,最上面這個可讀寫層的作用,就是專門用來存放你修改 rootfs 後產生的增量,無論是增、刪、改,都發生在這裡,而原先的只讀層裡的內容則不會有任何變化。

Init 層在只讀層與可讀寫層的中間,是 Docker 專案單獨生成的一個內部層,專門用來存放 /etc/hosts、/etc/resolv.conf 等資訊。需要這樣一層的原因是,這些檔案本來屬於只讀的 Ubuntu 映象的一部分,但是使用者往往需要在啟動容器時寫入一些指定的值比如 hostname,所以就需要在可讀寫層對它們進行修改。可是,這些修改往往只對當前的容器有效,我們並不希望執行 docker commit 時,把這些資訊連同可讀寫層一起提交掉。所以,Docker 做法是,在修改了這些檔案之後,以一個單獨的層掛載了出來。而使用者執行 docker commit 只會提交可讀寫層,所以是不包含這些內容的。

最終,這 7 個層都被聯合掛載到 /var/lib/docker/aufs/mnt 目錄下,表現為一個完整的 Ubuntu 作業系統供容器使用。

資料卷的原理

容器技術使用了 rootfs 機制和 Mount Namespace,構建出了一個同宿主機完全隔離開的檔案系統環境。那麼,容器裡程式新建的檔案,怎麼才能讓宿主機獲取到?又或者說,宿主機上的檔案和目錄,怎麼才能讓容器裡的程式訪問到?這就需要使用 Volume 機制,將宿主機上指定的目錄或者檔案,掛載到容器裡面進行讀取和修改操作。

現在有一個問題,Docker 是如何做到把一個宿主機上的目錄或者檔案,掛載到容器裡面去呢?

我們之前說過,容器程式被建立之後,儘管開啟了 Mount Namespace,但是在它執行 chroot 之前,容器程式一直可以看到宿主機上的整個檔案系統。而宿主機上的檔案系統,也自然包括了我們要使用的容器映象。這個映象的各個層,儲存在 /var/lib/docker/aufs/diff 目錄下,在容器程式啟動後,它們會被聯合掛載在 /var/lib/docker/aufs/mnt/ 目錄中,這樣容器所需的 rootfs 就準備好了。所以,我們只需要在 rootfs 準備好之後,在執行 chroot 之前,把 Volume 指定的宿主機目錄(比如 /home 目錄),掛載到指定的容器目錄(比如 /test 目錄)在宿主機上對應的目錄(即 /var/lib/docker/aufs/mnt/[可讀寫層 ID]/test)上,這個 Volume 的掛載工作就完成了。

更重要的是,由於執行這個掛載操作時,容器程式已經建立了,也就意味著此時 Mount Namespace 已經開啟了。所以,這個掛載事件只在這個容器裡可見。你在宿主機上,是看不見容器內部的這個掛載點的。這就保證了容器的隔離性不會被 Volume 打破。

那麼,什麼是掛載呢?

繫結掛載實際上是一個 inode 替換的過程。在 Linux 作業系統中,inode 可以理解為存放檔案內容的物件,而 dentry,也叫目錄項,就是訪問這個 inode 所使用的指標。
在這裡插入圖片描述
正如上圖所示,mount --bind /home /test,會將 /home 掛載到 /test 上。其實相當於將 /test 的 dentry,重定向到了 /home 的 inode。這樣當我們修改 /test 目錄時,實際修改的是 /home 目錄的 inode。這也就是為何,一旦執行 umount 命令,/test 目錄原先的內容就會恢復:因為修改真正發生在的,是 /home 目錄裡。

所以,在一個正確的時機,進行一次繫結掛載,Docker 就可以成功地將一個宿主機上的目錄或檔案,不動聲色地掛載到容器中。這樣,程式在容器裡對這個 /test 目錄進行的所有操作,都實際發生在宿主機的對應目錄(比如,/home,或者 /var/lib/docker/volumes/[VOLUME_ID]/_data)裡,而不會影響容器映象的內容。

那麼,這個 /test 目錄裡的內容,既然掛載在容器 rootfs 的可讀寫層,它會不會被 docker commit 提交掉呢?

也不會。容器的映象操作,比如 docker commit,都是發生在宿主機空間的。而由於 Mount Namespace 的隔離作用,宿主機並不知道這個繫結掛載的存在。所以,在宿主機看來,容器中可讀寫層的 /test 目錄(/var/lib/docker/aufs/mnt/[可讀寫層 ID]/test),始終是空的。

不過,由於 Docker 一開始還是要建立 /test 這個目錄作為掛載點,所以執行了 docker commit 之後,你會發現新產生的映象裡,會多出來一個空的 /test 目錄。畢竟,新建目錄操作,又不是掛載操作,Mount Namespace 對它可起不到障眼法的作用。

docker commit 的原理

docker commit,實際上就是在容器執行起來後,把最上層的可讀寫層,加上原先容器映象的只讀層,打包組成了一個新的映象。當然,下面這些只讀層在宿主機上是共享的,不會佔用額外的空間。

而由於使用了聯合檔案系統,你在容器裡對映象 rootfs 所做的任何修改,都會被作業系統先複製到這個可讀寫層,然後再修改。這就是所謂的:Copy-on-Write。

最後,我想用一句話結束本篇文章:

繼Namespace構建了四周的圍牆(程式隔離),Cgroups構建了受控的天空優先使用陽光雨露(資源限制),Mount namespace與rootfs構建了腳下的大地,這片土地是你熟悉和喜歡的,不管你走到哪裡,都可以帶著它,就好像你從未離開過家鄉,沒有絲毫的陌生感(容器的一致性)~ (@Jeff.W 的評論)

07 | 白話容器基礎(三):深入理解容器映象

相關文章