容器化進程中的文件系統視角
容器內進程看到的文件系統是什麼樣子?你可能立刻會想到 Mount Namespace——容器內的進程應該能看到一個完全獨立的文件系統。這樣,在容器目錄(例如 /tmp)中進行操作時,不會受到宿主機或其他容器的影響。事實果真如此嗎?
Mount Namespace 的角色
Mount Namespace 與其他 namespace 的運作方式略有不同:它對容器進程文件系統視角的影響,只有在執行掛載操作時才會生效。然而,作為一般使用者,我們希望一個更友善的情境:每次建立新容器時,容器進程看到的文件系統都應該是隔離的環境,而不是從宿主機繼承而來的。該如何達成呢?
不難想像,我們可以在啟動容器進程之前,先重新掛載整個根目錄「/」。借助 Mount Namespace,這個掛載動作對宿主機是不可見的,容器進程就能在裡面自由操作。在 Linux 作業系統中,有一個名為 chroot 的指令,可以在 shell 中輕鬆完成這項任務。顧名思義,它幫你「變更根文件系統」,將進程的根目錄重新導向到指定的位置。
容器鏡像與 rootfs
事實上,Mount Namespace 是基於 chroot 的持續改進而發明的,同時也是 Linux 中的第一個 Namespace。為了讓這個根目錄對容器來說顯得更「真實」,我們通常會在根目錄下掛載一個完整的作業系統文件系統,例如 Ubuntu 16.04 的 ISO。因此,容器啟動後,在容器內執行 “ls /” 就會看到 Ubuntu 16.04 的所有目錄和檔案。這個掛載在容器根目錄、為容器進程提供隔離執行環境的文件系統,就稱為「容器鏡像」。它也有一個更技術性的名稱:rootfs(根文件系統)。
進入容器後執行的 /bin/bash,是 /bin 目錄下的可執行檔案,與宿主機的 /bin/bash 完全不同。現在你應該明白,Docker 專案的核心原理,本質上就是為要建立的使用者進程設定 Linux Namespace、設定特定的 Cgroups 參數,並變更進程的根目錄(Change Root)。這樣,一個完整的容器就誕生了。
容器中的共享核心與應用程式依賴
不過,Docker 更傾向在最後一步使用 pivot_root 系統呼叫來切換,如果系統不支援 pivot_root,則會退而使用 chroot。雖然這兩個系統呼叫功能相似,但存在細微差別。此外,必須澄清一點:rootfs 僅包含作業系統的檔案、配置和目錄,並不包含核心。在 Linux 中,這兩部分是分開儲存的,只有開機時才會載入特定版本的核心映像。因此,rootfs 僅包含作業系統的「外殼」,而非其「靈魂」。
那麼,容器的作業系統「靈魂」在哪裡?實際上,同一台機器上的所有容器共享宿主機作業系統的核心。這表示,如果你的應用程式需要配置核心參數、載入額外核心模組,或直接與核心互動,你應該注意這些操作和依賴的目標是宿主機的核心,而這個核心對該機器上的所有容器來說是一個「全域變數」。這是容器相較於虛擬機器的主要缺點之一:後者不僅有模擬硬體作為沙箱,還在每個沙箱內執行一個完整的 Guest OS 讓應用程式使用。儘管如此,由於 rootfs 的存在,容器擁有了一個被廣為宣傳的重要特性:一致性。
容器鏡像中的層(Layer)概念
什麼是容器的「一致性」?由於雲端與本地伺服器環境的差異,應用程式的打包過程一直是在使用 PaaS 時最「痛苦」的步驟之一。然而,有了容器,或者更精確地說,有了容器鏡像(即 rootfs),這個問題被優雅地解決了。
由於 rootfs 不僅打包了應用程式,還打包了整個作業系統的檔案和目錄,它封裝了應用程式執行所需的所有依賴。事實上,對大多數開發者來說,他們對應用程式依賴的理解一直僅限於程式語言層面,例如 Golang 的 Godeps.json。然而,一個長期被忽略的事實是:對於應用程式而言,作業系統本身就是它執行所需最完整的「依賴庫」。
憑藉容器鏡像能夠「打包作業系統」的能力,這個基礎的依賴環境終於成為應用程式沙箱的一部分。這賦予了容器被吹捧的一致性:無論是在本地機器、雲端,還是其他地方,使用者只需解壓縮容器鏡像,就能重建應用程式執行所需的完整環境。
容器鏡像的增量層設計
這種作業系統層次的一致性,彌補了應用程式本地開發與遠端執行環境之間的差距。不過,你可能會注意到另一個棘手的問題:每次開發新應用程式或更新現有應用程式時,我們是否需要重新建立 rootfs?直觀的解決方案可能是:在建立 rootfs 的過程中,每次「有意義」的操作後都保存一個 rootfs,讓同事可以使用他們需要的 rootfs。
然而,這種解決方案不具有擴展性。原因是一旦同事修改了這個 rootfs,新舊 rootfs 之間就沒有任何關係,導致極度碎片化。既然這些修改是基於舊的 rootfs,我們能否讓這些更改是增量的?這種方法的好處是,每個人只需維護相對於基礎 rootfs 的增量內容,而不是每次修改都建立一個「分支」。
答案當然是肯定的。這正是 Docker 在實作 Docker 鏡像時沒有遵循標準的 rootfs 建立流程,而是做了一個小創新的原因:Docker 在其鏡像設計中引入了層(layer)的概念。使用者建立鏡像時的每次操作都會產生一個層,這是一個增量的 rootfs。這個想法並非憑空而來,而是利用了稱為 Union File System(UnionFS)的能力,其主要功能是將多個不同位置的目錄聯合掛載(union mount)到一個單一目錄中。
容器中的分層

