Docker 的實現原理剖析
Docker 的發展歷史
Docker 公司前身是 DotCloud,由 Solomon Hykes 在2010年成立,2013年更名 Docker。同年釋出了 Docker-compose 元件提供容器的編排工具。2014年 Docker 釋出1.0版本,2015年Docker 提供 Docker-machine,支援 windows 平臺。
在此期間,Docker 專案在開源社群大受追捧,同時也被業界詬病的是 Docker 公司對於 Docker 發展具有絕對的話語權,比如 Docker 公司推行了 libcontainer 難以被社群接受。於是在一同貢獻 Docker 程式碼的公司諸如 Redhat,谷歌的倡導下,成立了 OCI 開源社群,旨在於將 Docker 的發展權利迴歸社群,當然反過來講,Docker 公司也希望更多的廠商安心貢獻程式碼到Docker 專案,促進 Docker 專案的發展。於是通過OCI建立了 runc 專案,替代 libcontainer,這為開發者提供了除 Docker 之外的容器化實現的選擇。
至2017年,Docker 專案轉移到 Moby 專案,基於 Moby 專案,Docker 提供了兩種發行版,Docker CE 和 Docker EE, Docker CE 就是目前大家普遍使用的版本,Docker EE 成為付費版本,提供了容器的編排,Service 等概念。Docker 公司承諾 Docker 的發行版會基於 Moby 專案。這樣一來,通過 Moby 專案,你也可以自己打造一個定製化的容器引擎,而不會被 Docker 公司繫結。
Docker 底層的技術
Docker 的誤解:Docker 是輕量級的虛擬機器。
上圖大家看到最多的一張 Docker 和虛擬機器對比的圖,從圖上看來,確實看起來 Docker 實現了類似於虛擬化的技術,能夠讓應用跑在一些輕量級的容器裡。這麼理解其實是錯誤的。實際上 Docker 是使用了很多 Linux 的隔離功能,讓容器看起來像一個輕量級虛擬機器在獨立執行,容器的本質是被限制了的 Namespaces,cgroup,具有邏輯上獨立檔案系統,網路的一個程序。其底層運用瞭如下 Linux 的能力:
Namespaces
這裡提出一個問題,在宿主機上啟動兩個容器,在這兩個容器內都各有一個 PID=1的程序,眾所周知,Linux 裡 PID 是唯一的,既然 Docker 不是跑在宿主機上的兩個虛擬機器,那麼它是如何實現在宿主機上執行兩個相同 PID 的程序呢?
這裡就用到了 Linux Namespaces,它其實是 Linux 建立新程序時的一個可選引數,在 Linux 系統中建立程序的系統呼叫是 clone()方法。
通過呼叫這個方法,這個程序會獲得一個獨立的程序空間,它的 pid 是1,並且看不到宿主機上的其他程序,這也就是在容器內執行 PS 命令的結果。
不僅僅是 PID,當你啟動啟動容器之後,Docker 會為這個容器建立一系列其他 namespaces。
這些 namespaces 提供了不同層面的隔離。容器的執行受到各個層面 namespace 的限制。
Docker Engine 使用了以下 Linux 的隔離技術:
The pid namespace: 管理 PID 名稱空間 (PID: Process ID).
The net namespace: 管理網路名稱空間(NET: Networking).
The ipc namespace: 管理程序間通訊名稱空間(IPC: InterProcess Communication).
The mnt namespace: 管理檔案系統掛載點名稱空間 (MNT: Mount).
The uts namespace: Unix 時間系統隔離. (UTS: Unix Timesharing System).
通過這些技術,執行時的容器得以看到一個和宿主機上其他容器隔離的環境。
Cgroups
在容器環境裡,如果不做限制,執行一個 Java 應用,當應用有 Bug 導致 JVM 進行 Full GC 的時候,會佔用大量的記憶體,也可能導致 CPU 飆升到100%,如何避免由單個容器的問題,導致整個叢集不可用?Docker 用到了 Cgroups。Cgroups 是 Control Group 的縮寫,由2007年穀歌工程師研發,2008年併入 Linux Kernel 2.6.24,由 C 語言編寫。
Docker 底層使用 groups 對程序進行 CPU,Mem,網路等資源的使用限制,從而實現在宿主機上的資源分配,不至於出現一個容器佔用所有宿主機的 CPU 或者記憶體。
具體的實現原理如上圖所示,通過使用 cgroups,為不同的程序設定不同的配額,上圖中 cgroup1 的程序只能使用60%的 CPU,cgroup2 使用20%的 CPU,同樣可以為程序設定容器。所以當你在 Kubernetes 裡為某個 pod 宣告 CPU 限額時,底層就是呼叫的 cgroup 的設定。具體實現如下:
進入宿主機 cgroup 目錄: cd /sys/fs/cgroup/cpu
為程序建立一個目錄,例如 my_container, 然後 cgroup 會自動在這個目錄下建立多個預設配置:
在cpu.cfs_quota_us裡輸入對應的數值,即可實現對 CPU 的配額設定:
echo 60000 > /sys/fs/cgroup/cpu/container/cpu.cfs_ q uota_us,這裡的單位是毫秒,意思是每一毫秒內,該程序能夠使用60%的 CPU 時間。如何將該配置應用到程序裡?
echo 22880 > /sys/fs/cgroup/cpu/container/task s,其中22880是宿主機上的 PID,這時候你通過呼叫 top 命令,能夠看到該程序的 CPU 使用率不會超過60%,這樣就能實現對程序的限制,避免之前的問題發生。
UnionFS
容器有了程序隔離(視野隔離),CGroup 資源隔離,還缺少隔離的檔案系統。試想,容器 A 的應用如果能直接訪問到容器 B 的檔案,會造成非常混亂的局面。為了解決這個問題,Docker 預設使用了 AuFS(Advanced Union FS) 來支援 Docker 映象的 Layer,也支援其他 UnionFS 的版本。
UnionFS 的用法
在這個例子中,-o 是傳入的目錄引數, none 表示不會掛載任何驅動,最後是目標目錄。執行的結果,就是 merged-folder 目錄下會包含 fd1, fd2 兩個目錄的內容。在 Docker 的實現中,執行 Docker info 可以看到 aufs 的路徑:
那麼,如何使用 Aufs 的呢?可以回憶下,當我們用 Docker pull 時候的場景:
我們下載一個 Docker 映象的時候,為什麼會很多層?而且每一層的 id 是對應了什麼內容?
Docker的映象儲存即用到了 aufs 技術。Docker 映象的每一層都是隻讀的,如果需要對映象增加內容,Docker 會使用 aufs 掛載一個 branch,指向diff 的目錄,同時會給這一層 layer 設定一個唯一 id,這也就是為什麼我們 pull 一個映象的時候,會有多個帶 id 的 layer 會被下載。
在 /var/lib/docker/aufs 目錄下,可以找到 /diff,/layers/, /mnt 目錄,
/diff 管理 Docker 映象每一層的內容。
/layer 管理Docker 映象的元資料,層級關係。
/mnt 管理掛載點,通常對應一個映象,或者 layer,用於描述一個容器映象的所有層級內容。
Runc
之前提到,為了防止 Docker 這項開源技術被Docker 公司控制,在幾個核心貢獻的廠商,社群推動下成立了 OCI 社群。OCI 社群提供了 runc 的維護,而 runc 是基於 OCI 規範的執行容器的工具。換句話說,你可以通過 runc,提供自己的容器實現,而不需要依賴 Docker。當然,Docker 的發行版底層也是用的 runc。在 Docker 宿主機上執行 runc,你會發現它的大多數命令和 Docker 命令類似,感興趣的讀者可以自己實踐如何用 runc 啟動容器。
總結
至此,本文總結了 Docker 的發展歷史,以及底層的實現,希望能夠激發大家對 Docker 底層技術瞭解的興趣。
參考資料
https://docs.docker.com/storage/storagedriver/aufs-driver/#how-the-aufs-storage-driver-works
https://github.com/opencontainers/runc
http://www.sel.zju.edu.cn/?p=840
文章作者:王青