第一部分:唯讀層。這是容器 rootfs 的最底層五層,對應 ubuntu:latest 鏡像的五個層。它們以唯讀方式掛載(ro+wh,即 readonly+whiteout)。每一層增量地包含 Ubuntu 作業系統的一部分。
第二部分:讀寫層。這是容器 rootfs 的最上層(6e3be5d2ecccae7cc),以 rw(讀寫)方式掛載。在寫入任何檔案之前,這個目錄是空的。一旦在容器內執行寫入操作,修改會增量地出現在這一層。但你有想過嗎?如果你想從唯讀層刪除一個檔案,會發生什麼?為了實現刪除,AuFS 會在讀寫層建立一個 whiteout 檔案來「遮蔽」唯讀層中的檔案。例如,刪除唯讀層中名為 foo 的檔案,實際上是在讀寫層建立一個名為 .wh.foo 的檔案。因此,當這些層被聯合掛載時,foo 檔案就會被 .wh.foo 檔案遮蔽而「消失」。這就是「ro+wh」掛載方式所表示的意義:唯讀加上 whiteout。
第三部分:Init 層。這是一個由 Docker 專案內部產生的層,結尾為 “-init”,位於唯讀層和讀寫層之間。Init 層專門用來存放 /etc/hosts 和 /etc/resolv.conf 等資訊。之所以需要這樣的層,是因為這些檔案原本屬於唯讀的 Ubuntu 鏡像,但通常在容器啟動時需要寫入特定值(例如主機名稱),因此需要在讀寫層中修改。然而,這些修改通常僅適用於當前容器,並不希望在使用 docker commit 時與讀寫層一起提交。因此,Docker 的做法是將這些修改過的檔案掛載在一個單獨的層中。當使用者執行 docker commit 時,只會提交讀寫層,而不包含此內容。
容器鏡像分層設計的優勢
透過「分層鏡像」設計,以 Docker 鏡像為核心,不同公司和團隊的技術人員緊密連結。此外,由於對容器鏡像的操作是增量的,每次推送或拉取的內容遠小於多個完整作業系統;共享層意味著所有這些容器鏡像所需的總空間比每個鏡像單獨加起來要小。這種基於容器鏡像的協作敏捷性,遠遠超過了大小可能達數 GB 的虛擬機器磁碟鏡像。
更重要的是,一旦鏡像發布,全球任何地方下載的內容都完全相同,能夠完全複製鏡像製作者所建立的原始環境。
容器鏡像對軟體開發工作流程的影響
容器鏡像的發明不僅連接了「開發 - 測試 - 部署」流程的每一步,也標誌著容器鏡像將成為未來軟體分發的主流方式。這種分發方式具有輕量、高度一致、方便協作等優勢,使軟體開發和部署更加高效可靠。憑藉其輕量、一致和高效的特性,容器技術越來越成為軟體開發和運維中不可或缺的工具。隨著技術的不斷發展和創新,我們有理由相信容器技術在未來將發揮更重要的作用。
Novita AI,一個讓無限創意唾手可得的一站式平台,提供 100+ API。從圖像生成、語言處理到音訊增強和影片編輯,按用量付費,價格實惠,讓你在打造自有產品的同時免除 GPU 維護的困擾。立即免費試用。
